Skip to content

Bound the account-wide listings, and drop the boost aggregate - #590

Open
jeremy wants to merge 5 commits into
mainfrom
fix/account-wide-pagination
Open

Bound the account-wide listings, and drop the boost aggregate#590
jeremy wants to merge 5 commits into
mainfrom
fix/account-wide-pagination

Conversation

@jeremy

@jeremy jeremy commented Jul 29, 2026

Copy link
Copy Markdown
Member

Soaking v0.8.0-rc.1 against a ~80-project production account showed the account-wide listings unusable at real scale. The cause was a contract decision rather than a bug: ACCOUNT-WIDE-LISTINGS.md I5 pinned each command's account-wide default to its project-scoped default — but project-scoped "all" is one project's items and account-wide "all" is the whole account. The same word bought very different amounts of work.

I5 also blessed "fetch page 0, then truncate", which made --limit useless exactly when it mattered: cards list --limit 3 fetched all 2142 cards before trimming.

What changed

accountWideCollect — one bounded walk over positive pages, shared by all eight paths (todos, comment, messages, cards, checkins, forwards, files, boost). It stops on an empty page, a page that adds no items, the cap, or the server's declared total. Two rules callers must obey are stated on the helper:

  • Meta.TotalCount compares against len(items) — top-level elements, which on a grouped payload are the groups, never the inner-item counter.
  • It does not trim. It overshoots to the page boundary and every caller cuts to the exact cap.

Uniform default cap of 100; --all is how you ask for the account. Changes the default for cards, checkins, forwards, files. Project-scoped defaults untouched.

Pagination flags for bare files list--limit/-n, --page, --all. A listing with no escape hatch cannot recover from a server error mid-crawl, which is exactly how the files 500 became unrecoverable. Project-scoped files list has no pagination to thread, so it rejects all three by name rather than accepting and ignoring them.

