* fix(node): bound GraphQL query cost * fix(node)!: bound repo names so emitted cursors always parse The reposPage cursor embeds base64(JSON(owner_did, name)) while parse_repo_cursor rejects cursors over 4096 bytes, but repo names were unbounded on every write path. A single long-named visible repo made the server emit an endCursor it then rejected as "invalid repository cursor", dead-ending pagination for every caller past that row. Bound names to MAX_REPO_NAME_LEN (100 bytes) at the create_repo and fork_repo handlers via a shared validate_repo_name helper (bad charset is still a 400), and reject overlong peer-supplied names in Db::upsert_mirror_repo (the sync caller already drops the error and skips the mirror). Also in this round: - docs/graphql-pagination.md states the 200 page-size maximum and corrects the over-budget remedy: a smaller limit does not reduce the reposPage or repos price, which is charged at the full MAX_VISIBLE_REPO_PAGE_SIZE scan regardless of limit. - Tests pin refUpdates(limit: -1) clamping to zero rows, the parse_repo_cursor wrong-shape-JSON arm with its exact "invalid repository cursor" message, the longest-admissible-name cursor round-trip, and the mirror write-path rejection. BREAKING CHANGE: the GraphQL `repos` field now returns an error instead of the full list when more than MAX_VISIBLE_REPO_PAGE_SIZE (200) repositories are visible; larger lists must use `reposPage` with `limit`/`after`. Repository names are additionally limited to 100 bytes at creation, fork, and mirror-registration time. * fix(node): address beardthelion P3 findings on name bound and input messages - Assert each rejection's own message in the reposPage invalid-inputs loop: the loop no longer accepts either message for every query, so a misattributed message (limit arm returning the cursor message) goes red. - Pin the create/fork name-bound wiring through the handler: new test drives create_repo with an overlong name via test_state_lazy and asserts the 400; deleting the validate_repo_name call site would proceed to the lazy DB pool and fail with a connection error instead. - Document the API-side vs disk-side validate_repo_name divergence on the new function, and tie the store's literal 100 to MAX_REPO_NAME_LEN so the two validators cannot drift apart on length. --------- Co-authored-by: Marlowe <[email protected]> Co-authored-by: cairn-intern <[email protected]>
3.5 KiB
GraphQL repository pagination
repos returns the complete visible repository list when it contains at most
200 entries. Larger lists return a GraphQL error directing the caller to
reposPage; they are never silently truncated.
Use reposPage for larger lists:
query Repositories($after: String) {
reposPage(limit: 50, after: $after) {
nodes {
name
ownerDid
}
hasNextPage
endCursor
}
}
Start with after: null. When hasNextPage is true, pass the returned
endCursor as after on the next request. Stop when hasNextPage is false.
An empty page has a null endCursor. The default limit is 50 and the maximum
page size is 200; limits below 1 or above 200 are rejected (see Query budgets
below). Malformed cursors, including decoded owner or name strings containing
NUL, return invalid repository cursor before database access.
Pages are ordered by normalized owner DID and repository name. Visibility and mirror deduplication are applied in the database before limiting the page; hidden and quarantined repositories do not occupy page slots or create a continuation signal. Cursors contain a position from the last returned visible repository, not a permission grant. Each request checks the current caller's visibility independently. Keep the same caller while traversing a list. Malformed reader lists in a repository's root visibility rule deny access to non-owners for that repository while other visible repositories remain listable.
Pagination is not a snapshot: concurrent renames, ownership changes, or visibility
changes can alter subsequent pages. Treat cursors as opaque and restart from the
first page when a fresh complete traversal is needed. Small legacy repos
queries retain their activity ordering.
Query budgets
Every GraphQL document is validated against two budgets before any resolver
runs. Both apply per document (per request), not per client: /graphql has
no per-caller rate limiting, so a rejected document can always be retried
smaller. The constants live in crates/gitlawb-node/src/graphql/mod.rs.
-
Complexity budget: 400 per document (
GRAPHQL_MAX_COMPLEXITY). Each root field carries a base cost plus the clamped page size plus the selected child fields:repos: 50 +MAX_VISIBLE_REPO_PAGE_SIZE+ child fields.reposalways scans the full visible corpus, so it is priced as the full page it is.reposPage: 50 +MAX_VISIBLE_REPO_PAGE_SIZE+ child fields. Every page request sorts the full deduplicated corpus, so the price does not depend onlimit: sevenreposPage(limit: 1)aliases cost the same as seven max-size pages.refUpdates: 100 + clampedlimit(MAX_VISIBLE_REF_UPDATES) + child fields. The 100 base covers the collector's fixed scan (the deduplicated repo set, the quarantined set, the visibility rules, and the bounded up-to-2048-row walk past withheld rows); the limit term prices only the rows returned.tasks: 50 + clampedlimit(MAX_VISIBLE_TASK_PAGE_SIZE) + child fields.task, mutations, subscriptions: 50 + child fields.
A document whose total exceeds 400 is rejected with
Query is too complex.before any resolver runs, so an over-budget document needs fewer aliases or fewer child fields. A smallerlimitdoes not helpreposPageorrepos: both are priced at the fullMAX_VISIBLE_REPO_PAGE_SIZEscan regardless oflimit. -
Depth cap: 14 per document (
GRAPHQL_MAX_DEPTH). Documents nested deeper than 14 selections are rejected withQuery is nested too deep.