Skip to content

fix(test): enforce e2e poll budgets and stop reusing idle connections - #455

Merged
EricAndrechek merged 12 commits into
mainfrom
local-ci-fail
Aug 12, 2026
Merged

fix(test): enforce e2e poll budgets and stop reusing idle connections#455
EricAndrechek merged 12 commits into
mainfrom
local-ci-fail

Conversation

@EricAndrechek

Copy link
Copy Markdown
Member

Closes #440.

make ci had been failing locally ~3 runs in 5 while CI stayed green. #440 concluded that was environmental — CPU contention on one machine, no repo change would have prevented it. That conclusion was wrong, and this PR fixes the real cause. The issue has been corrected.

Two defects, the first hiding the second

waitForCondition couldn't enforce its budget. It checked the clock only on loop entry, so one slow fn() overran without bound — a 10s budget was measured running 28s, past the caller's testTimeout. Vitest then killed the test first and reported a timeout naming neither the condition nor how long the poll actually waited. It now races fn() against the deadline, aborts the in-flight call through an AbortSignal handed to fn, and reports poll shape: N poll(s), slowest Xms.

That last part is what made the second defect visible. Many fast polls means the write never landed; a few slow ones means the polling itself was starved. They had been indistinguishable.

chQuery inherited idle pooled connections. undici 8.8.0–8.9.0 stalls for seconds before writing a request onto a socket that has been idle a few seconds — nodejs/undici#5600, a scheduling regression in scheduleIdleSocketValidation(), fixed in 8.10.0. This suite has multi-second idle gaps by construction: the 5s ingest linger sits between every write and the first poll of its visibility wait, so every visibility wait sat in the triggering window.

Bisected with Node held constant at 22, varying only undici:

undici max latency, 6s idle gaps
7.29.0 – 8.7.0 clean (13–30 ms)
8.8.0 broken — 2708 ms
8.9.0 broken — 7164 ms
8.10.0 clean (13 ms)

Why CI never saw it

.nvmrc pins Node 22 and setup-env consumes it via node-version-file, so CI runs undici 6.28.0. Node 26 bundles 8.9.0. Locally .nvmrc is inert without a version manager and engines: ">=22" is a floor that 26 satisfies, so the drift was silent.

Result

before after
local make test-e2e 2 pass / 3 fail 5 pass / 0 fail
vitest duration 128.7 – 137.1s 115.7 – 124.5s

Faster as well as green — removing the stalls shortens every run, which is the mechanism's own prediction rather than just an absence of failures.

Ruled out by measurement, not reasoning

ClickHouse (p99 ≤ 2.5 ms measured during live failing runs, on the real tables with the real query shapes), Docker/OrbStack (reproduces against a bare host-loopback server with no container in the path), DNS, the libuv threadpool, GC, event-loop blocking, machine load, and CPU contention.

Also in here

  • The E2E banner prints the active node/undici version and warns when the local major differs from .nvmrc. This is the cheap fix for the whole class: a version-specific failure was previously indistinguishable from a code failure, which is what turned a transport bug into days of investigation.
  • The orchestrator clears a leftover wavehouse-cov before starting. A killed run leaves one, and it corrupts the next run through the shared tmp/data and log file — presenting as a dozen unrelated tests failing to see their rows, in a log blaming a container that no longer exists. It kills and continues (refusing would wedge a shared runner), tolerating a process that exits on its own in the meantime.
  • E2E_NO_COVERAGE=1 for local debugging, fenced so it can't green a coverage gate by omission when COV_DEFER is set.
  • @wavehouse/sdk engines.node >=18>=22. Nothing ever tested 18, and 18/20 are both past upstream EOL. Consumers on <22 now get EBADENGINE (npm) or a hard failure (pnpm with engine-strict), so the README and docs state the requirement — the README previously stated none.
  • batching's visibility wait had ~700ms of headroom over the 5s linger where every other wait allows 10s. Widened; the >= 4500ms lower bound that carries the test's meaning is unchanged.
  • vitest.config.ts: __dirnameimport.meta.dirname, silencing the Vite 8 configLoader: 'native' warning.
  • development.md described an E2E harness that doesn't exist (a setup.ts that "probes ports before starting Docker services" — it probes, then throws; and a make dev detection that reuses a healthy :8080 — the orchestrator always provisions its own stack). Corrected, with the env knobs documented and a working recipe for running vitest against a stack you manage yourself.

Verification

make ci green on both Node 22 and Node 26, so the workaround path and CI's path are each exercised. Flake rate measured over 5-run series before and after, at normal process priority, with an orphan guard between runs.

Follow-ups, not in scope

  • defaultMaxWait = 5s (internal/ingest/worker.go:56, standing TODO) is what creates the idle gaps and burns most of the suite's runtime. Making it configurable would shorten the suite and widen every margin.
  • tests/e2e/sdk is not typechecked by make verifytypecheck-ts covers only @wavehouse/sdk, so these helper changes would not have been caught by CI.
  • A CI Node matrix would have caught this on its own, without depending on which developer got unlucky.

EricAndrechek and others added 9 commits August 12, 2026 00:18
Two defects, one of which was hiding the other.

waitForCondition checked the clock only on loop entry, so one slow fn()
overran the budget without bound — a 10s budget was measured running 28s,
past the caller's testTimeout, so vitest killed the test first and reported
a timeout naming neither the condition nor how long the poll waited. It now
races fn() against the deadline, aborts the in-flight call, and reports poll
shape: "N poll(s), slowest Xms".

