Skip to content

feat(session list): per-key sort direction for --sort / ?order_by - #777

Merged
wesm merged 8 commits into
kenn-io:mainfrom
mjacobs:feat/per-key-sort-direction
Jun 19, 2026
Merged

feat(session list): per-key sort direction for --sort / ?order_by#777
wesm merged 8 commits into
kenn-io:mainfrom
mjacobs:feat/per-key-sort-direction

Conversation

@mjacobs

@mjacobs mjacobs commented Jun 19, 2026

Copy link
Copy Markdown
Contributor

Follow-up to #739. On that PR, @cpcloud asked whether sort direction could be specified per key rather than forcing one direction for all sort keys, given the SessionFilter abstraction. This implements that.

What changed

order_by (and --sort) now accept a comma-separated list of sort keys, each optionally suffixed :asc or :desc:

GET /api/v1/sessions?order_by=messages:desc,started:asc
agentsview session list --sort messages:desc,started:asc

Different keys can sort in different directions. A key with no suffix takes its direction from the descending param (HTTP) or --reverse (CLI), then from its own natural default; an explicit :asc/:desc always wins. The single-key forms (order_by=messages&descending=true, --sort messages -r) behave exactly as before, and existing pagination cursors keep working.

How it works

SessionFilter gains a structured Sort []SortKey (each key plus an optional direction); OrderBy/Descending remain as the single-key shorthand and parse fallback. OrderByClause and CursorPredicate now render N columns from one definition shared by SQLite, PostgreSQL, and DuckDB.

The keyset pagination predicate becomes the lexicographic OR-expansion -- (c1 OPdir1 v1) OR (c1 = v1 AND c2 OPdir2 v2) OR ... -- because a single row-value tuple comparison is only valid when every column shares one direction; mixed per-key directions require the expansion. The id tie-breaker follows the last sort term's direction (appended unless id is already a term). Cursors carry a per-key Keys list; legacy and single-key cursors still decode, and a cursor reused under a different sort list (different keys, order, or any direction) is rejected as an invalid cursor rather than silently paging wrong rows.

The order_by enum was replaced by free-form parsing with server-side validation (unknown key, bad direction token, duplicate key, empty term -> 400), shared by the list and sidebar-index routes; the valid keys are enumerated in the param doc.

Where to look

  • internal/db/sort.go -- sort registry, ParseSortSpec/ResolveSort, cursor helpers
  • internal/db/query_dialect.go -- OrderByClause / CursorPredicate OR-expansion
  • the three ListSessions implementations (internal/db, internal/postgres, internal/duckdb)
  • internal/server/huma_routes_sessions.go, internal/service/direct.go, cmd/agentsview/session_list.go

Limitations / possible follow-ups

mjacobs added 8 commits June 19, 2026 12:25
…tion

Introduce SortKey/ResolvedSort plus ParseSortSpec, FormatSortSpec,
ApplyFallbackDirection, and ResolveSort so a SessionFilter can carry an
ordered list of sort keys, each with its own direction, instead of a single
OrderBy/Descending pair.

OrderByClause and CursorPredicate now render N columns. The keyset predicate
uses the lexicographic OR-expansion that mixed per-key directions require -- a
single row-value tuple comparison is only valid when every column shares one
direction. Sort expressions are re-rendered per clause so secrets-sort bind
parameters stay positionally aligned across dialects.

Cursors gain a Keys list. Single-key cursors still populate the legacy
Sort/Desc/Value (and EndedAt) fields, and pre-sort legacy cursors still decode,
so live pagination tokens keep working. The SQLite, PostgreSQL, and DuckDB
stores resolve and render ordering from this one definition.

OrderBy/Descending remain as the single-key shorthand for existing callers and
as the parsed fallback when Sort is empty.
go fmt under Go 1.26 rewrites the ASCII '' in an activity-report test comment
into a Unicode curly quote. The comment is specifically about SQL empty-string
literals, and CI's gofmt does not make this change, so revert it to keep the
branch diff focused on the sort work.
Generalize ?order_by from a single enum value to a comma-separated list of
keys, each optionally suffixed :asc or :desc (e.g. messages:desc,started:asc).
A key with no suffix uses the descending param, then its natural direction, so
the single-key API stays backward compatible.

The service layer parses the spec into the structured SessionFilter.Sort and
validates it via ParseSortSpec, returning a 400 for unknown keys, bad direction
tokens, duplicates, or empty terms (replacing the dropped Huma enum). ResolveSort
keeps honoring a bare descending param against the implicit default recent key.
The order_by doc enumerates the valid keys, guarded against registry drift by
TestSortKeysDocumented.
--sort now takes a comma-separated list of keys, each optionally suffixed
:asc or :desc (e.g. messages:desc,started:asc). --reverse flips the natural
direction of any term without an explicit suffix and is folded into the
canonical spec string, so an explicit per-key direction is left untouched and
the wire form fully captures the ordering. Bad specs are rejected up front with
a clear "invalid sort" error.
Add end-to-end multi-key parity tests (messages ascending, started descending,
id tie-break) that walk the keyset cursor one page at a time and assert the
paginated order matches the full listing. These exercise the lexicographic
OR-expansion against the PostgreSQL ::bigint/::timestamptz casts and the DuckDB
CAST placeholders, matching the SQLite coverage.
Use strings.Cut for the sort-spec split and a range-over-int loop in the keyset
predicate expansion, as golangci-lint --fix suggests.
…le comment

`session list --sort "" --reverse` dropped the reversal silently: an empty spec
parsed to zero terms, so the --reverse fold had nothing to flip and the default
recent sort stayed descending. Materialize the implicit default key (new exported
db.DefaultSortKey) before folding so --reverse flips it to ascending, matching the
pre-multi-key behavior.

Also repoint the sessionSorts comment at the doc-string guard tests that replaced
the removed order_by enum and TestSortKeysMatchHumaEnum.
…c guard

The session-filter input struct is shared by the list and sidebar-index routes.
Dropping the order_by enum left the sidebar route accepting malformed specs like
?order_by=bogus (200, silently ignored) where the enum used to reject them.
Validate order_by in dbFilter() too so both routes return 400 on bad input.

Replace the substring-based doc guards with one test that parses the "Valid
keys:" clause into exact tokens and compares to db.SortKeys() in order, so a key
omitted from the clause can no longer be masked by the example text.
@roborev-ci

roborev-ci Bot commented Jun 19, 2026

Copy link
Copy Markdown

roborev: Combined Review (664f7cc)

No issues found.


Panel: ci_default_security | Synthesis: codex | Members: codex_default (codex/default, done, 7m2s), codex_security (codex/security, done, 1m46s) | Total: 8m48s

@wesm
wesm merged commit 287144f into kenn-io:main Jun 19, 2026
10 checks passed
@mjacobs
mjacobs deleted the feat/per-key-sort-direction branch June 20, 2026 03:42
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants