fabrica

hanna/fabrica

feat(store): bookmarks (repository stars)

86b8de2 · hanna committed on 2026-07-26

Add the bookmarks table (migration 0018) and store layer: add/remove a
bookmark, is_bookmarked, count_repo_bookmarks, and bookmarked_repos (a
join back to repos so callers can filter by the viewer's read access).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
21 files changed · +150 −2UnifiedSplit
crates/store/src/bookmarks.rs +98 −0
@@ -0,0 +1,98 @@
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+//! Bookmarks (stars): a user marking a repository. A bookmark is a `(user, repo)`
6+//! pair; the listing joins back to `repos` so callers can filter by visibility.
7+
8+use model::Repo;
9+use sqlx::{AssertSqlSafe, Row};
10+
11+use crate::repos::REPO_COLUMNS;
12+use crate::{Store, StoreError, new_id, now_ms};
13+
14+impl Store {
15+ /// Bookmark a repository for a user. Idempotent.
16+ ///
17+ /// # Errors
18+ ///
19+ /// Returns [`StoreError::Query`] on a database failure.
20+ pub async fn add_bookmark(&self, user_id: &str, repo_id: &str) -> Result<(), StoreError> {
21+ sqlx::query(
22+ "INSERT INTO bookmarks (id, user_id, repo_id, created_at) VALUES ($1, $2, $3, $4) \
23+ ON CONFLICT (user_id, repo_id) DO NOTHING",
24+ )
25+ .bind(new_id())
26+ .bind(user_id)
27+ .bind(repo_id)
28+ .bind(now_ms())
29+ .execute(&self.pool)
30+ .await?;
31+ Ok(())
32+ }
33+
34+ /// Remove a user's bookmark of a repository.
35+ ///
36+ /// # Errors
37+ ///
38+ /// Returns [`StoreError::Query`] on a database failure.
39+ pub async fn remove_bookmark(&self, user_id: &str, repo_id: &str) -> Result<(), StoreError> {
40+ sqlx::query("DELETE FROM bookmarks WHERE user_id = $1 AND repo_id = $2")
41+ .bind(user_id)
42+ .bind(repo_id)
43+ .execute(&self.pool)
44+ .await?;
45+ Ok(())
46+ }
47+
48+ /// Whether `user_id` has bookmarked `repo_id`.
49+ ///
50+ /// # Errors
51+ ///
52+ /// Returns [`StoreError::Query`] on a database failure.
53+ pub async fn is_bookmarked(&self, user_id: &str, repo_id: &str) -> Result<bool, StoreError> {
54+ let row = sqlx::query("SELECT 1 AS n FROM bookmarks WHERE user_id = $1 AND repo_id = $2")
55+ .bind(user_id)
56+ .bind(repo_id)
57+ .fetch_optional(&self.pool)
58+ .await?;
59+ Ok(row.is_some())
60+ }
61+
62+ /// How many users have bookmarked `repo_id`.
63+ ///
64+ /// # Errors
65+ ///
66+ /// Returns [`StoreError::Query`] on a database failure.
67+ pub async fn count_repo_bookmarks(&self, repo_id: &str) -> Result<i64, StoreError> {
68+ let n: i64 = sqlx::query("SELECT COUNT(*) AS n FROM bookmarks WHERE repo_id = $1")
69+ .bind(repo_id)
70+ .fetch_one(&self.pool)
71+ .await?
72+ .try_get("n")?;
73+ Ok(n)
74+ }
75+
76+ /// The repositories `user_id` has bookmarked, most recent first. Callers
77+ /// filter by the viewer's read access before display.
78+ ///
79+ /// # Errors
80+ ///
81+ /// Returns [`StoreError::Query`] on a database failure.
82+ pub async fn bookmarked_repos(&self, user_id: &str) -> Result<Vec<Repo>, StoreError> {
83+ // Qualify every repo column so nothing collides with `bookmarks`.
84+ let cols = format!("r.{}", REPO_COLUMNS.replace(", ", ", r."));
85+ let sql = format!(
86+ "SELECT {cols} FROM repos r JOIN bookmarks b ON b.repo_id = r.id \
87+ WHERE b.user_id = $1 ORDER BY b.created_at DESC"
88+ );
89+ let rows = sqlx::query(AssertSqlSafe(sql))
90+ .bind(user_id)
91+ .fetch_all(&self.pool)
92+ .await?;
93+ rows.iter()
94+ .map(|r| self.map_repo(r))
95+ .collect::<Result<_, _>>()
96+ .map_err(Into::into)
97+ }
98+}
crates/store/src/lib.rs +1 −0
@@ -26,6 +26,7 @@
2626 //! set is embedded at build time and selected at runtime by [`Store::migrate`].
2727
2828 mod admin;
29+mod bookmarks;
2930 mod groups;
3031 mod invites;
3132 mod issues;
crates/store/src/repos.rs +2 −2
@@ -11,7 +11,7 @@ use sqlx::{AssertSqlSafe, Row};
1111 use crate::{Store, StoreError, map_conflict, new_id, now_ms};
1212
1313 /// 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+pub(crate) const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \
1515 default_branch, object_format, issues_enabled, pulls_enabled, releases_enabled, is_mirror, \
1616 fork_parent_id, next_iid, archived_at, size_bytes, pushed_at, created_at, updated_at";
1717
@@ -483,7 +483,7 @@ impl Store {
483483 }
484484
485485 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].
486- fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {
486+ pub(crate) fn map_repo(&self, row: &AnyRow) -> Result<Repo, sqlx::Error> {
487487 Ok(Repo {
488488 id: row.try_get("id")?,
489489 owner_id: row.try_get("owner_id")?,
data/repos/01/01kye754pv7cb136x910ppnvja.git/FETCH_HEAD +2 −0
@@ -0,0 +1,2 @@
1+66215cd4f32408a28a91cc10f0080bb4b3beb2a6 branch 'a' of data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4
2+94ffb4ef79a86c7e24d038233859149ba61439aa branch 'main' of data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4
data/repos/01/01kye754pv7cb136x910ppnvja.git/HEAD +1 −0
@@ -0,0 +1 @@
1+ref: refs/heads/main
data/repos/01/01kye754pv7cb136x910ppnvja.git/config +9 −0
@@ -0,0 +1,9 @@
1+[core]
2+ bare = true
3+ repositoryformatversion = 0
4+ filemode = true
5+ logallrefupdates = true
6+[receive]
7+ denyNonFastForwards = false
8+[gc]
9+ auto = 0
data/repos/01/01kye754pv7cb136x910ppnvja.git/description +1 −0
@@ -0,0 +1 @@
1+Unnamed repository; edit this file 'description' to name the repository.
data/repos/01/01kye754pv7cb136x910ppnvja.git/hooks/README.sample +5 −0
@@ -0,0 +1,5 @@
1+#!/bin/sh
2+#
3+# Place appropriately named executable hook scripts into this directory
4+# to intercept various actions that git takes. See `git help hooks` for
5+# more information.
data/repos/01/01kye754pv7cb136x910ppnvja.git/hooks/post-receive +2 −0
@@ -0,0 +1,2 @@
1+#!/bin/sh
2+exec "/nix/store/2ncl66xwbsl6qf2766w3sxy3dv9vaply-fabrica-0.1.0/bin/.fabrica-wrapped" hook post-receive
data/repos/01/01kye754pv7cb136x910ppnvja.git/hooks/pre-receive +2 −0
@@ -0,0 +1,2 @@
1+#!/bin/sh
2+exit 0
data/repos/01/01kye754pv7cb136x910ppnvja.git/info/exclude +2 −0
@@ -0,0 +1,2 @@
1+# File patterns to ignore; see `git help ignore` for more information.
2+# Lines that start with '#' are comments.
data/repos/01/01kye754pv7cb136x910ppnvja.git/logs/HEAD +1 −0
@@ -0,0 +1 @@
1+0000000000000000000000000000000000000000 94ffb4ef79a86c7e24d038233859149ba61439aa hanna <me@hanna.lol> 1785036182 -0400 fetch --prune --quiet data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git +refs/heads/*:refs/heads/* +refs/tags/*:refs/tags/*: storing head
data/repos/01/01kye754pv7cb136x910ppnvja.git/logs/refs/heads/a +1 −0
@@ -0,0 +1 @@
1+0000000000000000000000000000000000000000 66215cd4f32408a28a91cc10f0080bb4b3beb2a6 hanna <me@hanna.lol> 1785036182 -0400 fetch --prune --quiet data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git +refs/heads/*:refs/heads/* +refs/tags/*:refs/tags/*: storing head
data/repos/01/01kye754pv7cb136x910ppnvja.git/logs/refs/heads/main +1 −0
@@ -0,0 +1 @@
1+0000000000000000000000000000000000000000 94ffb4ef79a86c7e24d038233859149ba61439aa hanna <me@hanna.lol> 1785036182 -0400 fetch --prune --quiet data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4.git +refs/heads/*:refs/heads/* +refs/tags/*:refs/tags/*: storing head
data/repos/01/01kye754pv7cb136x910ppnvja.git/objects/pack/pack-a7fdcb5c169f40c31c03119f33105da551053535.idx +0 −0

Binary file not shown.

data/repos/01/01kye754pv7cb136x910ppnvja.git/objects/pack/pack-a7fdcb5c169f40c31c03119f33105da551053535.pack +0 −0

Binary file not shown.

data/repos/01/01kye754pv7cb136x910ppnvja.git/objects/pack/pack-a7fdcb5c169f40c31c03119f33105da551053535.rev +0 −0

Binary file not shown.

data/repos/01/01kye754pv7cb136x910ppnvja.git/refs/heads/a +1 −0
@@ -0,0 +1 @@
1+66215cd4f32408a28a91cc10f0080bb4b3beb2a6
data/repos/01/01kye754pv7cb136x910ppnvja.git/refs/heads/main +1 −0
@@ -0,0 +1 @@
1+94ffb4ef79a86c7e24d038233859149ba61439aa
migrations/postgres/0018_bookmarks.sql +10 −0
@@ -0,0 +1,10 @@
1+-- Bookmarks (stars): a user marking a repository. Public on the user's profile,
2+-- filtered by the viewer's read access when listed.
3+CREATE TABLE bookmarks (
4+ id TEXT PRIMARY KEY,
5+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
7+ created_at BIGINT NOT NULL
8+);
9+CREATE UNIQUE INDEX idx_bookmarks_user_repo ON bookmarks (user_id, repo_id);
10+CREATE INDEX idx_bookmarks_repo ON bookmarks (repo_id);
migrations/sqlite/0018_bookmarks.sql +10 −0
@@ -0,0 +1,10 @@
1+-- Bookmarks (stars): a user marking a repository. Public on the user's profile,
2+-- filtered by the viewer's read access when listed.
3+CREATE TABLE bookmarks (
4+ id TEXT PRIMARY KEY,
5+ user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6+ repo_id TEXT NOT NULL REFERENCES repos(id) ON DELETE CASCADE,
7+ created_at BIGINT NOT NULL
8+);
9+CREATE UNIQUE INDEX idx_bookmarks_user_repo ON bookmarks (user_id, repo_id);
10+CREATE INDEX idx_bookmarks_repo ON bookmarks (repo_id);