That reporting is what exposed the second defect. chQuery used the global
fetch, which reuses pooled connections; undici 8.8.0-8.9.0 stalls for
seconds before writing a request onto a socket idle for a few seconds
(nodejs/undici#5600, fixed in 8.10.0). This suite has multi-second idle gaps
by construction — the 5s ingest linger sits between every write and the
first poll of its visibility wait — so every visibility wait sat in the
triggering window. Node 26 bundles undici 8.9.0; CI runs Node 22
(undici 6.28.0) via .nvmrc, which is why CI never saw it.

Local `make test-e2e`: 2 pass/3 fail -> 5 pass/0 fail, and every run faster
(115.7-124.5s vs 128.7-137.1s). ClickHouse measured p99 <= 2.5ms throughout;
Docker/OrbStack, DNS, the libuv threadpool, GC, event-loop blocking and
machine load were each excluded by measurement. See #440.

Also here:
- chQuery gets a 10s ceiling and honours the caller's AbortSignal, threaded
  through 19 call sites, so an abandoned poll tears its request down.
- batching's visibility wait had ~700ms of headroom over the 5s linger while
  every other wait allows 10s; widened. The >= 4500ms lower bound, which is
  what the test actually asserts, is unchanged.
- The e2e banner prints node/undici and warns when the local major differs
  from .nvmrc, so the next version-specific failure is attributable in
  seconds rather than days.
- The orchestrator refuses to start beside an orphaned wavehouse-cov. A
  killed run leaves one, and it corrupts the next run through the shared
  tmp/data and log file — presenting as a dozen unrelated tests failing to
  see their rows, in a log blaming a container that no longer exists.
- vitest.config: __dirname -> import.meta.dirname, silencing the vite 8
  configLoader warning.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJvon2pyU4zaSHTGJj8fE
@wavehouse/sdk advertised node >=18. Nothing tests 18, and both 18 and 20
are past end-of-life upstream — so the floor promised to consumers was
neither supported nor backed by evidence. The rest of the workspace already
requires >=22, and .nvmrc pins 22 for CI, so 22 is the oldest line that is
actually exercised.

Docs updated to match, since the runtime-support section quoted the old
minimum verbatim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJvon2pyU4zaSHTGJj8fE
Review follow-up on the stale-server guard. Refusing to start is right on a
laptop, where the message names the PID and the kill command — but on a
shared runner a process orphaned by a canceled job would wedge every
subsequent e2e run until someone got shell access. A match is by
construction this repo's own cover binary from a dead run, and the very next
statement wipes tmp/data out from under it regardless, so killing it is both
safe and what the guard was protecting against. Logged loudly; a kill that
fails still aborts with the manual command.

Also loosens the poll-stat assertion in helpers.test.ts, which put a 999ms
*upper* bound on a wall-clock measurement taken while ClickHouse and the
server share the machine — the exact shape of flake this branch exists to
remove, and against the file header's own "order-of-magnitude, not
milliseconds" rule. The property under test is that the in-flight poll was
counted at all, so a lower bound is sufficient.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJvon2pyU4zaSHTGJj8fE
Documentation sync for the two preceding commits.

CHANGELOG gains [Unreleased] entries for both. It needed them badly: nothing
in this repo has been released, so the Unreleased section *is* the shipping
description, and it still advertised "engines.node is relaxed from >=22 to
>=18 ... no longer warns or fails to install on Node 18/20" — the exact
opposite of what now ships. That bullet is annotated as superseded rather
than rewritten, so the decision history stays readable.

pnpm-workspace.yaml's engineStrict rationale was the last place still
asserting the two-tier ">=18 for consumers, >=22 for us" policy.

development.md described an E2E harness that does not exist: a setup.ts that
"probes ports before starting Docker services" (it probes, then throws) and
a `make dev` detection that reuses a healthy :8080 (the orchestrator always
provisions its own stack on a random port). Replaced with what the code
does, plus the supported way to run vitest against a hand-run stack, the new
leftover-server behavior, and a table of the env knobs this branch adds
(E2E_CH_QUERY_TIMEOUT_MS, E2E_NO_COVERAGE) alongside the existing V=1.

The two exhaustive E2E test-file lists (development.md, sdk/reference.md)
gained helpers, noting it is a stack-free unit test of the harness rather
than a pipeline test, so it doesn't read as inconsistent with the
surrounding "exercises the full pipeline" claim.

clients/ts/README.md is the page npm renders, and stated no Node
requirement at all — a consumer on 20 now hits EBADENGINE with nothing to
explain it. sdk/queries.md dropped a "Node 20+" qualifier that sits below
the supported floor, and sdk/index.mdx now says 22 is the only line tested
rather than the oldest, which implied a matrix we don't run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJvon2pyU4zaSHTGJj8fE
Second docs-review pass, all four on text this branch introduced.

The "run vitest against your own stack" recipe couldn't work as written.
The harness signs its tokens with the E2E fixture's `sdk-dev-secret`
(helpers.ts), while a default `make dev` server uses
`change-me-in-production` — so setup's schema calls are denied and global
setup burns its full 30s loop before dying on `schema not refreshed within
30s`, with nothing pointing at auth. The recipe now names
`WH_CONFIG=tests/e2e/fixtures/config.yaml` and says why, including the
fixture-only dedupe/DLQ/refresh settings several suites depend on.

The CHANGELOG entry described the stale-server guard as refusing to start —
the design from two commits earlier, which the commit before it replaced
with kill-and-continue. It now matches the shipped behavior.

`V=1` was described as streaming the subprocess log *instead of* capturing
it; the orchestrator tees through io.MultiWriter, so the file is still
written. What actually changes is that the on-failure tail dump is skipped.

Both E2E test-file lists called helpers.test.ts a test of the harness's
"wait/query helpers"; it covers waitForCondition only and never imports
chQuery.

Not fixed here: CONTRIBUTING.md:46's `configuration.md` → `.mdx`, which is
real but pre-existing and already tracked in #444.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJvon2pyU4zaSHTGJj8fE
…COVERAGE

Code-review follow-up, both on the orchestrator.

proc.Kill() returns os.ErrProcessDone when the leftover exits between pgrep
and the kill — the realistic trigger being a re-run while a Ctrl-C'd run's
server is still flushing coverage. That was treated as fatal, aborting the
whole run and telling the reader to `kill -9` a PID that no longer exists:
precisely the wedge the kill-instead-of-refuse change existed to avoid. The
same file already handles this correctly when reaping the server it started.

E2E_NO_COVERAGE was honored unconditionally, so an exported-and-forgotten
var would let `make ci` write a green tmp/ci-passed-tree-<sha> push marker
with the TS e2e report missing — test-e2e wipes tmp/coverage/ts-e2e first,
ts-e2e's own threshold is informational, and ts-total then gates on ts-unit
alone, yielding an `n/a` row that still passes. It is now ignored (with a
log line) whenever COV_DEFER is set, which is exactly the targets that gate;
standalone `make test-e2e` debugging is unaffected. The comment documented
this hazard where enforcement was one condition away.

Docs and CHANGELOG updated to match.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJvon2pyU4zaSHTGJj8fE
…ording

Third docs-review pass, both on text this branch added.

"halving its cost" for E2E_NO_COVERAGE was asserted, not measured — and it
does not survive arithmetic: the suite's wall clock is dominated by 23
visibility waits behind the 5s ingest linger plus cache.test.ts's explicit
ttl sleep, roughly 60-115s of unavoidable waiting inside the 115.7-124.5s
runs measured with coverage on. Halving would require v8 instrumentation to
account for more than the entire non-waiting portion of the run. Replaced
with what the flag actually does. A branch whose case rests on measured
before/after should not carry a number nobody measured.

clients/ts/README.md said "the oldest line this SDK is tested against" — the
exact framing removed from sdk/index.mdx two commits ago for implying a
matrix we do not run. .nvmrc pins 22 and setup-env consumes it, so 22 is the
only line CI exercises; "oldest" tells an npm reader on 24 or 26 that their
line is covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJvon2pyU4zaSHTGJj8fE
Fourth docs-review pass. The recipe named the config but not the
invocation, and the obvious thing to type — `WH_CONFIG=... make dev`, the
idiom this same page uses three times for other overrides — is silently
ignored: the dev recipe pins `WH_CONFIG=.config.local.yaml` inline, which
beats anything inherited from the environment. A reader following it lands
in exactly the misleading `schema not refreshed within 30s` failure the
paragraph warns about.

Now names `go run ./cmd/wavehouse`, the repo-root requirement (the
fixture's policy.file_path is cwd-relative), and the `make dev` trap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJvon2pyU4zaSHTGJj8fE
Found by actually running the recipe rather than reasoning about it. The
command and the fixture-secret claim both check out — the harness's admin
token gets 200 on /v1/schema/refresh and /v1/schema, and a token signed with
the make dev secret gets 401, which is the failure the paragraph describes.

What the run exposed is that the reader is told to point the *suite* at
ClickHouse via CLICKHOUSE_URL, but the *server* needs to reach it too, and
the fixture pins no clickhouse block — so it falls back to the config
loader's localhost:9000 / 8123 defaults. Fine with `make deps-up`, silently
wrong against a ClickHouse anywhere else. Names WH_CH_ADDR / WH_CH_HTTP_PORT
for that case.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJvon2pyU4zaSHTGJj8fE
@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file go Pull requests that update go code area/sdk TypeScript SDK (clients/ts/) area/docs Documentation, site/, README labels Aug 12, 2026
@coderabbitai

coderabbitai Bot commented Aug 12, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@EricAndrechek, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 20 minutes

You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository.

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 962bab0d-a0cf-4edf-ae0a-6a62e7f1f3be

📥 Commits

Reviewing files that changed from the base of the PR and between 5befb48 and c85e410.

📒 Files selected for processing (2)
  • CHANGELOG.md
  • docs/src/content/docs/development.md

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: a6f37c21-16b3-4b63-af2d-e4859bc74d00

📥 Commits

Reviewing files that changed from the base of the PR and between f4e64d5 and 5befb48.

📒 Files selected for processing (1)
  • tests/e2e/sdk/helpers.ts
📜 Recent review details
⏰ Context from checks skipped due to timeout. (5)
  • GitHub Check: Integration tests
  • GitHub Check: E2E tests
  • GitHub Check: Coverage
  • GitHub Check: Unit tests
  • GitHub Check: Docs build
🧰 Additional context used
🧠 Learnings (1)
📚 Learning: 2026-08-12T05:38:52.277Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 455
File: tests/e2e/sdk/helpers.ts:214-221
Timestamp: 2026-08-12T05:38:52.277Z
Learning: In `tests/e2e/sdk/helpers.ts`, `chQuery` must reclassify a caught error as a request timeout or caller abort only when the error is an abort error. On Node 22, `AbortSignal.timeout()` produces an error named `TimeoutError`, `AbortController.abort()` and caller cancellation through `AbortSignal.any()` produce `AbortError`, and `JSON.parse()` failures produce `SyntaxError`.

Applied to files:

  • tests/e2e/sdk/helpers.ts
🔇 Additional comments (3)
tests/e2e/sdk/helpers.ts (3)

160-185: LGTM!


239-250: LGTM!


76-149: 🩺 Stability & Availability

No change required. waitForCondition aborts its shared signal at the deadline, cancels interval waits, and handles late poll rejections. In-flight work must observe the signal to stop.


📝 Walkthrough

Summary by CodeRabbit

  • Compatibility

    • SDK support is now limited to Node.js 22 and newer.
    • Updated runtime requirements and streaming guidance, including EventSource polyfill instructions.
  • Reliability

    • End-to-end polling now enforces deadlines, cancels stalled requests, and provides clearer timeout diagnostics.
    • Improved request timeouts and connection handling reduce test hangs and idle-connection stalls.
  • Documentation

    • Expanded guidance for E2E setup, manually managed environments, runtime checks, and test helpers.
  • Testing

    • Added coverage for polling cancellation, timeout behavior, error handling, and late failures.

Walkthrough

The SDK now requires Node 22. E2E polling enforces deadlines, propagates cancellation, applies request timeouts, and reports diagnostics. Orchestration cleans stale processes, supports local coverage controls, reports runtime versions, and updates Vitest path handling.

Changes

E2E runtime and SDK support

Layer / File(s) Summary
Node 22 runtime contract
clients/ts/..., docs/src/content/docs/sdk/..., pnpm-workspace.yaml, CHANGELOG.md
Package metadata and documentation now require Node 22.
Bounded and cancellable E2E polling
tests/e2e/sdk/helpers.ts, tests/e2e/sdk/helpers.test.ts, tests/e2e/sdk/*
Polling now enforces whole-call deadlines, propagates abort signals, applies query timeouts, closes connections, and reports poll diagnostics. E2E tests pass cancellation signals through ClickHouse polling.
E2E orchestration and runtime setup
scripts/orchestrator/main.go, tests/e2e/sdk/setup.ts, tests/e2e/sdk/vitest.config.ts, docs/src/content/docs/development.md
The orchestrator cleans stale processes and controls local coverage. Setup reports runtime versions. Documentation and Vitest paths match the updated harness.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Sequence Diagram(s)

sequenceDiagram
  participant E2ETest
  participant waitForCondition
  participant chQuery
  participant ClickHouse
  E2ETest->>waitForCondition: start condition polling
  waitForCondition->>chQuery: poll with AbortSignal
  chQuery->>ClickHouse: execute query with request timeout
  ClickHouse-->>chQuery: response or timeout
  chQuery-->>waitForCondition: result or diagnostic error
  waitForCondition-->>E2ETest: success or timeout diagnostics
Loading

Possibly related PRs

  • Wave-RF/WaveHouse#129: Updates CI coverage workflow and process-management behavior related to the E2E orchestration changes.
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR includes broader changes beyond [#440], including the published SDK Node floor, coverage controls, and orchestrator process cleanup. Split the unrelated SDK runtime and orchestration changes into separate issues or link issues that explicitly authorize this expanded scope.
Docstring Coverage ⚠️ Warning Docstring coverage is 50.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The PR satisfies [#440] by enforcing poll deadlines, aborting stalled requests, and reporting condition-specific timeout diagnostics.
Title check ✅ Passed The title clearly summarizes the primary E2E fixes: enforcing poll budgets and preventing idle-connection reuse stalls.
Description check ✅ Passed The description directly explains the E2E polling, connection-stall, runtime, orchestration, documentation, and verification changes.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch local-ci-fail
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch local-ci-fail

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 12, 2026

Copy link
Copy Markdown

📚 Docs preview is livehttps://87a9af94-wavehouse-docs.wave-rf.workers.dev

  • Commitc85e410: Merge remote-tracking branch 'origin/main' into local-ci-fail
  • Author@EricAndrechek
  • Committed — 2026-08-12 09:59 (UTC-04:00)
  • Deployed — 2026-08-12 10:12 EDT

@github-code-quality

github-code-quality Bot commented Aug 12, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: Go

Go

The overall coverage in commit c85e410 in the local-ci-fail branch remains at 90%, unchanged from commit e945ecc in the main branch.

Show a code coverage summary of the most impacted files.
File main e945ecc local-ci-fail c85e410 +/-
internal/discov...y/validation.go 94% 94% 0%
internal/api/ingest.go 97% 97% 0%
internal/ingest/worker.go 95% 95% 0%
internal/api/cl...ckhouse_exec.go 83% 84% +1%
internal/discov...ry/discovery.go 98% 99% +1%
internal/discov...ry/timestamp.go 0% 98% +98%

Updated August 12, 2026 14:13 UTC

@coderabbitai coderabbitai 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.

Actionable comments posted: 3


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: ca7331a7-9745-465e-b27b-1d38fcb17569

📥 Commits

Reviewing files that changed from the base of the PR and between e945ecc and f4e64d5.

📒 Files selected for processing (20)
  • CHANGELOG.md
  • clients/ts/README.md
  • clients/ts/package.json
  • docs/src/content/docs/development.md
  • docs/src/content/docs/sdk/index.mdx
  • docs/src/content/docs/sdk/queries.md
  • docs/src/content/docs/sdk/reference.md
  • pnpm-workspace.yaml
  • scripts/orchestrator/main.go
  • tests/e2e/sdk/batching.test.ts
  • tests/e2e/sdk/cache.test.ts
  • tests/e2e/sdk/dlq.test.ts
  • tests/e2e/sdk/helpers.test.ts
  • tests/e2e/sdk/helpers.ts
  • tests/e2e/sdk/ingest.test.ts
  • tests/e2e/sdk/ndjson.test.ts
  • tests/e2e/sdk/query.test.ts
  • tests/e2e/sdk/setup.ts
  • tests/e2e/sdk/stress.test.ts
  • tests/e2e/sdk/vitest.config.ts
📜 Review details
⏰ Context from checks skipped due to timeout. (5)
  • GitHub Check: E2E tests
  • GitHub Check: Coverage
  • GitHub Check: Integration tests
  • GitHub Check: Docs build
  • GitHub Check: Analyze (go)
🧰 Additional context used
📓 Path-based instructions (3)
clients/ts/README.md

📄 CodeRabbit inference engine (AGENTS.md)

Keep the TypeScript SDK README in sync with SDK-facing changes and public client behavior.

Files:

  • clients/ts/README.md
docs/src/content/docs/**/*.{md,mdx}

📄 CodeRabbit inference engine (AGENTS.md)

Documentation prose under the Starlight docs site must stay accurate against code, include runnable examples where relevant, and reflect code↔docs sync for changed behavior.

Files:

  • docs/src/content/docs/sdk/queries.md
  • docs/src/content/docs/sdk/index.mdx
  • docs/src/content/docs/sdk/reference.md
  • docs/src/content/docs/development.md
docs/src/content/docs/development.md

📄 CodeRabbit inference engine (AGENTS.md)

Update the development docs when changing build or test process details.

Files:

  • docs/src/content/docs/development.md
🧠 Learnings (18)
📚 Learning: 2026-07-07T16:19:29.374Z
Learnt from: CR
Repo: Wave-RF/WaveHouse PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-07-07T16:19:29.374Z
Learning: Applies to clients/ts/README.md : Keep the TypeScript SDK README in sync with SDK-facing changes and public client behavior.

Applied to files:

  • clients/ts/package.json
  • clients/ts/README.md
  • pnpm-workspace.yaml
  • docs/src/content/docs/sdk/index.mdx
  • docs/src/content/docs/sdk/reference.md
  • CHANGELOG.md
  • tests/e2e/sdk/setup.ts
  • docs/src/content/docs/development.md
📚 Learning: 2026-07-07T16:19:29.374Z
Learnt from: CR
Repo: Wave-RF/WaveHouse PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-07-07T16:19:29.374Z
Learning: Applies to clients/ts/**/*.{ts,tsx} : The TypeScript SDK in `clients/ts/` is the canonical client; when backend API changes affect users, update the SDK surface, auth handling, query builder, streaming helpers, pipes/policy helpers, or regenerated types as needed.

Applied to files:

  • clients/ts/README.md
  • docs/src/content/docs/sdk/index.mdx
  • docs/src/content/docs/sdk/reference.md
  • CHANGELOG.md
  • docs/src/content/docs/development.md
📚 Learning: 2026-08-11T21:55:41.895Z
Learnt from: jfwoods
Repo: Wave-RF/WaveHouse PR: 434
File: clients/go/go.mod:3-6
Timestamp: 2026-08-11T21:55:41.895Z
Learning: In the WaveHouse repository, `clients/go/go.mod` declares `go 1.24` as the deliberate minimum supported Go version for the published Go SDK. This SDK compatibility floor is independent of the server build toolchain declared by the root `go.mod` and referenced in `AGENTS.md`; do not require the Go SDK module to use the server toolchain version.

Applied to files:

  • clients/ts/README.md
  • pnpm-workspace.yaml
📚 Learning: 2026-06-10T15:01:09.027Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 312
File: docs/src/content/docs/development.md:0-0
Timestamp: 2026-06-10T15:01:09.027Z
Learning: In this repo’s Markdown review (all .md files), do not flag capitalization/style issues for literal paths starting with ".github/" (or any substring that is a path beginning with ".github/"). Treat ".github" as the correct lowercase dotfile directory name, even when it appears inside prose or code spans; automated checks such as LanguageTool’s "(GITHUB)" rule commonly produce false positives for this literal filesystem path.

Applied to files:

  • clients/ts/README.md
  • docs/src/content/docs/sdk/queries.md
  • docs/src/content/docs/sdk/reference.md
  • CHANGELOG.md
  • docs/src/content/docs/development.md
📚 Learning: 2026-07-07T16:19:29.374Z
Learnt from: CR
Repo: Wave-RF/WaveHouse PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-07-07T16:19:29.374Z
Learning: Applies to docs/src/content/docs/development.md : Update the development docs when changing build or test process details.

Applied to files:

  • docs/src/content/docs/sdk/reference.md
  • docs/src/content/docs/development.md
📚 Learning: 2026-06-26T12:23:26.034Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 346
File: internal/stream/subscriber_test.go:9-28
Timestamp: 2026-06-26T12:23:26.034Z
Learning: In this Go repository, the `**/*_test.go` table-driven test guideline is intended for genuinely multi-scenario tests. Single sequential behavioral-flow tests, such as `internal/stream/subscriber_test.go`'s `TestSubscriber_SendDeliversThenDropsWhenFull`, do not need to be rewritten into `[]struct{...}` + `t.Run(...)` when that would be artificial and less clear.

Applied to files:

  • docs/src/content/docs/sdk/reference.md
  • docs/src/content/docs/development.md
📚 Learning: 2026-06-10T19:54:03.032Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 312
File: CHANGELOG.md:0-0
Timestamp: 2026-06-10T19:54:03.032Z
Learning: In the Wave-RF/WaveHouse repository, CHANGELOG.md entries under `[Unreleased]` use descriptive Keep-a-Changelog leads (e.g. "The structured-query column allowlist is now a hard cap…"), NOT the Conventional Commit PR title verbatim. Do not flag CHANGELOG entry leads for not matching the PR title — that is not a rule in this repo. There is no `.coderabbit.yaml`, and neither `AGENTS.md` nor `CONTRIBUTING.md` requires CHANGELOG leads to match PR titles.

Applied to files:

  • CHANGELOG.md
📚 Learning: 2026-07-07T16:19:29.374Z
Learnt from: CR
Repo: Wave-RF/WaveHouse PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-07-07T16:19:29.374Z
Learning: Applies to internal/stream/**/*.{go} : Streaming/SSE code must preserve the hub’s per-role projection model, subscriber queues, bucket fan-out, heartbeating, and metrics semantics.

Applied to files:

  • CHANGELOG.md
📚 Learning: 2026-08-11T12:41:01.909Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 446
File: .github/actions/setup-env/action.yml:112-127
Timestamp: 2026-08-11T12:41:01.909Z
Learning: In WaveHouse CI, the shared `gomod-v1` cache in `.github/actions/setup-env/action.yml` can be saved by multiple Go jobs on an exact-key miss. Every workflow path that can write this cache must fully populate `~/go/pkg/mod` through the Makefile `go-mod-download` prerequisite before the post-job cache save. The `cov` target must retain this prerequisite.

Applied to files:

  • CHANGELOG.md
📚 Learning: 2026-06-10T15:02:09.425Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 312
File: .github/workflows/ci.yml:232-237
Timestamp: 2026-06-10T15:02:09.425Z
Learning: In the Wave-RF/WaveHouse repository, `clickhouse/clickhouse-server:latest` is used deliberately in `tests/integration/setup_test.go`, `scripts/orchestrator/main.go`, and the CI workflow prefetch steps (`docker pull -q clickhouse/clickhouse-server:latest`). The `:latest` tag in the prefetch steps intentionally mirrors the tag testcontainers resolves at runtime — this is a deliberate canary approach. Pinning to a concrete version/digest is a separate decision tracked as a follow-up issue and should not be flagged as a supply-chain concern in CI workflow reviews for this repo.

Applied to files:

  • CHANGELOG.md
  • docs/src/content/docs/development.md
📚 Learning: 2026-08-11T21:56:03.599Z
Learnt from: jfwoods
Repo: Wave-RF/WaveHouse PR: 434
File: docs/src/content/docs/sdk/go/queries.md:356-362
Timestamp: 2026-08-11T21:56:03.599Z
Learning: In Wave-RF/WaveHouse Go SDK cursor pagination, `fetchNextTyped` uses only the first `QueryBuilder.OrderBy` column and a strict `gt` or `lt` filter. Duplicate values at a page boundary can skip rows. The Go SDK documentation must require a unique ordering column until the shared Go and TypeScript composite-cursor or tie-breaker implementation tracked in GitHub issue `#452` is available.

Applied to files:

  • CHANGELOG.md
📚 Learning: 2026-06-26T15:07:28.749Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 0
File: :0-0
Timestamp: 2026-06-26T15:07:28.749Z
Learning: In the Go SSE implementation in `internal/api/stream.go`, keepalive frames from `internal/stream.Heartbeater` are only written from the post-replay select loop. The replay/gap-fill step is synchronous before entering that loop, so registering the `internal/stream.Subscriber` before replay does not materially improve idle-time coverage during replay; it can at most buffer one heartbeat in the subscriber's capacity-1 queue. Covering a genuinely long replay would require interleaving replay with the select loop and is tied to the broader delivery-path rework tracked by Issue `#294`.

Applied to files:

  • CHANGELOG.md
📚 Learning: 2026-08-11T21:56:00.508Z
Learnt from: jfwoods
Repo: Wave-RF/WaveHouse PR: 434
File: clients/go/query_builder.go:278-291
Timestamp: 2026-08-11T21:56:00.508Z
Learning: In `clients/go/query_builder.go`, `fetchNextTyped` intentionally treats a failed JSON decode of a non-object typed `Row` as normal end-of-pagination. This behavior matches the existing “cursor column was not in the projection” path and TypeScript SDK parity. The broader behavior change is tracked in GitHub issue `#452`.

Applied to files:

  • CHANGELOG.md
📚 Learning: 2026-07-07T16:19:29.374Z
Learnt from: CR
Repo: Wave-RF/WaveHouse PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-07-07T16:19:29.374Z
Learning: Applies to internal/ingest/types.go : `EventMessage` JSON tags and ingest event shape must stay aligned with docs, SSE examples, and ClickHouse INSERT column order.

Applied to files:

  • CHANGELOG.md
📚 Learning: 2026-05-19T14:41:38.228Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 142
File: docs/scripts/screenshot.mjs:31-31
Timestamp: 2026-05-19T14:41:38.228Z
Learning: `docs/scripts/screenshot.mjs` in the Wave-RF/WaveHouse repo is an intentionally manual dev-iteration tool (not wired into CI). Response-status validation and retry logic are deliberately deferred until the script is promoted to a CI visual-regression workflow. Do not flag the absence of `response.ok()` checks as an issue in this file.

Applied to files:

  • tests/e2e/sdk/setup.ts
  • docs/src/content/docs/development.md
📚 Learning: 2026-05-13T20:41:09.256Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 125
File: internal/api/health_test.go:100-163
Timestamp: 2026-05-13T20:41:09.256Z
Learning: In `internal/api/health_test.go` (WaveHouse), every handler test explicitly asserts `Content-Type: application/json` and `X-Content-Type-Options: nosniff` headers, including on 503 responses. This is deliberate regression coverage: the comment in `TestHealth_Readiness_PingFails` explains that without the 503-path header test, a future refactor moving header setup into the success branch would silently drop headers on error responses. New boot-degraded tests should follow the same pattern.

Applied to files:

  • docs/src/content/docs/development.md
📚 Learning: 2026-07-07T12:38:12.052Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 378
File: internal/auth/auth.go:119-132
Timestamp: 2026-07-07T12:38:12.052Z
Learning: In this repo, do not add or recommend logging/tracing client IP addresses using naive or untrusted sources (e.g., `r.RemoteAddr` or directly trusting/deriving `X-Forwarded-For`) anywhere in the Go codebase. `middleware.RealIP` was removed due to IP-spoofing risks, and proper trusted-proxy-aware client-IP handling is intentionally deferred to issue `#333`. During code review, if proposed changes would record client IPs (including in audit paths such as `internal/auth/auth.go`), reject/redirect until `#333` lands with correct trusted-proxy configuration and safeguards.

Applied to files:

  • scripts/orchestrator/main.go
📚 Learning: 2026-08-11T21:55:53.726Z
Learnt from: jfwoods
Repo: Wave-RF/WaveHouse PR: 434
File: clients/go/stream_test.go:159-176
Timestamp: 2026-08-11T21:55:53.726Z
Learning: For Go files in this repository, do not report direct type assertions solely because the `forcetypeassert` rule is commented out in `.golangci.yml`. Only flag a type assertion when there is an independent correctness, safety, or maintainability issue.

Applied to files:

  • scripts/orchestrator/main.go
🪛 LanguageTool
docs/src/content/docs/development.md

[style] ~375-~375: Since ownership is already implied, this phrasing may be redundant.
Context: .../`. The orchestrator always provisions its own stack — a fresh ClickHouse testcontaine...

(PRP_OWN)

🪛 OpenGrep (1.26.0)
tests/e2e/sdk/helpers.test.ts

[ERROR] 89-89: Dynamic command passed to child_process.exec/execSync. Use child_process.execFile or spawn with an argument array instead.

(coderabbit.command-injection.exec-js)

🔇 Additional comments (19)
clients/ts/package.json (1)

26-26: LGTM!

docs/src/content/docs/sdk/queries.md (1)

65-65: LGTM!

tests/e2e/sdk/helpers.ts (3)

76-87: LGTM!


103-150: LGTM!


185-201: 🩺 Stability & Availability

Keep headers: { connection: "close" }. Node’s Undici implementation accepts and emits this header; the Fetch forbidden-header rule does not apply here.

			> Likely an incorrect or invalid review comment.
tests/e2e/sdk/helpers.test.ts (1)

15-125: LGTM!

tests/e2e/sdk/batching.test.ts (1)

36-39: LGTM!

Also applies to: 65-68, 100-122

tests/e2e/sdk/cache.test.ts (1)

53-56: LGTM!

tests/e2e/sdk/dlq.test.ts (1)

50-53: LGTM!

tests/e2e/sdk/ndjson.test.ts (1)

27-30: LGTM!

Also applies to: 65-68, 96-99, 127-130, 159-162

tests/e2e/sdk/query.test.ts (1)

34-37: LGTM!

tests/e2e/sdk/stress.test.ts (1)

43-46: LGTM!

tests/e2e/sdk/ingest.test.ts (1)

33-37: LGTM!

Also applies to: 60-63, 111-115, 147-151, 198-201, 254-257, 340-343

CHANGELOG.md (1)

26-27: LGTM!

Also applies to: 56-57, 112-112

scripts/orchestrator/main.go (1)

42-42: LGTM!

Also applies to: 70-110, 246-264, 319-338

tests/e2e/sdk/setup.ts (1)

15-16: LGTM!

Also applies to: 101-132

tests/e2e/sdk/vitest.config.ts (1)

8-9: LGTM!

Also applies to: 20-22, 45-47

docs/src/content/docs/development.md (1)

363-363: LGTM!

Also applies to: 375-397

docs/src/content/docs/sdk/reference.md (1)

156-156: LGTM!

Comment thread clients/ts/README.md
Comment thread tests/e2e/sdk/helpers.ts Outdated
Comment thread tests/e2e/sdk/helpers.ts
@github-project-automation github-project-automation Bot moved this from Backlog to In review in WaveHouse Task Board Aug 12, 2026
Both from CodeRabbit review on #455, both verified against Node 22 before
acting.

chQuery's catch rewrote any error as a timeout whenever `ceiling.aborted`
was set. The ceiling keeps running after fetch settles, so a `!res.ok` throw
or a JSON.parse failure on a slow-but-successful response could land there
with the flag already true — discarding ClickHouse's own error text, which
is the opposite of what the reclassification is for. It now requires the
caught error to actually be an abort. Measured: fetch rejects with
TimeoutError for an AbortSignal.timeout and AbortError for a controller
abort, so the name is a sound discriminator.

E2E_CH_QUERY_TIMEOUT_MS went to AbortSignal.timeout unvalidated. Exported
empty it becomes 0 and aborts every query on the next tick; typo'd it
becomes NaN, which throws a RangeError naming neither the variable nor the
value. Both verified on node 22 (0 is accepted, NaN/fractions/negatives/
out-of-range all RangeError). Now rejected up front with a message that
names both.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJvon2pyU4zaSHTGJj8fE
coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 12, 2026
The comment claimed AbortSignal.timeout rejects anything outside the signed
32-bit range. It doesn't: measured on Node 22.23.2 and 26.7.0, it accepts
any integer in [0, 4294967295], and values above 2^31-1 overflow setTimeout's
int32 so Node silently clamps the delay to 1ms with a TimeoutOverflowWarning
— an instant abort on every query, which is the failure class this
validation exists to prevent.

So the bound is right and the reason was wrong, which is the worse of the two
mistakes: a reader who checked the claim would find it false and might widen
the bound to 4294967295 "to match the API", reintroducing the 1ms clamp.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014EJvon2pyU4zaSHTGJj8fE
@EricAndrechek
EricAndrechek marked this pull request as ready for review August 12, 2026 13:29
@EricAndrechek
EricAndrechek requested review from a team and taitelee August 12, 2026 13:29
coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 12, 2026
@EricAndrechek
EricAndrechek merged commit 2dd2ab6 into main Aug 12, 2026
20 checks passed
@EricAndrechek
EricAndrechek deleted the local-ci-fail branch August 12, 2026 14:14
@github-project-automation github-project-automation Bot moved this from In review to Done in WaveHouse Task Board Aug 12, 2026
EricAndrechek added a commit that referenced this pull request Aug 12, 2026
Closes #269. Closes #464. Adds the SDK's HTTP-customization surface —
`options.headers`, `options.fetchOptions`, and `options.fetch` — so a
WaveHouse behind a gate is reachable from the client.

## Why

`ClientConfig` exposed only `baseURL`, `auth`, and `options.maxRetries`.
A WaveHouse fronted by a header-gated proxy (Cloudflare Access, an mTLS
sidecar, an auth gateway) simply couldn't be talked to — a
defense-in-depth gate forced consumers off the SDK entirely. Found via
WaveHouse-Stats dogfooding.

The second driver is a runtime bug consumers can't fix from inside the
SDK: undici 8.8.0–8.9.0 stalls a request before it goes out when a
keep-alive socket is reused on an idle event loop
([nodejs/undici#5600](nodejs/undici#5600),
fixed in 8.10.0), and Node 26 bundles 8.9.0. Severity varies with
runtime and idle gap — upstream measured ~450–465ms; on Node 26.7.0 with
6s gaps against a stub server answering instantly:

```
SDK  max=23990ms  >500ms: 4/5  [12, 23990, 2203, 2100, 2099]
```

## What landed

| Option | Purpose |
|---|---|
| `options.headers` | Static headers on every REST request — the
CF-Access case |
| `options.fetchOptions` | Extra `RequestInit` merged in — `cache`,
`keepalive`, `credentials`, Next.js `next: { tags }` |
| `options.fetch` | Replace the HTTP implementation outright |

Shaped after Supabase (`global.fetch`/`global.headers`) and
OpenAI/Anthropic (`fetch`/`fetchOptions`/`defaultHeaders`) rather than
invented here.

**Header precedence**, lowest to highest: `options.headers` → `auth` →
SDK-computed (`Content-Type`, `Accept`). Names match case-insensitively,
and a collision **drops** the configured value rather than joining it.
Both rules come from bugs other SDKs shipped: a global `Content-Type`
joined with an upload's own produced `application/json, image/png` and
415s, and a case-sensitive `Authorization` check let a lowercase
spelling ride alongside the canonical one. Two configured spellings of
one header collapse to the last, so `Headers` can't comma-join them on
the wire.

**`fetchOptions` can't corrupt the request** — `method`, `headers`,
`body`, and `signal` are applied *after* the spread.
`fetchOptions.headers` is ignored rather than merged; `options.headers`
is the header channel, and merging both would give one concept two
precedence stories and a side door around `auth`.

## The undici workaround took three tries to get right

Worth reading before you follow it elsewhere, because the obvious forms
don't work. Measured on undici 8.9.0, 10ms server, 1.5s idle gaps
(per-request ms):

```
default Agent()                    22 1513 1493  584
Agent({ keepAliveTimeout: 1_000 }) 13 1500 1496 1513   ← inert
Agent({ pipelining: 0 })           13   13   12   12
same script on undici 8.10.0       22   15   13   13
```

Lowering `keepAliveTimeout` does nothing — the retirement timer is
starved by the same idle event loop that causes the bug.

Worse, **importing a fixed undici isn't enough on its own.** undici
keeps its connection pool on a shared `globalThis` symbol claimed by
whichever copy loads first — the bundled one, on an affected runtime.
With 8.9.0 loaded first and 8.10.0's `fetch` doing the work:

```
no explicit dispatcher    21 1514 1495  583   ← still stalling
explicit new Agent()      17   14   12   13
```

So the documented snippet passes `dispatcher` explicitly. Verified
end-to-end through the SDK with 8.9.0 loaded first: `24, 15, 13, 14ms`.

## Breaking changes

**`PipeRef.fetch` no longer accepts `limit`** — closes #464, raised by
CodeRabbit here. It took the shared per-call options type, which carries
`limit`, but forwarded only `signal`, so `wh.pipe('x').fetch({ limit: 10
})` compiled and silently did nothing. Nothing to forward: the endpoint
binds the body as the pipe's *parameters* (`internal/api/pipes.go` →
`pipes.BindParams`), so a row cap belongs in the pipe's SQL as
`{{limit}}`, passed via `wh.pipe(name, { limit })` — which is what the
docs already showed. Now a dedicated exported `PipeRequestOptions` —
`signal?: AbortSignal; limit?: never`.

The `never` is load-bearing. The first attempt used
`Pick<RequestOptions, "signal">`, which CodeRabbit correctly flagged as
only half a fix: TypeScript's excess-property check is a freshness
heuristic, so it rejects a fresh literal but not a *variable*. `const
opts: RequestOptions = { signal, limit: 10 }; wh.pipe('x').fetch(opts)`
still compiled and still dropped the limit — the original defect, in the
shape real code is more likely to take. Both forms are now pinned by
`@ts-expect-error` tests.

**Collateral effect, and the half you'll actually hit:** a value
*declared* `RequestOptions` no longer assigns to a pipe `.fetch()` at
all, even carrying no limit at runtime, since the declared type permits
one. Type a shared options object as `PipeRequestOptions` — the table
and query-builder `.fetch()` accept it too, so it works everywhere — or
inline `{ signal }`. Structural wrappers are unaffected: method
parameters compare bivariantly, so `interface Fetchable { fetch(opts?:
RequestOptions): … }` is still satisfied by `PipeRef` (verified).

Pre-existing on main; folded in here because this PR renames that exact
type and gives it a JSDoc describing it as the options for `.fetch()`,
which made the false advertisement more prominent rather than less.

**`FetchOptions` → `RequestOptions`** (the per-call type accepted by
`.fetch()`), no deprecated alias. The old name collided conceptually
with the new `options.fetchOptions`, which — per the ecosystem — means
"extra `RequestInit`", not "options for our `.fetch()` method". Nothing
consumes it pre-1.0; renaming the import is the whole migration *for
this one* — the `PipeRef.fetch` narrowing above is a separate,
behavioural break in the same file. The module-private `RequestOptions`
in `http.ts` became `RequestSpec` to free the name.

## Design notes

`fetch` stays optional all the way to the internal `HttpContext` rather
than being resolved at construction, so the default path calls the
global directly. That keeps it **late-bound** (replacing
`globalThis.fetch` after a client exists still works — what
`vi.stubGlobal` does) and avoids invoking a **detached** `fetch`
reference, which throws "Illegal invocation" on browsers, workerd, and
Bun. Both are pinned by tests. Supabase's `resolveFetch` independently
converges on the same closure-per-call shape; the SDKs that capture at
construction are the ones with stale-fetch bug reports.

`FetchLike` is the standard `fetch` signature, written out rather than
as `typeof fetch` because that resolves differently depending on whether
the consumer's `lib` includes DOM.

## Scope

REST only. `.stream()` and `.liveQuery()`'s live connection go through
`EventSource`, which accepts neither headers nor a `fetch` — so a
header-gated deployment can query but not stream until #203 changes that
transport. Called out in a `:::caution` on the docs page rather than a
footnote: the equivalent gap in Supabase's realtime client was found by
a user whose RLS policies silently stopped matching. `.liveQuery()`'s
initial backfill is an ordinary REST call and *is* covered.

Browser callers should know custom headers must also pass CORS
preflight, and WaveHouse allow-lists a fixed set with no config knob —
documented.

## Tests

158 pass, 1 skipped across the package (30 in `client.test.ts`),
covering: routing through a supplied fetch with the full `RequestInit`
asserted; global fallback; late-binding after construction; retries
using the override; headers applied to every request; `Content-Type` not
displaceable; `auth` beating a lowercase `authorization`; two casings
collapsing to one; `fetchOptions` merged; `fetchOptions` unable to touch
`method`/`body`/`headers`; and two `@ts-expect-error` pins that
`PipeRef.fetch` rejects `limit`, as a literal and via a named
`RequestOptions` value.

Beyond unit tests, every documented snippet was compiled against the
**built** package under DOM-inclusive and Node-only `lib` configs, and
the header behaviour was verified **on the wire** against a live server
— the CF-Access header sent, `auth` beating an impostor, `Content-Type`
intact. That last step matters here: compile-plus-happy-path is exactly
the verification that let the implied-dispatcher bug through twice.

## Related

- #459 — per-call `headers`/`fetchOptions`/`fetch` and dynamic header
callbacks, deliberately deferred
- #458 — the `auth()` per-request contract, split out
- #203 — SSE transport; owns whether streaming ever gets these
- #455 / #440 — the e2e-harness fix for the same undici bug
(`connection: close`, right for a test helper, wrong for a production
client — hence this escape hatch)

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation, site/, README area/sdk TypeScript SDK (clients/ts/) dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation go Pull requests that update go code

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

fix(test): waitForCondition can't enforce its budget, so slow polls fail opaquely

1 participant