Files
user-system/internal/pagination/cursor.go

84 lines
2.6 KiB
Go
Raw Normal View History

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