mirror of
https://github.com/gastownhall/beads.git
synced 2026-09-14 20:17:24 +08:00
8aaefe2d4f
* feat(httpapi): serve `bd list`'s order with `sort` on listIssues
The issue listing served one order, `(created_at DESC, id ASC)`, because the
cursor is a keyset position in it. The spec stated the constraint in its own
words: "the sort order is welded to the cursor contract, and a new order needs
new surface." That welding put the cost on every client wanting `bd list`'s
ordering — the only way to get it was to page the whole result set and sort
locally, which is the one cost on this surface that grows with the project.
Measured against a real `bd serve` over 1400 rows at the 200-row page an HTTP
client actually uses: walk-and-sort is 7 requests plus a client-side
comparator, `?sort=priority` is 1. Both are checked against `bd list --json`
row for row, because the claim that matters is not "fewer requests" but "the
same order".
`sort` takes `created` and `priority` and nothing else. Each value is a keyset
contract — a position shape, a strictly-after predicate, an index — not a
display preference, and both keys are provably total (priority and created_at
are non-null, id is unique) so a page boundary inside a run of equal keys
resolves on id. The seven other orders `bd list --sort` takes have no such key:
`id` is natural-numeric and no database expresses it, `updated` moves on every
write, `closed` is nullable, and status/title/type/assignee are mutable and
unindexed. `priority` here is (priority ASC, created_at DESC, id ASC), which is
both `bd list`'s flagless order and what `--sort priority` produces — a stable
priority sort over the created order is the same sequence — so one served order
retires the walk for both.
Absent `sort` still means `created`, permanently: it is the compatibility
contract for every client written before the parameter existed.
THE CURSOR NOW CARRIES ITS ORDER. This is the half that makes the parameter
safe rather than merely useful, and the obvious implementation gets it wrong.
The token was base64 of `{t,i}` with no binding, so a created-order cursor
replayed under `sort=priority` decoded perfectly and was read as a position in
a different total order — a page that skips and duplicates rows, served with a
200, undetectable by the client. Tokens are now `v2` with an explicit order
member, and decode takes the wanted order as a parameter so a mismatch is
unrepresentable at the seam rather than merely detectable past it. Outstanding
`v1` tokens stay readable as the created-order positions they are, so no
traversal in flight has to restart. This does not contradict "the token carries
no filters": filters select the set, the order decides what the position means.
Storage grows one field. IssueFilter.AfterPriority extends the existing
position; the predicate nests the created one rather than restating it, because
the created/id comparison is only reachable at the cursor's own priority.
Flattening it the way the created predicate's leading-bound trick invites looks
equivalent and is not — it re-delivers rows at the cursor's priority created
after it, which the new conformance walk catches against real Dolt.
NO INDEX MIGRATION, by measurement rather than by omission. A composite
(priority, created_at, id) index was tried on Dolt 2.2.0 against the production
predicate, in both the plain ascending and the mixed-direction spelling Dolt's
DDL accepts: the plan is byte-identical in all three cases, keeping the
single-column priority seek and the TopN. It would have bought write
amplification on every insert and every priority update for no measured change.
The sargability guard pins what is achieved — an IndexedTableAccess range on
idx_issues_priority — and records the measurement to redo if Dolt learns to
stream the composite.
Agent-Signature: claude-opus-5
Co-authored-by: CI Bot <ci@beads.test>
* fix(changelog): drop leftover diff3 conflict marker in sort entry
The rebased adopt-PR head left a stray '|||||||' merge-base marker on the first line of the new `sort` CHANGELOG entry (CHANGELOG.md:381), which `git diff --check` flagged as a leftover conflict marker. Remove only the marker line; the resolved entry content is unchanged. Maintainer fixup for PR #5666 adoption review.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: CI Bot <ci@beads.test>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
131 lines
7.1 KiB
Go
131 lines
7.1 KiB
Go
package issueops
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
// readyScopeField is one ListRequest field the ReadyFlag arm cannot carry,
|
|
// paired with the `bd list` flag that sets it. Naming both is the point of the
|
|
// refusal: a library caller looks for the struct field and a CLI caller for
|
|
// the flag, and neither should have to map one onto the other to learn which
|
|
// part of their request could not be honored.
|
|
type readyScopeField struct {
|
|
name string
|
|
flag string
|
|
set func(ListRequest) bool
|
|
}
|
|
|
|
// readyScopeFields is the drop set: every field a list request can set that
|
|
// does not survive the trip to the blocker-aware ready query.
|
|
//
|
|
// It was read off the two functions between this request and that query —
|
|
// internal/workapi.BuildListFilter, which turns the request into a storage
|
|
// filter, and internal/workapi.ReadyFilterFromIssueFilter, which projects that
|
|
// filter onto the narrower ready-work filter — and it is exactly the
|
|
// intersection: fields the builder copies onto the filter and the projection
|
|
// then discards.
|
|
//
|
|
// Four groups are deliberately ABSENT, because listing them here would refuse
|
|
// requests that are answered correctly today:
|
|
//
|
|
// - What the projection carries: IssueType, all five label forms, Assignee,
|
|
// NoAssignee, the exact Priority, ParentID, MolType, WispType,
|
|
// MetadataFields, HasMetadataKey, ExcludeTypes (and with it IncludeGates
|
|
// and IncludeInfra's type suppression), IncludeEphemeral (and with it
|
|
// IncludeInfra's plane half, which the ready query admits through its own
|
|
// ephemeral gate), Limit, Offset, and the MaxRows cap with its
|
|
// attribution.
|
|
//
|
|
// - THE HYDRATION KNOBS, SkipLabels and SkipCounts. They are the one group
|
|
// the "exactly the intersection" rule above would otherwise sweep in: the
|
|
// builder does copy them onto the filter and the projection does discard
|
|
// them. They stay out because they select what is HYDRATED rather than
|
|
// which rows match, so a ReadyFlag request that sets one is answered with
|
|
// the rows it asked for and merely pays for a column it did not want.
|
|
// Refusing that would be refusing a correct answer over its cost, and the
|
|
// promise is already stated where a caller reads it (ListRequest.ReadyFlag).
|
|
//
|
|
// - Status and AllFlag. The builder resolves both to "open" under ReadyFlag
|
|
// before the projection ever runs, so IssueFilter.Statuses is never
|
|
// populated on this path and there is nothing for the projection to drop.
|
|
// Ready work is open work; that override is pinned by the builder's golden
|
|
// file (internal/workapi/testdata/list_filter_golden.json,
|
|
// ready_flag_overrides_status).
|
|
//
|
|
// - NoPinnedFlag. Pinned is dropped by the projection, but the ready-work
|
|
// WHERE clause excludes pinned rows unconditionally
|
|
// (internal/storage/sqlbuild/ready.go), so a caller who asks for the
|
|
// pinned rows to be left out gets exactly that. The opposite request,
|
|
// PinnedFlag, asks for pinned rows only and would be answered with a set
|
|
// that can never contain one — it is in the list below.
|
|
var readyScopeFields = []readyScopeField{
|
|
{"IDFilter", "--id", func(r ListRequest) bool { return r.IDFilter != "" }},
|
|
{"TitleSearch", "--title", func(r ListRequest) bool { return r.TitleSearch != "" }},
|
|
{"SpecPrefix", "--spec", func(r ListRequest) bool { return r.SpecPrefix != "" }},
|
|
|
|
{"TitleContains", "--title-contains", func(r ListRequest) bool { return r.TitleContains != "" }},
|
|
{"DescContains", "--desc-contains", func(r ListRequest) bool { return r.DescContains != "" }},
|
|
{"NotesContains", "--notes-contains", func(r ListRequest) bool { return r.NotesContains != "" }},
|
|
{"ExternalContains", "--external-contains", func(r ListRequest) bool { return r.ExternalContains != "" }},
|
|
{"ExternalRef", "--external-ref", func(r ListRequest) bool { return r.ExternalRef != "" }},
|
|
|
|
{"CreatedAfter", "--created-after", func(r ListRequest) bool { return r.CreatedAfter != nil }},
|
|
{"CreatedBefore", "--created-before", func(r ListRequest) bool { return r.CreatedBefore != nil }},
|
|
{"UpdatedAfter", "--updated-after", func(r ListRequest) bool { return r.UpdatedAfter != nil }},
|
|
{"UpdatedBefore", "--updated-before", func(r ListRequest) bool { return r.UpdatedBefore != nil }},
|
|
{"ClosedAfter", "--closed-after", func(r ListRequest) bool { return r.ClosedAfter != nil }},
|
|
{"ClosedBefore", "--closed-before", func(r ListRequest) bool { return r.ClosedBefore != nil }},
|
|
{"DeferAfter", "--defer-after", func(r ListRequest) bool { return r.DeferAfter != nil }},
|
|
{"DeferBefore", "--defer-before", func(r ListRequest) bool { return r.DeferBefore != nil }},
|
|
{"DueAfter", "--due-after", func(r ListRequest) bool { return r.DueAfter != nil }},
|
|
{"DueBefore", "--due-before", func(r ListRequest) bool { return r.DueBefore != nil }},
|
|
|
|
{"DeferredFlag", "--deferred", func(r ListRequest) bool { return r.DeferredFlag }},
|
|
{"OverdueFlag", "--overdue", func(r ListRequest) bool { return r.OverdueFlag }},
|
|
{"EmptyDesc", "--empty-description", func(r ListRequest) bool { return r.EmptyDesc }},
|
|
{"NoLabels", "--no-labels", func(r ListRequest) bool { return r.NoLabels }},
|
|
{"NoParent", "--no-parent", func(r ListRequest) bool { return r.NoParent }},
|
|
{"PinnedFlag", "--pinned", func(r ListRequest) bool { return r.PinnedFlag }},
|
|
|
|
{"PriorityMin", "--priority-min", func(r ListRequest) bool { return r.PriorityMin != nil }},
|
|
{"PriorityMax", "--priority-max", func(r ListRequest) bool { return r.PriorityMax != nil }},
|
|
|
|
// The keyset position is a tuple and only the timestamp half decides
|
|
// whether one was supplied; neither AfterID nor AfterPriority alone is a
|
|
// position. All three are named in the refusal because a caller who dropped
|
|
// only the ones named would still be sending part of a position.
|
|
{"AfterCreatedAt/AfterID/AfterPriority", "cursor", func(r ListRequest) bool { return r.AfterCreatedAt != nil }},
|
|
}
|
|
|
|
// ValidateReadyFlagScope refuses a list request that combines ReadyFlag with a
|
|
// filter the blocker-aware ready query cannot carry, naming every field it
|
|
// could not honor. A generic refusal would leave the caller to bisect their
|
|
// own request.
|
|
//
|
|
// This is the enforcement half of ListRequest.ReadyFlag's promise; the other
|
|
// half is the doc comment there. It lives beside that promise rather than in
|
|
// any implementation, and it is called from the single builder every
|
|
// implementation of Reader runs (internal/workapi.BuildListFilter), so no
|
|
// backend can be missing it — a per-backend copy is the drift this role exists
|
|
// to remove.
|
|
//
|
|
// A request without ReadyFlag is never refused here: every field below is
|
|
// honored by the ordinary listing.
|
|
func ValidateReadyFlagScope(req ListRequest) error {
|
|
if !req.ReadyFlag {
|
|
return nil
|
|
}
|
|
var named []string
|
|
for _, f := range readyScopeFields {
|
|
if f.set(req) {
|
|
named = append(named, f.name+" ("+f.flag+")")
|
|
}
|
|
}
|
|
if len(named) == 0 {
|
|
return nil
|
|
}
|
|
return fmt.Errorf("%w: --ready cannot filter on %s; the blocker-aware ready query carries only part of the list vocabulary, so answering would return every ready issue rather than the ones asked for — drop --ready, or drop what it cannot carry",
|
|
ErrValidation, strings.Join(named, ", "))
|
|
}
|