Add transport-neutral keyset pagination through native Bob query mods. Preserve supported cursor value types in a compact versioned format, with bidirectional pages, explicit row keys, and a separate opt-in count. Include SQLite tests, PostgreSQL service CI, and the cursor-format ADR. Closes #6
217 lines
5.7 KiB
Go
217 lines
5.7 KiB
Go
package page
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/base64"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"strconv"
|
|
"time"
|
|
"unicode/utf8"
|
|
"uuid"
|
|
)
|
|
|
|
const maxCursorBytes = 16384
|
|
|
|
// CursorError identifies malformed, version, sort, value, or size cursor failures via
|
|
// Kind. It intentionally omits cursor payloads from its message.
|
|
type CursorError struct{ Kind string }
|
|
|
|
// Error implements error without including cursor contents.
|
|
func (e *CursorError) Error() string { return "page: cursor " + e.Kind }
|
|
|
|
// ValueError identifies an unsupported concrete type or invalid supported value
|
|
// at Position in the row key, without exposing the value itself.
|
|
type ValueError struct {
|
|
Position int
|
|
Type string
|
|
}
|
|
|
|
// Error implements error without including key values.
|
|
func (e *ValueError) Error() string {
|
|
return fmt.Sprintf("page: invalid key value at %d (%s)", e.Position, e.Type)
|
|
}
|
|
|
|
type envelope struct {
|
|
Version int `json:"v"`
|
|
Sort string `json:"sort"`
|
|
Values [][2]string `json:"values"`
|
|
}
|
|
|
|
func encodeCursor(sortID string, values []any) (string, error) {
|
|
e := envelope{Version: 1, Sort: sortID, Values: make([][2]string, len(values))}
|
|
for i, v := range values {
|
|
pair, ok := encodeValue(v)
|
|
if !ok {
|
|
return "", &ValueError{Position: i, Type: fmt.Sprintf("%T", v)}
|
|
}
|
|
e.Values[i] = pair
|
|
}
|
|
data, err := json.Marshal(e)
|
|
if err != nil {
|
|
return "", &CursorError{Kind: "malformed"}
|
|
}
|
|
if base64.RawURLEncoding.EncodedLen(len(data)) > maxCursorBytes {
|
|
return "", &CursorError{Kind: "size"}
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(data), nil
|
|
}
|
|
|
|
func encodeValue(value any) ([2]string, bool) {
|
|
var tag, payload string
|
|
switch v := value.(type) {
|
|
case int:
|
|
tag, payload = "int", strconv.FormatInt(int64(v), 10)
|
|
case int8:
|
|
tag, payload = "int8", strconv.FormatInt(int64(v), 10)
|
|
case int16:
|
|
tag, payload = "int16", strconv.FormatInt(int64(v), 10)
|
|
case int32:
|
|
tag, payload = "int32", strconv.FormatInt(int64(v), 10)
|
|
case int64:
|
|
tag, payload = "int64", strconv.FormatInt(v, 10)
|
|
case string:
|
|
if !utf8.ValidString(v) {
|
|
return [2]string{}, false
|
|
}
|
|
tag, payload = "string", v
|
|
case time.Time:
|
|
v = v.UTC()
|
|
if v.Year() < 0 || v.Year() > 9999 {
|
|
return [2]string{}, false
|
|
}
|
|
tag, payload = "time", v.Format(time.RFC3339Nano)
|
|
case uuid.UUID:
|
|
tag, payload = "uuid", v.String()
|
|
default:
|
|
return [2]string{}, false
|
|
}
|
|
return [2]string{tag, payload}, true
|
|
}
|
|
|
|
func decodeCursor(token string, sort Sort) ([]any, error) {
|
|
failure := func(kind string) ([]any, error) { return nil, &CursorError{Kind: kind} }
|
|
if len(token) > maxCursorBytes {
|
|
return failure("size")
|
|
}
|
|
data, err := base64.RawURLEncoding.Strict().DecodeString(token)
|
|
if err != nil || !utf8.Valid(data) || base64.RawURLEncoding.EncodeToString(data) != token {
|
|
return failure("malformed")
|
|
}
|
|
// invariant: exactly three distinct envelope fields; struct decoding alone permits duplicates.
|
|
decoder := json.NewDecoder(bytes.NewReader(data))
|
|
start, err := decoder.Token()
|
|
if err != nil || start != json.Delim('{') {
|
|
return failure("malformed")
|
|
}
|
|
fields := make(map[string]json.RawMessage, 3)
|
|
for decoder.More() {
|
|
name, err := decoder.Token()
|
|
if err != nil {
|
|
return failure("malformed")
|
|
}
|
|
key, ok := name.(string)
|
|
if !ok || (key != "v" && key != "sort" && key != "values") {
|
|
return failure("malformed")
|
|
}
|
|
if _, exists := fields[key]; exists {
|
|
return failure("malformed")
|
|
}
|
|
var raw json.RawMessage
|
|
if decoder.Decode(&raw) != nil {
|
|
return failure("malformed")
|
|
}
|
|
fields[key] = raw
|
|
}
|
|
if _, err := decoder.Token(); err != nil || len(fields) != 3 {
|
|
return failure("malformed")
|
|
}
|
|
if _, err := decoder.Token(); err != io.EOF {
|
|
return failure("malformed")
|
|
}
|
|
var version int
|
|
if bytes.Equal(fields["v"], []byte("null")) || json.Unmarshal(fields["v"], &version) != nil {
|
|
return failure("malformed")
|
|
}
|
|
if version != 1 {
|
|
return failure("version")
|
|
}
|
|
var id string
|
|
if !jsonString(fields["sort"], &id) {
|
|
return failure("malformed")
|
|
}
|
|
if id != sort.ID {
|
|
return failure("sort")
|
|
}
|
|
var entries []json.RawMessage
|
|
if len(fields["values"]) == 0 || fields["values"][0] != '[' || json.Unmarshal(fields["values"], &entries) != nil || len(entries) != len(sort.Columns) {
|
|
return failure("malformed")
|
|
}
|
|
values := make([]any, len(entries))
|
|
for i, entry := range entries {
|
|
var pair []json.RawMessage
|
|
if len(entry) == 0 || entry[0] != '[' || json.Unmarshal(entry, &pair) != nil || len(pair) != 2 {
|
|
return failure("malformed")
|
|
}
|
|
var tag, payload string
|
|
if !jsonString(pair[0], &tag) || !jsonString(pair[1], &payload) {
|
|
return failure("malformed")
|
|
}
|
|
value, ok := decodeValue(tag, payload)
|
|
if !ok {
|
|
return failure("value")
|
|
}
|
|
values[i] = value
|
|
}
|
|
return values, nil
|
|
}
|
|
|
|
func jsonString(raw json.RawMessage, value *string) bool {
|
|
return len(raw) > 0 && raw[0] == '"' && json.Unmarshal(raw, value) == nil && utf8.ValidString(*value)
|
|
}
|
|
|
|
func decodeValue(tag, payload string) (any, bool) {
|
|
switch tag {
|
|
case "int", "int8", "int16", "int32", "int64":
|
|
bits := strconv.IntSize
|
|
switch tag {
|
|
case "int8":
|
|
bits = 8
|
|
case "int16":
|
|
bits = 16
|
|
case "int32":
|
|
bits = 32
|
|
case "int64":
|
|
bits = 64
|
|
}
|
|
n, err := strconv.ParseInt(payload, 10, bits)
|
|
if err != nil || strconv.FormatInt(n, 10) != payload {
|
|
return nil, false
|
|
}
|
|
switch tag {
|
|
case "int":
|
|
return int(n), true
|
|
case "int8":
|
|
return int8(n), true
|
|
case "int16":
|
|
return int16(n), true
|
|
case "int32":
|
|
return int32(n), true
|
|
default:
|
|
return n, true
|
|
}
|
|
case "string":
|
|
return payload, utf8.ValidString(payload)
|
|
case "time":
|
|
t, err := time.Parse(time.RFC3339Nano, payload)
|
|
t = t.UTC()
|
|
return t, err == nil && t.Year() >= 0 && t.Year() <= 9999
|
|
case "uuid":
|
|
id, err := uuid.Parse(payload)
|
|
return id, err == nil && id.String() == payload
|
|
default:
|
|
return nil, false
|
|
}
|
|
}
|