Przeglądaj źródła

Paginate datasource-backed pages

Stored posts page by a cursor: the datastore is asked for the $limit posts older
than a timestamp, and the paginator hands back the oldest one on the page as the
next cursor.  That is the right shape when the alternative is reading the whole
datastore in order to skip most of it.

It is the wrong shape for a datasource, which has already built every post by
the time the paginator sees them -- and it does not work at all for one which
stamps them all with the time it built them, which every libvirt guest is.  Every
cursor on such a page is the same instant, so Prev asks for everything older than
now and gets nothing.  These pages page by number instead, which costs nothing
here and moves.

That needs an order to hand out page 2 of, so the datasource contract gains
order($query, @posts): newest first, then title, then id.  The tiebreaks are what
carry it in practice, since a source that builds its posts on the fly tends to
stamp them identically -- what it really does for a guest list is sort it by
name.  A source with an order of its own says so.

paginator.tx grows a branch for it: Prev and Next as page numbers, no date jump
since these posts share a date, and the search carried along so paging through a
search stays inside it.  Stored pages render exactly as before.

Three bugs found doing it, all pre-existing and none about datasources.

series() looked its own post up with the reader's query, so a ?page= meant for
the children paged the lookup of the series itself -- page two of a single post
being nothing, a series could not find itself and 404'd on its own page.  Latent
only because nothing emitted page numbers until now.

FlatFile::read() stopped reading at limit, but get() paginates after it, so there
was nothing left to slice for page two: offset pagination returned an empty page
for every page but the first, on that model, always.  It now reads enough for the
page asked for.  The equality test it used to stop on is a >= now as well, since
a file holds every version of its post and one file can step past the mark.

And paginate() splices without checking, so a page past the end warned rather
than simply being empty -- which is what a stale bookmark asks for.

t/Trog-Data-SQLite.t asserts the invariant that actually matters across both
models: walking the pages visits every post, exactly once.  Not page for page --
the two models return posts in different orders and are allowed to.  The
lifecycle suite drives twelve guests through the real route on both models, and
covers a stored series with a page number in its URL, which is what the series()
fix was for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T6Q1gRJ2Rr85S8FQ47EjnQ
George Baugh 1 miesiąc temu
rodzic
commit
36673c0dc7

+ 10 - 0
Readme.md

@@ -144,6 +144,9 @@ A source has to provide posts($series, $query).  It may also provide:
   with the posts it filtered.  Trog::DataSource::filter is the default, and
   searches a post's title and body; implement your own to search the fields your
   posts actually carry, as Virt does for a guest's name, state and hypervisor.
+* order($query, @posts) - the order they belong in.  Defaults to newest first,
+  then by title, then by id.  Pagination hands out page 2 of an order, so there
+  has to be one and it has to be the same one next time somebody asks.
 * lang() and help() - what the search box on such a page is searching, and where
   to read about it.  The data model's answer describes the datastore, which is
   not what these pages are serving.
@@ -151,6 +154,13 @@ A source has to provide posts($series, $query).  It may also provide:
   building its posts from somewhere else has nothing to edit, and saying nothing
   means no.
 
+Such pages paginate by page number rather than by the created cursor stored posts
+use.  A datasource has already built every post by the time the paginator sees
+them, so there is nothing to save by paging with a cursor -- and it would not
+work anyway for a source that stamps everything with the time it built them, as
+libvirt guests are: every cursor on the page is the same instant, so Prev asks
+for everything older than now and gets nothing.
+
 See lib/Trog/DataSource.pm for the whole contract.
 
 Components

+ 12 - 2
lib/Trog/Data/FlatFile.pm

@@ -103,8 +103,18 @@ sub read ( $self, $query = {} ) {
         my @filtered = $query->{raw} ? @$parsed : $self->filter( $query, @$parsed );
 
         push( @items, @filtered ) if @filtered;
-        next                      if $query->{limit} == 0;                # 0 = unlimited
-        last                      if scalar(@items) == $query->{limit};
+        next                      if $query->{limit} == 0;    # 0 = unlimited
+
+        # Enough for the page that was asked for, not one page's worth.  get()
+        # paginates *after* this, so stopping at limit meant it had nothing left
+        # to slice for page two and every page but the first came back empty --
+        # offset pagination simply did not work on this model.
+        #
+        # >= rather than ==, because a file holds every version of its post and
+        # one of them can push @items past the mark in a single step, which the
+        # equality test would then never match.
+        my $needed = $query->{limit} * ( $query->{page} || 1 );
+        last if scalar(@items) >= $needed;
     }
 
     return \@items;

