Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
crates/auth/src/access.rs +1 −0
| @@ -150,6 +150,7 @@ mod tests { | |||
| 150 | object_format: "sha1".to_string(), | 150 | object_format: "sha1".to_string(), |
| 151 | issues_enabled: true, | 151 | issues_enabled: true, |
| 152 | pulls_enabled: true, | 152 | pulls_enabled: true, |
| 153 | releases_enabled: true, | ||
| 153 | is_mirror: false, | 154 | is_mirror: false, |
| 154 | fork_parent_id: None, | 155 | fork_parent_id: None, |
| 155 | next_iid: 1, | 156 | next_iid: 1, |
crates/model/src/entity.rs +5 −0
| @@ -400,6 +400,9 @@ pub struct PullRequest { | |||
| 400 | 400 | ||
| 401 | /// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the | 401 | /// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the |
| 402 | /// database is the sole authority mapping `(owner, path)` to this id. | 402 | /// database is the sole authority mapping `(owner, path)` to this id. |
| 403 | // Several independent feature toggles (issues/pulls/releases/mirror) are flags, | ||
| 404 | // not a state machine — a struct of bools is the clearest representation. | ||
| 405 | #[allow(clippy::struct_excessive_bools)] | ||
| 403 | #[derive(Debug, Clone, PartialEq, Eq)] | 406 | #[derive(Debug, Clone, PartialEq, Eq)] |
| 404 | pub struct Repo { | 407 | pub struct Repo { |
| 405 | /// ULID primary key, and the on-disk directory stem. | 408 | /// ULID primary key, and the on-disk directory stem. |
| @@ -425,6 +428,8 @@ pub struct Repo { | |||
| 425 | pub issues_enabled: bool, | 428 | pub issues_enabled: bool, |
| 426 | /// Whether pull requests are enabled for this repository. | 429 | /// Whether pull requests are enabled for this repository. |
| 427 | pub pulls_enabled: bool, | 430 | pub pulls_enabled: bool, |
| 431 | /// Whether releases are enabled for this repository. | ||
| 432 | pub releases_enabled: bool, | ||
| 428 | /// Whether this repository is a mirror (kept in sync from a remote). | 433 | /// Whether this repository is a mirror (kept in sync from a remote). |
| 429 | pub is_mirror: bool, | 434 | pub is_mirror: bool, |
| 430 | /// The repository this one was forked from, if any. | 435 | /// The repository this one was forked from, if any. |
crates/store/src/lib.rs +2 −0
| @@ -33,6 +33,7 @@ mod keys; | |||
| 33 | mod labels; | 33 | mod labels; |
| 34 | mod lfs; | 34 | mod lfs; |
| 35 | mod mirrors; | 35 | mod mirrors; |
| 36 | mod releases; | ||
| 36 | mod repos; | 37 | mod repos; |
| 37 | mod sessions; | 38 | mod sessions; |
| 38 | mod tokens; | 39 | mod tokens; |
| @@ -51,6 +52,7 @@ pub use crate::invites::NewInvite; | |||
| 51 | pub use crate::issues::StateCounts; | 52 | pub use crate::issues::StateCounts; |
| 52 | pub use crate::keys::NewKey; | 53 | pub use crate::keys::NewKey; |
| 53 | pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror}; | 54 | pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror}; |
| 55 | pub use crate::releases::{NewRelease, Release, ReleaseAsset}; | ||
| 54 | pub use crate::repos::{Collaborator, NewRepo}; | 56 | pub use crate::repos::{Collaborator, NewRepo}; |
| 55 | pub use crate::sessions::NewSession; | 57 | pub use crate::sessions::NewSession; |
| 56 | pub use crate::tokens::NewToken; | 58 | pub use crate::tokens::NewToken; |
crates/store/src/releases.rs +345 −0
| @@ -0,0 +1,345 @@ | |||
| 1 | // This Source Code Form is subject to the terms of the Mozilla Public | ||
| 2 | // License, v. 2.0. If a copy of the MPL was not distributed with this | ||
| 3 | // file, You can obtain one at https://mozilla.org/MPL/2.0/. | ||
| 4 | |||
| 5 | //! Releases: tag-based release notes and their uploaded assets. The asset bytes | ||
| 6 | //! live on disk (under the data directory); this layer tracks the metadata. | ||
| 7 | |||
| 8 | use sqlx::any::AnyRow; | ||
| 9 | use sqlx::{AssertSqlSafe, Row}; | ||
| 10 | |||
| 11 | use crate::{Store, StoreError, new_id, now_ms}; | ||
| 12 | |||
| 13 | /// A release: notes attached to a git tag, optionally a draft or pre-release. | ||
| 14 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 15 | pub struct Release { | ||
| 16 | /// ULID primary key. | ||
| 17 | pub id: String, | ||
| 18 | /// The repository this release belongs to. | ||
| 19 | pub repo_id: String, | ||
| 20 | /// The git tag the release is cut from. | ||
| 21 | pub tag: String, | ||
| 22 | /// The release title. | ||
| 23 | pub name: String, | ||
| 24 | /// The markdown release notes. | ||
| 25 | pub body: Option<String>, | ||
| 26 | /// Whether this is a pre-release (not the "latest"). | ||
| 27 | pub is_prerelease: bool, | ||
| 28 | /// Whether this is an unpublished draft. | ||
| 29 | pub is_draft: bool, | ||
| 30 | /// The author's user id, if still present. | ||
| 31 | pub author_id: Option<String>, | ||
| 32 | /// Creation time. | ||
| 33 | pub created_at: i64, | ||
| 34 | /// Last edit time. | ||
| 35 | pub updated_at: i64, | ||
| 36 | } | ||
| 37 | |||
| 38 | /// Fields to create a release. | ||
| 39 | #[derive(Debug, Clone)] | ||
| 40 | pub struct NewRelease { | ||
| 41 | /// The repository. | ||
| 42 | pub repo_id: String, | ||
| 43 | /// The git tag. | ||
| 44 | pub tag: String, | ||
| 45 | /// The release title. | ||
| 46 | pub name: String, | ||
| 47 | /// The markdown body. | ||
| 48 | pub body: Option<String>, | ||
| 49 | /// Whether it is a pre-release. | ||
| 50 | pub is_prerelease: bool, | ||
| 51 | /// Whether it is a draft. | ||
| 52 | pub is_draft: bool, | ||
| 53 | /// The author's user id. | ||
| 54 | pub author_id: String, | ||
| 55 | } | ||
| 56 | |||
| 57 | /// An uploaded release asset. The bytes are stored on disk keyed by `id`. | ||
| 58 | #[derive(Debug, Clone, PartialEq, Eq)] | ||
| 59 | pub struct ReleaseAsset { | ||
| 60 | /// ULID primary key (and on-disk file stem). | ||
| 61 | pub id: String, | ||
| 62 | /// The owning release. | ||
| 63 | pub release_id: String, | ||
| 64 | /// The original file name. | ||
| 65 | pub name: String, | ||
| 66 | /// Size in bytes. | ||
| 67 | pub size: i64, | ||
| 68 | /// The MIME content type. | ||
| 69 | pub content_type: String, | ||
| 70 | /// How many times it has been downloaded. | ||
| 71 | pub download_count: i64, | ||
| 72 | /// Upload time. | ||
| 73 | pub created_at: i64, | ||
| 74 | } | ||
| 75 | |||
| 76 | const RELEASE_COLUMNS: &str = "id, repo_id, tag, name, body, is_prerelease, is_draft, \ | ||
| 77 | author_id, created_at, updated_at"; | ||
| 78 | const ASSET_COLUMNS: &str = "id, release_id, name, size, content_type, download_count, created_at"; | ||
| 79 | |||
| 80 | impl Store { | ||
| 81 | fn map_release(&self, row: &AnyRow) -> Result<Release, sqlx::Error> { | ||
| 82 | Ok(Release { | ||
| 83 | id: row.try_get("id")?, | ||
| 84 | repo_id: row.try_get("repo_id")?, | ||
| 85 | tag: row.try_get("tag")?, | ||
| 86 | name: row.try_get("name")?, | ||
| 87 | body: row.try_get("body")?, | ||
| 88 | is_prerelease: self.get_bool(row, "is_prerelease")?, | ||
| 89 | is_draft: self.get_bool(row, "is_draft")?, | ||
| 90 | author_id: row.try_get("author_id")?, | ||
| 91 | created_at: row.try_get("created_at")?, | ||
| 92 | updated_at: row.try_get("updated_at")?, | ||
| 93 | }) | ||
| 94 | } | ||
| 95 | |||
| 96 | /// Create a release. | ||
| 97 | /// | ||
| 98 | /// # Errors | ||
| 99 | /// | ||
| 100 | /// [`StoreError::Conflict`] if a release already exists for the tag, else | ||
| 101 | /// [`StoreError::Query`]. | ||
| 102 | pub async fn create_release(&self, new: NewRelease) -> Result<Release, StoreError> { | ||
| 103 | let now = now_ms(); | ||
| 104 | let release = Release { | ||
| 105 | id: new_id(), | ||
| 106 | repo_id: new.repo_id, | ||
| 107 | tag: new.tag, | ||
| 108 | name: new.name, | ||
| 109 | body: new.body, | ||
| 110 | is_prerelease: new.is_prerelease, | ||
| 111 | is_draft: new.is_draft, | ||
| 112 | author_id: Some(new.author_id), | ||
| 113 | created_at: now, | ||
| 114 | updated_at: now, | ||
| 115 | }; | ||
| 116 | sqlx::query( | ||
| 117 | "INSERT INTO releases \ | ||
| 118 | (id, repo_id, tag, name, body, is_prerelease, is_draft, author_id, created_at, updated_at) \ | ||
| 119 | VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", | ||
| 120 | ) | ||
| 121 | .bind(&release.id) | ||
| 122 | .bind(&release.repo_id) | ||
| 123 | .bind(&release.tag) | ||
| 124 | .bind(&release.name) | ||
| 125 | .bind(&release.body) | ||
| 126 | .bind(release.is_prerelease) | ||
| 127 | .bind(release.is_draft) | ||
| 128 | .bind(&release.author_id) | ||
| 129 | .bind(release.created_at) | ||
| 130 | .bind(release.updated_at) | ||
| 131 | .execute(&self.pool) | ||
| 132 | .await | ||
| 133 | .map_err(|e| crate::map_conflict(e, "release", &[("tag", "tag")]))?; | ||
| 134 | Ok(release) | ||
| 135 | } | ||
| 136 | |||
| 137 | /// List a repository's releases, newest first (drafts included). | ||
| 138 | /// | ||
| 139 | /// # Errors | ||
| 140 | /// | ||
| 141 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 142 | pub async fn list_releases(&self, repo_id: &str) -> Result<Vec<Release>, StoreError> { | ||
| 143 | let sql = format!( | ||
| 144 | "SELECT {RELEASE_COLUMNS} FROM releases WHERE repo_id = $1 ORDER BY created_at DESC" | ||
| 145 | ); | ||
| 146 | let rows = sqlx::query(AssertSqlSafe(sql)) | ||
| 147 | .bind(repo_id) | ||
| 148 | .fetch_all(&self.pool) | ||
| 149 | .await?; | ||
| 150 | rows.iter() | ||
| 151 | .map(|r| self.map_release(r)) | ||
| 152 | .collect::<Result<_, _>>() | ||
| 153 | .map_err(Into::into) | ||
| 154 | } | ||
| 155 | |||
| 156 | /// Fetch a release by id. | ||
| 157 | /// | ||
| 158 | /// # Errors | ||
| 159 | /// | ||
| 160 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 161 | pub async fn release_by_id(&self, id: &str) -> Result<Option<Release>, StoreError> { | ||
| 162 | let sql = format!("SELECT {RELEASE_COLUMNS} FROM releases WHERE id = $1"); | ||
| 163 | let row = sqlx::query(AssertSqlSafe(sql)) | ||
| 164 | .bind(id) | ||
| 165 | .fetch_optional(&self.pool) | ||
| 166 | .await?; | ||
| 167 | Ok(row.as_ref().map(|r| self.map_release(r)).transpose()?) | ||
| 168 | } | ||
| 169 | |||
| 170 | /// Fetch a repository's release for a given tag. | ||
| 171 | /// | ||
| 172 | /// # Errors | ||
| 173 | /// | ||
| 174 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 175 | pub async fn release_by_tag( | ||
| 176 | &self, | ||
| 177 | repo_id: &str, | ||
| 178 | tag: &str, | ||
| 179 | ) -> Result<Option<Release>, StoreError> { | ||
| 180 | let sql = format!("SELECT {RELEASE_COLUMNS} FROM releases WHERE repo_id = $1 AND tag = $2"); | ||
| 181 | let row = sqlx::query(AssertSqlSafe(sql)) | ||
| 182 | .bind(repo_id) | ||
| 183 | .bind(tag) | ||
| 184 | .fetch_optional(&self.pool) | ||
| 185 | .await?; | ||
| 186 | Ok(row.as_ref().map(|r| self.map_release(r)).transpose()?) | ||
| 187 | } | ||
| 188 | |||
| 189 | /// Update a release's editable fields. | ||
| 190 | /// | ||
| 191 | /// # Errors | ||
| 192 | /// | ||
| 193 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 194 | pub async fn update_release( | ||
| 195 | &self, | ||
| 196 | id: &str, | ||
| 197 | name: &str, | ||
| 198 | body: Option<&str>, | ||
| 199 | is_prerelease: bool, | ||
| 200 | is_draft: bool, | ||
| 201 | ) -> Result<(), StoreError> { | ||
| 202 | sqlx::query( | ||
| 203 | "UPDATE releases SET name = $1, body = $2, is_prerelease = $3, is_draft = $4, \ | ||
| 204 | updated_at = $5 WHERE id = $6", | ||
| 205 | ) | ||
| 206 | .bind(name) | ||
| 207 | .bind(body) | ||
| 208 | .bind(is_prerelease) | ||
| 209 | .bind(is_draft) | ||
| 210 | .bind(now_ms()) | ||
| 211 | .bind(id) | ||
| 212 | .execute(&self.pool) | ||
| 213 | .await?; | ||
| 214 | Ok(()) | ||
| 215 | } | ||
| 216 | |||
| 217 | /// Delete a release (its assets cascade). | ||
| 218 | /// | ||
| 219 | /// # Errors | ||
| 220 | /// | ||
| 221 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 222 | pub async fn delete_release(&self, id: &str) -> Result<bool, StoreError> { | ||
| 223 | let n = sqlx::query("DELETE FROM releases WHERE id = $1") | ||
| 224 | .bind(id) | ||
| 225 | .execute(&self.pool) | ||
| 226 | .await? | ||
| 227 | .rows_affected(); | ||
| 228 | Ok(n > 0) | ||
| 229 | } | ||
| 230 | |||
| 231 | // ---- Assets ---- | ||
| 232 | |||
| 233 | fn map_asset(row: &AnyRow) -> Result<ReleaseAsset, sqlx::Error> { | ||
| 234 | Ok(ReleaseAsset { | ||
| 235 | id: row.try_get("id")?, | ||
| 236 | release_id: row.try_get("release_id")?, | ||
| 237 | name: row.try_get("name")?, | ||
| 238 | size: row.try_get("size")?, | ||
| 239 | content_type: row.try_get("content_type")?, | ||
| 240 | download_count: row.try_get("download_count")?, | ||
| 241 | created_at: row.try_get("created_at")?, | ||
| 242 | }) | ||
| 243 | } | ||
| 244 | |||
| 245 | /// Record an uploaded asset, returning its id (the on-disk file stem). | ||
| 246 | /// | ||
| 247 | /// # Errors | ||
| 248 | /// | ||
| 249 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 250 | pub async fn add_release_asset( | ||
| 251 | &self, | ||
| 252 | release_id: &str, | ||
| 253 | name: &str, | ||
| 254 | size: i64, | ||
| 255 | content_type: &str, | ||
| 256 | ) -> Result<ReleaseAsset, StoreError> { | ||
| 257 | let asset = ReleaseAsset { | ||
| 258 | id: new_id(), | ||
| 259 | release_id: release_id.to_string(), | ||
| 260 | name: name.to_string(), | ||
| 261 | size, | ||
| 262 | content_type: content_type.to_string(), | ||
| 263 | download_count: 0, | ||
| 264 | created_at: now_ms(), | ||
| 265 | }; | ||
| 266 | sqlx::query( | ||
| 267 | "INSERT INTO release_assets \ | ||
| 268 | (id, release_id, name, size, content_type, download_count, created_at) \ | ||
| 269 | VALUES ($1, $2, $3, $4, $5, $6, $7)", | ||
| 270 | ) | ||
| 271 | .bind(&asset.id) | ||
| 272 | .bind(&asset.release_id) | ||
| 273 | .bind(&asset.name) | ||
| 274 | .bind(asset.size) | ||
| 275 | .bind(&asset.content_type) | ||
| 276 | .bind(asset.download_count) | ||
| 277 | .bind(asset.created_at) | ||
| 278 | .execute(&self.pool) | ||
| 279 | .await?; | ||
| 280 | Ok(asset) | ||
| 281 | } | ||
| 282 | |||
| 283 | /// List a release's assets, oldest first. | ||
| 284 | /// | ||
| 285 | /// # Errors | ||
| 286 | /// | ||
| 287 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 288 | pub async fn list_release_assets( | ||
| 289 | &self, | ||
| 290 | release_id: &str, | ||
| 291 | ) -> Result<Vec<ReleaseAsset>, StoreError> { | ||
| 292 | let sql = format!( | ||
| 293 | "SELECT {ASSET_COLUMNS} FROM release_assets WHERE release_id = $1 ORDER BY created_at ASC" | ||
| 294 | ); | ||
| 295 | let rows = sqlx::query(AssertSqlSafe(sql)) | ||
| 296 | .bind(release_id) | ||
| 297 | .fetch_all(&self.pool) | ||
| 298 | .await?; | ||
| 299 | rows.iter() | ||
| 300 | .map(Self::map_asset) | ||
| 301 | .collect::<Result<_, _>>() | ||
| 302 | .map_err(Into::into) | ||
| 303 | } | ||
| 304 | |||
| 305 | /// Fetch an asset by id. | ||
| 306 | /// | ||
| 307 | /// # Errors | ||
| 308 | /// | ||
| 309 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 310 | pub async fn release_asset_by_id(&self, id: &str) -> Result<Option<ReleaseAsset>, StoreError> { | ||
| 311 | let sql = format!("SELECT {ASSET_COLUMNS} FROM release_assets WHERE id = $1"); | ||
| 312 | let row = sqlx::query(AssertSqlSafe(sql)) | ||
| 313 | .bind(id) | ||
| 314 | .fetch_optional(&self.pool) | ||
| 315 | .await?; | ||
| 316 | Ok(row.as_ref().map(Self::map_asset).transpose()?) | ||
| 317 | } | ||
| 318 | |||
| 319 | /// Delete an asset record (the caller removes the on-disk file). | ||
| 320 | /// | ||
| 321 | /// # Errors | ||
| 322 | /// | ||
| 323 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 324 | pub async fn delete_release_asset(&self, id: &str) -> Result<bool, StoreError> { | ||
| 325 | let n = sqlx::query("DELETE FROM release_assets WHERE id = $1") | ||
| 326 | .bind(id) | ||
| 327 | .execute(&self.pool) | ||
| 328 | .await? | ||
| 329 | .rows_affected(); | ||
| 330 | Ok(n > 0) | ||
| 331 | } | ||
| 332 | |||
| 333 | /// Increment an asset's download counter (best-effort). | ||
| 334 | /// | ||
| 335 | /// # Errors | ||
| 336 | /// | ||
| 337 | /// Returns [`StoreError::Query`] on a database failure. | ||
| 338 | pub async fn bump_asset_download(&self, id: &str) -> Result<(), StoreError> { | ||
| 339 | sqlx::query("UPDATE release_assets SET download_count = download_count + 1 WHERE id = $1") | ||
| 340 | .bind(id) | ||
| 341 | .execute(&self.pool) | ||
| 342 | .await?; | ||
| 343 | Ok(()) | ||
| 344 | } | ||
| 345 | } | ||
crates/store/src/repos.rs +5 −2
| @@ -12,8 +12,8 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms}; | |||
| 12 | 12 | ||
| 13 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. | 13 | /// The columns of `repos` in a fixed order, shared by every `SELECT`. |
| 14 | const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \ | 14 | const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \ |
| 15 | default_branch, object_format, issues_enabled, pulls_enabled, is_mirror, fork_parent_id, \ | 15 | default_branch, object_format, issues_enabled, pulls_enabled, releases_enabled, is_mirror, \ |
| 16 | next_iid, archived_at, size_bytes, pushed_at, created_at, updated_at"; | 16 | fork_parent_id, next_iid, archived_at, size_bytes, pushed_at, created_at, updated_at"; |
| 17 | 17 | ||
| 18 | /// Fields needed to create a repository. The server fills in the id, timestamps, | 18 | /// Fields needed to create a repository. The server fills in the id, timestamps, |
| 19 | /// and the derived size/push bookkeeping. | 19 | /// and the derived size/push bookkeeping. |
| @@ -69,6 +69,7 @@ impl Store { | |||
| 69 | object_format: "sha1".to_string(), | 69 | object_format: "sha1".to_string(), |
| 70 | issues_enabled: true, | 70 | issues_enabled: true, |
| 71 | pulls_enabled: true, | 71 | pulls_enabled: true, |
| 72 | releases_enabled: true, | ||
| 72 | is_mirror: false, | 73 | is_mirror: false, |
| 73 | fork_parent_id: None, | 74 | fork_parent_id: None, |
| 74 | next_iid: 1, | 75 | next_iid: 1, |
| @@ -277,6 +278,7 @@ impl Store { | |||
| 277 | let column = match feature { | 278 | let column = match feature { |
| 278 | "issues" => "issues_enabled", | 279 | "issues" => "issues_enabled", |
| 279 | "pulls" => "pulls_enabled", | 280 | "pulls" => "pulls_enabled", |
| 281 | "releases" => "releases_enabled", | ||
| 280 | _ => return Ok(false), | 282 | _ => return Ok(false), |
| 281 | }; | 283 | }; |
| 282 | let sql = format!("UPDATE repos SET {column} = $1, updated_at = $2 WHERE id = $3"); | 284 | let sql = format!("UPDATE repos SET {column} = $1, updated_at = $2 WHERE id = $3"); |
| @@ -495,6 +497,7 @@ impl Store { | |||
| 495 | object_format: row.try_get("object_format")?, | 497 | object_format: row.try_get("object_format")?, |
| 496 | issues_enabled: self.get_bool(row, "issues_enabled")?, | 498 | issues_enabled: self.get_bool(row, "issues_enabled")?, |
| 497 | pulls_enabled: self.get_bool(row, "pulls_enabled")?, | 499 | pulls_enabled: self.get_bool(row, "pulls_enabled")?, |
| 500 | releases_enabled: self.get_bool(row, "releases_enabled")?, | ||
| 498 | is_mirror: self.get_bool(row, "is_mirror")?, | 501 | is_mirror: self.get_bool(row, "is_mirror")?, |
| 499 | fork_parent_id: row.try_get("fork_parent_id")?, | 502 | fork_parent_id: row.try_get("fork_parent_id")?, |
| 500 | next_iid: row.try_get("next_iid")?, | 503 | next_iid: row.try_get("next_iid")?, |
migrations/postgres/0017_releases.sql +28 −0
| @@ -0,0 +1,28 @@ | |||
| 1 | -- Releases: tag-based release notes with optional uploaded assets. Toggleable | ||
| 2 | -- per repository (releases_enabled), like issues and pull requests. | ||
| 3 | ALTER TABLE repos ADD COLUMN releases_enabled BOOLEAN NOT NULL DEFAULT TRUE; | ||
| 4 | |||
| 5 | CREATE TABLE releases ( | ||
| 6 | id TEXT PRIMARY KEY, | ||
| 7 | repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | ||
| 8 | tag TEXT NOT NULL, | ||
| 9 | name TEXT NOT NULL, | ||
| 10 | body TEXT, | ||
| 11 | is_prerelease BOOLEAN NOT NULL DEFAULT FALSE, | ||
| 12 | is_draft BOOLEAN NOT NULL DEFAULT FALSE, | ||
| 13 | author_id TEXT REFERENCES users(id) ON DELETE SET NULL, | ||
| 14 | created_at BIGINT NOT NULL, | ||
| 15 | updated_at BIGINT NOT NULL | ||
| 16 | ); | ||
| 17 | CREATE UNIQUE INDEX idx_releases_repo_tag ON releases (repo_id, tag); | ||
| 18 | |||
| 19 | CREATE TABLE release_assets ( | ||
| 20 | id TEXT PRIMARY KEY, | ||
| 21 | release_id TEXT NOT NULL REFERENCES releases(id) ON DELETE CASCADE, | ||
| 22 | name TEXT NOT NULL, | ||
| 23 | size BIGINT NOT NULL, | ||
| 24 | content_type TEXT NOT NULL, | ||
| 25 | download_count BIGINT NOT NULL DEFAULT 0, | ||
| 26 | created_at BIGINT NOT NULL | ||
| 27 | ); | ||
| 28 | CREATE INDEX idx_release_assets_release ON release_assets (release_id); | ||
migrations/sqlite/0017_releases.sql +28 −0
| @@ -0,0 +1,28 @@ | |||
| 1 | -- Releases: tag-based release notes with optional uploaded assets. Toggleable | ||
| 2 | -- per repository (releases_enabled), like issues and pull requests. | ||
| 3 | ALTER TABLE repos ADD COLUMN releases_enabled INTEGER NOT NULL DEFAULT 1; | ||
| 4 | |||
| 5 | CREATE TABLE releases ( | ||
| 6 | id TEXT PRIMARY KEY, | ||
| 7 | repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE, | ||
| 8 | tag TEXT NOT NULL, | ||
| 9 | name TEXT NOT NULL, | ||
| 10 | body TEXT, | ||
| 11 | is_prerelease INTEGER NOT NULL DEFAULT 0, | ||
| 12 | is_draft INTEGER NOT NULL DEFAULT 0, | ||
| 13 | author_id TEXT REFERENCES users(id) ON DELETE SET NULL, | ||
| 14 | created_at BIGINT NOT NULL, | ||
| 15 | updated_at BIGINT NOT NULL | ||
| 16 | ); | ||
| 17 | CREATE UNIQUE INDEX idx_releases_repo_tag ON releases (repo_id, tag); | ||
| 18 | |||
| 19 | CREATE TABLE release_assets ( | ||
| 20 | id TEXT PRIMARY KEY, | ||
| 21 | release_id TEXT NOT NULL REFERENCES releases(id) ON DELETE CASCADE, | ||
| 22 | name TEXT NOT NULL, | ||
| 23 | size BIGINT NOT NULL, | ||
| 24 | content_type TEXT NOT NULL, | ||
| 25 | download_count BIGINT NOT NULL DEFAULT 0, | ||
| 26 | created_at BIGINT NOT NULL | ||
| 27 | ); | ||
| 28 | CREATE INDEX idx_release_assets_release ON release_assets (release_id); | ||