Files
xarmian ca184cff96 feat(needs): sort=importance|simplicity with a score-keyed cursor (TASK-3959, part 2) (#2274)
* feat(needs): sort=importance|simplicity with a score-keyed cursor (TASK-3959, part 2)

GET /me/needs takes sort=importance and sort=simplicity: the primary score
then the secondary, both highest first, then the item id; a row a scorer left
unscored sorts last. The cursor carries the key as score/score2 (absent under
age, so an age cursor is unchanged), and a score cursor missing either, or
out of -1..100, is invalid_cursor. docs/api.md documents the sorts.

Claude-Session: https://claude.ai/code/session_01UghmjfBZb6A8UbRgi4rPaw

* fix(needs): r1 — differing-order sort test, degraded-page and missing-score docs, comments (TASK-3959, part 2 r1)

Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD

* fix(needs): r1 — range-check every cursor field, pin the as_of clamp (TASK-3959, part 2 r1)

Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD

* fix(needs): a cursor the server minted is always followed; key times clamp to int64 nanoseconds (TASK-3959, part 2 r2)

r2 found that r1's key/created_at range check (> 0, before 2200) refused
cursors the server itself mints: a pre-1970 date, or a zero time from an
unparseable stored created_at, gives a non-positive UnixNano, and paging
dead-ended at that row under every sort. A check may refuse only what the
server could never mint, so key and created_at are no longer checked.

A time outside int64 nanoseconds (a zero time, or past 2262) also did not
round-trip: UnixNano wraps, so an age cursor named a different instant from
the row's key. KeyFor now clamps both key times to that range, so the sort
and the cursor see the same instant.

The table drops the key/created_at 400 rows, keeps negative values as
followed, and adds the age-cursor-with-a-score rows (r2 L2). A new test pages
pre-1970, zero, unparseable and year-3000 boundary rows through every sort
at limits 1 and 2.

Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD

* fix(needs): the cursor docs say only what the server could never mint is refused; the odd-time test runs on Postgres (TASK-3959, part 2 r3)

r3 (both reviewers): docs/api.md still listed the key/created_at range
check removed in r2. The sentence now lists exactly what is refused, says the
key times are positions that any value of follows, and says a key time
outside about 1677 to 2262 is clamped, so such rows tie on it (hunter L3).

The odd-boundary test wrote created_at through the raw *sql.DB with a ?
placeholder, which is not rewritten for Postgres; it now writes literal SQL,
as setCreated does, and passes on both backends. It reports each sort with
Errorf, and its comment says what each sort proves: every sort that the
cursor's key times are followed, and only age that the clamp holds (r3 M2).

Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD

* fix(needs): the cursor docs list the version check; the real-scorer sort test runs on both backends (TASK-3959, part 2 r4)

r4 hunter L1: the sentence listing exactly what a cursor check refuses left
out a cursor of another version (v). r4 hunter L2: the test that sorts by the
real scorers through the router ran on SQLite only; it now runs under
needsBackends, and passes on Postgres.

Claude-Session: https://claude.ai/code/session_01DQDip8AC96dA71t2UCPLTD
2026-10-21 12:38:23 -04:00

201 lines
6.5 KiB
Go

package needs
import (
"encoding/base64"
"encoding/json"
"errors"
"math"
"time"
)
// The sorts. SortAge is oldest-waiting first; SortImportance and
// SortSimplicity order by the scorers' values, highest first (TASK-3959).
const (
SortAge = "age"
SortImportance = "importance"
SortSimplicity = "simplicity"
)
// ValidSort reports whether s names a sort.
func ValidSort(s string) bool {
return s == SortAge || s == SortImportance || s == SortSimplicity
}
// Key is a row's place in the total order of one sort. Age orders by (waiting
// since, created, item id) ascending. The score sorts order by the primary
// score then the secondary, both descending (importance: importance then
// simplicity; simplicity: the mirror), then item id ascending. A row a scorer
// left without a score keys at NoScore and so sorts after every scored row.
// The item id makes every order total.
type Key struct {
Sort string
Waiting time.Time
Created time.Time
ItemID string
Primary int
Secondary int
}
// NoScore is the key value of a row with no score.
const NoScore = -1
// KeyOf is the row's age key.
func KeyOf(r *Row) Key { return KeyFor(SortAge, r) }
// The instants a cursor can carry: Unix nanoseconds in int64, about 1677 to
// 2262. A key time outside them (a zero time from an unparseable stored date,
// or a far-future one) is clamped to the nearer end, so the sort and the cursor
// see the same instant and the cursor round-trips exactly (TASK-3959 part 2 r2).
// Clamped times tie and the tie falls through to created, then the item id.
var (
keyTimeMin = time.Unix(0, math.MinInt64).UTC()
keyTimeMax = time.Unix(0, math.MaxInt64).UTC()
)
func clampKeyTime(t time.Time) time.Time {
if t.Before(keyTimeMin) {
return keyTimeMin
}
if t.After(keyTimeMax) {
return keyTimeMax
}
return t
}
// KeyFor is the row's key under a sort.
func KeyFor(sort string, r *Row) Key {
k := Key{Sort: sort, Waiting: clampKeyTime(r.WaitingSince), Created: clampKeyTime(r.Item.CreatedAt), ItemID: r.ItemID}
val := func(s *Score) int {
if s == nil {
return NoScore
}
return s.Value
}
switch sort {
case SortImportance:
k.Primary, k.Secondary = val(r.Importance), val(r.Simplicity)
case SortSimplicity:
k.Primary, k.Secondary = val(r.Simplicity), val(r.Importance)
}
return k
}
// Less reports whether a sorts before b. Both keys come from one sort.
func (a Key) Less(b Key) bool {
if a.Sort == SortImportance || a.Sort == SortSimplicity {
if a.Primary != b.Primary {
return a.Primary > b.Primary
}
if a.Secondary != b.Secondary {
return a.Secondary > b.Secondary
}
return a.ItemID < b.ItemID
}
if !a.Waiting.Equal(b.Waiting) {
return a.Waiting.Before(b.Waiting)
}
if !a.Created.Equal(b.Created) {
return a.Created.Before(b.Created)
}
return a.ItemID < b.ItemID
}
const cursorVersion = 1
// validCursorItemID accepts the store's ids (UUIDs) and nothing that could not
// be one: 1 to 64 characters of letters, digits and hyphens.
func validCursorItemID(id string) bool {
if id == "" || len(id) > 64 {
return false
}
for _, r := range id {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-') {
return false
}
}
return true
}
// ErrInvalidCursor is any cursor the server will not follow: malformed, from
// another version, or minted for a different sort, view or workspace filter.
var ErrInvalidCursor = errors.New("invalid cursor")
// Cursor is a position in a result, and nothing more: it carries no
// authority. Every page re-runs the whole filtered pipeline and takes the rows
// strictly after this key, so a cursor naming an item the caller cannot see
// reveals nothing. AsOf is the time page 1 was computed, so a deferral that
// expires between pages does not reshuffle them.
type Cursor struct {
V int `json:"v"`
Sort string `json:"sort"`
View string `json:"view"`
WS string `json:"ws"`
AsOf int64 `json:"as_of"`
Waiting int64 `json:"key"`
CreatedAt int64 `json:"created_at"`
ItemID string `json:"item_id"`
// Score and Score2 are the key's primary and secondary score under the
// score sorts, and absent under age, so an age cursor is unchanged.
Score *int `json:"score,omitempty"`
Score2 *int `json:"score2,omitempty"`
}
// NewCursor mints the cursor after row r.
func NewCursor(sort, view, ws string, asOf time.Time, r *Row) Cursor {
k := KeyFor(sort, r)
c := Cursor{V: cursorVersion, Sort: sort, View: view, WS: ws, AsOf: asOf.UnixNano(),
Waiting: k.Waiting.UnixNano(), CreatedAt: k.Created.UnixNano(), ItemID: k.ItemID}
if sort != SortAge {
c.Score, c.Score2 = &k.Primary, &k.Secondary
}
return c
}
// Encode is the opaque token form.
func (c Cursor) Encode() string {
b, _ := json.Marshal(c)
return base64.RawURLEncoding.EncodeToString(b)
}
// Key is the position the cursor names.
func (c Cursor) Key() Key {
k := Key{Sort: c.Sort, Waiting: time.Unix(0, c.Waiting).UTC(), Created: time.Unix(0, c.CreatedAt).UTC(), ItemID: c.ItemID}
if c.Score != nil && c.Score2 != nil {
k.Primary, k.Secondary = *c.Score, *c.Score2
}
return k
}
// At is the as_of time.
func (c Cursor) At() time.Time { return time.Unix(0, c.AsOf).UTC() }
// DecodeCursor parses a token and checks it was minted for this sort, view and
// workspace filter. Anything else is ErrInvalidCursor.
func DecodeCursor(token, sort, view, ws string) (Cursor, error) {
var c Cursor
b, err := base64.RawURLEncoding.DecodeString(token)
if err != nil || json.Unmarshal(b, &c) != nil {
return Cursor{}, ErrInvalidCursor
}
// A check may refuse only what this server could never mint. v, sort, view
// and ws must equal this request's; as_of must be positive, since it is the
// server's own clock (a future one is clamped by the handler, not refused);
// item_id must look like a store id. key and created_at are NOT checked:
// every int64 is an instant a key can hold, a pre-1970 date included
// (TASK-3959 part 2 r2).
if c.V != cursorVersion || c.Sort != sort || c.View != view || c.WS != ws || c.AsOf <= 0 ||
!validCursorItemID(c.ItemID) {
return Cursor{}, ErrInvalidCursor
}
// A score sort's cursor must carry both scores in range; an age cursor
// must carry neither. Anything else was not minted by this server.
if sort == SortAge {
if c.Score != nil || c.Score2 != nil {
return Cursor{}, ErrInvalidCursor
}
} else if c.Score == nil || c.Score2 == nil || *c.Score < NoScore || *c.Score > 100 || *c.Score2 < NoScore || *c.Score2 > 100 {
return Cursor{}, ErrInvalidCursor
}
return c, nil
}