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 {
150150 object_format: "sha1".to_string(),
151151 issues_enabled: true,
152152 pulls_enabled: true,
153+ releases_enabled: true,
153154 is_mirror: false,
154155 fork_parent_id: None,
155156 next_iid: 1,
crates/model/src/entity.rs +5 −0
@@ -400,6 +400,9 @@ pub struct PullRequest {
400400
401401 /// A git repository. On disk it lives at `{repo_dir}/{id[0..2]}/{id}.git`; the
402402 /// 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)]
403406 #[derive(Debug, Clone, PartialEq, Eq)]
404407 pub struct Repo {
405408 /// ULID primary key, and the on-disk directory stem.
@@ -425,6 +428,8 @@ pub struct Repo {
425428 pub issues_enabled: bool,
426429 /// Whether pull requests are enabled for this repository.
427430 pub pulls_enabled: bool,
431+ /// Whether releases are enabled for this repository.
432+ pub releases_enabled: bool,
428433 /// Whether this repository is a mirror (kept in sync from a remote).
429434 pub is_mirror: bool,
430435 /// The repository this one was forked from, if any.
crates/store/src/lib.rs +2 −0
@@ -33,6 +33,7 @@ mod keys;
3333 mod labels;
3434 mod lfs;
3535 mod mirrors;
36+mod releases;
3637 mod repos;
3738 mod sessions;
3839 mod tokens;
@@ -51,6 +52,7 @@ pub use crate::invites::NewInvite;
5152 pub use crate::issues::StateCounts;
5253 pub use crate::keys::NewKey;
5354 pub use crate::mirrors::{Mirror, MirrorDirection, NewMirror};
55+pub use crate::releases::{NewRelease, Release, ReleaseAsset};
5456 pub use crate::repos::{Collaborator, NewRepo};
5557 pub use crate::sessions::NewSession;
5658 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};
1212
1313 /// The columns of `repos` in a fixed order, shared by every `SELECT`.
1414 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, \
16- next_iid, archived_at, size_bytes, pushed_at, created_at, updated_at";
15+ default_branch, object_format, issues_enabled, pulls_enabled, releases_enabled, is_mirror, \
16+ fork_parent_id, next_iid, archived_at, size_bytes, pushed_at, created_at, updated_at";
1717
1818 /// Fields needed to create a repository. The server fills in the id, timestamps,
1919 /// and the derived size/push bookkeeping.
@@ -69,6 +69,7 @@ impl Store {
6969 object_format: "sha1".to_string(),
7070 issues_enabled: true,
7171 pulls_enabled: true,
72+ releases_enabled: true,
7273 is_mirror: false,
7374 fork_parent_id: None,
7475 next_iid: 1,
@@ -277,6 +278,7 @@ impl Store {
277278 let column = match feature {
278279 "issues" => "issues_enabled",
279280 "pulls" => "pulls_enabled",
281+ "releases" => "releases_enabled",
280282 _ => return Ok(false),
281283 };
282284 let sql = format!("UPDATE repos SET {column} = $1, updated_at = $2 WHERE id = $3");
@@ -495,6 +497,7 @@ impl Store {
495497 object_format: row.try_get("object_format")?,
496498 issues_enabled: self.get_bool(row, "issues_enabled")?,
497499 pulls_enabled: self.get_bool(row, "pulls_enabled")?,
500+ releases_enabled: self.get_bool(row, "releases_enabled")?,
498501 is_mirror: self.get_bool(row, "is_mirror")?,
499502 fork_parent_id: row.try_get("fork_parent_id")?,
500503 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);