The account-wide boost listing is withdrawn. /boosts.json turned out to be an easter egg — unlinked from the web UI, and served by a query costing ~44s per page regardless of depth (basecamp/bc3#12458). It is being removed server-side, so the CLI stops calling it rather than building a contract on pathological database performance. boost list now requires an item ID and carries no --all-projects/--limit/--page/--all. Seven commands list account-wide, not eight.

Both overdue paths now accept --all and reject --page. They are unpaginated, so --page addresses nothing; but capping at 100 and rejecting --all had left item 101 unreachable by any combination of flags.

Soak results (account 2914079)

Command rc.1 this branch
cards list --all-projects --status completed timeout at 94s 24.9s, 1 request
checkins answers --all-projects timeout 1.5s, 4 requests
cards list --limit 3 16s (fetched 2142, trimmed) 1.2s, 1 request
forwards list --all-projects 1.6s, 4 requests
comments list --all-projects 2.1s, 4 requests
messages list --all-projects 2.6s, 4 requests

No default or --limit request omits page= any more. --all still omits it deliberately — that is the spelling for the full traversal.

What this does not fix

One listing remains broken for a server-side reason, filed and holding the release (basecamp-cli#589):

  • basecamp/bc3#12459files.json 500s on any page holding an attachment whose parent is an autolinked image. The bounded default does not dodge it: GearedPagination ramps page sizes (15, 30, 50, …), so the first 100 items span pages 1–4 and the walk lands on the failing page.

The other blocker, basecamp/bc3#12458 (boosts.json at ~44s/page), is resolved here by removing the caller — see above.

Merging this is independent of #12459; the release is what waits on it.

Notes for review

  • accountWideRespOpts lost its moreFlag parameter. It existed so the two flagless commands would not be advised to use an --all they did not have — they have one now, and the linter caught the parameter going constant.
  • The stub transport grew page-awareness (stubRoute.pages). Without it a fixture serves the same body for every page number, so a bounded walk never sees an empty page and runs to the cap — which would have hidden the behavior under test.
  • Unit tests cover each guard, the TotalCount-vs-len(items) rule, page-boundary overshoot, the exact-cap case (TotalCount == limit is complete, not truncated), and a total present only on page 1.
  • .surface: 21 additive lines (3 flags × 7 files-family spellings). Boost contributes the only removals--all-projects and the argument going optional → required, both of which shipped in v0.8.0-rc.1, so both are acknowledged in .surface-breaking. The --limit/--page/--all this branch briefly gave boost never shipped, so they are rewound out of the baseline rather than recorded as breaks that never happened. Net change against rc.1 is exactly the two real ones.

Summary by cubic

Bound account-wide listings to a fast, bounded page walk with a 100-item default cap, replacing full-account crawls and making --limit effective. Removed the account-wide boosts feed due to extreme server-side latency; boost list is now item-scoped only and requires <id|url>.

  • New Features

    • Shared bounded page walker across todos, comments, messages, cards, checkins, forwards, and account-wide files; stops on empty page, no new items, cap, or known total.
    • Uniform default cap of 100 for account-wide lists; use --all to traverse the whole account. Project-scoped defaults unchanged.
    • Added --limit, --page, --all to account-wide files list; project-scoped files list rejects these. Overdue listings accept --all and reject --page.
  • Bug Fixes

    • --limit no longer fetches everything before trimming; callers trim after a page-boundary overshoot.
    • Correct Meta.TotalCount handling, including totals present only on page 1; stop reporting truncation when total equals the limit.
    • Removed the now-constant moreFlag parameter; tests and stub transport are page-aware to validate bounded walks.

Written for commit 23a794a. Summary will update on new commits.

Review in cubic

jeremy added 4 commits July 29, 2026 16:36
Soaking v0.8.0-rc.1 against a ~80-project account showed the account-wide
listings are unusable at real scale. The cause is a contract decision, not a
bug: I5 pinned each command's account-wide default to its project-scoped
default. But project-scoped "all" is one project's items and account-wide
"all" is the whole account, so the same word bought very different work.

  files list --all-projects              HTTP 500 mid-crawl, no flag to recover with
  cards list --all-projects --status completed   timed out at 94s (--page 1: 24s)
  checkins answers --all-projects        timed out (one page: 1s)

I5 also blessed "fetch page 0, then truncate", which made --limit useless
exactly when it mattered: cards --limit 3 fetched all 2142 cards before
trimming.

Add accountWideCollect, one bounded walk over positive pages shared by all
eight paths. It stops on an empty page, a page that adds no items, the cap, or
the server's total. Two rules callers must know, so they are stated on the
helper: Meta.TotalCount compares against len(items) — the top-level elements,
which for a grouped payload are the groups, not the items nested inside — and
the walk does not trim, so every caller cuts to the exact cap itself.

Default to 100 for the paginated listings; --all is how you ask for the
account. This changes the default for cards, checkins, forwards, and files.
Project-scoped defaults are untouched.

Give boost list and bare files list the --limit/--page/--all they never had. A
listing with no escape hatch cannot recover from a server error mid-crawl,
which is exactly how the files 500 became unrecoverable. Where a path has no
pagination to thread — project-scoped files list, item-scoped boost list — it
rejects all three by name rather than accepting and ignoring them.

Both overdue paths now accept --all and reject --page. They are unpaginated, so
--page addresses nothing, but capping at 100 while also rejecting --all left
item 101 unreachable by any combination of flags.

boost keeps a first-page default: /boosts.json?page=1 alone measured 93s, so
walking toward 100 by default would multiply an already unacceptable wait. The
exemption covers the default only — its flags walk like everywhere else — and
its notices are per-mode, since "Showing first page" is false for --page 2. The
slowness is server-side and tracked there.

Dropping accountWideRespOpts's moreFlag parameter falls out of this: it existed
so the two flagless commands would not be told to use an --all they did not
have. They have one now.
The contract doc carried a hypothesis; production tracing settles it. Server
timings for one page=40 request, 44,630ms total:

  Boost Load   22,097.6ms   the paginated SELECT
  ~60 preloads      <2.3ms each
  Boost Count  22,265.7ms   GearedPagination's total-count query
  Completed 200 OK in 44630ms (Views: 244.7ms | ActiveRecord: 44370.9ms)

99.5% database time, in two statements of near-identical cost. That corrects
the record twice over: the N+1 hypothesis is dead — the controller's preloads
work, and 60 of 64 queries are trivial — and ordering the derived table, which
the doc named as the suspect, is only half the story. The count pays the same
materialization again for pagination metadata, so fixing the ORDER BY alone
would leave roughly half the latency behind.

The 93s figure was also wrong: that was the client giving up after three 30s
attempts. A page costs ~44s, and page 40 costs the same as page 1 — the flat
cost curve the materialization predicts.

Since ~44s exceeds the SDK's 30s client timeout, boost list --all-projects does
not merely run slow, it cannot complete. Filed as basecamp/bc3#12458, tracked
alongside the files.json 500 (basecamp/bc3#12459) in basecamp-cli#589.
accountWideCollect checked the cap before exhaustion, so a listing whose total
equalled the limit came back capped=true and the callers turned that into
"more may exist" about a listing with nothing left in it. Test the known total
first, and let the trim decide: holding every item still counts as capped when
the caller is about to cut down to the cap.

The exhaustion bound also read TotalCount from the current page while the
helper documents the first page's as authoritative. A later page omitting
X-Total-Count switched the bound off mid-walk and kept requesting past the
declared end of the listing.

messages and comments were discarding capped entirely, which made truncation
silent on exactly the feeds that withhold a total — the case the notice exists
for. messages also had a local `capped` meaning "this invocation applies a
cap", which is a different question from "the walk stopped short"; it is now
`bounded`, and the sorted path sets capped when its own trim removes anything.
The doc claimed the derived-table materialization as established. The tracing
established the shape — two ~22s statements, 99.5% db time, N+1 disproved —
but not the query plan behind it, and bc3#12458 correctly asks for an EXPLAIN
rather than asserting one. Say the same thing here.
Copilot AI review requested due to automatic review settings July 29, 2026 23:45
@github-actions github-actions Bot added commands CLI command implementations tests Tests (unit and e2e) skills Agent skills docs enhancement New feature or request labels Jul 29, 2026

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 2782cfa5a9

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +61 to +64
// accountWideDefaultLimit bounds every paginated account-wide listing that has
// no reason to differ. Project-scoped defaults are untouched: capping one
// project's items and capping the whole account are not the same promise.
const accountWideDefaultLimit = 100

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Update help text for the new capped defaults

When these commands take the account-wide path, this new default caps results at 100, but their public flag descriptions still advertise --limit 0 as fetching all (cards.go:109, checkins.go:606, and forwards.go:68). Consequently, users following --help can receive only the first 100 records while expecting a complete listing; make the descriptions scope-aware or state that account-wide zero defaults to 100 and --all is required.

Useful? React with 👍 / 👎.

Comment on lines +250 to 251
if explicitLimit {
notice += " (raise or drop --limit for more)"

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Do not tell users to drop a larger explicit limit

When an account contains more than an explicit limit above 100—for example, --limit 500 with 1,000 results—dropping the flag now restores the new 100-item default and returns fewer results, contrary to this notice. The same incorrect advice is duplicated for boosts at boost.go:332, where dropping a limit falls back to one page; both notices should recommend raising the limit or replacing it with --all.

Useful? React with 👍 / 👎.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR makes account-wide listing commands scale on large Basecamp accounts by replacing implicit full-account crawls with a shared bounded page-walk (default cap 100), and by adding missing pagination flags so operators can explicitly request more (or everything) when needed.

Changes:

  • Introduces accountWideCollect to bound account-wide fetches via positive pages, with consistent stop conditions and caller-side trimming.
  • Standardizes account-wide defaults to a cap of 100 (with the documented boost list default exception) and updates notices to match bounded vs complete results.
  • Adds --limit/-n, --page, --all to previously-flagless account-wide listings (files list, boost list) and rejects these flags on truly unpaginated scoped paths.

Tip

If you aren't ready for review, convert to a draft PR.
Click "Convert to draft" or run gh pr ready --undo.
Click "Ready for review" or run gh pr ready to reengage.

Reviewed changes

Copilot reviewed 21 out of 21 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
skills/basecamp/SKILL.md Documents new bounded defaults and pagination flags for account-wide listings.
internal/commands/accountwide.go Adds shared bounded collector + helpers, updates response option helper signature.
internal/commands/accountwide_test.go Adds unit tests covering all accountWideCollect stop/guard semantics.
internal/commands/recording_test.go Makes stub routes page-aware to support bounded-walk testing.
internal/commands/todos.go Switches grouped todo account-wide collection to accountWideCollect; updates overdue --all behavior.
internal/commands/todos_test.go Updates defaults and adds coverage for overdue --all behavior.
internal/commands/messages.go Uses bounded collection for unsorted account-wide; preserves full-fetch for sorted mode; adjusts notices.
internal/commands/messages_test.go Updates expectations for new default cap behavior and request sequencing.
internal/commands/comment.go Switches account-wide comment collection to bounded walk + cap notice helper.
internal/commands/forwards.go Bounds the default account-wide listing; supports explicit page/--all behavior.
internal/commands/forwards_test.go Pins request sequences for bounded defaults via page-aware stubs.
internal/commands/checkins.go Bounds default account-wide answers listing and adds cap notices consistent with bounded fetch.
internal/commands/checkins_test.go Updates assertions for bounded behavior and new notice wording.
internal/commands/files.go Adds pagination flags for account-wide files; rejects paging flags on project-scoped folder listing.
internal/commands/files_test.go Adds coverage for new files pagination flags and project-scoped rejection behavior.
internal/commands/cards.go Bounds default account-wide card listings; updates overdue behavior to accept --all and cap by default.
internal/commands/cards_test.go Pins request sequences and adds tests for overdue --all semantics and updated notices.
internal/commands/boost.go Adds pagination flags to account-wide boosts with first-page default; rejects paging flags for item-scoped boosts.
internal/commands/boost_test.go Adds coverage for the four boost account-wide modes and new flags/rejections.
ACCOUNT-WIDE-LISTINGS.md Updates the contract: bounded defaults, new pagination rules/flags, and documented exceptions.
.surface Records the additive CLI surface changes for new flags across spellings.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +159 to +161
if !capped || meta.TotalCount > count {
return ""
}
The /boosts.json aggregate is an easter egg: unlinked from the Basecamp web UI,
and served by a query that spends ~44s in MySQL on every page regardless of
depth (basecamp/bc3#12458). It is being withdrawn server-side, so the CLI stops
calling it instead of building a contract on top of that.

boost list now requires an item ID. It loses --all-projects along with the
--limit/--page/--all this branch had given it: an item's boosts arrive in one
unpaginated response, and the SDK documents BoostListOptions.Page as not
honoring a page number, so there was never anything for those flags to address
once the aggregate went away. rejectScopedPaginationFlags is no longer needed
here — flags that do not exist cannot be silently ignored.

Everything().Boosts() stays in the SDK. Whether it should is the SDK's call;
this repo just stops reaching for it, and API-COVERAGE.md now records 16 of the
17 aggregates as surfaced with the reason for the sixteenth.

Seven commands list account-wide, not eight.

On the surface baseline: --limit/--page/--all on boost were added and removed
inside this unmerged branch, so they never shipped and recording them in
.surface-breaking would claim a break that never happened. They are rewound out
of the baseline instead. The two changes that did ship in v0.8.0-rc.1 --
`--all-projects`, and the argument going from optional to required -- are
acknowledged as real removals. Net surface change against rc.1 is exactly
those two.
Copilot AI review requested due to automatic review settings July 30, 2026 02:13
@jeremy jeremy changed the title Bound the account-wide listings instead of crawling the account Bound the account-wide listings, and drop the boost aggregate Jul 30, 2026
@github-actions github-actions Bot added the breaking Breaking change label Jul 30, 2026
@github-actions

Copy link
Copy Markdown

⚠️ Potential breaking changes detected:

  • The CLI command 'boost list' no longer supports listing boosts across every project using the --all-projects flag. It now requires an item ID for the listing to function and does not fall back to account-wide listings, which will break scripts or workflows that rely on the removed functionality.

Review carefully before merging. Consider a major version bump.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 23 out of 23 changed files in this pull request and generated no new comments.

Comments suppressed due to low confidence (4)

internal/commands/accountwide.go:157

  • The comment above accountWideCapNotice says it defers whenever the server reported a total, but the implementation actually defers only when meta.TotalCount > count. Updating the comment to match the real behavior will avoid confusion for future readers.
// It defers to accountWideRespOpts whenever the server reported a total, since
// "N of M" is strictly more informative than "more may exist". The walk stops
// early by design, so on the feeds that withhold X-Total-Count the count is all
// there is to say.

internal/commands/accountwide.go:253

  • When meta.TotalCount > count and the user is on the default cap (no explicit --limit), the notice currently only suggests --all. That pushes users toward a potentially slow full crawl even though --limit is also a supported (and often cheaper) way to raise the cap.
			notice += " (use --all for the complete list)"

internal/commands/accountwide.go:229

  • This comment references "1 boosts" as the motivating example for singular/plural handling, but account-wide boosts have been removed in this PR. Consider making the wording generic so it stays accurate even as listings come and go.
// accountWideRespOpts builds the summary and truncation notice shared by the
// account-wide listings. singular/plural are the noun in both forms, since a
// one-item account-wide listing is common enough that "1 boosts" shows up.

internal/commands/accountwide.go:164

  • accountWideCapNotice takes only a plural noun and will emit ungrammatical text for singular counts (e.g., "Showing the first 1 messages"). Consider changing the helper to accept both singular and plural (or otherwise handle singularization) and update callers accordingly.
	return fmt.Sprintf(
		"Showing the first %d %s; more may exist (use --all for every page, or --limit to raise the cap)",
		count, plural)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

breaking Breaking change commands CLI command implementations docs enhancement New feature or request skills Agent skills tests Tests (unit and e2e)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants