Skip to content

feat(core): QUERY is a read, not a write (Refs #462) - #675

Open
rejifald wants to merge 1 commit into
mainfrom
claude/query-safe-method
Open

feat(core): QUERY is a read, not a write (Refs #462)#675
rejifald wants to merge 1 commit into
mainfrom
claude/query-safe-method

Conversation

@rejifald

@rejifald rejifald commented Aug 6, 2026

Copy link
Copy Markdown
Owner

Implements part 1 only of #462 — first-class QUERY. Part 2 (keepalive/Beacon) is untouched, which is why this is Refs, not Closes; the issue itself says the two are independently shippable.

method: 'QUERY' already sent its body and already cached correctly under cache: { methods: 'QUERY' }, because the key folds the request body in. What it did not have was the engine's agreement that it is a read: "safe method" was spelled === 'GET' || === 'HEAD' inline, so everything else was a write by default.

The four sites, and what each one actually asks

The issue's framing is that GET/HEAD is special-cased in three places and one isSafe helper fixes all three. That is the one thing here I'd push back on. The sites look alike and ask three different questions, and conflating them is precisely how a QUERY loses its body. So: one predicate, used at the two sites that mean it, and a sharpened comment at the two that don't.

site question it asks change
engine.ts applyIdempotency is this a read? isSafeMethod
stitch.ts warnConstruction is this a read? (the 4th site, see below) isSafeMethod
http-adapter.ts encodeRequestBody may this method carry a body on the wire? left as GET/HEAD, commented
http-adapter.ts followRedirects does a 301/302 downgrade this to a GET? QUERY exempted — spec text, not safety
cache.ts ['GET','HEAD'] default unchanged, documented

isSafeMethod — RFC 9110 §9.2.1's safe set, plus QUERY

GET, HEAD, OPTIONS, TRACE, QUERY. Internal (util.ts, not on the barrel), case-insensitive, fail-closed on an unknown verb.

  • No Idempotency-Key on a safe method. With idempotency configured, a QUERY is no longer stamped. There is no side effect to collapse, and the header would have varied the cache key on every send — so a stitch that opted into methods: 'QUERY' was quietly getting a 0% hit rate.
  • The construction nudge follows it. Declaring idempotency on a QUERY now logs the same "the key is sent on writes only" hint a GET gets.

A second, incidental behaviour change, stated plainly: OPTIONS and TRACE stop being stamped too, and now get the nudge. They were only ever getting a key because they were not GET or HEAD. I chose the RFC-complete set over {GET, HEAD, QUERY} because a predicate called isSafeMethod has to mean what RFC 9110 says it means — a future call site routed through a set that quietly omits OPTIONS would inherit a bug. It is pinned by test, not left to be discovered. (It is not free: see the budget section — it is 21 of the 58 bytes.)

stitch.ts:427 — the fourth site, which the issue does not list

It is the same "is this a read?" notion, not a different question wearing the same shape. It is the construction-time mirror of applyIdempotency: it fires exactly when the key would be dropped, to say so. Fixing the engine without it would have created the failure the nudge exists to prevent — a QUERY + idempotency config that is now silently inert with nothing printed. Sharing one predicate means the two cannot drift: a method the engine skips is a method this warns about.

encodeRequestBody — deliberately NOT routed through the helper

This branch enforces a transport constraint, not a safety rule: fetch throws a TypeError when a GET/HEAD init carries a body. QUERY is equally safe and must keep its body — the body is the query. Widening this to "safe" would have deleted the payload of every QUERY request, i.e. the one thing that already worked. Left at GET/HEAD with a comment saying why, and OPTIONS (safe, body kept) is asserted in the suite as the proof this is a different predicate.

Redirects — changed, because the draft says so by name

The issue does not mention this; I read the spec while checking whether the site should be routed through the helper, and found a real bug.

The downgrade-to-GET on a 301/302 is a historical exception granted to POST (RFC 9110 §15.4.2/§15.4.3). draft-ietf-httpbis-safe-method-w-body rules it out for QUERY in as many words:

the exceptions for redirecting a POST as a GET request after a 301 or 302 response do not apply to QUERY requests

— the server is asking for "a similar QUERY request to the new target URI". Applying it dropped the body, so a redirect silently turned a filtered read into an unfiltered one. Now exempt.

Scoped to exactly what the spec mandates:

  • Not isSafeMethod. OPTIONS/TRACE are safe and no spec text exempts them, so they keep today's behaviour. This is the one place where the RFC-complete set would have been the wrong predicate.
  • 303 stays unconditional — the draft agrees it means the result "can be accomplished via a normal retrieval request" at the Location.
  • 307/308 already preserved method and body; unchanged.
  • POST/PUT/PATCH/DELETE redirect exactly as before, asserted.
  • Default fetch transport only. axiosAdapter delegates rewriting to follow-redirects and xhrAdapter to the browser; neither is reachable from here.

One pre-existing deviation I noticed and did not touch: this loop rewrites HEADGET on a 303, where the fetch spec preserves HEAD. Unrelated to QUERY, so it stays out.

Cache default — not changed, on purpose

Still ['GET','HEAD']. The issue says explicit opt-in "is defensible" and asks only that it be documented, so that is all this does. QUERY is now documented as a valid cache.methods entry that keys on the body, alongside the GraphQL POST opt-in it is exactly analogous to.

The optional type widening — included

StitchConfig.method is now KnownMethod | (string & {}). Included because it is what closes the issue's gap 4 (nothing told an author QUERY exists), it is purely additive, and it passes every gate: check:contract clean (KnownMethod trips no R1 suffix), check:types across 38 projects, tsd, check:exports. Zero runtime bytes — the type is erased.

known-method.test-d.ts pins the additive claim rather than asserting it, and I verified it is load-bearing: narrowing method to a closed KnownMethod makes it fail on both the custom-verb and the declare const runtimeVerb: string cases.

✖  31:47  Type "PROPFIND" is not assignable to type KnownMethod.
✖  36:47  Type string is not assignable to type KnownMethod.

One cosmetic regression, so it is not discovered later: the playground autocomplete's detail line for method now reads KnownMethod | (string & {}) where it read string. A reader who cannot resolve the name learns less than before. The IDE hover — the thing the widening is for — expands it properly. Worth flagging as a revert candidate if you dislike it; it is one line of types.ts and the generated file.

What I left out of the issue's proposal

isIdempotentMethod is not here. It has zero call sites: retry is gated on status (retry.on / the verdict), never on method idempotency, so the helper would be a function with no behaviour attached, shipping bytes on an entry with one byte of headroom (below). The set it would return is derivable the moment something needs it (safe ∪ PUT, DELETE). Adding it now is how a codebase acquires a second, silently-diverging source of truth — the exact thing this PR is fixing.

Tests

New packages/core/test/query-method.spec.ts — 18 tests, driven through the published kit (mockAdapter from stitchapi/testing) and the internal node mock server, plus packages/core/test-d/known-method.test-d.ts.

They are real guards, verified by reverting. With the three call-site changes stashed and util.ts kept (so the file still compiles), 6 of 18 fail:

× a QUERY gets no Idempotency-Key — and still sends its body
× GET/HEAD are unchanged, and OPTIONS/TRACE now classify as the reads they are
× a QUERY with `idempotency` is nudged, exactly as a GET is
× a 301 re-sends the QUERY, body intact, to the new target
× a 302 likewise — while a POST is still downgraded to a bodyless GET
× a QUERY stitch sends its body and no Idempotency-Key   (end-to-end, real socket)
   Tests  6 failed | 12 passed (18)

The 12 that pass either way are the regression guards on unchanged behaviour — GET/HEAD still drop a body, OPTIONS does not, POST/PUT/PATCH/DELETE still get a key, a caller-supplied key still wins, a 303 still downgrades, a 307 still doesn't, the default cache still refuses a QUERY, and methods: 'QUERY' caches with two different bodies landing in two entries.

Bundle budget — raised, and the advertised figure moves

main had one byte of headroom on import { stitch } (22015 B of a 22016 B ceiling) and 0.02 KB on the entry. The next core-path change of any size was going to pay for the raise; this is that change.

scenario main here Δ budget headroom
whole entry 24.08 KB 24.12 KB +42 B 24.10 → 24.20 0.08 KB
import { stitch } 21.50 KB 21.56 KB +58 B 21.50 → 21.65 0.09 KB

Attributed by measuring each piece: +33 B the helper and its two rerouted call sites, +21 B carrying OPTIONS/TRACE in the safe set, +4 B the redirect exemption. None of it can move behind a subpath — applyIdempotency is in buildRequest, the nudge is in makeStitch, fetchAdapter is the default transport. I also measured a regex form of the predicate (identical gzip, worse brotli) and a {GET, HEAD, QUERY}-only set (22052 B — still over), so there is no version of this that fits under a zero-byte ceiling. A minimum step, not the ~0.2 KB this gate usually restores, matching #477/#524/#485: the change is 58 bytes and the entry is full, and the tight ceiling is the signal.

The advertised figure moves. 22015 B is 21.499 KB and rounds to 21; 22073 B is 21.556 and rounds to 22. So import { stitch } goes ~21 → ~22 kB across the eight sites under the bundle-advertised-size tether — both READMEs, installation, principles, the home-page metrics component, and the docs' source blurb. The whole entry stays ~24. This is a maintainer-visible number, so it is called out here and written into the budget comment rather than left in the diff. Propagated by hand (yakir will not auto-resolve a two-value conflict) and then verified with the tether, not assumed:

ok  bundle-advertised-size  —  all sites agree on "22, 24"
6 tether(s): 6 ok, 0 fixed, 0 drift, 0 conflict, 0 broken

playground-completions also drifted (the method JSDoc feeds it) and was regenerated. While regenerating I found the generator renders {@link A | b} as a bare | b, so the JSDoc avoids the piped form.

Docs

method and cache.methods JSDoc (which is what AutoTypeTable renders), the IdempotencyOptions doc comment — "a read" is now defined rather than spelled (GET/HEAD) — a new The QUERY method section on the stitch() guide, a cache.methods paragraph on the config-types reference, and a Reads never get a key section on the idempotency guide. The guide section carries the caveat the rest of this cannot: QUERY is young, and the proxies, CDNs and WAFs between you and the server may not route it.

CHANGELOG under Unreleased → Added.

Verification

Every gate, run to completion, exit codes:

check:contract ✅ 0 — no new violations (0 known, baselined)
check:lint ✅ 0 — clean across 36 packages
check:types ✅ 0 — 38 projects
check:types-d (tsd) ✅ 0
test ✅ 0 — core 140 files / 1506 tests passed, every package green
check:format ✅ 0
check:changelog ✅ 0 — 23 subsections, Unreleased in Keep a Changelog order
check:docs-links ✅ 0 — 116 routes, every /docs/... link resolves
check:unknown-keys ✅ 0
check:exports (attw) ✅ 0
check:release ✅ 0
check:size ✅ 0 — against the raised budget above
yakir --tier executable / --tier token ✅ 0 / 0

Nothing failed. yakir.lock is not touched: its baseline was already behind on four tethers before this branch (bundle-advertised-size records "20, 23" against a main measuring "21, 24"), and check passes on site agreement, so refreshing it here would have swept up unrelated tethers.

🤖 Generated with Claude Code

Part 1 of #462 only — the keepalive/Beacon half (part 2) is untouched, which is
why this is `Refs`, not `Closes`.

`method: 'QUERY'` already sent its body and already cached correctly under
`cache: { methods: 'QUERY' }`. What it did not have was the engine's agreement
that it is a read: "safe method" was spelled `=== 'GET' || === 'HEAD'` inline,
so everything else was a write by default.

One `isSafeMethod` predicate (RFC 9110 §9.2.1's safe set plus QUERY) now answers
that in the two places that ask it — `applyIdempotency`, so a QUERY is no longer
stamped with an `Idempotency-Key`, and the construction nudge that mirrors that
drop, so it is never silent. OPTIONS/TRACE stop being stamped too; they were
only getting a key because they were not GET or HEAD.

A 301/302 no longer downgrades a QUERY to a bodyless GET on the default fetch
transport. That downgrade is a historical exception granted to POST, and
draft-ietf-httpbis-safe-method-w-body rules it out by name for QUERY; applying
it dropped the body, silently turning a filtered read into an unfiltered one.

The two look-alike method tests are deliberately NOT routed through the
predicate, and the comments say why: `encodeRequestBody` still drops a body on
GET/HEAD only (a transport constraint — widening it to "safe" would delete the
payload of every QUERY), and the redirect exemption is spec text about QUERY,
not a safety rule. `cache.methods` keeps its `['GET','HEAD']` default; QUERY is
documented as a valid opt-in entry, not added to it.

`StitchConfig.method` widens to `KnownMethod | (string & {})` — an autocomplete
list, never an allowlist; a tsd test pins that any string still typechecks.

The bundle budget rises 24.10→24.20 / 21.50→21.65 KB (measured 24.12 / 21.56).
`main` had one byte of headroom on `import { stitch }`, so the whole +58 B lands
over; the advertised figure moves ~21 → ~22 kB across the eight tethered sites.

Refs #462

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant