fix(server): tag the poll answers by their bytes and answer 304 to a matching If-None-Match #365

Merged
dominik.polakovics merged 1 commit from afk/361 into main 2026-11-01 02:14:02 +01:00

Closes #361

What

  • Server: GET /api/tree, /api/badges, /api/publish/pending and /api/locks send a strong ETag and Cache-Control: no-store on every 200. The tag is the xxhash64 of the body's bytes, not the derived stamp. A derived body's tag is computed once, when the entry is stored (Handler.derived now returns body, tag, err). The lock list and the badges/pending "unavailable" answers are hashed per request.
  • One owner for the conditional answer: writePollBody (internal/server/etag.go) answers a matching If-None-Match with 304 and no body, following RFC 9110 §13.1.2 (comma-separated list, weak comparison, *). The 304 keeps ETag, Cache-Control and, in branch mode, X-Deckle-Draft-Tip, and drops Content-Type. Error answers carry no tag and are never 304.
  • Web client: requestTextConditional replaces requestTextWithHeader. The tree, badges and locks stores keep the tag beside the body they hold and send it. A 304 is a successful poll that changes nothing: connected stays true, the badges still go out, and onForeignTip still fires when the tip moved. A language change drops the tree and badges tags; a page change drops the locks tag. The pending list is still fetched without a condition.
  • Docs: a "Polls are conditional" bullet in docs/api.md, plus a pointer line in each of the four route sections. A sentence in docs/architecture.md, the new ADR 0031, and a pointer to it from ADR 0030. Skills, AGENTS.md, the README and the styleguide are untouched, as the brief says.

Proof

  • go vet ./... and go test ./... pass. New tests in internal/server/etag_test.go cover:
    • a 304 on all four routes, with the draft tip in branch mode;
    • a title save gives 200, an element-only save gives 304 (this pins the body-tag decision), and a hand edit gives 200;
    • a lock taken or released gives 200;
    • *, a list and W/ give 304, and another language's tag gives 200;
    • error answers carry no tag;
    • the unavailable answers are tagged.
  • Benchmarks on the generated 2,574-page site: BenchmarkPollTree uncached / cached / conditional at 148 / 23 / 25 ms, writing 567,354 / 567,354 / 0 body bytes. BenchmarkPollCycleConditional is new.
  • npm test passes (140 files, 3477 tests) and tsc --noEmit passes. The new web/src/test/conditionalPolls.test.ts and the client.test.ts cases cover every vitest acceptance criterion.

One note: a page title changes through POST /api/page/settings/save. POST /api/page/save edits elements only, so the tests use each one for the case it actually covers.

🤖 Generated with Claude Code

Closes #361 ## What - **Server**: `GET /api/tree`, `/api/badges`, `/api/publish/pending` and `/api/locks` send a strong `ETag` and `Cache-Control: no-store` on every `200`. The tag is the xxhash64 of the body's bytes, not the derived stamp. A derived body's tag is computed once, when the entry is stored (`Handler.derived` now returns `body, tag, err`). The lock list and the badges/pending "unavailable" answers are hashed per request. - **One owner for the conditional answer**: `writePollBody` (`internal/server/etag.go`) answers a matching `If-None-Match` with `304` and no body, following RFC 9110 §13.1.2 (comma-separated list, weak comparison, `*`). The `304` keeps `ETag`, `Cache-Control` and, in branch mode, `X-Deckle-Draft-Tip`, and drops `Content-Type`. Error answers carry no tag and are never `304`. - **Web client**: `requestTextConditional` replaces `requestTextWithHeader`. The tree, badges and locks stores keep the tag beside the body they hold and send it. A `304` is a successful poll that changes nothing: `connected` stays true, the badges still go out, and `onForeignTip` still fires when the tip moved. A language change drops the tree and badges tags; a page change drops the locks tag. The pending list is still fetched without a condition. - **Docs**: a "Polls are conditional" bullet in `docs/api.md`, plus a pointer line in each of the four route sections. A sentence in `docs/architecture.md`, the new ADR 0031, and a pointer to it from ADR 0030. Skills, AGENTS.md, the README and the styleguide are untouched, as the brief says. ## Proof - `go vet ./...` and `go test ./...` pass. New tests in `internal/server/etag_test.go` cover: - a `304` on all four routes, with the draft tip in branch mode; - a title save gives `200`, an element-only save gives `304` (this pins the body-tag decision), and a hand edit gives `200`; - a lock taken or released gives `200`; - `*`, a list and `W/` give `304`, and another language's tag gives `200`; - error answers carry no tag; - the unavailable answers are tagged. - Benchmarks on the generated 2,574-page site: `BenchmarkPollTree` uncached / cached / conditional at 148 / 23 / 25 ms, writing 567,354 / 567,354 / 0 body bytes. `BenchmarkPollCycleConditional` is new. - `npm test` passes (140 files, 3477 tests) and `tsc --noEmit` passes. The new `web/src/test/conditionalPolls.test.ts` and the `client.test.ts` cases cover every vitest acceptance criterion. One note: a page title changes through `POST /api/page/settings/save`. `POST /api/page/save` edits elements only, so the tests use each one for the case it actually covers. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
fix(server): tag the poll answers by their bytes and answer 304 to a matching If-None-Match
All checks were successful
ci / build-and-test (pull_request) Successful in 6m44s
ci / release (pull_request) Has been skipped
4337a23b1e
GET /api/tree, /api/badges, /api/publish/pending and /api/locks now carry
a strong ETag (xxhash64 of the body, computed once per derived entry, per
request for the lock list and the unavailable answers) and Cache-Control:
no-store; one helper, writePollBody, answers a matching If-None-Match
(RFC 9110 §13.1.2) with 304 and no body, keeping the draft tip in branch
mode. Error answers carry no tag.

