internal/control/cursor.go
127 lines · 3586 bytes
1package control
2
3import (
4 "encoding/base64"
5 "errors"
6 "fmt"
7 "io"
8 "reflect"
9 "strconv"
10 "strings"
11
12 "gitbay.org/gitbay/internal/protocol"
13)
14
15// Cursor pagination. A cursor is opaque to clients: base64url of
16// "<kind>:<key>", where key is the sort key of the last row of the
17// previous page. The kind keeps a cursor minted by one command from
18// being fed to another.
19
20const maxPageLimit = 200
21
22func encodeCursor(kind, key string) string {
23 return base64.RawURLEncoding.EncodeToString([]byte(kind + ":" + key))
24}
25
26func decodeCursor(kind, cursor string) (string, error) {
27 raw, err := base64.RawURLEncoding.DecodeString(cursor)
28 if err != nil {
29 return "", errors.New("bad cursor")
30 }
31 k, key, ok := strings.Cut(string(raw), ":")
32 if !ok || k != kind || key == "" {
33 return "", errors.New("bad cursor")
34 }
35 return key, nil
36}
37
38// page carries parsed --limit/--cursor flags. active marks that either
39// flag was given: only then does the output switch to the paged shape.
40type page struct {
41 limit int
42 key string // decoded cursor key, "" means from the start
43 active bool
44}
45
46// queryLimit is what the store is asked for: one row beyond the page, so
47// the presence of a following page is known without a second query.
48func (p page) queryLimit() int {
49 if p.limit == 0 {
50 return 0
51 }
52 return p.limit + 1
53}
54
55// keyInt returns the cursor key as a number; parsePageFlags has already
56// validated it for numeric kinds.
57func (p page) keyInt() int64 {
58 n, _ := strconv.ParseInt(p.key, 10, 64)
59 return n
60}
61
62// parsePageFlags strips --limit and --cursor from args. kind names the
63// cursor namespace; numeric declares the sort key an integer.
64func parsePageFlags(c *Ctx, args []string, kind string, numeric bool) (rest []string, p page, code int) {
65 for i := 0; i < len(args); i++ {
66 switch args[i] {
67 case "--limit":
68 if i+1 >= len(args) {
69 return nil, p, c.fail(protocol.ExitUsage, "--limit requires a value")
70 }
71 n, err := strconv.Atoi(args[i+1])
72 if err != nil || n < 1 || n > maxPageLimit {
73 return nil, p, c.fail(protocol.ExitUsage, "--limit must be 1 to %d", maxPageLimit)
74 }
75 p.limit, p.active = n, true
76 i++
77 case "--cursor":
78 if i+1 >= len(args) {
79 return nil, p, c.fail(protocol.ExitUsage, "--cursor requires a value")
80 }
81 key, err := decodeCursor(kind, args[i+1])
82 if err == nil && numeric {
83 _, err = strconv.ParseInt(key, 10, 64)
84 }
85 if err != nil {
86 return nil, p, c.fail(protocol.ExitUsage, "bad cursor")
87 }
88 p.key, p.active = key, true
89 i++
90 default:
91 rest = append(rest, args[i])
92 }
93 }
94 return rest, p, -1
95}
96
97// trimPage cuts the probe row and derives the next cursor from the last
98// row kept.
99func trimPage[T any](p page, items []T, kind string, key func(T) string) ([]T, string) {
100 if p.limit == 0 || len(items) <= p.limit {
101 return items, ""
102 }
103 items = items[:p.limit]
104 return items, encodeCursor(kind, key(items[len(items)-1]))
105}
106
107// emitPage renders a list result. Without pagination flags the shape is
108// the bare array it has always been; with them the array moves under
109// "items" with the next cursor alongside.
110func (c *Ctx) emitPage(p page, items any, next string, plain func(w io.Writer)) int {
111 if !p.active {
112 return c.emit(items, plain)
113 }
114 if v := reflect.ValueOf(items); v.Kind() == reflect.Slice && v.IsNil() {
115 items = reflect.MakeSlice(v.Type(), 0, 0).Interface()
116 }
117 type out struct {
118 Items any `json:"items"`
119 Next string `json:"next,omitempty"`
120 }
121 return c.emit(out{items, next}, func(w io.Writer) {
122 plain(w)
123 if next != "" {
124 fmt.Fprintf(w, "next\t%s\n", next)
125 }
126 })
127}