fabrica

hanna/fabrica

feat(store): releases schema and queries

922b543 · hanna committed on 2026-07-26

Add the releases and release_assets tables plus repos.releases_enabled
(migration 0017), the Release/ReleaseAsset store types, and CRUD:
create/list/get/update/delete releases, add/list/get/delete assets, and
a download counter. Releases are toggleable per repo like issues/PRs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
7 files changed · +414 −2UnifiedSplit
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 {
400400
401/// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the401/// 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)]
404pub struct Repo {407pub 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;
33mod labels;33mod labels;
34mod lfs;34mod lfs;
35mod mirrors;35mod mirrors;
36mod releases;
36mod repos;37mod repos;
37mod sessions;38mod sessions;
38mod tokens;39mod tokens;
@@ -51,6 +52,7 @@ pub use crate::invites::NewInvite;
51pub use crate::issues::StateCounts;52pub use crate::issues::StateCounts;
52pub use crate::keys::NewKey;53pub use crate::keys::NewKey;
53pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror};54pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror};
55pub use crate::releases::{NewRelease, Release, ReleaseAsset};
54pub use crate::repos::{Collaborator, NewRepo};56pub use crate::repos::{Collaborator, NewRepo};
55pub use crate::sessions::NewSession;57pub use crate::sessions::NewSession;
56pub use crate::tokens::NewToken;58pub 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
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}
crates/store/src/repos.rs +5 −2
@@ -12,8 +12,8 @@ use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1212
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`.
14const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \14const 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";
1717
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.
3ALTER TABLE repos ADD COLUMN releases_enabled BOOLEAN NOT NULL DEFAULT TRUE;
4
5CREATE 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);
17CREATE UNIQUE INDEX idx_releases_repo_tag ON releases (repo_id, tag);
18
19CREATE 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);
28CREATE 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.
3ALTER TABLE repos ADD COLUMN releases_enabled INTEGER NOT NULL DEFAULT 1;
4
5CREATE 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);
17CREATE UNIQUE INDEX idx_releases_repo_tag ON releases (repo_id, tag);
18
19CREATE 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);
28CREATE INDEX idx_release_assets_release ON release_assets (release_id);