Skip to main content

mas_storage_pg/user/
session.rs

1// Copyright 2025, 2026 Element Creations Ltd.
2// Copyright 2024, 2025 New Vector Ltd.
3// Copyright 2022-2024 The Matrix.org Foundation C.I.C.
4//
5// SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
6// Please see LICENSE files in the repository root for full details.
7
8use std::net::IpAddr;
9
10use async_trait::async_trait;
11use chrono::{DateTime, Utc};
12use mas_data_model::{
13    Authentication, AuthenticationMethod, BrowserSession, Clock, Password, UlidExt as _,
14    UpstreamOAuthAuthorizationSession, User,
15};
16use mas_storage::{
17    Page, Pagination,
18    pagination::Node,
19    user::{BrowserSessionFilter, BrowserSessionRepository},
20};
21use rand::Rng;
22use sea_query::{Expr, ExprTrait, PostgresQueryBuilder, Query};
23use sea_query_sqlx::SqlxBinder;
24use sqlx::PgConnection;
25use ulid::Ulid;
26use uuid::Uuid;
27
28use crate::{
29    DatabaseError, DatabaseInconsistencyError,
30    filter::StatementExt,
31    iden::{UpstreamOAuthAuthorizationSessions, UserSessions, Users},
32    pagination::QueryBuilderExt,
33    tracing::ExecuteExt,
34    ulid_at::{max_ulid_at, min_ulid_at},
35};
36
37/// An implementation of [`BrowserSessionRepository`] for a PostgreSQL
38/// connection
39pub struct PgBrowserSessionRepository<'c> {
40    conn: &'c mut PgConnection,
41}
42
43impl<'c> PgBrowserSessionRepository<'c> {
44    /// Create a new [`PgBrowserSessionRepository`] from an active PostgreSQL
45    /// connection
46    pub fn new(conn: &'c mut PgConnection) -> Self {
47        Self { conn }
48    }
49}
50
51#[expect(clippy::struct_field_names)]
52#[derive(sqlx::FromRow)]
53#[sea_query::enum_def]
54struct SessionLookup {
55    user_session_id: Uuid,
56    user_session_created_at: DateTime<Utc>,
57    user_session_finished_at: Option<DateTime<Utc>>,
58    user_session_user_agent: Option<String>,
59    user_session_last_active_at: Option<DateTime<Utc>>,
60    user_session_last_active_ip: Option<IpAddr>,
61    user_id: Uuid,
62    user_username: String,
63    user_created_at: DateTime<Utc>,
64    user_locked_at: Option<DateTime<Utc>>,
65    user_deactivated_at: Option<DateTime<Utc>>,
66    user_can_request_admin: bool,
67    user_is_guest: bool,
68}
69
70impl Node<Ulid> for SessionLookup {
71    fn cursor(&self) -> Ulid {
72        self.user_session_id.into()
73    }
74}
75
76impl TryFrom<SessionLookup> for BrowserSession {
77    type Error = DatabaseInconsistencyError;
78
79    fn try_from(value: SessionLookup) -> Result<Self, Self::Error> {
80        let id = Ulid::from(value.user_id);
81        let user = User {
82            id,
83            username: value.user_username,
84            sub: id.to_string(),
85            created_at: value.user_created_at,
86            locked_at: value.user_locked_at,
87            deactivated_at: value.user_deactivated_at,
88            can_request_admin: value.user_can_request_admin,
89            is_guest: value.user_is_guest,
90        };
91
92        Ok(BrowserSession {
93            id: value.user_session_id.into(),
94            user,
95            created_at: value.user_session_created_at,
96            finished_at: value.user_session_finished_at,
97            user_agent: value.user_session_user_agent,
98            last_active_at: value.user_session_last_active_at,
99            last_active_ip: value.user_session_last_active_ip,
100        })
101    }
102}
103
104struct AuthenticationLookup {
105    user_session_authentication_id: Uuid,
106    created_at: DateTime<Utc>,
107    user_password_id: Option<Uuid>,
108    upstream_oauth_authorization_session_id: Option<Uuid>,
109}
110
111impl TryFrom<AuthenticationLookup> for Authentication {
112    type Error = DatabaseInconsistencyError;
113
114    fn try_from(value: AuthenticationLookup) -> Result<Self, Self::Error> {
115        let id = Ulid::from(value.user_session_authentication_id);
116        let authentication_method = match (
117            value.user_password_id.map(Into::into),
118            value
119                .upstream_oauth_authorization_session_id
120                .map(Into::into),
121        ) {
122            (Some(user_password_id), None) => AuthenticationMethod::Password { user_password_id },
123            (None, Some(upstream_oauth2_session_id)) => AuthenticationMethod::UpstreamOAuth2 {
124                upstream_oauth2_session_id,
125            },
126            (None, None) => AuthenticationMethod::Unknown,
127            _ => {
128                return Err(DatabaseInconsistencyError::on("user_session_authentications").row(id));
129            }
130        };
131
132        Ok(Authentication {
133            id,
134            created_at: value.created_at,
135            authentication_method,
136        })
137    }
138}
139
140impl crate::filter::Filter for BrowserSessionFilter<'_> {
141    fn generate_condition(&self, _has_joins: bool) -> impl sea_query::IntoCondition {
142        sea_query::Condition::all()
143            .add_option(self.user().map(|user| {
144                Expr::col((UserSessions::Table, UserSessions::UserId)).eq(Uuid::from(user.id))
145            }))
146            .add_option(self.state().map(|state| {
147                if state.is_active() {
148                    Expr::col((UserSessions::Table, UserSessions::FinishedAt)).is_null()
149                } else {
150                    Expr::col((UserSessions::Table, UserSessions::FinishedAt)).is_not_null()
151                }
152            }))
153            .add_option(self.last_active_after().map(|last_active_after| {
154                Expr::col((UserSessions::Table, UserSessions::LastActiveAt)).gt(last_active_after)
155            }))
156            .add_option(self.last_active_before().map(|last_active_before| {
157                Expr::col((UserSessions::Table, UserSessions::LastActiveAt)).lt(last_active_before)
158            }))
159            .add_option(self.created_after().map(|created_after| {
160                // ULIDs encode the creation time in their high 48 bits, so we
161                // can use the primary key index to filter on creation time
162                // without touching the `created_at` column.
163                Expr::col((UserSessions::Table, UserSessions::UserSessionId))
164                    .gt(max_ulid_at(created_after))
165            }))
166            .add_option(self.created_before().map(|created_before| {
167                Expr::col((UserSessions::Table, UserSessions::UserSessionId))
168                    .lt(min_ulid_at(created_before))
169            }))
170            .add_option(self.linked_to_upstream_sessions().map(|filter| {
171                Expr::col((UserSessions::Table, UserSessions::UserSessionId)).in_subquery(
172                    Query::select()
173                        .expr(Expr::col((
174                            UpstreamOAuthAuthorizationSessions::Table,
175                            UpstreamOAuthAuthorizationSessions::UserSessionId,
176                        )))
177                        .from(UpstreamOAuthAuthorizationSessions::Table)
178                        .apply_filter(filter)
179                        .take(),
180                )
181            }))
182    }
183}
184
185#[async_trait]
186impl BrowserSessionRepository for PgBrowserSessionRepository<'_> {
187    type Error = DatabaseError;
188
189    #[tracing::instrument(
190        name = "db.browser_session.lookup",
191        skip_all,
192        fields(
193            db.query.text,
194            user_session.id = %id,
195        ),
196    )]
197    async fn lookup(&mut self, id: Ulid) -> Result<Option<BrowserSession>, Self::Error> {
198        let res = sqlx::query_as!(
199            SessionLookup,
200            r#"
201                SELECT s.user_session_id
202                     , s.created_at            AS "user_session_created_at"
203                     , s.finished_at           AS "user_session_finished_at"
204                     , s.user_agent            AS "user_session_user_agent"
205                     , s.last_active_at        AS "user_session_last_active_at"
206                     , s.last_active_ip        AS "user_session_last_active_ip: IpAddr"
207                     , u.user_id
208                     , u.username              AS "user_username"
209                     , u.created_at            AS "user_created_at"
210                     , u.locked_at             AS "user_locked_at"
211                     , u.deactivated_at        AS "user_deactivated_at"
212                     , u.can_request_admin     AS "user_can_request_admin"
213                     , u.is_guest              AS "user_is_guest"
214                FROM user_sessions s
215                INNER JOIN users u
216                    USING (user_id)
217                WHERE s.user_session_id = $1
218            "#,
219            Uuid::from(id),
220        )
221        .traced()
222        .fetch_optional(&mut *self.conn)
223        .await?;
224
225        let Some(res) = res else { return Ok(None) };
226
227        Ok(Some(res.try_into()?))
228    }
229
230    #[tracing::instrument(
231        name = "db.browser_session.add",
232        skip_all,
233        fields(
234            db.query.text,
235            %user.id,
236            user_session.id,
237        ),
238    )]
239    async fn add(
240        &mut self,
241        rng: &mut (dyn Rng + Send),
242        clock: &dyn Clock,
243        user: &User,
244        user_agent: Option<String>,
245    ) -> Result<BrowserSession, Self::Error> {
246        let created_at = clock.now();
247        let id = Ulid::from_datetime_with_rng(created_at, rng);
248        tracing::Span::current().record("user_session.id", tracing::field::display(id));
249
250        sqlx::query!(
251            r#"
252                INSERT INTO user_sessions (user_session_id, user_id, created_at, user_agent)
253                VALUES ($1, $2, $3, $4)
254            "#,
255            Uuid::from(id),
256            Uuid::from(user.id),
257            created_at,
258            user_agent.as_deref(),
259        )
260        .traced()
261        .execute(&mut *self.conn)
262        .await?;
263
264        let session = BrowserSession {
265            id,
266            // XXX
267            user: user.clone(),
268            created_at,
269            finished_at: None,
270            user_agent,
271            last_active_at: None,
272            last_active_ip: None,
273        };
274
275        Ok(session)
276    }
277
278    #[tracing::instrument(
279        name = "db.browser_session.finish",
280        skip_all,
281        fields(
282            db.query.text,
283            %user_session.id,
284        ),
285    )]
286    async fn finish(
287        &mut self,
288        clock: &dyn Clock,
289        mut user_session: BrowserSession,
290    ) -> Result<BrowserSession, Self::Error> {
291        let finished_at = clock.now();
292        let res = sqlx::query!(
293            r#"
294                UPDATE user_sessions
295                SET finished_at = $1
296                WHERE user_session_id = $2
297            "#,
298            finished_at,
299            Uuid::from(user_session.id),
300        )
301        .traced()
302        .execute(&mut *self.conn)
303        .await?;
304
305        user_session.finished_at = Some(finished_at);
306
307        DatabaseError::ensure_affected_rows(&res, 1)?;
308
309        Ok(user_session)
310    }
311
312    #[tracing::instrument(
313        name = "db.browser_session.finish_bulk",
314        skip_all,
315        fields(
316            db.query.text,
317        ),
318    )]
319    async fn finish_bulk(
320        &mut self,
321        clock: &dyn Clock,
322        filter: BrowserSessionFilter<'_>,
323    ) -> Result<usize, Self::Error> {
324        let finished_at = clock.now();
325        let (sql, arguments) = sea_query::Query::update()
326            .table(UserSessions::Table)
327            .value(UserSessions::FinishedAt, finished_at)
328            .apply_filter(filter)
329            .build_sqlx(PostgresQueryBuilder);
330
331        let res = sqlx::query_with(&sql, arguments)
332            .traced()
333            .execute(&mut *self.conn)
334            .await?;
335
336        Ok(res.rows_affected().try_into().unwrap_or(usize::MAX))
337    }
338
339    #[tracing::instrument(
340        name = "db.browser_session.list",
341        skip_all,
342        fields(
343            db.query.text,
344        ),
345    )]
346    async fn list(
347        &mut self,
348        filter: BrowserSessionFilter<'_>,
349        pagination: Pagination,
350    ) -> Result<Page<BrowserSession>, Self::Error> {
351        let (sql, arguments) = sea_query::Query::select()
352            .expr_as(
353                Expr::col((UserSessions::Table, UserSessions::UserSessionId)),
354                SessionLookupIden::UserSessionId,
355            )
356            .expr_as(
357                Expr::col((UserSessions::Table, UserSessions::CreatedAt)),
358                SessionLookupIden::UserSessionCreatedAt,
359            )
360            .expr_as(
361                Expr::col((UserSessions::Table, UserSessions::FinishedAt)),
362                SessionLookupIden::UserSessionFinishedAt,
363            )
364            .expr_as(
365                Expr::col((UserSessions::Table, UserSessions::UserAgent)),
366                SessionLookupIden::UserSessionUserAgent,
367            )
368            .expr_as(
369                Expr::col((UserSessions::Table, UserSessions::LastActiveAt)),
370                SessionLookupIden::UserSessionLastActiveAt,
371            )
372            .expr_as(
373                Expr::col((UserSessions::Table, UserSessions::LastActiveIp)),
374                SessionLookupIden::UserSessionLastActiveIp,
375            )
376            .expr_as(
377                Expr::col((Users::Table, Users::UserId)),
378                SessionLookupIden::UserId,
379            )
380            .expr_as(
381                Expr::col((Users::Table, Users::Username)),
382                SessionLookupIden::UserUsername,
383            )
384            .expr_as(
385                Expr::col((Users::Table, Users::CreatedAt)),
386                SessionLookupIden::UserCreatedAt,
387            )
388            .expr_as(
389                Expr::col((Users::Table, Users::LockedAt)),
390                SessionLookupIden::UserLockedAt,
391            )
392            .expr_as(
393                Expr::col((Users::Table, Users::DeactivatedAt)),
394                SessionLookupIden::UserDeactivatedAt,
395            )
396            .expr_as(
397                Expr::col((Users::Table, Users::CanRequestAdmin)),
398                SessionLookupIden::UserCanRequestAdmin,
399            )
400            .expr_as(
401                Expr::col((Users::Table, Users::IsGuest)),
402                SessionLookupIden::UserIsGuest,
403            )
404            .from(UserSessions::Table)
405            .inner_join(
406                Users::Table,
407                Expr::col((UserSessions::Table, UserSessions::UserId))
408                    .equals((Users::Table, Users::UserId)),
409            )
410            .apply_filter(filter)
411            .generate_pagination(
412                (UserSessions::Table, UserSessions::UserSessionId),
413                pagination,
414            )
415            .build_sqlx(PostgresQueryBuilder);
416
417        let edges: Vec<SessionLookup> = sqlx::query_as_with(&sql, arguments)
418            .traced()
419            .fetch_all(&mut *self.conn)
420            .await?;
421
422        let page = pagination
423            .process(edges)
424            .try_map(BrowserSession::try_from)?;
425
426        Ok(page)
427    }
428
429    #[tracing::instrument(
430        name = "db.browser_session.count",
431        skip_all,
432        fields(
433            db.query.text,
434        ),
435    )]
436    async fn count(&mut self, filter: BrowserSessionFilter<'_>) -> Result<usize, Self::Error> {
437        let (sql, arguments) = sea_query::Query::select()
438            .expr(Expr::col((UserSessions::Table, UserSessions::UserSessionId)).count())
439            .from(UserSessions::Table)
440            .apply_filter(filter)
441            .build_sqlx(PostgresQueryBuilder);
442
443        let count: i64 = sqlx::query_scalar_with(&sql, arguments)
444            .traced()
445            .fetch_one(&mut *self.conn)
446            .await?;
447
448        count
449            .try_into()
450            .map_err(DatabaseError::to_invalid_operation)
451    }
452
453    #[tracing::instrument(
454        name = "db.browser_session.authenticate_with_password",
455        skip_all,
456        fields(
457            db.query.text,
458            %user_session.id,
459            %user_password.id,
460            user_session_authentication.id,
461        ),
462    )]
463    async fn authenticate_with_password(
464        &mut self,
465        rng: &mut (dyn Rng + Send),
466        clock: &dyn Clock,
467        user_session: &BrowserSession,
468        user_password: &Password,
469    ) -> Result<Authentication, Self::Error> {
470        let created_at = clock.now();
471        let id = Ulid::from_datetime_with_rng(created_at, rng);
472        tracing::Span::current().record(
473            "user_session_authentication.id",
474            tracing::field::display(id),
475        );
476
477        sqlx::query!(
478            r#"
479                INSERT INTO user_session_authentications
480                    (user_session_authentication_id, user_session_id, created_at, user_password_id)
481                VALUES ($1, $2, $3, $4)
482            "#,
483            Uuid::from(id),
484            Uuid::from(user_session.id),
485            created_at,
486            Uuid::from(user_password.id),
487        )
488        .traced()
489        .execute(&mut *self.conn)
490        .await?;
491
492        Ok(Authentication {
493            id,
494            created_at,
495            authentication_method: AuthenticationMethod::Password {
496                user_password_id: user_password.id,
497            },
498        })
499    }
500
501    #[tracing::instrument(
502        name = "db.browser_session.authenticate_with_upstream",
503        skip_all,
504        fields(
505            db.query.text,
506            %user_session.id,
507            %upstream_oauth_session.id,
508            user_session_authentication.id,
509        ),
510    )]
511    async fn authenticate_with_upstream(
512        &mut self,
513        rng: &mut (dyn Rng + Send),
514        clock: &dyn Clock,
515        user_session: &BrowserSession,
516        upstream_oauth_session: &UpstreamOAuthAuthorizationSession,
517    ) -> Result<Authentication, Self::Error> {
518        let created_at = clock.now();
519        let id = Ulid::from_datetime_with_rng(created_at, rng);
520        tracing::Span::current().record(
521            "user_session_authentication.id",
522            tracing::field::display(id),
523        );
524
525        sqlx::query!(
526            r#"
527                INSERT INTO user_session_authentications
528                    (user_session_authentication_id, user_session_id, created_at, upstream_oauth_authorization_session_id)
529                VALUES ($1, $2, $3, $4)
530            "#,
531            Uuid::from(id),
532            Uuid::from(user_session.id),
533            created_at,
534            Uuid::from(upstream_oauth_session.id),
535        )
536        .traced()
537        .execute(&mut *self.conn)
538        .await?;
539
540        Ok(Authentication {
541            id,
542            created_at,
543            authentication_method: AuthenticationMethod::UpstreamOAuth2 {
544                upstream_oauth2_session_id: upstream_oauth_session.id,
545            },
546        })
547    }
548
549    #[tracing::instrument(
550        name = "db.browser_session.get_last_authentication",
551        skip_all,
552        fields(
553            db.query.text,
554            %user_session.id,
555        ),
556    )]
557    async fn get_last_authentication(
558        &mut self,
559        user_session: &BrowserSession,
560    ) -> Result<Option<Authentication>, Self::Error> {
561        let authentication = sqlx::query_as!(
562            AuthenticationLookup,
563            r#"
564                SELECT user_session_authentication_id
565                     , created_at
566                     , user_password_id
567                     , upstream_oauth_authorization_session_id
568                FROM user_session_authentications
569                WHERE user_session_id = $1
570                ORDER BY created_at DESC
571                LIMIT 1
572            "#,
573            Uuid::from(user_session.id),
574        )
575        .traced()
576        .fetch_optional(&mut *self.conn)
577        .await?;
578
579        let Some(authentication) = authentication else {
580            return Ok(None);
581        };
582
583        let authentication = Authentication::try_from(authentication)?;
584        Ok(Some(authentication))
585    }
586
587    #[tracing::instrument(
588        name = "db.browser_session.record_batch_activity",
589        skip_all,
590        fields(
591            db.query.text,
592        ),
593    )]
594    async fn record_batch_activity(
595        &mut self,
596        mut activities: Vec<(Ulid, DateTime<Utc>, Option<IpAddr>)>,
597    ) -> Result<(), Self::Error> {
598        // Sort the activity by ID, so that when batching the updates, Postgres
599        // locks the rows in a stable order, preventing deadlocks
600        activities.sort_unstable();
601        let mut ids = Vec::with_capacity(activities.len());
602        let mut last_activities = Vec::with_capacity(activities.len());
603        let mut ips = Vec::with_capacity(activities.len());
604
605        for (id, last_activity, ip) in activities {
606            ids.push(Uuid::from(id));
607            last_activities.push(last_activity);
608            ips.push(ip);
609        }
610
611        let res = sqlx::query!(
612            r#"
613                UPDATE user_sessions
614                SET last_active_at = GREATEST(t.last_active_at, user_sessions.last_active_at)
615                  , last_active_ip = COALESCE(t.last_active_ip, user_sessions.last_active_ip)
616                FROM (
617                    SELECT *
618                    FROM UNNEST($1::uuid[], $2::timestamptz[], $3::inet[])
619                        AS t(user_session_id, last_active_at, last_active_ip)
620                ) AS t
621                WHERE user_sessions.user_session_id = t.user_session_id
622            "#,
623            &ids,
624            &last_activities,
625            &ips as &[Option<IpAddr>],
626        )
627        .traced()
628        .execute(&mut *self.conn)
629        .await?;
630
631        DatabaseError::ensure_affected_rows(&res, ids.len().try_into().unwrap_or(u64::MAX))?;
632
633        Ok(())
634    }
635
636    #[tracing::instrument(
637        name = "db.browser_session.cleanup_finished",
638        skip_all,
639        fields(
640            db.query.text,
641            since = since.map(tracing::field::display),
642            until = %until,
643            limit = limit,
644        ),
645    )]
646    async fn cleanup_finished(
647        &mut self,
648        since: Option<DateTime<Utc>>,
649        until: DateTime<Utc>,
650        limit: usize,
651    ) -> Result<(usize, Option<DateTime<Utc>>), Self::Error> {
652        let res = sqlx::query!(
653            r#"
654                WITH
655                    to_delete AS (
656                        SELECT user_session_id, finished_at
657                        FROM user_sessions us
658                        WHERE us.finished_at IS NOT NULL
659                          AND ($1::timestamptz IS NULL OR us.finished_at >= $1)
660                          AND us.finished_at < $2
661                          -- Only delete if no oauth2_sessions reference this user_session
662                          AND NOT EXISTS (
663                              SELECT 1 FROM oauth2_sessions os
664                              WHERE os.user_session_id = us.user_session_id
665                          )
666                          -- Only delete if no compat_sessions reference this user_session
667                          AND NOT EXISTS (
668                              SELECT 1 FROM compat_sessions cs
669                              WHERE cs.user_session_id = us.user_session_id
670                          )
671                        ORDER BY us.finished_at ASC
672                        LIMIT $3
673                        FOR UPDATE OF us
674                    ),
675                    deleted_authentications AS (
676                        DELETE FROM user_session_authentications USING to_delete
677                        WHERE user_session_authentications.user_session_id = to_delete.user_session_id
678                    ),
679                    deleted_sessions AS (
680                        DELETE FROM user_sessions USING to_delete
681                        WHERE user_sessions.user_session_id = to_delete.user_session_id
682                        RETURNING user_sessions.finished_at
683                    )
684                SELECT COUNT(*) as "count!", MAX(finished_at) as last_finished_at FROM deleted_sessions
685            "#,
686            since,
687            until,
688            i64::try_from(limit).unwrap_or(i64::MAX),
689        )
690        .traced()
691        .fetch_one(&mut *self.conn)
692        .await?;
693
694        Ok((
695            res.count.try_into().unwrap_or(usize::MAX),
696            res.last_finished_at,
697        ))
698    }
699
700    #[tracing::instrument(
701        name = "db.browser_session.cleanup_inactive_ips",
702        skip_all,
703        fields(
704            db.query.text,
705            since = since.map(tracing::field::display),
706            threshold = %threshold,
707            limit = limit,
708        ),
709    )]
710    async fn cleanup_inactive_ips(
711        &mut self,
712        since: Option<DateTime<Utc>>,
713        threshold: DateTime<Utc>,
714        limit: usize,
715    ) -> Result<(usize, Option<DateTime<Utc>>), Self::Error> {
716        let res = sqlx::query!(
717            r#"
718                WITH to_update AS (
719                    SELECT user_session_id, last_active_at
720                    FROM user_sessions
721                    WHERE last_active_ip IS NOT NULL
722                      AND last_active_at IS NOT NULL
723                      AND ($1::timestamptz IS NULL OR last_active_at >= $1)
724                      AND last_active_at < $2
725                    ORDER BY last_active_at ASC
726                    LIMIT $3
727                    FOR UPDATE
728                ),
729                updated AS (
730                    UPDATE user_sessions
731                    SET last_active_ip = NULL
732                    FROM to_update
733                    WHERE user_sessions.user_session_id = to_update.user_session_id
734                    RETURNING user_sessions.last_active_at
735                )
736                SELECT COUNT(*) AS "count!", MAX(last_active_at) AS last_active_at FROM updated
737            "#,
738            since,
739            threshold,
740            i64::try_from(limit).unwrap_or(i64::MAX),
741        )
742        .traced()
743        .fetch_one(&mut *self.conn)
744        .await?;
745
746        Ok((
747            res.count.try_into().unwrap_or(usize::MAX),
748            res.last_active_at,
749        ))
750    }
751}