Skip to content

Sources

Decide where a paginator's items come from, whether that's an array you already hold or one page at a time from your database. Covers a source without a total, the page every source returns, and writing one of your own.

A source decides which items belong on page N. Pagination used the simpler of the two sources seedcord ships. This page covers the other one, plus writing your own.

ArraySource loads the whole list on every click and slices it. For a mod log with fifty thousand rows, that's fifty thousand rows read to show ten.

CursorSource fetches one page at a time and reports whether another follows, which is what a SQL LIMIT/OFFSET query or a paged HTTP API gives you.

src/handlers/History.tshover for typestap for types, arrow keys walk the tokens
import {
    
function ButtonRoute<const Defs extends readonly AnyCustomId[]>(
    ...defs: Defs
): <TCtor extends AnyHandlerCtor>(
    constructor: AssertComponentRoute<InteractionKind.Button, Defs, TCtor>
) => void
ButtonRoute
,
class CursorSource<Item>CursorSource, class Paginator<Item, const Prefix extends string>Paginator,
class SlashHandler<
    Route extends keyof SlashRegistry,
    Cache extends CacheType = CacheFor<Route>
>
SlashHandler
,
function SlashRoute<const Route extends keyof SlashRegistry>(
    ...routes: Route[]
): <TCtor extends AnyHandlerCtor>(
    constructor: AssertSlashRoute<Route, TCtor>
) => void
SlashRoute
} from '@seedcord/gateway'; interface interface EntryEntry { Entry.at: stringat: string; Entry.action: stringaction: string; } export const const History: Paginator<Entry, "history">History = new new Paginator<Entry, "history">(config: PaginatorConfig<Entry, "history", PageContext>): Paginator<Entry, "history">Paginator({ PaginatorConfig<Entry, "history", PageContext>.prefix: "history"prefix: 'history', PaginatorConfig<Entry, "history", PageContext>.source: PageSourceBase<Entry, PageContext>source: new
new CursorSource<Entry>(fetch: (ctx: PageContext, page: number, perPage: number) => Promisable<{
    items: readonly Entry[];
    hasNext: boolean;
}>, opts?: {
    perPage?: number;
}): CursorSource<Entry>
CursorSource
(
(ctx: PageContextctx, page: numberpage, perPage: numberperPage) =>
function readAuditPage(
    guildId: string | undefined,
    offset: number,
    limit: number
): Promise<{
    items: Entry[];
    hasNext: boolean;
}>
readAuditPage
(ctx: PageContextctx.PageContext.guild: Guild | nullguild?.BaseGuild.id: string | undefinedid, page: numberpage * perPage: numberperPage, perPage: numberperPage),
{ perPage?: numberperPage: 10 } ), PaginatorConfig<Entry, "history", PageContext>.renderItem?: ItemRender<Entry>renderItem: (entry: Entryentry) => `\`${entry: Entryentry.Entry.at: stringat}\` ${entry: Entryentry.Entry.action: stringaction}` }); @ButtonRoute<readonly [PageCursor<"history">]>(defs_0: PageCursor<"history">): <TCtor>(constructor: AssertComponentRoute<InteractionKind.Button, readonly [PageCursor<"history">], TCtor>) => voidButtonRoute(const History: Paginator<Entry, "history">History.PaginatorBase<Entry, "history", PageContext>.cursor: PageCursor<"history">cursor) export class class HistoryNavHistoryNav extends const History: Paginator<Entry, "history">History.Paginator<Entry, "history">.Handler: PaginatorHandlerCtor<"history">Handler {} @SlashRoute<"history">(...routes: "history"[]): <TCtor>(constructor: AssertSlashRoute<"history", TCtor>) => voidSlashRoute('history') export class class HistoryListHistoryList extends
class SlashHandler<
    Route extends keyof SlashRegistry,
    Cache extends CacheType = CacheFor<Route>
>
SlashHandler
<'history'> {
public async HistoryList.execute(): Promise<void>execute(): interface Promise<T>Promise<void> { await const History: Paginator<Entry, "history">History.Paginator<Entry, "history">.start(handler: RepliableHandler<Repliables>, n?: number): Promise<SentMessage>start(this); } }

The fetcher receives the page number and the page size, so page * perPage above is the offset. Return the slice and a hasNext flag, as a promise or a plain object.

A cursor source can't see where the list ends, so it passes a page past the last one to your fetcher unchanged. Return an empty slice with hasNext: false there. A true there leaves Next enabled, so someone can keep clicking into empty pages.

Gateway and http differ

The fetcher's first argument is a PageContext. Gateway's carries guild: Guild | null while http's carries guildId: string | null, so ctx.guild fails to compile there.

Without a total

CursorSource never asks for a total, because counting every row costs a second query. Three things follow.

  • totalPages comes back undefined.
  • The nav row drops First and Last, leaving Prev, the indicator, and Next.
  • The indicator reads Page 3 with no total after it.

Use ArraySource when the list is small enough to load whole and you want the count.

The page a source returns

Every source returns a PageView, the two shipped ones and yours alike.

fieldwhat it holds
itemsthe items on this page
pagethe zero-based page number, clamped to the last page by ArraySource
perPagethe page size the source used
totalPagesthe count, or undefined when the source cannot get one cheaply
hasPrevwhether a page comes before this one
hasNextwhether a page comes after it

perPage defaults to 10 on both sources. A zero, a fraction, or a negative throws PaginationInvalidPerPage while you construct the source.

Writing your own

PageSource declares one method. Write your own for something neither shipped source does, like reporting a real total on a query you still fetch one slice at a time.

export class AuditSource implements PageSource<Entry> {
    constructor(private readonly perPage: number) {}

    public async page(
        ctx: PageContext,
        n: number
    ): Promise<PageView<Entry>> {
        const total = await countAuditRows(ctx.guild?.id);
        const totalPages = Math.max(
            1,
            Math.ceil(total / this.perPage)
        );
        const page = Math.min(
            Math.max(0, Math.trunc(n)),
            totalPages - 1
        );

        return {
            items: await readAuditRows(
                ctx.guild?.id,
                page * this.perPage,
                this.perPage
            ),
            page,
            perPage: this.perPage,
            totalPages,
            hasPrev: page > 0,
            hasNext: page < totalPages - 1
        };
    }
}

One COUNT beside the page query fills in totalPages, which puts First and Last back without loading the table.

Clamp n yourself, as the sample does. Say the table had eight pages when a button was minted. After someone deletes half the rows, that button still carries page 7. start() also passes any number you give it.

seedcord has rendered every page so far. Custom rendering hands you the whole message.