Skip to content

Commit 3d7a883

Browse files
committed
chore: cursor support backward compat
1 parent 396cf83 commit 3d7a883

File tree

2 files changed

+177
-58
lines changed

2 files changed

+177
-58
lines changed

internal/logstore/cursor/cursor.go

Lines changed: 94 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,9 @@ import (
88
"github.com/hookdeck/outpost/internal/logstore/driver"
99
)
1010

11-
// cursorVersion is the current cursor format version.
12-
// Increment this when making breaking changes to cursor format.
13-
const cursorVersion = "v1"
14-
1511
// Cursor represents a pagination cursor with embedded sort parameters.
1612
// This ensures cursors are only valid for queries with matching sort configuration.
13+
// Implementations use this type directly - versioning is handled internally by Encode/Decode.
1714
type Cursor struct {
1815
SortBy string // "event_time" or "delivery_time"
1916
SortOrder string // "asc" or "desc"
@@ -26,65 +23,37 @@ func (c Cursor) IsEmpty() bool {
2623
}
2724

2825
// Encode converts a Cursor to a URL-safe base62 string.
29-
// Format: v1:{sortBy}:{sortOrder}:{position} -> base62 encoded
26+
// Always encodes using the current version format.
3027
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)
28+
return encodeV1(c)
3529
}
3630