+ 12 - 3
lib/Trog/DataModule.pm

@@ -259,10 +259,19 @@ present to page at all; limit defaults to 25 when computing the offset.
 =cut
 
 sub paginate ( $self, $query, @filtered ) {
+    return @filtered unless $query->{page} && $query->{limit};
+
     my $offset = int( $query->{limit} // 25 );
-    $offset   = @filtered < $offset ? @filtered : $offset;
-    @filtered = splice( @filtered, ( int( $query->{page} ) - 1 ) * $offset, $offset ) if $query->{page} && $query->{limit};
-    return @filtered;
+    $offset = @filtered < $offset ? @filtered : $offset;
+
+    # splice() warns rather than simply returning nothing when the offset is
+    # past the end, and a page past the end is what a stale bookmark or a
+    # walked-off paginator asks for.  Empty is the right answer; the warning is
+    # not part of it.
+    my $start = ( int( $query->{page} ) - 1 ) * $offset;
+    return () if $start > scalar(@filtered);
+
+    return splice( @filtered, $start, $offset );
 }
 
 sub _dedup_versions ( $version = -1, @posts ) {

+ 27 - 2
lib/Trog/DataSource.pm

@@ -37,6 +37,11 @@ saying nothing means no.
 Apply the viewer's search to the synthesized posts.  Defaulted below; implement
 it to search the fields your posts actually carry.
 
+    order($query, @posts) = @posts        optional
+
+The order they belong in.  Defaulted below; implement it if your posts have an
+order of their own that a date and a title don't capture.
+
     lang() = STRING                       optional
     help() = STRING                       optional
 
@@ -57,8 +62,8 @@ its page by holding them, and every synthesized post belongs to that series.
 Applying them again would at best be redundant, and at worst would blank the
 page for a source whose posts carry no tags -- which nothing requires them to.
 
-Nor does it paginate.  A datasource runs before the paginator's own variables
-are worked out, so there is nothing yet to paginate against.
+Nor does it paginate.  That is the route's job and is the same for every source
+-- see order(), which is the part a source does get a say in.
 
 A source with more to search than a title should say so by implementing filter()
 rather than by hoping this one covers it.
@@ -113,6 +118,26 @@ sub searchable_match ( $like, @values ) {
     return 0;
 }
 
+=head2 order($query, @posts) = @posts
+
+The order synthesized posts belong in: newest first, then by title, then by id.
+
+Pagination hands out page 2 of an order, so there had better be one, and it had
+better be the same order next time somebody asks -- a list that comes back
+shuffled makes page 2 a lottery rather than the next page.  Sorting the ties out
+by title and then id is what makes this total rather than merely mostly decided.
+
+Newest-first matches how stored posts are listed.  The tiebreaks carry the
+weight in practice, because a source that builds its posts on the fly tends to
+stamp them all with the time it built them: every libvirt guest comes back with
+the same created, so what this really does for that page is sort it by name.
+
+=cut
+
+sub order ( $query, @posts ) {
+    return sort { ( $b->{created} // 0 ) <=> ( $a->{created} // 0 ) || ( $a->{title} // '' ) cmp ( $b->{title} // '' ) || ( $a->{id} // '' ) cmp ( $b->{id} // '' ) } @posts;
+}
+
 =head2 lang() = STRING
 
 What the search box searches on a page drawn from a datasource which hasn't said.

+ 73 - 4
lib/Trog/Routes/HTML.pm

@@ -1183,7 +1183,13 @@ sub series ($query) {
     # That will essentially necessitate it *becoming* the ID for real.
 
     #Grab the relevant tag (aclname), then pass that to posts
-    my @posts = _post_helper( $query, ['series'], $query->{user_acls} );
+    #
+    # Deliberately not $query: this is looking up the one series post named by
+    # the route, and handing it the reader's pagination pages *that* lookup.
+    # Asking for page two of a single post gets nothing, so a series carrying a
+    # ?page= in its URL could not find itself and 404'd on its own page.  Latent
+    # until something started emitting page numbers -- see _paginate_offset.
+    my @posts = _post_helper( { %$query, page => 1, limit => 1 }, ['series'], $query->{user_acls} );
 
     # The series post carries its *children's* relations, so that an editor
     # picker rendered on this page has something to populate from -- that is
@@ -1423,6 +1429,14 @@ sub posts ( $query, $direct = 0 ) {
     my $now_year    = ( localtime(time) )[5] + 1900;
     my $oldest_year = $now_year - 20;                  #XXX actually find oldest post year
 
+    # Datasource pages page by offset, everything else by the created cursor.
+    # See _paginate_offset() for why they cannot share.
+    my ( $paginate_offset, $page_of, $pages_total ) = ( 0, 1, 1 );
+    if ( $query->{is_datasource} ) {
+        $paginate_offset = 1;
+        ( $page_of, $pages_total, @posts ) = _paginate_offset( $query, $limit, @posts );
+    }
+
     my $older = !@posts ? 0 : $posts[-1]->{created};
     $query->{failure} //= -1;
     $query->{id}      //= '';
@@ -1516,7 +1530,10 @@ sub posts ( $query, $direct = 0 ) {
             in_series         => exists $query->{in_series} || !!( $query->{route} =~ m/^\/series\// ),
             route             => $query->{route},
             limit             => $limit,
-            pages             => scalar(@posts) == $limit,
+            pages             => $paginate_offset ? $pages_total > 1 : scalar(@posts) == $limit,
+            paginate_offset   => $paginate_offset,
+            page              => $page_of,
+            pages_total       => $pages_total,
             older             => $older,
             newer             => $newer,
             sizes             => [ 25, 50, 100 ],
@@ -1620,10 +1637,62 @@ sub _datasource_posts ( $query, $posts ) {
         my $err = "$@";
         $err =~ s/\n.*//s;
         WARN("Datasource '$source' could not filter its posts: $err");
-        return @synthesized;
+        @filtered = @synthesized;
+    }
+
+    # Pagination hands out page 2 of an order, so settle on one.
+    my $order   = $source->can('order') // \&Trog::DataSource::order;
+    my @ordered = eval { $order->( $query, @filtered ) };
+    if ($@) {
+        my $err = "$@";
+        $err =~ s/\n.*//s;
+        WARN("Datasource '$source' could not order its posts: $err");
+        @ordered = @filtered;
     }
 
-    return @filtered;
+    # Which the paginator below reads, since these page by offset rather than by
+    # the created cursor stored posts use.
+    $query->{is_datasource} = 1;
+
+    return @ordered;
+}
+
+=head2 _paginate_offset($query, $limit, @posts) = ($page, $pages_total, @page_of_posts)
+
+The slice of @posts the reader asked for, by page number.
+
+Stored posts page by a cursor: the datastore is asked for the $limit posts older
+than a timestamp, and the paginator hands back the oldest one on the page as the
+next cursor.  That is the right shape when the alternative is reading the whole
+datastore to skip most of it.
+
+It is the wrong shape for a datasource, which has already built every post by
+the time we see them -- and it does not work at all for one which stamps them
+all with the time it built them, as libvirt guests are.  Every cursor on such a
+page is the same instant, so Prev asks for everything older than now and gets
+nothing.  Offset costs nothing here and moves.
+
+=cut
+
+sub _paginate_offset ( $query, $limit, @posts ) {
+    $limit = 25 if !$limit || $limit < 1;
+
+    my $total       = scalar(@posts);
+    my $pages_total = $total ? int( ( $total + $limit - 1 ) / $limit ) : 1;
+
+    # Straight off a query string, so digits only -- the same coercion older and
+    # newer get, rather than int() warning its way through a word.  Past the end
+    # shows the last page rather than an empty one, which is what a stale
+    # bookmark deserves.
+    ( my $wanted = $query->{page} // '' ) =~ s/[^0-9]//g;
+    my $page = length($wanted) ? int($wanted) : 1;
+    $page = 1            if $page < 1;
+    $page = $pages_total if $page > $pages_total;
+
+    my $offset = ( $page - 1 ) * $limit;
+    my @slice  = $offset < $total ? @posts[ $offset .. List::Util::min( $offset + $limit, $total ) - 1 ] : ();
+
+    return ( $page, $pages_total, @slice );
 }
 
 =head2 _search_language($query)

+ 24 - 0
t/Trog-Data-SQLite.t

@@ -153,6 +153,30 @@ subtest 'the two models answer the same questions the same way' => sub {
     }
 };
 
+subtest 'both models paginate by offset' => sub {
+
+    # Not compared page for page: the flat file model returns posts in readdir
+    # order and this one by created, so which posts land on page two is allowed
+    # to differ.  What is not allowed to differ is that walking the pages visits
+    # every post, exactly once -- which is the whole of what pagination promises.
+    foreach my $case ( [ flatfile => $flat ], [ sqlite => $sqlite ] ) {
+        my ( $name, $model ) = @$case;
+
+        my @all   = $model->get( limit => 0, acls => ['admin'] );
+        my $limit = 3;
+
+        my ( @walked, %seen );
+        foreach my $page ( 1 .. 5 ) {
+            my @got = $model->get( limit => $limit, page => $page, acls => ['admin'] );
+            ok( scalar(@got) <= $limit, "$name: page $page holds no more than a page" );
+            push( @walked, map { $_->{id} } @got );
+        }
+
+        is_deeply( [ sort @walked ], [ sort map { $_->{id} } @all ], "$name: the pages between them hold every post" );
+        is( scalar( grep { $seen{$_}++ } @walked ), 0, "$name: and none of them twice" );
+    }
+};
+
 subtest 'the id query really does return one post' => sub {
 
     # Guards the comparison above: two empty lists are also "the same".

+ 123 - 0
t/Trog-DataSource.t

@@ -284,4 +284,127 @@ subtest 'the search box says what it is searching' => sub {
     );
 };
 
+subtest 'ordering is total, so page 2 means something' => sub {
+    my @same = (
+        guest( 'charlie', created => 100 ),
+        guest( 'alpha',   created => 100 ),
+        guest( 'bravo',   created => 100 ),
+        guest( 'delta',   created => 200 ),
+    );
+
+    my @ordered = Trog::DataSource::order( {}, @same );
+    is_deeply( [ map { $_->{title} } @ordered ], [qw{delta alpha bravo charlie}], 'newest first, then by title' );
+
+    # The property that actually matters: same input, same order, every time.
+    is_deeply(
+        [ map { $_->{title} } Trog::DataSource::order( {}, reverse @same ) ],
+        [ map { $_->{title} } @ordered ],
+        'and it does not depend on what order they arrived in'
+    );
+
+    # A source that stamps everything with the time it built them -- which is
+    # every libvirt guest -- falls back to being sorted by name.
+    my @built = map { guest( $_, created => 1700 ) } qw{zulu alpha mike};
+    is_deeply( [ map { $_->{title} } Trog::DataSource::order( {}, @built ) ], [qw{alpha mike zulu}], 'identical timestamps sort by name' );
+
+    # Missing fields must not make it die or shuffle.
+    my @sparse = ( { id => 'b' }, { id => 'a' }, guest('titled') );
+    is( exception { Trog::DataSource::order( {}, @sparse ) }, undef, 'posts missing created and title are still orderable' );
+};
+
+subtest 'offset pagination' => sub {
+    require Trog::Routes::HTML;
+
+    my @ten = map { guest( sprintf( 'g%02d', $_ ), created => 100 + $_ ) } 1 .. 10;
+
+    my ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 1 }, 4, @ten );
+    is( $page,            1,     'page one is page one' );
+    is( $total,           3,     'ten posts at four a page is three pages' );
+    is( scalar(@slice),   4,     'and a full page' );
+    is( $slice[0]{title}, 'g01', 'starting at the beginning' );
+
+    ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 2 }, 4, @ten );
+    is( $slice[0]{title}, 'g05', 'page two carries on where page one stopped' );
+    is( scalar(@slice),   4,     'and is also full' );
+
+    ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 3 }, 4, @ten );
+    is( scalar(@slice),   2,     'the last page holds the remainder' );
+    is( $slice[0]{title}, 'g09', 'from where page two stopped' );
+
+    # Every post appears exactly once across the pages, which is the whole point.
+    my @walked;
+    push( @walked, ( Trog::Routes::HTML::_paginate_offset( { page => $_ }, 4, @ten ) )[ 2 .. 5 ] ) for 1 .. 3;
+    @walked = grep { defined } @walked;
+    is_deeply( [ sort map { $_->{title} } @walked ], [ sort map { $_->{title} } @ten ], 'walking the pages visits everything, once' );
+
+    # Straight off a query string.
+    ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 99 }, 4, @ten );
+    is( $page, 3, 'a page past the end shows the last one' );
+    ok( scalar(@slice), 'rather than nothing at all' );
+
+    ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 0 }, 4, @ten );
+    is( $page, 1, 'and page zero is page one' );
+    ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 'nonsense' }, 4, @ten );
+    is( $page, 1, 'as is a page that is not a number' );
+    ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( {}, 4, @ten );
+    is( $page, 1, 'and no page at all' );
+
+    # A limit that would divide by zero.
+    ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 1 }, 0, @ten );
+    is( $total,         1,  'a limit of zero falls back to the default rather than dividing by it' );
+    is( scalar(@slice), 10, 'and shows everything' );
+
+    ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 1 }, 4 );
+    is( $total,         1, 'no posts is one empty page' );
+    is( scalar(@slice), 0, 'holding nothing' );
+
+    ( $page, $total, @slice ) = Trog::Routes::HTML::_paginate_offset( { page => 1 }, 25, @ten );
+    is( $total,         1,  'fewer posts than a page is one page' );
+    is( scalar(@slice), 10, 'holding all of them' );
+};
+
+subtest 'the paginator renders page links for a datasource' => sub {
+    require Trog::Renderer::Base;
+
+    my $render = sub {
+        my (%vars) = @_;
+        return Trog::Renderer::Base::render(
+            template    => 'paginator.tx',
+            contenttype => 'text/html',
+            component   => 1,
+            data        => { limit => 25, sizes => [ 25, 50, 100 ], years => [2024], months => [ 0 .. 11 ], older => 0, newer => 0, %vars },
+        );
+    };
+
+    my $offset = $render->( paginate_offset => 1, pages => 1, page => 2, pages_total => 5, like => 'web' );
+    like( $offset, qr/Page 2 of 5/,             'it says where you are' );
+    like( $offset, qr/href="\?page=1&limit=25/, 'Prev goes back a page' );
+    like( $offset, qr/href="\?page=3&limit=25/, 'Next goes on a page' );
+
+    # &amp; because Xslate escapes what an expression emits, which is what an
+    # ampersand in an attribute is supposed to be.  The cursor branch is the same.
+    like( $offset, qr/like=web/, 'and the search survives the trip' );
+    unlike( $offset, qr/older=|newer=/, 'no cursor, which could not move on such a page anyway' );
+    unlike( $offset, qr/Jump to/,       'and no date jump, since these posts share a date' );
+
+    my $first = $render->( paginate_offset => 1, pages => 1, page => 1, pages_total => 3 );
+    unlike( $first, qr/rel="prev"/, 'the first page offers no Prev' );
+    like( $first, qr/rel="next"/, 'but does offer Next' );
+
+    my $last = $render->( paginate_offset => 1, pages => 1, page => 3, pages_total => 3 );
+    like( $last, qr/rel="prev"/, 'the last page offers Prev' );
+    unlike( $last, qr/rel="next"/, 'and no Next' );
+
+    my $only = $render->( paginate_offset => 1, pages => 0, page => 1, pages_total => 1 );
+    unlike( $only, qr/rel="prev"|rel="next"/, 'a single page offers neither' );
+    like( $only, qr/id="paginator" class="disabled"/, 'and says so' );
+
+    # Stored posts are untouched by any of this.
+    my $cursor = $render->( paginate_offset => 0, pages => 1, older => 1700, newer => 1800, like => 'x' );
+    like( $cursor, qr/href="\?older=1700&limit=25/, 'the cursor paginator still pages by cursor' );
+    like( $cursor, qr/href="\?newer=1800&limit=25/, 'both ways' );
+    like( $cursor, qr/Jump to/,                     'and keeps its date jump' );
+    unlike( $cursor, qr/\?page=/, 'with no page numbers' );
+};
+
 done_testing();

+ 136 - 0
t/Trog-Routes-HTML-series-lifecycle.t

@@ -526,6 +526,142 @@ subtest 'a datasource-backed type gets no editor' => sub {
     ok( !-e 'www/templates/html/components/forms/spec_bogus.tx', 'and nothing was written' );
 };
 
+subtest 'a datasource-backed series searches and paginates' => sub {
+
+    # The whole point of a datasource is that its posts are built rather than
+    # stored, so nothing the datastore does to a query ever touches them.  Stand
+    # one up with enough guests to page through, and drive it through the real
+    # route rather than calling the helpers.
+    require Test::MockModule;
+    require Trog::DataSource::Virt;
+
+    my ( $code, $body, $err ) = _render(
+        _admin(
+            route          => '/admin/wyzzerdd/save',
+            method         => 'POST',
+            name           => 'spec_guests',
+            datasource     => 'Trog::DataSource::Virt',
+            body_form      => 'form_common.tx',
+            wrapper        => 1,
+            inc_post_title => 1,
+            display        => q{<div class="guest"><: $post.title :> is <: $post.state :></div>},
+        ),
+        \&Trog::Routes::HTML::post_wizard_save,
+    );
+    is( $code, 200, 'the datasource-backed type was created' ) or diag($err);
+
+    _reindex();
+    my $series = _make_series( 'spec_guests', 'specguests', 'spec_guests.tx', 'Spec Guests Series', no_topbar => 1 ) or return;
+
+    # Twelve guests, every one stamped with the same created -- which is what
+    # libvirt guests really are, and precisely what the cursor paginator cannot
+    # page through.
+    my $built = time();
+    my $virt  = Test::MockModule->new('Trog::DataSource::Virt');
+    $virt->redefine(
+        posts => sub {
+            my ( $s, $q ) = @_;
+            return map {
+                {
+                    id         => "guest-$_",
+                    title      => sprintf( 'guest%02d', $_ ),
+                    form       => $s->{child_form},
+                    data       => '',
+                    state      => $_ % 2 ? 'running' : 'shut off',
+                    local_href => "/guest/hv/guest$_",
+                    href       => '',
+                    tags       => $s->{tags}       // [],
+                    visibility => $s->{visibility} // 'private',
+                    created    => $built,
+                    version    => 0,
+                    user       => $s->{user},
+                    method     => 'GET',
+                }
+            } 1 .. 12;
+        }
+    );
+
+    my $page_of = sub {
+        my (%q) = @_;
+        my ( $c, $b, $e ) = _render( _anon( route => '/specguests', %q ), \&Trog::Routes::HTML::series );
+        is( $c, 200, 'guests page renders' ) or diag($e);
+        return $b // '';
+    };
+
+    my $titles = sub {
+        my ($html) = @_;
+        return [ $html =~ m{<div class="guest">(guest\d+) is}g ];
+    };
+
+    my $first = $page_of->( limit => 5 );
+    is_deeply( $titles->($first), [qw{guest01 guest02 guest03 guest04 guest05}], 'the first page holds the first five, in a settled order' );
+    like( $first, qr/Page 1 of 3/,            'and says which page it is' );
+    like( $first, qr{href="\?page=2&limit=5}, 'offering the next one' );
+    unlike( $first, qr/rel="prev"/, 'and no previous one' );
+
+    my $second = $page_of->( limit => 5, page => 2 );
+    is_deeply( $titles->($second), [qw{guest06 guest07 guest08 guest09 guest10}], 'the second page carries on where the first stopped' );
+    like( $second, qr/Page 2 of 3/, 'and says so' );
+
+    my $third = $page_of->( limit => 5, page => 3 );
+    is_deeply( $titles->($third), [qw{guest11 guest12}], 'the last page holds the remainder' );
+    unlike( $third, qr/rel="next"/, 'and offers nothing after it' );
+
+    # The cursor paginator would be worse than useless here: every guest shares
+    # a created, so Prev would ask for everything older than that one instant.
+    unlike( $first, qr/older=$built/, 'no cursor is offered on a page that cannot use one' );
+
+    # Search, which before this reached the datastore and was thrown away along
+    # with the posts it filtered.
+    my $searched = $page_of->( limit => 25, like => 'guest1' );
+    is_deeply( $titles->($searched), [qw{guest10 guest11 guest12}], 'searching a datasource page searches the page' );
+
+    # Virt searches what its posts carry, not just a title and an empty body.
+    my $by_state = $page_of->( limit => 25, like => 'shut' );
+    is_deeply( $titles->($by_state), [qw{guest02 guest04 guest06 guest08 guest10 guest12}], 'including a field only it knows about' );
+
+    # And the two compose, which is what a reader paging through a search does.
+    my $searched_page = $page_of->( limit => 4, like => 'shut', page => 2 );
+    is_deeply( $titles->($searched_page), [qw{guest10 guest12}], 'paging through a search stays inside it' );
+    like( $searched_page, qr/Page 2 of 2/, 'and pages against the matches, not everything' );
+    like( $searched_page, qr/like=shut/,   'carrying the search into the links' );
+};
+
+subtest 'an ordinary series survives a page number in its URL' => sub {
+
+    # series() looks its own post up with the reader's query, so a ?page= meant
+    # for the children used to page the lookup of the series itself -- page two
+    # of one post being nothing, the series could not find itself and 404'd on
+    # its own page.  Latent until the datasource paginator started emitting page
+    # numbers, and it was never about datasources.
+    _reindex();
+    _make_series( 'spec_paged', 'specpaged', 'blog.tx', 'Spec Paged Series', no_topbar => 1 ) or return;
+
+    foreach my $n ( 1, 2 ) {
+        _save_post(
+            "spec_paged child $n",
+            form       => 'blog.tx',
+            title      => "Spec Paged Child $n",
+            data       => "child $n body",
+            visibility => 'public',
+            tags       => ['specpaged'],
+            to         => '/specpaged',
+        ) or return;
+    }
+
+    # One child a page, so page two exists and holds the other one.
+    my ( $code, $body, $err ) = _render( _anon( route => '/specpaged', limit => 1, page => 2 ), \&Trog::Routes::HTML::series );
+    is( $code, 200, 'a series page with ?page=2 still finds its series' ) or diag($err);
+    like( $body, qr/Spec Paged Series/, 'and is the right one' );
+
+    ( $code, $body, $err ) = _render( _anon( route => '/specpaged', limit => 1, page => 1 ), \&Trog::Routes::HTML::series );
+    is( $code, 200, 'as does page one' ) or diag($err);
+
+    # A stored series still pages by cursor, not by number: nothing about this
+    # fix moves it onto the datasource paginator.
+    unlike( $body, qr/Page 1 of/, 'and it still pages by cursor rather than by number' );
+};
+
 subtest 'a field can be declared indexed' => sub {
     my ( $code, $body, $err ) = _render(
         _admin(

+ 26 - 0
www/templates/html/components/paginator.tx

@@ -3,6 +3,31 @@
 : } else {
 <div id="paginator" class="disabled">
 : }
+: if ( $paginate_offset ) {
+    <div style="float:left; margin-top:.5rem;">Page <: $page :> of <: $pages_total :></div>
+    <div style="float:right;">
+        : if ( $page > 1 ) {
+        <a rel="prev" href="?page=<: $page - 1 :>&limit=<: $limit :><: if( $like ) { "&like=" ~ $like } :>">Prev</a>
+        : }
+        : if ( $page < $pages_total ) {
+        <a rel="next" href="?page=<: $page + 1 :>&limit=<: $limit :><: if( $like ) { "&like=" ~ $like } :>">Next</a>
+        : }
+        Size:
+        <form style="display:inline;">
+            : if ( $like ) {
+            <input type="hidden" name="like" value="<: $like :>" />
+            : }
+            <!-- Back to the first page: a page number means nothing once the pages are a different size. -->
+            <input type="hidden" name="page" value="1" />
+            <select name="limit" class="coolbutton">
+                : for $sizes -> $size {
+                <option value="<: $size :>"><: $size :></option>
+                : }
+            </select>
+        </form>
+    </div>
+</div>
+: } else {
     : if ( $older || $newer ) {
     <div style="float:left; margin-top:.5rem;"> <: $limit :> most recent posts older than <span id="paginatorTime"><: $older :></span></div>
     : }
@@ -40,6 +65,7 @@
         </form>
     </div>
 </div>
+: }
 <script type="text/javascript">
     function updateOlder () {
         var year  = document.querySelector("#paginator-jump select[name='year']");