// 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() }