The SPA's tree, badges and locks polls keep the tag beside the body they
hold, send it through requestTextConditional, and treat a 304 as a
successful poll that changes nothing; a language or page change drops the
tag. The pending list stays unconditioned.

Documented in docs/api.md, docs/architecture.md and ADR 0031.

Closes #361

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Author
Owner

[autoland] verdict: pass

PASS. Signal: labctl pr checks 365 = success (ci / build-and-test ran npm run build, npm test, go vet ./..., go test ./...; release skipped by design). Conventions: Conventional Commits title, Closes #361 present, merges cleanly with current main (git merge-tree, no conflicts; main's #364 touches disjoint files). Scope matches the brief: one owner for the conditional answer (internal/server/etag.go writePollBody/ifNoneMatch), Handler.derived returns body+tag with the tag computed once per stored entry, all four poll handlers go through it, error answers untagged; client requestTextConditional replaces requestTextWithHeader, tree/badges/locks stores keep tag beside body and drop it on lang/page change (plus the badges tag when another language's tree lands, which a 304 would otherwise leave stale). Tests cover every acceptance criterion (internal/server/etag_test.go incl. the element-save 304 / title-save 200 / hand-edit 200 pin; web/src/test/conditionalPolls.test.ts, client.test.ts). Docs: api.md convention bullet + four route pointers, architecture.md sentence, ADR 0031, ADR 0030 pointer; skills/AGENTS.md/README/styleguide untouched as the brief states. gofmt clean.

[autoland] verdict: pass PASS. Signal: labctl pr checks 365 = success (ci / build-and-test ran npm run build, npm test, go vet ./..., go test ./...; release skipped by design). Conventions: Conventional Commits title, Closes #361 present, merges cleanly with current main (git merge-tree, no conflicts; main's #364 touches disjoint files). Scope matches the brief: one owner for the conditional answer (internal/server/etag.go writePollBody/ifNoneMatch), Handler.derived returns body+tag with the tag computed once per stored entry, all four poll handlers go through it, error answers untagged; client requestTextConditional replaces requestTextWithHeader, tree/badges/locks stores keep tag beside body and drop it on lang/page change (plus the badges tag when another language's tree lands, which a 304 would otherwise leave stale). Tests cover every acceptance criterion (internal/server/etag_test.go incl. the element-save 304 / title-save 200 / hand-edit 200 pin; web/src/test/conditionalPolls.test.ts, client.test.ts). Docs: api.md convention bullet + four route pointers, architecture.md sentence, ADR 0031, ADR 0030 pointer; skills/AGENTS.md/README/styleguide untouched as the brief states. gofmt clean.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
Cloonar/deckle!365
No description provided.