3731
// 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.
32+
// Automatically detects and handles different cursor versions.
33+
// Returns driver.ErrInvalidCursor if the cursor is malformed.
3934
func Decode(encoded string) (Cursor, error) {
4035
if encoded == "" {
4136
return Cursor{}, nil
4237
}
4338

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
39+
raw, err := decodeBase62(encoded)
40+
if err != nil {
41+
return Cursor{}, err
6942
}
7043

71-
// Validate sortOrder
72-
if sortOrder != "asc" && sortOrder != "desc" {
73-
return Cursor{}, driver.ErrInvalidCursor
44+
// Detect version and decode accordingly
45+
if strings.HasPrefix(raw, v1Prefix) {
46+
return decodeV1(raw)
7447
}
7548

76-
return Cursor{
77-
SortBy: sortBy,
78-
SortOrder: sortOrder,
79-
Position: position,
80-
}, nil
49+
// Fall back to v0 format (legacy)
50+
return decodeV0(raw)
8151
}
8252

8353
// Validate checks if the cursor matches the expected sort parameters.
8454
// Returns driver.ErrInvalidCursor if there's a mismatch.
8555
func Validate(c Cursor, expectedSortBy, expectedSortOrder string) error {
8656
if c.IsEmpty() {
87-
// Empty cursor is always valid
8857
return nil
8958
}
9059

@@ -103,7 +72,6 @@ func Validate(c Cursor, expectedSortBy, expectedSortOrder string) error {
10372

10473
// DecodeAndValidate is a helper that decodes and validates both Next and Prev cursors.
10574
// 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.
10775
func DecodeAndValidate(next, prev, sortBy, sortOrder string) (nextCursor, prevCursor Cursor, err error) {
10876
if next != "" {
10977
nextCursor, err = Decode(next)
@@ -125,3 +93,84 @@ func DecodeAndValidate(next, prev, sortBy, sortOrder string) (nextCursor, prevCu
12593
}
12694
return nextCursor, prevCursor, nil
12795
}
96+
97+
// =============================================================================
98+
// Internal: Base62 encoding/decoding
99+
// =============================================================================
100+
101+
func encodeBase62(raw string) string {
102+
num := new(big.Int)
103+
num.SetBytes([]byte(raw))
104+
return num.Text(62)
105+
}
106+
107+
func decodeBase62(encoded string) (string, error) {
108+
num := new(big.Int)
109+
num, ok := num.SetString(encoded, 62)
110+
if !ok {
111+
return "", driver.ErrInvalidCursor
112+
}
113+
return string(num.Bytes()), nil
114+
}
115+
116+
// =============================================================================
117+
// Internal: v1 cursor format
118+
// Format: v1:{sortBy}:{sortOrder}:{position}
119+
// =============================================================================
120+
121+
const v1Prefix = "v1:"
122+
123+
func encodeV1(c Cursor) string {
124+
raw := fmt.Sprintf("v1:%s:%s:%s", c.SortBy, c.SortOrder, c.Position)
125+
return encodeBase62(raw)
126+
}
127+
128+
func decodeV1(raw string) (Cursor, error) {
129+
parts := strings.SplitN(raw, ":", 4)
130+
if len(parts) != 4 {
131+
return Cursor{}, driver.ErrInvalidCursor
132+
}
133+
134+
sortBy := parts[1]
135+
sortOrder := parts[2]
136+
position := parts[3]
137+
138+
if sortBy != "event_time" && sortBy != "delivery_time" {
139+
return Cursor{}, driver.ErrInvalidCursor
140+
}
141+
142+
if sortOrder != "asc" && sortOrder != "desc" {
143+
return Cursor{}, driver.ErrInvalidCursor
144+
}
145+
146+
if position == "" {
147+
return Cursor{}, driver.ErrInvalidCursor
148+
}
149+
150+
return Cursor{
151+
SortBy: sortBy,
152+
SortOrder: sortOrder,
153+
Position: position,
154+
}, nil
155+
}
156+
157+
// =============================================================================
158+
// Internal: v0 cursor format (legacy, backward compatibility)
159+
// Format: {position} (no version prefix, no sort params)
160+
// Defaults: sortBy=event_time, sortOrder=desc
161+
// =============================================================================
162+
163+
const (
164+
v0DefaultSortBy = "event_time"
165+
v0DefaultSortOrder = "desc"
166+
)
167+
168+
func decodeV0(raw string) (Cursor, error) {
169+
// v0 cursors are just the position, no validation needed
170+
// If position is invalid, the DB query will simply not find it
171+
return Cursor{
172+
SortBy: v0DefaultSortBy,
173+
SortOrder: v0DefaultSortOrder,
174+
Position: raw,
175+
}, nil
176+
}

internal/logstore/cursor/cursor_test.go

Lines changed: 83 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ func TestDecode(t *testing.T) {
5858
assert.True(t, c.IsEmpty())
5959
})
6060

61-
t.Run("decodes valid cursor", func(t *testing.T) {
61+
t.Run("decodes v1 cursor", func(t *testing.T) {
6262
original := Cursor{
6363
SortBy: "delivery_time",
6464
SortOrder: "desc",
@@ -92,40 +92,110 @@ func TestDecode(t *testing.T) {
9292
assert.True(t, errors.Is(err, driver.ErrInvalidCursor))
9393
})
9494

95-
t.Run("invalid format returns error", func(t *testing.T) {
96-
// Encode something that's not in the right format
97-
_, err := Decode("abc123")
95+
t.Run("v1 invalid sortBy returns error", func(t *testing.T) {
96+
raw := "v1:invalid_sort:desc:position"
97+
encoded := encodeRaw(raw)
98+
99+
_, err := Decode(encoded)
98100
require.Error(t, err)
99101
assert.True(t, errors.Is(err, driver.ErrInvalidCursor))
100102
})
101103

102-
t.Run("invalid sortBy returns error", func(t *testing.T) {
103-
// Manually create a cursor with invalid sortBy by encoding raw bytes
104-
raw := "v1:invalid_sort:desc:position"
104+
t.Run("v1 invalid sortOrder returns error", func(t *testing.T) {
105+
raw := "v1:event_time:invalid_order:position"
105106
encoded := encodeRaw(raw)
106107

107108
_, err := Decode(encoded)
108109
require.Error(t, err)
109110
assert.True(t, errors.Is(err, driver.ErrInvalidCursor))
110111
})
111112

112-
t.Run("invalid sortOrder returns error", func(t *testing.T) {
113-
raw := "v1:event_time:invalid_order:position"
113+
t.Run("v1 empty position returns error", func(t *testing.T) {
114+
raw := "v1:event_time:desc:"
114115
encoded := encodeRaw(raw)
115116

116117
_, err := Decode(encoded)
117118
require.Error(t, err)
118119
assert.True(t, errors.Is(err, driver.ErrInvalidCursor))
119120
})
120121

121-
t.Run("unsupported version returns error", func(t *testing.T) {
122-
raw := "v99:event_time:desc:position"
122+
t.Run("v1 missing parts returns error", func(t *testing.T) {
123+
raw := "v1:event_time:desc" // missing position
123124
encoded := encodeRaw(raw)
124125

125126
_, err := Decode(encoded)
126127
require.Error(t, err)
127128
assert.True(t, errors.Is(err, driver.ErrInvalidCursor))
128-
assert.Contains(t, err.Error(), "unsupported cursor version")
129+
})
130+
}
131+
132+
func TestDecodeV0BackwardCompatibility(t *testing.T) {
133+
t.Run("decodes v0 cursor with defaults", func(t *testing.T) {
134+
// v0 format: just position, no version prefix
135+
position := "1704067200_evt_abc"
136+
encoded := encodeRaw(position)
137+
138+
decoded, err := Decode(encoded)
139+
require.NoError(t, err)
140+
assert.Equal(t, position, decoded.Position)
141+
assert.Equal(t, "event_time", decoded.SortBy, "v0 defaults to event_time")
142+
assert.Equal(t, "desc", decoded.SortOrder, "v0 defaults to desc")
143+
})
144+
145+
t.Run("decodes v0 composite cursor", func(t *testing.T) {
146+
// v0 composite cursor for event_time sort
147+
position := "1704067200_evt_abc_1704067500_del_xyz"
148+
encoded := encodeRaw(position)
149+
150+
decoded, err := Decode(encoded)
151+
require.NoError(t, err)
152+
assert.Equal(t, position, decoded.Position)
153+
assert.Equal(t, "event_time", decoded.SortBy)
154+
assert.Equal(t, "desc", decoded.SortOrder)
155+
})
156+
157+
t.Run("v0 cursor validates with matching defaults", func(t *testing.T) {
158+
position := "1704067200_evt_abc"
159+
encoded := encodeRaw(position)
160+
161+
// Should work with default sort params
162+
next, _, err := DecodeAndValidate(encoded, "", "event_time", "desc")
163+
require.NoError(t, err)
164+
assert.Equal(t, position, next.Position)
165+
})
166+
167+
t.Run("v0 cursor fails validation with non-default sort params", func(t *testing.T) {
168+
position := "1704067200_del_xyz"
169+
encoded := encodeRaw(position)
170+
171+
// Should fail because v0 defaults to event_time, not delivery_time
172+
_, _, err := DecodeAndValidate(encoded, "", "delivery_time", "desc")
173+
require.Error(t, err)
174+
assert.True(t, errors.Is(err, driver.ErrInvalidCursor))
175+
assert.Contains(t, err.Error(), "sortBy")
176+
})
177+
178+
t.Run("v0 cursor fails validation with different sort order", func(t *testing.T) {
179+
position := "1704067200_evt_abc"
180+
encoded := encodeRaw(position)
181+
182+
// Should fail because v0 defaults to desc, not asc
183+
_, _, err := DecodeAndValidate(encoded, "", "event_time", "asc")
184+
require.Error(t, err)
185+
assert.True(t, errors.Is(err, driver.ErrInvalidCursor))
186+
assert.Contains(t, err.Error(), "sortOrder")
187+
})
188+
189+
t.Run("random string treated as v0 position", func(t *testing.T) {
190+
// Any valid base62 that doesn't start with "v1:" is treated as v0
191+
position := "some_random_position_string"
192+
encoded := encodeRaw(position)
193+
194+
decoded, err := Decode(encoded)
195+
require.NoError(t, err)
196+
assert.Equal(t, position, decoded.Position)
197+
assert.Equal(t, "event_time", decoded.SortBy)
198+
assert.Equal(t, "desc", decoded.SortOrder)
129199
})
130200
}
131201

@@ -240,7 +310,7 @@ func TestRoundTrip(t *testing.T) {
240310
}
241311
}
242312

243-
// encodeRaw is a helper to encode raw strings for testing invalid formats
313+
// encodeRaw is a helper to encode raw strings for testing
244314
func encodeRaw(raw string) string {
245315
num := new(big.Int)
246316
num.SetBytes([]byte(raw))

0 commit comments

Comments
 (0)