84 lines
2.6 KiB
Go
84 lines
2.6 KiB
Go
|
|
// Package pagination provides cursor-based (keyset) pagination utilities.
|
||
|
|
//
|
||
|
|
// Unlike offset-based pagination (OFFSET/LIMIT), cursor pagination uses
|
||
|
|
// a composite key (typically created_at + id) to locate the "position" in
|
||
|
|
// the result set, giving O(limit) performance regardless of how deep you page.
|
||
|
|
package pagination
|
||
|
|
|
||
|
|
import (
|
||
|
|
"encoding/base64"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Cursor represents an opaque position in a sorted result set.
|
||
|
|
// It is serialized as a URL-safe base64 string for transport.
|
||
|
|
type Cursor struct {
|
||
|
|
// LastID is the primary key of the last item on the current page.
|
||
|
|
LastID int64 `json:"last_id"`
|
||
|
|
// LastValue is the sort column value of the last item (e.g. created_at).
|
||
|
|
LastValue time.Time `json:"last_value"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// Encode serializes a Cursor to a URL-safe base64 string suitable for query params.
|
||
|
|
func (c *Cursor) Encode() string {
|
||
|
|
if c == nil || c.LastID == 0 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
data, _ := json.Marshal(c)
|
||
|
|
return base64.URLEncoding.WithPadding(base64.NoPadding).EncodeToString(data)
|
||
|
|
}
|
||
|
|
|
||
|
|
// Decode parses a base64-encoded cursor string back into a Cursor.
|
||
|
|
// Returns nil for empty strings (meaning "first page").
|
||
|
|
func Decode(encoded string) (*Cursor, error) {
|
||
|
|
if encoded == "" {
|
||
|
|
return nil, nil
|
||
|
|
}
|
||
|
|
data, err := base64.URLEncoding.WithPadding(base64.NoPadding).DecodeString(encoded)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("invalid cursor encoding: %w", err)
|
||
|
|
}
|
||
|
|
var c Cursor
|
||
|
|
if err := json.Unmarshal(data, &c); err != nil {
|
||
|
|
return nil, fmt.Errorf("invalid cursor data: %w", err)
|
||
|
|
}
|
||
|
|
return &c, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// PageResult wraps a paginated response with cursor navigation info.
|
||
|
|
type PageResult[T any] struct {
|
||
|
|
Items []T `json:"items"`
|
||
|
|
Total int64 `json:"total"` // Approximate or exact total (optional for pure cursor mode)
|
||
|
|
NextCursor string `json:"next_cursor"` // Empty means no more pages
|
||
|
|
HasMore bool `json:"has_more"`
|
||
|
|
PageSize int `json:"page_size"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// DefaultPageSize is the default number of items per page.
|
||
|
|
const DefaultPageSize = 20
|
||
|
|
|
||
|
|
// MaxPageSize caps the maximum allowed items per request to prevent abuse.
|
||
|
|
const MaxPageSize = 100
|
||
|
|
|
||
|
|
// ClampPageSize ensures size is within [1, MaxPageSize], falling back to DefaultPageSize.
|
||
|
|
func ClampPageSize(size int) int {
|
||
|
|
if size <= 0 {
|
||
|
|
return DefaultPageSize
|
||
|
|
}
|
||
|
|
if size > MaxPageSize {
|
||
|
|
return MaxPageSize
|
||
|
|
}
|
||
|
|
return size
|
||
|
|
}
|
||
|
|
|
||
|
|
// BuildNextCursor creates a cursor from the last item's ID and timestamp.
|
||
|
|
// Returns empty string if there are no items.
|
||
|
|
func BuildNextCursor(lastID int64, lastTime time.Time) string {
|
||
|
|
if lastID == 0 {
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
return (&Cursor{LastID: lastID, LastValue: lastTime}).Encode()
|
||
|
|
}
|