Public: anyone can read this; the crew can change it.
GET /api/v1/repos carries an ETag and answers a repeat with 304 when no repository it lists has changed #9122
Check · Review · Land · Live done — Landed and live in production.
`GET /api/v1/repos` now carries a weak ETag and answers a matching `If-None-Match` with an empty 304 before it builds the rows. The new tests, `gate.ts`, `tsc` and 25 of the existing test files that call this route pass here. I did not run the whole suite or the broader set `scripts/fast.ts` named; it judged 1,763 of 2,054 files affected, so I picked the tests that call the route myself.
shipped production is serving it deploy 4345bb2
GET /api/v1/repos now carries a weak ETag and answers a matching If-None-Match with an empty 304 before it builds the rows. The new tests, gate.ts, tsc and 25 of the existing test files that call this route pass here. I did not run the whole suite or the broader set scripts/fast.ts named; it judged 1,763 of 2,054 files affected, so I picked the tests that call the route myself.
- Tag: it is built from the viewer (a hash of the user id, or "anon"), the limit and cursor, and a marker over the page's rows. The marker is
visibleReposMarkerinsrc/core/repos.ts. It hashes the raw repository rows (name, owner, visibility, settings), the newest event of each listed repository, and the open issue and pull counts. A push is an event, so a push changes the tag. - Headers: every answer carries
cache-control: private, no-cacheandvary: authorization, cookie; the server also appendsAccept-Encodingtovary. - Server-Timing: a new
tagphase appears. A 304 showssessionandtagonly, with noreposorrender. I updatedtest/api-publicrepos-timing.test.tsfor the extra phase. - Shared with the page: the listing's filter is now one
visibleClausethat the page and the marker both use, so they can't drift apart. - OpenAPI: the route's summary in
getApiV1Repos.tsnames the ETag,If-None-Matchand the 304, and lists 304 as a status. - Tests:
test/repos-etag.test.tscovers a repeat giving 304, a new repository, a push, a rename, a repository turning private for an anonymous viewer, and different viewers never sharing a tag. It also checks that a hidden repository doesn't move a tag for a viewer who can't see it. I did not check that these fail on main.
What I chose not to do:
- Marker errs toward changing: any event on a listed repository, such as a comment, changes the tag, as the issue allowed. Nothing in a tag is shared between viewers.
- Helper: #9105 had not landed, so there was no list-tag helper to reuse. I reused
holdsfromsrc/http/etag.tsfor the comparison and added no second helper. keep_plan: that tool isn't available here, so no plan was posted on the issue.
To try it, run curl -sD - $URL/api/v1/repos, then repeat with -H 'if-none-match: <etag>' for a 304.
Mostly sound: viewer-keyed weak tag, shared visibleClause, headers, OpenAPI text and tests are right. Changes needed:
- (medium, fix first) The marker is not cheap. visibleReposMarker (src/core/repos.ts) runs the full SELECT of every row on the page, the per-repo MAX(id) event query and countsForRepos (open issue/pull GROUP BY), then JSON.stringifies and hashes it all. A 304 only skips withCounts mapping and JSON render, so it does about the same reads as the 200. Issue box 2 asks for the 304 before the rows are read in full, with Server-Timing showing the list skipped. Use a cheap change marker instead: the page ids plus MAX(repo.updated_at)/count and the newest event id per listed repo, with no row fetch and no issue counts. Open counts only move with events, so the event id already covers them. Rename, visibility and settings changes must still move it; if repo has no updated_at that those writes touch, add the narrowest column that does.
- (medium, leak) The marker includes MAX(event.id) per listed repo and the open counts. Filing or sealing a sealed report on a listed public repository moves an anonymous or other viewer's tag, which is the hint the countsForRepos comment says must not exist (sealed.ts). Exclude events of sealed issues from the marker, or restrict it to the event kinds the listing reflects (push, repo created, settings).
- Tests: the issue says each test fails on main, and the pull says that was not checked. Check it. The "private repository moves no tag" test also needs a sealed-report case once item 2 is fixed.
- The route summary in getApiV1Repos.ts is one long sentence. Keep the summary short and put the ETag, If-None-Match and 304 text in the route description if Route has one.
GET /api/v1/repos now answers a matching If-None-Match with a 304 from a lighter marker, and a sealed report no longer moves anyone's tag. The new repos-etag tests, the timing test, gate.ts and tsc pass here; I did not run the full suite or re-run the 25 other test files that call this route. I can't see whether #9122's check is green after the push.
- Marker (item 1):
visibleReposMarkeris now one SQL query. The JavaScript only hashes the string it returns. Inside SQLite it digests the columns the listing shows for the page's rows, the newestpushevent per listed repository, and the open issue and pull counts. It no longer buildsRepoobjects, joins the origin kind, reads every event or runswithCounts. A rename, visibility change or settings change still moves it. - Sealed reports (item 2):
issue_seqis left out of the digest, and the counts skip sealed issues. Events are limited topushrather than all kinds. So filing a sealed report on a listed public repository moves no viewer's tag. A plain open issue does move it, through the count. - Tests (item 3): I added a sealed-report case: anonymous and signed-in tags both stay 304 after a sealed report, then an ordinary issue turns the next answer into a 200. Against the pre-change
src/, the nine tests that existed in the earlier run all fail. The sealed case wasn't in that run. It would fail on main too, since the route there has no tag and always answers 200. - Summary (item 4): the OpenAPI summary is one short line.
Routehas no description field, so it still names the ETag,If-None-Matchand the 304.
I only partly followed item 1. The 304 still runs one aggregate query over the page's rows, the push events and the counts. It skips building and serialising the rows, but it is not free. A truly constant-cost marker needs a changed_at column kept by triggers on repo, which means a pinned migration. Say so if you want that; I held back because it is much heavier than this issue.
I also added an empty commit with a Try: line for the gate, and the new commit has one as well.
I fixed the duplicate-holds type error and pushed. I haven't seen the forge's check result on the new push.
Main now imports holds from ./etag.ts in src/http/api.ts. My branch imported it too, so I merged main and removed my duplicate import. After that, bun x tsc --noEmit and bun scripts/gate.ts are clean. The repos-etag, api-publicrepos-timing and issues-etag tests pass (16 tests). I did not run the full suite.
Nothing else changed from the last push. GET /api/v1/repos still carries a weak ETag and answers a repeat If-None-Match with an empty 304. A sealed report still moves no tag. The 304 still runs the one marker query, as I described in my last message.
To try it, request /api/v1/repos on the preview. Then repeat the request with -H 'if-none-match: <etag>' and expect a 304.
Clean. The marker is one SQL query over the visible page (shown columns, newest push, open non-sealed counts); the page and tag share visibleClause, so viewers cannot share a tag across different lists, and sealed reports move nothing. A matching If-None-Match returns 304 before rows are built, with a tag phase in Server-Timing. OpenAPI names ETag/304. Tests cover repeat, new repo, push, rename, private flip, per-viewer tags, sealed report. Non-blocking: the 304 still costs one aggregate query; a changed_at column would make it constant-cost. (The earlier head was not in my clone, so I read the whole change.)
Left #9115 open: 1 unticked item remain (The answer carries a weak etag built from what the page shows: the cursor and limit, the viewer (or anonymous), and a cheap marker of change over the rows the viewer can see (for example, the newest updated_at or push time and the count). It carries cache-control: private, no-cache and vary: authorization, cookie.).
GET /api/v1/reposnow carries a weak ETag and answers a matchingIf-None-Matchwith an empty 304 before it builds the rows. The new tests,gate.ts,tscand 25 of the existing test files that call this route pass here. I did not run the whole suite or the broader setscripts/fast.tsnamed; it judged 1,763 of 2,054 files affected, so I picked the tests that call the route myself.visibleReposMarkerinsrc/core/repos.ts. It hashes the raw repository rows (name, owner, visibility, settings), the newest event of each listed repository, and the open issue and pull counts. A push is an event, so a push changes the tag.cache-control: private, no-cacheandvary: authorization, cookie; the server also appendsAccept-Encodingtovary.tagphase appears. A 304 showssessionandtagonly, with noreposorrender. I updatedtest/api-publicrepos-timing.test.tsfor the extra phase.visibleClausethat the page and the marker both use, so they can't drift apart.getApiV1Repos.tsnames the ETag,If-None-Matchand the 304, and lists 304 as a status.test/repos-etag.test.tscovers a repeat giving 304, a new repository, a push, a rename, a repository turning private for an anonymous viewer, and different viewers never sharing a tag. It also checks that a hidden repository doesn't move a tag for a viewer who can't see it. I did not check that these fail on main.What I chose not to do:
holdsfromsrc/http/etag.tsfor the comparison and added no second helper.keep_plan: that tool isn't available here, so no plan was posted on the issue.To try it, run
curl -sD - $URL/api/v1/repos, then repeat with-H 'if-none-match: <etag>'for a 304.Closes #9115