docs: add package godoc and clarify public API

Prepare the extracted library for external consumption with grouped
godoc, extension point docs, and setup requirements. Wire
ErrBadCursorString into decode paths and drop redundant Paginate*Conds
aliases.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 17:14:05 -07:00
parent aecd90eb84
commit 683b0ddbf4
8 changed files with 141 additions and 59 deletions
+47 -21
View File
@@ -13,7 +13,7 @@ import (
)
var (
// ErrBadCursor is returned when a cursor is invalid.
// ErrBadCursorString is returned when a cursor string cannot be decoded.
ErrBadCursorString = errors.New("bad cursor string")
columnRegistry = make(map[ColumnKey]mysql.Column)
@@ -52,6 +52,9 @@ func (k ColumnKey) String() string {
}
// RegisterColumn registers one or more columns with the cursor registry.
//
// Required at application startup before encoding, decoding, or SQL generation.
// Every index column, order column, and OrderValue column must be registered.
func RegisterColumn(columns ...mysql.Column) {
columnRegistryLock.Lock()
defer columnRegistryLock.Unlock()
@@ -61,6 +64,7 @@ func RegisterColumn(columns ...mysql.Column) {
}
// RegisterColumnList registers a list of columns with the cursor registry.
// See [RegisterColumn] for setup requirements.
func RegisterColumnList(columns mysql.ColumnList) {
RegisterColumn([]mysql.Column(columns)...)
}
@@ -142,13 +146,18 @@ var _ ExprMarshaler[mysql.IntegerExpression, mysql.ColumnInteger] = (*Int64Value
var _ ExprMarshaler[mysql.IntegerExpression, mysql.ColumnInteger] = (*Uint64Value)(nil)
var _ ExprMarshaler[mysql.TimestampExpression, mysql.ColumnTimestamp] = (*TimestampValue)(nil)
// ExprMarshaler is the typed index-value extension point. Implement GenericExpr plus
// Expr and Col to produce go-jet expressions. Col panics when the column is not
// registered via [RegisterColumn].
type ExprMarshaler[E mysql.Expression, C mysql.Column] interface {
GenericExpr
Expr() E
Col() C
}
// GenericExpr is the untyped cursor value contract shared by index and order values.
// GenericExpr is the cursor value extension point shared by index and order columns.
// Implement ColumnKey, IsEmpty, and driver.Valuer. Order values decoded from cursor
// JSON must match a registered go-jet column kind; see package documentation.
type GenericExpr interface {
ColumnKey() ColumnKey
IsEmpty() bool
@@ -165,6 +174,8 @@ type StringValue struct {
Val string `json:"val"`
}
// NewStringValue creates a cursor value for a string column (including UUIDs).
// The column must be registered via [RegisterColumn] before Col or decode use it.
func NewStringValue(val string, col mysql.ColumnString) *StringValue {
return &StringValue{
Key: NewColumnKey(col),
@@ -201,6 +212,8 @@ type Int64Value struct {
Val int64 `json:"val"`
}
// NewInt64Value creates a cursor value for a signed integer column.
// The column must be registered via [RegisterColumn] before Col or decode use it.
func NewInt64Value(val int64, col mysql.ColumnInteger) *Int64Value {
return &Int64Value{
Key: NewColumnKey(col),
@@ -237,6 +250,8 @@ type Uint64Value struct {
Val uint64 `json:"val"`
}
// NewUint64Value creates a cursor value for an unsigned integer column.
// The column must be registered via [RegisterColumn] before Col or decode use it.
func NewUint64Value(val uint64, col mysql.ColumnInteger) *Uint64Value {
return &Uint64Value{
Key: NewColumnKey(col),
@@ -275,6 +290,7 @@ type TimestampValue struct {
}
// NewTimestampValue creates a timestamp cursor value for the given column.
// The column must be registered via [RegisterColumn] before Col or decode use it.
func NewTimestampValue(val time.Time, col mysql.ColumnTimestamp) *TimestampValue {
return &TimestampValue{
Key: NewColumnKey(col),
@@ -310,9 +326,9 @@ func (t *TimestampValue) IsEmpty() bool {
return t == nil || t.Key.Table == "" || t.Key.Column == "" || t.Val.IsZero()
}
// GenericCursor is an interface for a cursor that can be used with any type of
// expression and column. Only the methods that do not depend on the specific
// types of expressions and columns are defined here.
// GenericCursor is the cursor extension point used by edge building, page counting,
// and pagination helpers without concrete type parameters. [Cursor] implements this
// interface.
type GenericCursor interface {
IsEmpty() bool
IsComposite() bool
@@ -365,7 +381,7 @@ func NewCursor[IE mysql.Expression, IC mysql.Column](index ExprMarshaler[IE, IC]
}
// NewCursorFromAfterPtr decodes a Relay-style after cursor. Nil or empty after
// returns (nil, nil).
// returns (nil, nil). Returns [ErrBadCursorString] when after is not valid cursor JSON.
func NewCursorFromAfterPtr[IE mysql.Expression, IC mysql.Column](
newZero func() *Cursor[IE, IC],
after *string,
@@ -378,6 +394,7 @@ func NewCursorFromAfterPtr[IE mysql.Expression, IC mysql.Column](
}
// NewCursorFromJSON returns a Cursor from a JSON representation.
// Returns [ErrBadCursorString] when src is not valid cursor JSON.
func NewCursorFromJSON[IE mysql.Expression, IC mysql.Column](zeroIndex ExprMarshaler[IE, IC], src []byte) (*Cursor[IE, IC], error) {
cursor := Cursor[IE, IC]{
Index: zeroIndex,
@@ -389,13 +406,15 @@ func NewCursorFromJSON[IE mysql.Expression, IC mysql.Column](zeroIndex ExprMarsh
return &cursor, nil
}
// CopyWithVal returns a new cursor with the specified value and the current ordering.
// CopyWithVal returns a new cursor with the specified index value and the current
// ordering. Use for simple pagination where the index and order column are the same.
func (c *Cursor[IE, IC]) CopyWithVal(val ExprMarshaler[IE, IC]) *Cursor[IE, IC] {
return NewCursor(val, c.OrderCol(), c.OrderDir)
}
// CopyWithVals returns a new cursor with the specified index and order values.
// Use this when IsComposite() is true.
// Use when building edge cursors under tuple ordering: set both values so
// [IsComposite] is true and after pagination compares (order_col, index_col).
func (c *Cursor[IE, IC]) CopyWithVals(index ExprMarshaler[IE, IC], orderVal GenericExpr) *Cursor[IE, IC] {
result := NewCursor(index, c.OrderCol(), c.OrderDir)
result.OrderValue = orderVal
@@ -403,8 +422,10 @@ func (c *Cursor[IE, IC]) CopyWithVals(index ExprMarshaler[IE, IC], orderVal Gene
}
// UsesTupleOrdering reports whether results are sorted by (order_col, index_col).
// Unlike IsComposite, this does not require OrderValue and applies to default
// cursors on the first page.
//
// True when the order column differs from the index column. Applies to default
// cursors on the first page and drives [OrderByClauses]. Unlike [IsComposite],
// OrderValue is not required.
func (c *Cursor[IE, IC]) UsesTupleOrdering() bool {
if c == nil || c.Index == nil {
return false
@@ -416,7 +437,11 @@ func (c *Cursor[IE, IC]) UsesTupleOrdering() bool {
return c.OrderColumnKey != indexKey
}
// IsComposite reports whether pagination uses (order_col, index_col) tuple comparison.
// IsComposite reports whether after-pagination filters on (order_col, index_col).
//
// True when tuple ordering is active and OrderValue is set—typical for encoded edge
// cursors when the sort column is non-unique. Drives [PaginateConds] and page
// counting via lexicographic tuple comparison.
func (c *Cursor[IE, IC]) IsComposite() bool {
if c == nil || c.Index == nil || c.Index.IsEmpty() {
return false
@@ -471,6 +496,7 @@ func (c *Cursor[IE, IC]) Encode() (string, error) {
}
// Decode decodes a stringified JSON representation of the cursor into this object.
// Returns [ErrBadCursorString] when the input is not valid cursor JSON.
func (c *Cursor[IE, IC]) Decode(src string) error {
if c == nil {
return fmt.Errorf("cursor is nil")
@@ -478,8 +504,8 @@ func (c *Cursor[IE, IC]) Decode(src string) error {
return decodeCursorJSON(c, []byte(src), false, OrderAscending)
}
// DecodeAndOrder decodes a stringified JSON representation of the cursor into
// this object and applies a new order direction.
// DecodeAndOrder decodes a cursor string like [Cursor.Decode] but replaces the
// encoded order direction with orderDir.
func (c *Cursor[IE, IC]) DecodeAndOrder(src string, orderDir OrderDirection) error {
return decodeCursorJSON(c, []byte(src), true, orderDir)
}
@@ -492,11 +518,11 @@ func decodeCursorJSON[IE mysql.Expression, IC mysql.Column](
) error {
var raw cursorJSON
if err := json.Unmarshal(src, &raw); err != nil {
return fmt.Errorf("failed to unmarshal cursor: %w", err)
return fmt.Errorf("%w: invalid JSON: %v", ErrBadCursorString, err)
}
if err := json.Unmarshal(raw.Index, c.Index); err != nil {
return fmt.Errorf("failed to unmarshal cursor index: %w", err)
return fmt.Errorf("%w: invalid index: %v", ErrBadCursorString, err)
}
c.OrderColumnKey = raw.OrderColumnKey
@@ -508,7 +534,7 @@ func decodeCursorJSON[IE mysql.Expression, IC mysql.Column](
if len(raw.OrderValue) > 0 {
orderVal, err := unmarshalGenericExpr(raw.OrderValue, raw.OrderColumnKey)
if err != nil {
return fmt.Errorf("failed to unmarshal cursor order value: %w", err)
return fmt.Errorf("%w: invalid order value: %v", ErrBadCursorString, err)
}
c.OrderValue = orderVal
} else {
@@ -521,30 +547,30 @@ func decodeCursorJSON[IE mysql.Expression, IC mysql.Column](
func unmarshalGenericExpr(data []byte, key ColumnKey) (GenericExpr, error) {
col, err := GetColumnByKey(key)
if err != nil {
return nil, err
return nil, fmt.Errorf("%w: %v", ErrBadCursorString, err)
}
switch col.(type) {
case mysql.ColumnTimestamp:
var v TimestampValue
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
return nil, fmt.Errorf("%w: %v", ErrBadCursorString, err)
}
return &v, nil
case mysql.ColumnInteger:
var v Uint64Value
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
return nil, fmt.Errorf("%w: %v", ErrBadCursorString, err)
}
return &v, nil
case mysql.ColumnString:
var v StringValue
if err := json.Unmarshal(data, &v); err != nil {
return nil, err
return nil, fmt.Errorf("%w: %v", ErrBadCursorString, err)
}
return &v, nil
default:
return nil, fmt.Errorf("unsupported order column type for %s", key)
return nil, fmt.Errorf("%w: unsupported order column type for %s", ErrBadCursorString, key)
}
}