Skip to content

Commit 7d9ea5b

Browse files
committed
feat: cursor
1 parent 7d1e2cb commit 7d9ea5b

File tree

4 files changed

+482
-23
lines changed

4 files changed

+482
-23
lines changed

internal/logstore/cursor/cursor.go

Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
package cursor
2+
3+
import (
4+
"fmt"
5+
"math/big"
6+
"strings"
7+
8+
"github.com/hookdeck/outpost/internal/logstore/driver"
9+
)
10+
11+
// cursorVersion is the current cursor format version.
12+
// Increment this when making breaking changes to cursor format.
13+
const cursorVersion = "v1"
14+
15+
// Cursor represents a pagination cursor with embedded sort parameters.
16+
// This ensures cursors are only valid for queries with matching sort configuration.
17+
type Cursor struct {
18+
SortBy string // "event_time" or "delivery_time"
19+
SortOrder string // "asc" or "desc"
20+
Position string // implementation-specific position value
21+
}
22+
23+
// IsEmpty returns true if this cursor has no position (i.e., no cursor was provided).
24+
func (c Cursor) IsEmpty() bool {
25+
return c.Position == ""
26+
}
27+
28+
// Encode converts a Cursor to a URL-safe base62 string.
29+
// Format: v1:{sortBy}:{sortOrder}:{position} -> base62 encoded
30+
func Encode(c Cursor) string {
31+
raw := fmt.Sprintf("%s:%s:%s:%s", cursorVersion, c.SortBy, c.SortOrder, c.Position)
32+
num := new(big.Int)
33+
num.SetBytes([]byte(raw))
34+
return num.Text(62)
35+
}
36+
37+
// Decode converts a base62 encoded cursor string back to a Cursor.
38+
// Returns driver.ErrInvalidCursor if the cursor is malformed or has an unsupported version.
39+
func Decode(encoded string) (Cursor, error) {
40+
if encoded == "" {
41+
return Cursor{}, nil
42+
}
43+
44+
num := new(big.Int)
45+
num, ok := num.SetString(encoded, 62)
46+
if !ok {
47+
return Cursor{}, driver.ErrInvalidCursor
48+
}
49+
50+
raw := string(num.Bytes())
51+
parts := strings.SplitN(raw, ":", 4)
52+
if len(parts) != 4 {
53+
return Cursor{}, driver.ErrInvalidCursor
54+
}
55+
56+
version := parts[0]
57+
sortBy := parts[1]
58+
sortOrder := parts[2]
59+
position := parts[3]
60+
61+
// Validate version
62+
if version != cursorVersion {
63+
return Cursor{}, fmt.Errorf("%w: unsupported cursor version %q", driver.ErrInvalidCursor, version)
64+
}
65+
66+
// Validate sortBy
67+
if sortBy != "event_time" && sortBy != "delivery_time" {
68+
return Cursor{}, driver.ErrInvalidCursor
69+
}
70+
71+
// Validate sortOrder
72+
if sortOrder != "asc" && sortOrder != "desc" {
73+
return Cursor{}, driver.ErrInvalidCursor
74+
}
75+
76+
return Cursor{
77+
SortBy: sortBy,
78+
SortOrder: sortOrder,
79+
Position: position,
80+
}, nil
81+
}
82+
83+
// Validate checks if the cursor matches the expected sort parameters.
84+
// Returns driver.ErrInvalidCursor if there's a mismatch.
85+
func Validate(c Cursor, expectedSortBy, expectedSortOrder string) error {
86+
if c.IsEmpty() {
87+
// Empty cursor is always valid
88+
return nil
89+
}
90+
91+
if c.SortBy != expectedSortBy {
92+
return fmt.Errorf("%w: cursor sortBy %q does not match request sortBy %q",
93+
driver.ErrInvalidCursor, c.SortBy, expectedSortBy)
94+
}
95+
96+
if c.SortOrder != expectedSortOrder {
97+
return fmt.Errorf("%w: cursor sortOrder %q does not match request sortOrder %q",
98+
driver.ErrInvalidCursor, c.SortOrder, expectedSortOrder)
99+
}
100+
101+
return nil
102+
}
103+
104+
// DecodeAndValidate is a helper that decodes and validates both Next and Prev cursors.
105+
// This is the common pattern used by all LogStore implementations.
106+
// Returns the decoded cursors or an error if either cursor is invalid or mismatched.
107+
func DecodeAndValidate(next, prev, sortBy, sortOrder string) (nextCursor, prevCursor Cursor, err error) {
108+
if next != "" {
109+
nextCursor, err = Decode(next)
110+
if err != nil {
111+
return Cursor{}, Cursor{}, err
112+
}
113+
if err := Validate(nextCursor, sortBy, sortOrder); err != nil {
114+
return Cursor{}, Cursor{}, err
115+
}
116+
}
117+
if prev != "" {
118+
prevCursor, err = Decode(prev)
119+
if err != nil {
120+
return Cursor{}, Cursor{}, err
121+
}
122+
if err := Validate(prevCursor, sortBy, sortOrder); err != nil {
123+
return Cursor{}, Cursor{}, err
124+
}
125+
}
126+
return nextCursor, prevCursor, nil
127+
}

internal/logstore/driver/driver.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,16 @@ package driver
22

33
import (
44
"context"
5+
"errors"
56
"time"
67

78
"github.com/hookdeck/outpost/internal/models"
89
)
910

11+
// ErrInvalidCursor is returned when the cursor is malformed or doesn't match
12+
// the current query parameters (e.g., different sort order).
13+
var ErrInvalidCursor = errors.New("invalid cursor")
14+
1015
type LogStore interface {
1116
ListDeliveryEvent(context.Context, ListDeliveryEventRequest) (ListDeliveryEventResponse, error)
1217
RetrieveEvent(ctx context.Context, request RetrieveEventRequest) (*models.Event, error)

0 commit comments

Comments
 (0)