mirror of
https://github.com/PerpetualSoftware/pad.git
synced 2026-10-25 22:10:25 +00:00
ca184cff96
* 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
201 lines
6.5 KiB
Go
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
|
|
}
|