fabrica

hanna/fabrica

11050 bytes
Raw
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
8use sqlx::any::AnyRow;
9use sqlx::{AssertSqlSafe, Row};
10
11use 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)]
15pub 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)]
40pub 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)]
59pub 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
76const RELEASE_COLUMNS: &str = "id, repo_id, tag, name, body, is_prerelease, is_draft, \
77 author_id, created_at, updated_at";
78const ASSET_COLUMNS: &str = "id, release_id, name, size, content_type, download_count, created_at";
79
80impl 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}