diff --git a/crates/graphql/src/lib.rs b/crates/graphql/src/lib.rs index 6e58bec7..765db8e5 100644 --- a/crates/graphql/src/lib.rs +++ b/crates/graphql/src/lib.rs @@ -34,7 +34,7 @@ use mas_storage::{ oauth2::OAuth2ClientRepository, upstream_oauth2::{UpstreamOAuthLinkRepository, UpstreamOAuthProviderRepository}, user::{BrowserSessionRepository, UserEmailRepository}, - PgRepository, Repository, + Pagination, PgRepository, Repository, }; use model::CreationEvent; use sqlx::PgPool; @@ -228,10 +228,11 @@ impl RootQuery { x.extract_for_type(NodeType::UpstreamOAuth2Provider) }) .transpose()?; + let pagination = Pagination::try_new(before_id, after_id, first, last)?; let page = repo .upstream_oauth_provider() - .list_paginated(before_id, after_id, first, last) + .list_paginated(&pagination) .await?; let mut connection = Connection::new(page.has_previous_page, page.has_next_page); diff --git a/crates/graphql/src/model/users.rs b/crates/graphql/src/model/users.rs index 9cd8d53b..9a9062d0 100644 --- a/crates/graphql/src/model/users.rs +++ b/crates/graphql/src/model/users.rs @@ -22,7 +22,7 @@ use mas_storage::{ oauth2::OAuth2SessionRepository, upstream_oauth2::UpstreamOAuthLinkRepository, user::{BrowserSessionRepository, UserEmailRepository}, - PgRepository, Repository, + Pagination, PgRepository, Repository, }; use sqlx::PgPool; @@ -95,10 +95,11 @@ impl User { let before_id = before .map(|x: OpaqueCursor| x.extract_for_type(NodeType::CompatSsoLogin)) .transpose()?; + let pagination = Pagination::try_new(before_id, after_id, first, last)?; let page = repo .compat_sso_login() - .list_paginated(&self.0, before_id, after_id, first, last) + .list_paginated(&self.0, &pagination) .await?; let mut connection = Connection::new(page.has_previous_page, page.has_next_page); @@ -141,10 +142,11 @@ impl User { let before_id = before .map(|x: OpaqueCursor| x.extract_for_type(NodeType::BrowserSession)) .transpose()?; + let pagination = Pagination::try_new(before_id, after_id, first, last)?; let page = repo .browser_session() - .list_active_paginated(&self.0, before_id, after_id, first, last) + .list_active_paginated(&self.0, &pagination) .await?; let mut connection = Connection::new(page.has_previous_page, page.has_next_page); @@ -187,10 +189,11 @@ impl User { let before_id = before .map(|x: OpaqueCursor| x.extract_for_type(NodeType::UserEmail)) .transpose()?; + let pagination = Pagination::try_new(before_id, after_id, first, last)?; let page = repo .user_email() - .list_paginated(&self.0, before_id, after_id, first, last) + .list_paginated(&self.0, &pagination) .await?; let mut connection = Connection::with_additional_fields( @@ -237,10 +240,11 @@ impl User { let before_id = before .map(|x: OpaqueCursor| x.extract_for_type(NodeType::OAuth2Session)) .transpose()?; + let pagination = Pagination::try_new(before_id, after_id, first, last)?; let page = repo .oauth2_session() - .list_paginated(&self.0, before_id, after_id, first, last) + .list_paginated(&self.0, &pagination) .await?; let mut connection = Connection::new(page.has_previous_page, page.has_next_page); @@ -287,10 +291,11 @@ impl User { x.extract_for_type(NodeType::UpstreamOAuth2Link) }) .transpose()?; + let pagination = Pagination::try_new(before_id, after_id, first, last)?; let page = repo .upstream_oauth_link() - .list_paginated(&self.0, before_id, after_id, first, last) + .list_paginated(&self.0, &pagination) .await?; let mut connection = Connection::new(page.has_previous_page, page.has_next_page); diff --git a/crates/storage/src/compat/sso_login.rs b/crates/storage/src/compat/sso_login.rs index 8cb84dc1..31c3da3c 100644 --- a/crates/storage/src/compat/sso_login.rs +++ b/crates/storage/src/compat/sso_login.rs @@ -24,7 +24,7 @@ use uuid::Uuid; use crate::{ pagination::{Page, QueryBuilderExt}, tracing::ExecuteExt, - Clock, DatabaseError, DatabaseInconsistencyError, LookupResultExt, + Clock, DatabaseError, DatabaseInconsistencyError, LookupResultExt, Pagination, }; #[async_trait] @@ -68,10 +68,7 @@ pub trait CompatSsoLoginRepository: Send + Sync { async fn list_paginated( &mut self, user: &User, - before: Option, - after: Option, - first: Option, - last: Option, + pagination: &Pagination, ) -> Result, Self::Error>; } @@ -354,10 +351,7 @@ impl<'c> CompatSsoLoginRepository for PgCompatSsoLoginRepository<'c> { async fn list_paginated( &mut self, user: &User, - before: Option, - after: Option, - first: Option, - last: Option, + pagination: &Pagination, ) -> Result, Self::Error> { let mut query = QueryBuilder::new( r#" @@ -377,7 +371,7 @@ impl<'c> CompatSsoLoginRepository for PgCompatSsoLoginRepository<'c> { query .push(" WHERE user_id = ") .push_bind(Uuid::from(user.id)) - .generate_pagination("cl.compat_sso_login_id", before, after, first, last)?; + .generate_pagination("cl.compat_sso_login_id", &pagination); let edges: Vec = query .build_query_as() @@ -385,7 +379,9 @@ impl<'c> CompatSsoLoginRepository for PgCompatSsoLoginRepository<'c> { .fetch_all(&mut *self.conn) .await?; - let page = Page::process(edges, first, last)?.try_map(CompatSsoLogin::try_from)?; + let page = pagination + .process(edges) + .try_map(CompatSsoLogin::try_from)?; Ok(page) } } diff --git a/crates/storage/src/lib.rs b/crates/storage/src/lib.rs index 49c9c3a9..e92d37fe 100644 --- a/crates/storage/src/lib.rs +++ b/crates/storage/src/lib.rs @@ -253,7 +253,10 @@ pub(crate) mod tracing; pub mod upstream_oauth2; pub mod user; -pub use self::repository::{PgRepository, Repository}; +pub use self::{ + pagination::Pagination, + repository::{PgRepository, Repository}, +}; /// Embedded migrations, allowing them to run on startup pub static MIGRATOR: Migrator = sqlx::migrate!(); diff --git a/crates/storage/src/oauth2/session.rs b/crates/storage/src/oauth2/session.rs index 0a6b5c99..0fa8cb8f 100644 --- a/crates/storage/src/oauth2/session.rs +++ b/crates/storage/src/oauth2/session.rs @@ -23,7 +23,7 @@ use uuid::Uuid; use crate::{ pagination::{Page, QueryBuilderExt}, tracing::ExecuteExt, - Clock, DatabaseError, DatabaseInconsistencyError, LookupResultExt, + Clock, DatabaseError, DatabaseInconsistencyError, LookupResultExt, Pagination, }; #[async_trait] @@ -45,10 +45,7 @@ pub trait OAuth2SessionRepository: Send + Sync { async fn list_paginated( &mut self, user: &User, - before: Option, - after: Option, - first: Option, - last: Option, + pagination: &Pagination, ) -> Result, Self::Error>; } @@ -243,10 +240,7 @@ impl<'c> OAuth2SessionRepository for PgOAuth2SessionRepository<'c> { async fn list_paginated( &mut self, user: &User, - before: Option, - after: Option, - first: Option, - last: Option, + pagination: &Pagination, ) -> Result, Self::Error> { let mut query = QueryBuilder::new( r#" @@ -263,7 +257,7 @@ impl<'c> OAuth2SessionRepository for PgOAuth2SessionRepository<'c> { query .push(" WHERE us.user_id = ") .push_bind(Uuid::from(user.id)) - .generate_pagination("oauth2_session_id", before, after, first, last)?; + .generate_pagination("oauth2_session_id", pagination); let edges: Vec = query .build_query_as() @@ -271,7 +265,7 @@ impl<'c> OAuth2SessionRepository for PgOAuth2SessionRepository<'c> { .fetch_all(&mut *self.conn) .await?; - let page = Page::process(edges, first, last)?.try_map(Session::try_from)?; + let page = pagination.process(edges).try_map(Session::try_from)?; Ok(page) } } diff --git a/crates/storage/src/pagination.rs b/crates/storage/src/pagination.rs index 5887117c..1fbcbc51 100644 --- a/crates/storage/src/pagination.rs +++ b/crates/storage/src/pagination.rs @@ -1,4 +1,4 @@ -// Copyright 2022 The Matrix.org Foundation C.I.C. +// Copyright 2022, 2023 The Matrix.org Foundation C.I.C. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -12,74 +12,166 @@ // See the License for the specific language governing permissions and // limitations under the License. +//! Utilities to manage paginated queries. + use sqlx::{Database, QueryBuilder}; use thiserror::Error; use ulid::Ulid; use uuid::Uuid; +/// An error returned when invalid pagination parameters are provided #[derive(Debug, Error)] #[error("Either 'first' or 'last' must be specified")] pub struct InvalidPagination; -/// Add cursor-based pagination to a query, as used in paginated GraphQL -/// connections -pub fn generate_pagination<'a, DB>( - query: &mut QueryBuilder<'a, DB>, - id_field: &'static str, +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Pagination { before: Option, after: Option, - first: Option, - last: Option, -) -> Result<(), InvalidPagination> -where - DB: Database, - Uuid: sqlx::Type + sqlx::Encode<'a, DB>, - i64: sqlx::Type + sqlx::Encode<'a, DB>, -{ - // ref: https://github.com/graphql/graphql-relay-js/issues/94#issuecomment-232410564 - // 1. Start from the greedy query: SELECT * FROM table + count: usize, + direction: PaginationDirection, +} - // 2. If the after argument is provided, add `id > parsed_cursor` to the `WHERE` - // clause - if let Some(after) = after { - query - .push(" AND ") - .push(id_field) - .push(" > ") - .push_bind(Uuid::from(after)); +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PaginationDirection { + Forward, + Backward, +} + +impl Pagination { + /// Creates a new [`Pagination`] from user-provided parameters. + /// + /// # Errors + /// + /// Either `first` or `last` must be provided, else this function will + /// return an [`InvalidPagination`] error. + pub const fn try_new( + before: Option, + after: Option, + first: Option, + last: Option, + ) -> Result { + let (direction, count) = match (first, last) { + (Some(first), _) => (PaginationDirection::Forward, first), + (_, Some(last)) => (PaginationDirection::Backward, last), + (None, None) => return Err(InvalidPagination), + }; + + Ok(Self { + before, + after, + count, + direction, + }) } - // 3. If the before argument is provided, add `id < parsed_cursor` to the - // `WHERE` clause - if let Some(before) = before { - query - .push(" AND ") - .push(id_field) - .push(" < ") - .push_bind(Uuid::from(before)); + /// Creates a [`Pagination`] which gets the first N items + pub const fn first(first: usize) -> Self { + Self { + before: None, + after: None, + count: first, + direction: PaginationDirection::Forward, + } } - // 4. If the first argument is provided, add `ORDER BY id ASC LIMIT first+1` to - // the query - if let Some(count) = first { - query - .push(" ORDER BY ") - .push(id_field) - .push(" ASC LIMIT ") - .push_bind((count + 1) as i64); - // 5. If the first argument is provided, add `ORDER BY id DESC LIMIT last+1` - // to the query - } else if let Some(count) = last { - query - .push(" ORDER BY ") - .push(id_field) - .push(" DESC LIMIT ") - .push_bind((count + 1) as i64); - } else { - return Err(InvalidPagination); + /// Creates a [`Pagination`] which gets the last N items + pub const fn last(last: usize) -> Self { + Self { + before: None, + after: None, + count: last, + direction: PaginationDirection::Backward, + } } - Ok(()) + /// Get items before the given cursor + pub const fn before(mut self, id: Ulid) -> Self { + self.before = Some(id); + self + } + + /// Get items after the given cursor + pub const fn after(mut self, id: Ulid) -> Self { + self.after = Some(id); + self + } + + /// Add cursor-based pagination to a query, as used in paginated GraphQL + /// connections + fn generate_pagination<'a, DB>(&self, query: &mut QueryBuilder<'a, DB>, id_field: &'static str) + where + DB: Database, + Uuid: sqlx::Type + sqlx::Encode<'a, DB>, + i64: sqlx::Type + sqlx::Encode<'a, DB>, + { + // ref: https://github.com/graphql/graphql-relay-js/issues/94#issuecomment-232410564 + // 1. Start from the greedy query: SELECT * FROM table + + // 2. If the after argument is provided, add `id > parsed_cursor` to the `WHERE` + // clause + if let Some(after) = self.after { + query + .push(" AND ") + .push(id_field) + .push(" > ") + .push_bind(Uuid::from(after)); + } + + // 3. If the before argument is provided, add `id < parsed_cursor` to the + // `WHERE` clause + if let Some(before) = self.before { + query + .push(" AND ") + .push(id_field) + .push(" < ") + .push_bind(Uuid::from(before)); + } + + match self.direction { + // 4. If the first argument is provided, add `ORDER BY id ASC LIMIT first+1` to the + // query + PaginationDirection::Forward => { + query + .push(" ORDER BY ") + .push(id_field) + .push(" ASC LIMIT ") + .push_bind((self.count + 1) as i64); + } + // 5. If the first argument is provided, add `ORDER BY id DESC LIMIT last+1` to the + // query + PaginationDirection::Backward => { + query + .push(" ORDER BY ") + .push(id_field) + .push(" DESC LIMIT ") + .push_bind((self.count + 1) as i64); + } + }; + } + + /// Process a page returned by a paginated query + pub fn process(&self, mut edges: Vec) -> Page { + let is_full = edges.len() == (self.count + 1); + if is_full { + edges.pop(); + } + + let (has_previous_page, has_next_page) = match self.direction { + PaginationDirection::Forward => (false, is_full), + PaginationDirection::Backward => { + // 6. If the last argument is provided, I reverse the order of the results + edges.reverse(); + (is_full, false) + } + }; + + Page { + has_next_page, + has_previous_page, + edges, + } + } } pub struct Page { @@ -89,39 +181,6 @@ pub struct Page { } impl Page { - /// Process a page returned by a paginated query - pub fn process( - mut edges: Vec, - first: Option, - last: Option, - ) -> Result { - let limit = match (first, last) { - (Some(count), _) | (_, Some(count)) => count, - _ => return Err(InvalidPagination), - }; - - let is_full = edges.len() == (limit + 1); - if is_full { - edges.pop(); - } - - let (has_previous_page, has_next_page) = if first.is_some() { - (false, is_full) - } else if last.is_some() { - // 6. If the last argument is provided, I reverse the order of the results - edges.reverse(); - (is_full, false) - } else { - unreachable!() - }; - - Ok(Page { - has_next_page, - has_previous_page, - edges, - }) - } - pub fn map(self, f: F) -> Page where F: FnMut(T) -> T2, @@ -147,17 +206,13 @@ impl Page { } } -impl Page {} - +/// An extension trait to the `sqlx` [`QueryBuilder`], to help adding pagination +/// to a query pub trait QueryBuilderExt { - fn generate_pagination( - &mut self, - id_field: &'static str, - before: Option, - after: Option, - first: Option, - last: Option, - ) -> Result<&mut Self, InvalidPagination>; + /// Add cursor-based pagination to a query, as used in paginated GraphQL + /// connections + fn generate_pagination(&mut self, id_field: &'static str, pagination: &Pagination) + -> &mut Self; } impl<'a, DB> QueryBuilderExt for QueryBuilder<'a, DB> @@ -169,12 +224,9 @@ where fn generate_pagination( &mut self, id_field: &'static str, - before: Option, - after: Option, - first: Option, - last: Option, - ) -> Result<&mut Self, InvalidPagination> { - generate_pagination(self, id_field, before, after, first, last)?; - Ok(self) + pagination: &Pagination, + ) -> &mut Self { + pagination.generate_pagination(self, id_field); + self } } diff --git a/crates/storage/src/upstream_oauth2/link.rs b/crates/storage/src/upstream_oauth2/link.rs index f72a8504..13e86e17 100644 --- a/crates/storage/src/upstream_oauth2/link.rs +++ b/crates/storage/src/upstream_oauth2/link.rs @@ -23,7 +23,7 @@ use uuid::Uuid; use crate::{ pagination::{Page, QueryBuilderExt}, tracing::ExecuteExt, - Clock, DatabaseError, LookupResultExt, + Clock, DatabaseError, LookupResultExt, Pagination, }; #[async_trait] @@ -60,10 +60,7 @@ pub trait UpstreamOAuthLinkRepository: Send + Sync { async fn list_paginated( &mut self, user: &User, - before: Option, - after: Option, - first: Option, - last: Option, + pagination: &Pagination, ) -> Result, Self::Error>; } @@ -275,10 +272,7 @@ impl<'c> UpstreamOAuthLinkRepository for PgUpstreamOAuthLinkRepository<'c> { async fn list_paginated( &mut self, user: &User, - before: Option, - after: Option, - first: Option, - last: Option, + pagination: &Pagination, ) -> Result, Self::Error> { let mut query = QueryBuilder::new( r#" @@ -295,7 +289,7 @@ impl<'c> UpstreamOAuthLinkRepository for PgUpstreamOAuthLinkRepository<'c> { query .push(" WHERE user_id = ") .push_bind(Uuid::from(user.id)) - .generate_pagination("upstream_oauth_link_id", before, after, first, last)?; + .generate_pagination("upstream_oauth_link_id", pagination); let edges: Vec = query .build_query_as() @@ -303,7 +297,7 @@ impl<'c> UpstreamOAuthLinkRepository for PgUpstreamOAuthLinkRepository<'c> { .fetch_all(&mut *self.conn) .await?; - let page = Page::process(edges, first, last)?.map(UpstreamOAuthLink::from); + let page = pagination.process(edges).map(UpstreamOAuthLink::from); Ok(page) } } diff --git a/crates/storage/src/upstream_oauth2/mod.rs b/crates/storage/src/upstream_oauth2/mod.rs index 0763624f..d72e5f48 100644 --- a/crates/storage/src/upstream_oauth2/mod.rs +++ b/crates/storage/src/upstream_oauth2/mod.rs @@ -29,7 +29,7 @@ mod tests { use sqlx::PgPool; use super::*; - use crate::{user::UserRepository, Clock, PgRepository, Repository}; + use crate::{user::UserRepository, Clock, Pagination, PgRepository, Repository}; #[sqlx::test(migrator = "crate::MIGRATOR")] async fn test_repository(pool: PgPool) -> Result<(), Box> { @@ -144,7 +144,7 @@ mod tests { let links = repo .upstream_oauth_link() - .list_paginated(&user, None, None, Some(10), None) + .list_paginated(&user, &Pagination::first(10)) .await?; assert!(!links.has_previous_page); assert!(!links.has_next_page); diff --git a/crates/storage/src/upstream_oauth2/provider.rs b/crates/storage/src/upstream_oauth2/provider.rs index 088e9e93..eb09fd79 100644 --- a/crates/storage/src/upstream_oauth2/provider.rs +++ b/crates/storage/src/upstream_oauth2/provider.rs @@ -25,7 +25,7 @@ use uuid::Uuid; use crate::{ pagination::{Page, QueryBuilderExt}, tracing::ExecuteExt, - Clock, DatabaseError, DatabaseInconsistencyError, LookupResultExt, + Clock, DatabaseError, DatabaseInconsistencyError, LookupResultExt, Pagination, }; #[async_trait] @@ -52,10 +52,7 @@ pub trait UpstreamOAuthProviderRepository: Send + Sync { /// Get a paginated list of upstream OAuth providers async fn list_paginated( &mut self, - before: Option, - after: Option, - first: Option, - last: Option, + pagination: &Pagination, ) -> Result, Self::Error>; /// Get all upstream OAuth providers @@ -243,10 +240,7 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' )] async fn list_paginated( &mut self, - before: Option, - after: Option, - first: Option, - last: Option, + pagination: &Pagination, ) -> Result, Self::Error> { let mut query = QueryBuilder::new( r#" @@ -264,7 +258,7 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' "#, ); - query.generate_pagination("upstream_oauth_provider_id", before, after, first, last)?; + query.generate_pagination("upstream_oauth_provider_id", pagination); let edges: Vec = query .build_query_as() @@ -272,7 +266,7 @@ impl<'c> UpstreamOAuthProviderRepository for PgUpstreamOAuthProviderRepository<' .fetch_all(&mut *self.conn) .await?; - let page = Page::process(edges, first, last)?.try_map(TryInto::try_into)?; + let page = pagination.process(edges).try_map(TryInto::try_into)?; Ok(page) } diff --git a/crates/storage/src/user/email.rs b/crates/storage/src/user/email.rs index 2d5ad987..cef4fa27 100644 --- a/crates/storage/src/user/email.rs +++ b/crates/storage/src/user/email.rs @@ -24,7 +24,7 @@ use uuid::Uuid; use crate::{ pagination::{Page, QueryBuilderExt}, tracing::ExecuteExt, - Clock, DatabaseError, DatabaseInconsistencyError, LookupResultExt, + Clock, DatabaseError, DatabaseInconsistencyError, LookupResultExt, Pagination, }; #[async_trait] @@ -39,10 +39,7 @@ pub trait UserEmailRepository: Send + Sync { async fn list_paginated( &mut self, user: &User, - before: Option, - after: Option, - first: Option, - last: Option, + pagination: &Pagination, ) -> Result, Self::Error>; async fn count(&mut self, user: &User) -> Result; @@ -289,10 +286,7 @@ impl<'c> UserEmailRepository for PgUserEmailRepository<'c> { async fn list_paginated( &mut self, user: &User, - before: Option, - after: Option, - first: Option, - last: Option, + pagination: &Pagination, ) -> Result, DatabaseError> { let mut query = QueryBuilder::new( r#" @@ -308,7 +302,7 @@ impl<'c> UserEmailRepository for PgUserEmailRepository<'c> { query .push(" WHERE user_id = ") .push_bind(Uuid::from(user.id)) - .generate_pagination("ue.user_email_id", before, after, first, last)?; + .generate_pagination("ue.user_email_id", &pagination); let edges: Vec = query .build_query_as() @@ -316,7 +310,7 @@ impl<'c> UserEmailRepository for PgUserEmailRepository<'c> { .fetch_all(&mut *self.conn) .await?; - let page = Page::process(edges, first, last)?.map(UserEmail::from); + let page = pagination.process(edges).map(UserEmail::from); Ok(page) } diff --git a/crates/storage/src/user/session.rs b/crates/storage/src/user/session.rs index f2ceed2a..a837c204 100644 --- a/crates/storage/src/user/session.rs +++ b/crates/storage/src/user/session.rs @@ -23,7 +23,7 @@ use uuid::Uuid; use crate::{ pagination::{Page, QueryBuilderExt}, tracing::ExecuteExt, - Clock, DatabaseError, DatabaseInconsistencyError, LookupResultExt, + Clock, DatabaseError, DatabaseInconsistencyError, LookupResultExt, Pagination, }; #[async_trait] @@ -45,10 +45,7 @@ pub trait BrowserSessionRepository: Send + Sync { async fn list_active_paginated( &mut self, user: &User, - before: Option, - after: Option, - first: Option, - last: Option, + pagination: &Pagination, ) -> Result, Self::Error>; async fn count_active(&mut self, user: &User) -> Result; @@ -264,10 +261,7 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> { async fn list_active_paginated( &mut self, user: &User, - before: Option, - after: Option, - first: Option, - last: Option, + pagination: &Pagination, ) -> Result, Self::Error> { // TODO: ordering of last authentication is wrong let mut query = QueryBuilder::new( @@ -290,7 +284,7 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> { query .push(" WHERE s.finished_at IS NULL AND s.user_id = ") .push_bind(Uuid::from(user.id)) - .generate_pagination("s.user_session_id", before, after, first, last)?; + .generate_pagination("s.user_session_id", pagination); let edges: Vec = query .build_query_as() @@ -298,7 +292,9 @@ impl<'c> BrowserSessionRepository for PgBrowserSessionRepository<'c> { .fetch_all(&mut *self.conn) .await?; - let page = Page::process(edges, first, last)?.try_map(BrowserSession::try_from)?; + let page = pagination + .process(edges) + .try_map(BrowserSession::try_from)?; Ok(page) }