fabrica

hanna/fabrica

feat(store): follows (user followers/following)

f727cb7 · hanna committed on 2026-07-26

Add the follows table (migration 0019) and queries: follow/unfollow
(self-follow is a no-op), is_following, follower/following counts, and
the follower/following user lists.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed by hannaSSH key fingerprint: SHA256:4g9cWhkAAw8gwqhJUcVbSnGEvdGwklW+1Aa/fMUu59k
4 files changed · +161 −0UnifiedSplit
crates/store/src/follows.rs +142 −0
@@ -0,0 +1,142 @@
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+//! Follows: one user following another. Public — profiles show follower and
6+//! following counts and lists.
7+
8+// `follower_id` / `followee_id` are the natural, clear parameter names here.
9+#![allow(clippy::similar_names)]
10+
11+use model::User;
12+use sqlx::{AssertSqlSafe, Row};
13+
14+use crate::users::USER_COLUMNS;
15+use crate::{Store, StoreError, new_id, now_ms};
16+
17+impl Store {
18+ /// Follow `followee` as `follower`. Idempotent; a self-follow is a no-op.
19+ ///
20+ /// # Errors
21+ ///
22+ /// Returns [`StoreError::Query`] on a database failure.
23+ pub async fn follow(&self, follower_id: &str, followee_id: &str) -> Result<(), StoreError> {
24+ if follower_id == followee_id {
25+ return Ok(());
26+ }
27+ sqlx::query(
28+ "INSERT INTO follows (id, follower_id, followee_id, created_at) VALUES ($1, $2, $3, $4) \
29+ ON CONFLICT (follower_id, followee_id) DO NOTHING",
30+ )
31+ .bind(new_id())
32+ .bind(follower_id)
33+ .bind(followee_id)
34+ .bind(now_ms())
35+ .execute(&self.pool)
36+ .await?;
37+ Ok(())
38+ }
39+
40+ /// Unfollow `followee`.
41+ ///
42+ /// # Errors
43+ ///
44+ /// Returns [`StoreError::Query`] on a database failure.
45+ pub async fn unfollow(&self, follower_id: &str, followee_id: &str) -> Result<(), StoreError> {
46+ sqlx::query("DELETE FROM follows WHERE follower_id = $1 AND followee_id = $2")
47+ .bind(follower_id)
48+ .bind(followee_id)
49+ .execute(&self.pool)
50+ .await?;
51+ Ok(())
52+ }
53+
54+ /// Whether `follower` follows `followee`.
55+ ///
56+ /// # Errors
57+ ///
58+ /// Returns [`StoreError::Query`] on a database failure.
59+ pub async fn is_following(
60+ &self,
61+ follower_id: &str,
62+ followee_id: &str,
63+ ) -> Result<bool, StoreError> {
64+ let row =
65+ sqlx::query("SELECT 1 AS n FROM follows WHERE follower_id = $1 AND followee_id = $2")
66+ .bind(follower_id)
67+ .bind(followee_id)
68+ .fetch_optional(&self.pool)
69+ .await?;
70+ Ok(row.is_some())
71+ }
72+
73+ /// The number of users following `user_id`.
74+ ///
75+ /// # Errors
76+ ///
77+ /// Returns [`StoreError::Query`] on a database failure.
78+ pub async fn count_followers(&self, user_id: &str) -> Result<i64, StoreError> {
79+ let n: i64 = sqlx::query("SELECT COUNT(*) AS n FROM follows WHERE followee_id = $1")
80+ .bind(user_id)
81+ .fetch_one(&self.pool)
82+ .await?
83+ .try_get("n")?;
84+ Ok(n)
85+ }
86+
87+ /// The number of users `user_id` follows.
88+ ///
89+ /// # Errors
90+ ///
91+ /// Returns [`StoreError::Query`] on a database failure.
92+ pub async fn count_following(&self, user_id: &str) -> Result<i64, StoreError> {
93+ let n: i64 = sqlx::query("SELECT COUNT(*) AS n FROM follows WHERE follower_id = $1")
94+ .bind(user_id)
95+ .fetch_one(&self.pool)
96+ .await?
97+ .try_get("n")?;
98+ Ok(n)
99+ }
100+
101+ /// The users following `user_id`, most recent first.
102+ ///
103+ /// # Errors
104+ ///
105+ /// Returns [`StoreError::Query`] on a database failure.
106+ pub async fn followers(&self, user_id: &str) -> Result<Vec<User>, StoreError> {
107+ let cols = format!("u.{}", USER_COLUMNS.replace(", ", ", u."));
108+ let sql = format!(
109+ "SELECT {cols} FROM users u JOIN follows f ON f.follower_id = u.id \
110+ WHERE f.followee_id = $1 ORDER BY f.created_at DESC"
111+ );
112+ let rows = sqlx::query(AssertSqlSafe(sql))
113+ .bind(user_id)
114+ .fetch_all(&self.pool)
115+ .await?;
116+ rows.iter()
117+ .map(|r| self.map_user(r))
118+ .collect::<Result<_, _>>()
119+ .map_err(Into::into)
120+ }
121+
122+ /// The users `user_id` follows, most recent first.
123+ ///
124+ /// # Errors
125+ ///
126+ /// Returns [`StoreError::Query`] on a database failure.
127+ pub async fn following(&self, user_id: &str) -> Result<Vec<User>, StoreError> {
128+ let cols = format!("u.{}", USER_COLUMNS.replace(", ", ", u."));
129+ let sql = format!(
130+ "SELECT {cols} FROM users u JOIN follows f ON f.followee_id = u.id \
131+ WHERE f.follower_id = $1 ORDER BY f.created_at DESC"
132+ );
133+ let rows = sqlx::query(AssertSqlSafe(sql))
134+ .bind(user_id)
135+ .fetch_all(&self.pool)
136+ .await?;
137+ rows.iter()
138+ .map(|r| self.map_user(r))
139+ .collect::<Result<_, _>>()
140+ .map_err(Into::into)
141+ }
142+}
crates/store/src/lib.rs +1 −0
@@ -27,6 +27,7 @@
2727
2828 mod admin;
2929 mod bookmarks;
30+mod follows;
3031 mod groups;
3132 mod invites;
3233 mod issues;
migrations/postgres/0019_follows.sql +9 −0
@@ -0,0 +1,9 @@
1+-- Followers: a user following another user. Public.
2+CREATE TABLE follows (
3+ id TEXT PRIMARY KEY,
4+ follower_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5+ followee_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6+ created_at BIGINT NOT NULL
7+);
8+CREATE UNIQUE INDEX idx_follows_pair ON follows (follower_id, followee_id);
9+CREATE INDEX idx_follows_followee ON follows (followee_id);
migrations/sqlite/0019_follows.sql +9 −0
@@ -0,0 +1,9 @@
1+-- Followers: a user following another user. Public.
2+CREATE TABLE follows (
3+ id TEXT PRIMARY KEY,
4+ follower_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
5+ followee_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6+ created_at BIGINT NOT NULL
7+);
8+CREATE UNIQUE INDEX idx_follows_pair ON follows (follower_id, followee_id);
9+CREATE INDEX idx_follows_followee ON follows (followee_id);