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
8use model::Repo;
9use sqlx::{AssertSqlSafe, Row};
10
11use crate::repos::REPO_COLUMNS;
12use crate::{Store, StoreError, new_id, now_ms};
13
14impl 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 @@
26//! set is embedded at build time and selected at runtime by [`Store::migrate`].26//! set is embedded at build time and selected at runtime by [`Store::migrate`].
2727
28mod admin;28mod admin;
29mod bookmarks;
29mod groups;30mod groups;
30mod invites;31mod invites;
31mod issues;32mod issues;
crates/store/src/repos.rs +2 −2
@@ -11,7 +11,7 @@ use sqlx::{AssertSqlSafe, Row};
11use crate::{Store, StoreError, map_conflict, new_id, now_ms};11use 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, \14pub(crate) const REPO_COLUMNS: &str = "id, owner_id, group_id, name, path, description, visibility, \
15 default_branch, object_format, issues_enabled, pulls_enabled, releases_enabled, is_mirror, \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";16 fork_parent_id, next_iid, archived_at, size_bytes, pushed_at, created_at, updated_at";
1717
@@ -483,7 +483,7 @@ impl Store {
483 }483 }
484484
485 /// Map a `repos` row (selected via [`REPO_COLUMNS`]) to a [`Repo`].485 /// 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> {
487 Ok(Repo {487 Ok(Repo {
488 id: row.try_get("id")?,488 id: row.try_get("id")?,
489 owner_id: row.try_get("owner_id")?,489 owner_id: row.try_get("owner_id")?,
data/repos/01/01kye754pv7cb136x910ppnvja.git/FETCH_HEAD +2 −0
@@ -0,0 +1,2 @@
166215cd4f32408a28a91cc10f0080bb4b3beb2a6 branch 'a' of data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4
294ffb4ef79a86c7e24d038233859149ba61439aa branch 'main' of data/repos/01/01kyc0j4dzrnx6afv3wwfvxgn4
data/repos/01/01kye754pv7cb136x910ppnvja.git/HEAD +1 −0
@@ -0,0 +1 @@
1ref: 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 @@
1Unnamed 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
2exec "/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
2exit 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 @@
10000000000000000000000000000000000000000 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 @@
10000000000000000000000000000000000000000 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 @@
10000000000000000000000000000000000000000 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 @@
166215cd4f32408a28a91cc10f0080bb4b3beb2a6
data/repos/01/01kye754pv7cb136x910ppnvja.git/refs/heads/main +1 −0
@@ -0,0 +1 @@
194ffb4ef79a86c7e24d038233859149ba61439aa
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.
3CREATE 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);
9CREATE UNIQUE INDEX idx_bookmarks_user_repo ON bookmarks (user_id, repo_id);
10CREATE 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.
3CREATE 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);
9CREATE UNIQUE INDEX idx_bookmarks_user_repo ON bookmarks (user_id, repo_id);
10CREATE INDEX idx_bookmarks_repo ON bookmarks (repo_id);