Skip to content

feat(sdk)!: stream over fetch so SSE authenticates by header - #470

Merged
EricAndrechek merged 51 commits into
mainfrom
sse-auth
Aug 18, 2026
Merged

feat(sdk)!: stream over fetch so SSE authenticates by header#470
EricAndrechek merged 51 commits into
mainfrom
sse-auth

Conversation

@EricAndrechek

@EricAndrechek EricAndrechek commented Aug 13, 2026

Copy link
Copy Markdown
Member

Summary

Moves .stream() / .liveQuery() off EventSource and onto fetch, so the JWT travels as Authorization: Bearer instead of ?token= in the request URI — where every proxy, CDN, and load balancer in front of WaveHouse is free to log it.

Server untouched. bearerToken() has always preferred the header, and the CORS preflight has allow-listed Authorization + Last-Event-ID since #215internal/api/router_test.go:352 is an existing test naming #203 as its reason. ?token= stays accepted for clients that genuinely can't set headers.

Advances #203; does not close it. Tasks 1 and 2 of 3 are done (header auth, cURL flow). The third — retiring ?token= server-side — is #468, and closing that closes this.

This does not make streams authenticated, and (regarding #203) does not close it. /v1/stream stays ungated: an expired or missing token still resolves to default_role and gets a filtered 200, never a 401. What changes is that the JWT stops appearing in request URIs, and therefore in proxy, CDN, and load-balancer logs. Enforcing expiry on a live stream is #239 and is deliberately not delivered here.

Part of #194.

Decisions worth your eyes

1. Redirects are refused only when the request carries a credential.

Platforms strip Authorization on a cross-origin redirect (whatwg/fetch#1544 — the mitigation for the class behind CVE-2022-1650, which was this exact bug in the eventsource package) while forwarding other headers intact. A credentialed hop therefore either silently downgrades the stream to default_role — this endpoint answers an unauthenticated caller rather than rejecting them — or hands a configured proxy secret to whatever the redirect names. Refusing costs nothing, because following would never have produced an authenticated stream anyway.

With no credential there's nothing to protect, so redirects are followed and CDN canonicalization, geo/LB indirection, and http→https upgrades all work. options.fetch overrides redirect if you need the credentialed case followed regardless.

manual rather than error: error rejects with a bare TypeError indistinguishable from a connection failure, which the reconnect loop would retry forever against a redirect that will never stop happening.

2. FetchLike's URL parameter narrows to string. The design note on #269 said the type was narrow so hand-written (url: string, init?) => Promise<Response> middleware would assign. What shipped was string | URL | Request, which — parameters being contravariant — rejects exactly that. Purely additive for implementers; only code that imported FetchLike and called through it with a URL breaks.

3. The SDK gains its first runtime dependency: eventsource-parser@^3.1.0. MIT, zero transitive deps, npm provenance-attested (SLSA v1), no install scripts, 61.7M downloads/week, same maintainer as the canonical eventsource. Dual CJS/ESM, so it composes with our dist/index.cjs. Measured cost in the CDN IIFE bundle: 3402 B minified, 1430 B gzipped.

A caret rather than an exact pin because pinning in a published library duplicates the package in any consumer tree already resolving a 3.x and freezes them out of patch/security releases until we cut one — our lockfile still governs CI. The range stops below the ESM-only 4.0.0.

Why rent rather than hand-roll: the correct parser is ~440 lines, and the parts that matter — reassembling a frame split across chunk boundaries without quadratic recopying, disambiguating a trailing \r at a chunk boundary between a bare-CR terminator and half a split \r\n, and capping buffered input against a hostile stream — are what a naive version gets wrong, and are unavoidable even though we control the server, since HTTP/2 and intermediaries re-chunk freely. The buffer cap is set explicitly to 16 MiB; the parser defaults to unbounded.

This retires the "zero-dependency" claim everywhere it appeared — both READMEs, AGENTS.md invariant 14, four site pages, and an older Unreleased CHANGELOG entry that would otherwise have shipped in the same release notes as the entry introducing the dependency. The only surviving mentions are in released CHANGELOG history, which is a record rather than a claim.

What this fixes beyond the headline

  • Expired-token silent downgrade. auth() was called once and baked into the URL; EventSource then reconnected forever with that token. Once expired the stream stayed open serving a reduced view. This is why feat(sdk): migrate SSE auth from ?token=JWT to Fetch EventSource #203 is a prerequisite for feat(stream): re-validate JWT / enforce token expiry on long-lived SSE connections #239.
  • Blank id: clearing resumption. The hub emits id: for passthrough payloads; per spec an empty id clears the last-event-id. The transport retains the last non-empty id.
  • Real errors. A rejection carries its actual status and message instead of EventSource's status-free onerror — which, in the old transport, meant a gateway 401 surfaced as a silent closed with no error callback at all. Note the limit: a browser stream going cross-origin only sees the status if the rejection passes CORS and the gateway answered the Authorization preflight; otherwise it degrades to a retryable network error.
  • Non-SSE 200s refused (SSE_BAD_CONTENT_TYPE). An auth gateway's login page would otherwise feed HTML to the parser, which per the SSE grammar parses to nothing — leaving the stream live and permanently silent.
  • No Node polyfill. polyfills.ts and the eventsource devDependency are deleted.
  • options.fetch / headers / fetchOptions reach streams, closing the carve-out documented in feat(sdk)!: add options.headers, fetchOptions, and fetch #456.

Behavior changes to be aware of

  • A credentialed cross-origin browser stream now preflights on the initial connect. EventSource never preflighted at all (its request isn't a fetch(), so Fetch's unsafe-request flag is never set). A proxy that answers CORS itself must allow Authorization on OPTIONS /v1/stream or the stream never opens. Documented in reverse-proxy.mdx.
  • A proxy that strips Authorization on /v1/stream, or redirects it while credentialed, now breaks. Both previously "worked" by accident.
  • Resumption is at-least-once and time-bounded — this was always true; the docs now say so. The last event you saw is certainly redelivered (the id is a received_timestamp and replay is inclusive), the SDK does not dedupe live frames, and replay is capped by mq.gap_window_minutes (15 default).

Testing

sse.test.ts is rewritten against an injected fetch returning a scripted ReadableStream. The old harness stubbed a global FakeEventSource and could only assert on URL strings — framing, reconnect, and resumption had no coverage at all. 204 tests now, with 14 behavioral fixes mutation-verified: reverting each one makes a test fail, and — after review caught a case where it didn't — fail on the assertion that names it.

The e2e auth test was rewritten to be discriminating: anon is denied payload on the events table, so dropping the Authorization header flips the assertion. The previous version could not fail.

Beyond the nominal scope

Two REST-path fixes ride along, both surfaced by review of the streaming work and both in clients/ts/src/http.ts. Flagging them because an SSE PR is not where you would look for them, and either can be split out on request.

  • A cancelled request could throw instead of returning ABORTED. The network-error backoff is the one sleep inside request()'s catch, so its rejection had no handler and escaped as a raw DOMException. Nothing wraps request(), so it reached callers as an unhandled rejection — and the AbortController example in our own reference demonstrated a branch that could not be taken against an unreachable server.
  • Abort is now classified from the signal, not the rejection's type. Keying off the error made the outcome depend on maxRetries: AbortSignal.timeout() raises a TimeoutError, so it reported NETWORK_ERROR at maxRetries: 0 and ABORTED at 2. It also mis-handled middleware — an options.fetch enforcing its own per-attempt deadline aborts an internal controller while the caller never cancelled, which is transient and should be retried, not reported as a terminal ABORTED.

The same rule then had to be applied to the stream transport, where the old error-type check was worse: an AbortError from auth() or a custom fetch ended the stream terminally and emitted nothing.

Review

Fifteen pre-push rounds against both gating reviewers, who verify by executing the code rather than reading it. Worth knowing what they caught, since none of it was reachable by CI:

Behavior bugs SSE_CONNECT_ERROR never reaching a subscriber; a closed stream stuck reporting live; a consumed-body guard that didn't guard; an unhandled rejection that killed the host process; a stranded reconnect timer
Regression vs EventSource close() from inside a handler no longer stopped delivery
Coverage holes deleting the bearer half of the credentialed-redirect rule left the suite green
False claims in docs EventSource "preflighted on reconnect" (it never preflights); "WaveHouse does not reject a stream" (it 400s on a missing table); "the SDK isolates a throwing handler" (true only inside the transport — several paths outside it are not, now enumerated in the SDK reference and filed as #473)

Two recurring shapes, both worth knowing before you read the diff.

In the code: a guard or cleanup applied to N−1 of N call sites. Six defects shared it. if (this._closed) return now appears eight times in sse.ts, several added a round apart. Assume any new early-exit path in this transport is the one that got missed.

In the prose: a true mechanism attached to a wider case set than it holds for. This accounts for essentially every documentation defect found here, and it recurred for eight consecutive rounds — three times inside the sentence written to fix the previous instance. The reviewers' diagnosis is the useful part: none of these were factual errors about the system, they were missing quantifiers. Nearly every claim in this area is a function of a variable the docs cannot name — the reader's token-provider latency, which origin, which credentials mode — so the domain lives only in the author's head at the moment of writing, and the next revision reaches for the deepest true mechanism and silently re-attaches it to the whole case set. The empirical tell was sharp: the rule-shaped sentences never needed correcting; the value-shaped ones were corrected every round.

The Live Queries failure section is written to that conclusion — it states rules and gives the reader a test, rather than reporting which outcome is typical. Two amplifiers were also removed: sentences that counted table rows (a ninth row would have silently falsified five of them) and facts restated independently in four or five files. If you are reviewing prose here, the question that finds bugs is "for which cases is this true?", not "is this true?" Four axes account for essentially every defect found on this branch, and a claim that is silent about which side it means is the shape to distrust:

  1. Who rejected it — WaveHouse (a 400 on the stream route; a 404/405 off it) versus something in front. Never "the server said 401": /v1/stream is ungated.
  2. Where the caller runs — server-side or same-origin (statuses visible) versus browser cross-origin, where CORS can make any rejection, including a rejected preflight, indistinguishable from a network drop.
  3. Whether the request carries a credential — decides redirect: "manual" versus "follow", and the test is Authorization-or-headers, so cookies are not credentials by it (fix(sdk): cookie-authenticated streams bypass the redirect guard #478).
  4. Whether the failure is semantically transient — the 4xx-is-terminal rule is a transport mechanism, not a claim about the world (fix(sdk): treat a stream 429 as retryable and honor Retry-After #469).

One structural note so it isn't rediscovered: CHANGELOG.md is denylisted from the docs-prose gate (scripts/docs-prose.sh:38), so that entry has never been read by the automated docs reviewer. It is worth reading at docs scrutiny rather than skimming as boilerplate — a false claim survived three rounds there for exactly that reason.

The Go diff is one comment, so this looks deployment-free. It isn't. A credentialed cross-origin browser stream now preflights where EventSource never did, so a proxy that answers CORS itself must allow Authorization on OPTIONS /v1/stream or streams stop opening — silently, in a retry loop, not with a visible error. That break is documented in reverse-proxy.mdx; a reviewer reading only internal/ will conclude nothing operational changed.

Follow-ups filed

Design work deferred out of this PR:

Defects found by review of this branch and left unfixed here, each because the
fix lands outside the transport or carries a design question I didn't want to
answer unilaterally in a PR about auth:

Reviewer notes

The interesting file is clients/ts/src/stream/sse.ts. controller.ts is untouched — the transport sits behind the same StreamTransport interface — so the diff is scoped to the transport, its tests, and the docs the change invalidated.

clients/ts/src/stream/live-query.test.ts is new and is the first test coverage LiveQuery has had. It pins one thing worth knowing about: the backfill, not the stream, spends the first auth() call, and that ordering is emergent from four independent details rather than declared anywhere. One added await on the REST path silently swaps the two failure modes the docs describe, so the test exists to make that a red build rather than a documentation drift.

Merged with main at 1064a4fe, which brought #381 (per-subscriber SSE row filtering) and #457. Both conflicted textually with this branch and both were resolved keeping each side; #381 adds no new status code, so this PR's claim that /v1/stream raises exactly one 4xx itself still holds.

Replaces the EventSource-based SSE transport with one built on fetch, so
the JWT travels as `Authorization: Bearer` instead of `?token=` in the
request URI, where every proxy and CDN in front of WaveHouse could log it.

The server already preferred the header and already allow-listed it (plus
Last-Event-ID) in the CORS preflight, so nothing changes server-side;
`?token=` stays accepted for clients that cannot set headers.

Owning the transport means owning what EventSource provided: framing
(rented from eventsource-parser, exact-pinned), jittered reconnect
backoff, and Last-Event-ID resumption. Three behavioral consequences:

- `auth` is re-read per connection attempt, so a stream outliving its
  token no longer reconnects forever with the expired one and silently
  degrades to default_role. Prerequisite for #239.
- The last *non-empty* event id is retained; the hub emits a blank `id:`
  for passthrough payloads, which per spec would clear resumption state.
- The Node EventSource polyfill requirement is gone.

`options.fetch` / `headers` / `fetchOptions` now reach streams, closing
the carve-out documented in #456, and FetchLike's URL parameter narrows
to `string` so hand-written middleware assigns — what the type intended
when it shipped. Streaming needs a readable response body, which the type
cannot express, so the transport fails with SSE_NO_STREAM_BODY instead of
hanging on a fetch wrapper that buffers.

Closes #203.
`redirect: "error"` rejects with a bare TypeError indistinguishable from
a connection failure, so the reconnect loop would have retried a
deterministic redirect forever. `manual` keeps the outcome inspectable:
Node surfaces the 3xx, browsers an opaque status-0 response, and both
land on one terminal SSE_REDIRECT naming the fix.

Also adds the Content-Type check EventSource performed and this
transport was missing. An auth gateway answering a 200 with its login
page would otherwise feed HTML to the frame parser, yield no events, and
leave the stream "live" and permanently silent — indistinguishable from
a quiet table, and exactly the deployment #269 is about.
Code:
- SSE_CONNECT_ERROR was emitted synchronously inside connect(), which runs
  in the StreamController constructor — so it fired before .stream() had
  returned and no subscriber could receive it. A regression against main,
  where the same failure landed in a microtask via .catch(). _run() now
  yields before the first attempt. The transport tests all attached
  callbacks before connect(), which is why they missed it; the regression
  test lives at the createClient level.
- Backoff never escalated against a server that accepts and immediately
  closes, because any connection that opened reset the schedule —
  measured 8 attempts in 6s, ~4x more aggressive than the fixed ~3s delay
  EventSource used, against a server already in trouble. The schedule now
  resets only after a connection has held for 3s.
- fetchOptions.body is neutralized like the REST path already does;
  a body on a GET makes fetch throw, which read as a retryable network
  failure and looped forever.
- Redirects are now refused only when the request carries a credential.
  Following never yields an authenticated stream (the platform strips
  Authorization cross-origin) but does leak configured headers to the
  target; with no credential there is nothing to protect, so CDN
  canonicalization, geo/LB indirection and http->https upgrades work.

Docs: the SDK gained its first runtime dependency, so eight
"zero-dependency" claims across both READMEs, AGENTS.md and the site are
now false. clients/ts/README.md — the page npm publishes — still told
users to polyfill EventSource. Also corrects the undici cast rationale
(FetchLike's URL parameter is now `string`, so that cast is dead), the
two "every REST request" section bodies, SSE_PARSE_ERROR's retry
semantics, and the auth-rejection error code.

CHANGELOG no longer claims the ?token= deprecation is tracked; it isn't,
and this PR does not close #203 because of it.
`ws://` is the tempting mistake for a streaming endpoint, and an absolute
URL with an unusable scheme sails past resolveURL and only fails at fetch
— as a generic rejection the reconnect loop reads as transient and
retries forever. Validate the scheme where the failure is still
attributable, and say plainly that there is no WebSocket endpoint.

A bare host (`localhost:8080`, `example.com`) already throws out of
resolveURL onto the same terminal path; this covers the
absolute-but-wrong case it can't catch.
- onStatus("live") wasn't guarded against a disconnect landing between the
  fetch settling and the emission. Aborting doesn't retract an already
  resolved Response, so the continuation ran anyway and flipped a closed
  controller back to live — where it stayed, since the loop then exits
  without emitting anything further. Status now routes through the same
  _closed guard as errors; disconnect's terminal "closed" still bypasses it.

- The e2e "authenticated via Authorization header" test couldn't fail if
  the header were dropped: anon had full access to the table, so both
  roles saw identical frames. It now streams a column anon is denied and
  asserts both sides — the authenticated stream sees payload, the public
  one doesn't. That is the only end-to-end proof the credential move works.

- Terminal non-2xx paths cancel the response body so undici returns the
  socket to the pool rather than waiting for GC.

Docs: the SSE_NO_STREAM_BODY promise was overstated — the guard catches a
returned bodyless response, but a wrapper that awaits the body never
returns at all and hangs in the caller's own function. Scoped in three
places. Also corrects an impossible HTTP_401 example (/v1/stream is
ungated and answers a bad token with a filtered view, which is the whole
reason auth is re-read per attempt), the credentials caution (the
transport owns cache and redirect, not credentials), the conditional
redirect wording, buffer-cap error ordering, and the http/https scheme
requirement.
The SSE_NO_STREAM_BODY guard tested `!body`, but a Response whose body has
already been read still exposes a non-null ReadableStream — it is merely
locked. So the guard passed, "live" was emitted, and getReader() threw
`Invalid state: ReadableStream is locked` from outside the read loop,
surfacing as a terminal SSE_CONNECT_ERROR instead of the documented code.
Both `locked` and `bodyUsed` are now checked, and neither implies the
other: after .text() both are set, after a bare getReader() only `locked`
is. This is the failure the docs already promised was handled.

Also finishes the round-2 body-release fix: SSE_BAD_CONTENT_TYPE was the
one terminal branch left without it, and the only one with a real body to
drain (a gateway's HTML login page). Release now goes through a helper
that skips locked streams, since cancel() throws on those.

Tests: the errored-mid-read reconnect had no coverage — a reset
connection is the canonical production drop this transport took over from
the platform, and only the clean-close path was exercised. The fixture
grows a fail() handle; the test asserts SSE_READ_ERROR, retryable, and a
re-dial carrying Last-Event-ID. Plus the consumed-body case above.

Docs: the SSE_PARSE_ERROR table row contradicted its own section on the
buffer cap, the credentials framing read as "owned" in one passage and
"stays yours" in another, and the six-member response contract was
REST-only prose presented as universal — a stub built to it fails a
stream.
#468 now carries the staged plan — counter and Deprecation/Sunset headers
before removal — so the entry can name it instead of describing a
follow-up that did not exist when it was written.
releaseBody() cancelled without a rejection handler. Cancelling an
*errored* stream returns a rejected promise, so a connection reset
arriving between the response headers and a terminal branch produced an
unhandled rejection — which under Node's default
--unhandled-rejections=throw takes the consumer's process down.
Reproduced at exit code 1, fixed by the same .catch() that _pump has used
all along, and now pinned by a test that fails without it (the earlier
suite did not cover it).

Also adds the two uncovered parser callbacks: a server-sent `retry:`
floor with its clamp — server-controlled input into client reconnect
timing had no test at all — and the SSE_PARSE_ERROR path, asserting the
documented reported-and-skipped behavior by continuing to deliver on the
same connection.

Widens `eventsource-parser` from an exact pin to ^3.1.0. Exact-pinning in
a *published library* duplicates the package in any consumer tree already
resolving a 3.x and freezes them out of patch and security releases until
we cut one, while our own lockfile still pins the resolved version for
CI. The caret still excludes the ESM-only 4.0.0, so the original
rationale is intact.

Docs: a credentialed cross-origin browser stream now preflights on the
initial connect, where EventSource only preflighted on reconnect — a
behavior change this branch introduces that the proxy checklist did not
mention, and one that breaks every such stream if the proxy answers CORS
and rejects Authorization on OPTIONS. Also drops `keepalive: true` from a
global fetchOptions example (it caps request bodies at 64 KiB in
browsers, which the documented NDJSON upload path exceeds), and fixes a
field count that contradicted itself.
The entry opened "closes #203" and then, four sentences later, said the PR
deliberately does not close it. That contradiction was also the obvious
source for the PR body, where it would have auto-closed an issue whose
third task is untouched. Now "advances #203", naming #468 as what closes
it.

Three factual corrections, all self-inflicted:

- "nine days after options.fetch shipped" was invented. #456 merged
  2026-08-12T21:53Z — about twelve hours ago — and there are no release
  tags, so it has never shipped at all. The timeline carries the argument
  for why breaking FetchLike is acceptable, and the true version is the
  stronger one.
- "exact-pinned" described the dependency as it was before the previous
  commit widened it to ^3.1.0.
- "~5 KB raw / ~2 KB gzipped" attributed to the parser a bundle delta
  that also contained the transport rewrite. Bundling the parser alone
  measures 3402 B minified, 1430 B gzipped.

Also aligns the "three RequestInit fields" count with the two the code
actually owns, in both the CHANGELOG and the SDK page, and fixes the
prose regressions the last round introduced: an example that no longer
contained the field the next paragraph discussed, and a rejection-
handling paragraph that read as covering streams when it describes REST.
_dispatch was the one callback without the _closed guard that _emitError
and _emitStatus both carry. parser.feed() dispatches every complete frame
in a chunk synchronously, so a subscriber using the ordinary "read until
I see my event, then stop" pattern kept receiving the rest of that chunk
after calling close() — and StreamController never clears its
subscribers, so they were delivered for real, or buffered for an async
iterator that had already broken.

A regression against EventSource, where close() inside a handler ended
delivery. Usually invisible because the server flushes per frame, but
gap-fill replay and any coalescing proxy put several frames in one chunk.

Docs: the Streaming page led with a WebSocket-era sentence that asserted
nothing and never said how a stream authenticates — the question the page
now exists to answer. It also duplicated the per-attempt token claim and
dead-ended on error semantics with no link to the code table. Fixed as
paragraphs rather than clauses, which is how the last two rounds
introduced stale cross-references. Also corrects StreamSubscriber.status
to StreamStatus, and the fetchOptions caution, whose example reference
still described a field the example no longer contained.
`credentialed` is `Authorization present || headers configured`, but only
the headers half was asserted. The auth-only test checked the resulting
SSE_REDIRECT, which the scripted fetch produces regardless of
init.redirect — so deleting the Authorization term left the whole suite
green while making a bearer-token stream follow a cross-origin redirect
and lose its token. That is the security property this PR is largely
about. Now asserted at the init level and mutation-verified.

Docs: "unlike the REST codes, retryable is not advisory" read as a claim
that REST ignores the flag, contradicting http.ts and the table three
lines above; the real distinction is when you see it, not whether it
drives retries. Also points the Streaming page at the options that now
reach the live connection — the header-gated-proxy reader lands there,
and that fact only existed on the SDK index page.

Files a follow-up (#469) for a reviewer observation left out of scope: a
429 from a fronting proxy is terminal, so a rate-limited reconnect storm
kills every client until reload.
…laims

_sleep installed a timer with no _closed check. A disconnect() raised
synchronously from the "reconnecting" status callback runs one line
before that timer exists, so _wake is still null and nothing can cancel
it — the loop exits without reconnecting but holds the event loop for the
whole backoff, measured at 825ms at attempt 0 and 6.9s at attempt 3, up
to 30s at the ceiling. Reachable through the public API, since
StreamController.close() on last-unsubscribe runs on that same
synchronous path. This is the fifth instance of the same habit in this
file: a guard added to the case in front of me instead of to its
siblings.

Docs, both claims I asserted rather than checked:

- "a bare EventSource only preflighted on reconnect" is false.
  EventSource never preflights — its request is not a fetch(), so Fetch's
  unsafe-request flag is never set and Last-Event-ID rides on the plain
  GET. Our own router_test.go already scoped the allow-list to
  "fetch-based stream clients"; I had read that comment.
- "WaveHouse itself does not reject a stream" is absolute and wrong.
  internal/api/stream.go returns 400 for a missing or empty table, which
  wh.from('').stream() reaches. A reader debugging HTTP_400 was being
  pointed at their proxy.

Also flags that options.maxRetries is REST-only — it was the last row in
a table whose other three now say "streaming included" — and states that
stream reconnection is unbounded until a terminal error or close().
…ed()

"resumes with Last-Event-ID so the server gap-fills what was missed" was
a claim this branch newly wrote, and it overstates the guarantee in both
directions.

Duplicates aren't possible, they're certain: the id is the event's
received_timestamp (hub.go:208), the server replays from that instant
inclusively via DeliverByStartTime, and the SDK does not dedupe live
frames — liveQuery dedupes once, at the backfill seam. The server's own
comment already said so (stream.go:80, "at-least-once, deduped
client-side by id:"); the docs promised something cleaner.

And a long drop loses events silently: replay is bounded by
mq.gap_window_minutes, 15 by default, past which the sweeper has purged
the messages.

Also names the exception to "a 4xx means retrying wouldn't help" — a 429
or 408 from a rate limiter is transient, the stream still ends, and the
caller has to open a new one (#469) — and documents
StreamController.connected(), which is public, JSDoc'd and used four
times by our own e2e but appeared in neither the "complete API tree" nor
the per-method sections. That omission predates this branch but starts to
bite now that the docs say re-dialing is unbounded: "how do I wait for
live, and how do I give up" has an answer in the API and had none in the
docs.
@github-actions github-actions Bot added documentation Improvements or additions to documentation dependencies Pull requests that update a dependency file area/sdk TypeScript SDK (clients/ts/) area/docs Documentation, site/, README labels Aug 13, 2026
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Streaming now uses fetch and readable streams without an EventSource polyfill.
    • Added configurable headers, fetch options, custom fetch implementations, automatic reconnection, retry backoff, event resumption, and connected() readiness tracking.
    • Streaming authentication uses Bearer headers with refreshed credentials on reconnect.
    • Added a lightweight SSE parser dependency.
  • Bug Fixes

    • Improved connection, authentication, parsing, network-error, and cancellation handling.
  • Documentation

    • Updated streaming, authentication, CORS, redirects, reconnection, filtering, and dependency guidance.

Walkthrough

The TypeScript SDK replaces EventSource with fetch-based SSE. It adds header authentication, streaming configuration, parsing, reconnection, resumption, validation, and lifecycle handling. REST cancellation now returns standardized ABORTED results. Tests and documentation cover the new behavior.

Changes

SDK streaming and HTTP behavior

Layer / File(s) Summary
Transport contracts and wiring
clients/ts/package.json, clients/ts/src/types.ts, clients/ts/src/http.ts, clients/ts/src/client.ts, clients/ts/src/stream/sse.ts
The SDK adds eventsource-parser, supports custom fetch settings for streaming, exports mergeHeaders, narrows FetchLike URLs to strings, and removes the EventSource requirement.
Connection, parsing, and reconnect flow
clients/ts/src/stream/sse.ts, clients/ts/src/stream/sse.test.ts, clients/ts/src/client.test.ts
SSETransport sends Bearer authentication, validates URLs and responses, parses SSE frames, tracks Last-Event-ID, applies retry hints and jittered backoff, and manages disconnects.
HTTP cancellation and live-query behavior
clients/ts/src/http.ts, clients/ts/src/http.test.ts, clients/ts/src/stream/live-query.ts, clients/ts/src/stream/live-query.test.ts
Caller cancellation returns ABORTED during requests and retry backoff. Tests cover cancellation classification and live-query authentication ordering.
Transport validation and end-to-end coverage
clients/ts/src/stream/sse.test.ts, tests/e2e/sdk/*, tests/e2e/sdk/vitest.config.ts
Tests cover fetch construction, framing, retries, resumption, validation, lifecycle races, asynchronous startup failures, header authentication, restricted payloads, and removal of EventSource polyfills.
SDK documentation and release notes
AGENTS.md, README.md, CHANGELOG.md, clients/ts/README.md, docs/src/content/docs/*, internal/api/stream.go
Documentation describes fetch-based streaming, runtime requirements, Authorization headers, CORS, redirects, reconnect behavior, replay semantics, error handling, dependencies, and live-query behavior.

Estimated code review effort: 4 (Complex) | ~60 minutes

Merge Risk: 🔵 Low · up to 924d3

The SDK now authenticates streams through fetch headers, reducing token exposure in request URLs while changing redirect, preflight, and proxy behavior. The PR is mergeable with explicit owner follow-up for strict SSE validation, accurate proxy upload guidance, reconnect/close edge cases, and test diagnostics; these are bounded risks rather than release-blocking failures.

Sequence Diagram(s)

sequenceDiagram
  participant Client
  participant SSETransport
  participant TokenProvider
  participant Fetch
  participant ReadableStream
  Client->>SSETransport: start stream
  SSETransport->>TokenProvider: request token
  TokenProvider-->>SSETransport: return token
  SSETransport->>Fetch: fetch SSE request with Authorization
  Fetch-->>SSETransport: return streaming response
  SSETransport->>ReadableStream: read response chunks
  SSETransport->>SSETransport: parse events and track Last-Event-ID
  SSETransport->>Fetch: reconnect after retryable failure
Loading

Possibly related issues

Possibly related PRs

Suggested labels: area/streaming

Suggested reviewers: taitelee

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 90.91% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary change: replacing the SSE EventSource transport with fetch and header-based authentication.
Description check ✅ Passed The description directly explains the fetch-based SSE transport, header authentication, behavior changes, testing, and documented follow-ups.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch sse-auth
✨ Simplify code
  • Create PR with simplified code
  • Commit simplified code in branch sse-auth

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 13, 2026

Copy link
Copy Markdown

📚 Docs preview is livehttps://54d0207d-wavehouse-docs.wave-rf.workers.dev

  • Commit924d30d: docs(changelog): scope the last pre-fetch polyfill claim, and credit queries.md
  • Author@EricAndrechek, Claude Opus 5 (1M context)
  • Committed — 2026-08-18 16:09 (UTC-04:00)
  • Deployed — 2026-08-18 16:25 EDT

@github-code-quality

github-code-quality Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Code Coverage Overview

Languages: Go

Go

The overall coverage in commit 924d30d in the sse-auth branch remains at 91%, unchanged from commit 91ac2be in the main branch.


Updated August 18, 2026 20:25 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: 10


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 574111d6-fbcd-496d-9aff-6224803cf733

📥 Commits

Reviewing files that changed from the base of the PR and between ea4fbf6 and 77feb9d.

⛔ Files ignored due to path filters (1)
  • pnpm-lock.yaml is excluded by !**/pnpm-lock.yaml
📒 Files selected for processing (23)
  • AGENTS.md
  • CHANGELOG.md
  • README.md
  • clients/ts/README.md
  • clients/ts/package.json
  • clients/ts/src/client.test.ts
  • clients/ts/src/client.ts
  • clients/ts/src/http.ts
  • clients/ts/src/stream/sse.test.ts
  • clients/ts/src/stream/sse.ts
  • clients/ts/src/types.ts
  • docs/src/content/docs/api.md
  • docs/src/content/docs/getting-started.md
  • docs/src/content/docs/index.mdx
  • docs/src/content/docs/reverse-proxy.mdx
  • docs/src/content/docs/sdk/index.mdx
  • docs/src/content/docs/sdk/reference.md
  • docs/src/content/docs/sdk/streaming.md
  • docs/src/content/docs/why-wavehouse.md
  • tests/e2e/sdk/package.json
  • tests/e2e/sdk/polyfills.ts
  • tests/e2e/sdk/streaming.test.ts
  • tests/e2e/sdk/vitest.config.ts
💤 Files with no reviewable changes (2)
  • tests/e2e/sdk/package.json
  • tests/e2e/sdk/polyfills.ts
📜 Review details
⏰ Context from checks skipped due to timeout. (8)
  • GitHub Check: Unit tests
  • GitHub Check: Coverage
  • GitHub Check: E2E tests
  • GitHub Check: Integration tests
  • GitHub Check: Docs build
  • GitHub Check: Lint
  • GitHub Check: Analyze (go)
  • GitHub Check: Analyze (go)
🧰 Additional context used
📓 Path-based instructions (2)
**/*.md

📄 CodeRabbit inference engine (AGENTS.md)

  • Comment the why, not the what. Add a comment only when the reason isn't obvious from the code; a line that matches the surrounding pattern needs none. Keep comments to 1–2 lines and match the file's existing density.

Files:

  • docs/src/content/docs/getting-started.md
  • AGENTS.md
  • README.md
  • docs/src/content/docs/why-wavehouse.md
  • clients/ts/README.md
  • docs/src/content/docs/sdk/reference.md
  • docs/src/content/docs/api.md
  • CHANGELOG.md
  • docs/src/content/docs/sdk/streaming.md
tests/e2e/sdk/*.test.ts

📄 CodeRabbit inference engine (AGENTS.md)

tests/e2e/sdk/*.test.ts: Add new E2E scenarios as tests/e2e/sdk/*.test.ts files using helpers from tests/e2e/sdk/helpers.ts.
A new test file must (1) add its suite name to SUITES in tables.ts and (2) get its names via const T = suiteTables("<suite>"), then reference T.clicks etc. — never a bare clicks.

Files:

  • tests/e2e/sdk/streaming.test.ts
🧠 Learnings (30)
📓 Common learnings
Learnt from: CR
Repo: Wave-RF/WaveHouse PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-08-12T21:55:01.697Z
Learning: Applies to clients/ts/**/*.ts : The TypeScript SDK (`wavehouse/sdk` in `clients/ts/`) is the canonical client and ships from this repo.
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 448
File: clients/ts/src/types.ts:61-71
Timestamp: 2026-08-11T15:22:23.813Z
Learning: In the TypeScript SDK, `ClientConfig.baseURL` must be an absolute URL with a scheme and host. A relative `baseURL` causes `resolveURL` to throw a `TypeError` on the first request. REST requests reject because `resolveURL` runs outside `request()`'s retry `try` block. In `clients/ts/src/stream/sse.ts`, `SSETransport` catches this failure and reports `SSE_CONNECT_ERROR` through the optional `StreamSubscriber.error` callback, so a subscriber without that callback can observe no error.
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 456
File: clients/ts/src/client.test.ts:0-0
Timestamp: 2026-08-12T20:34:04.763Z
Learning: In the TypeScript SDK, `clients/ts/src/types.ts` defines `FetchLike` as `(input: string | URL | Request, init?: RequestInit) => Promise<Response>`. This explicit standard fetch-compatible signature avoids `typeof fetch` differences when DOM library types are absent and allows fetch wrappers to be shared with SDKs that use the standard wide input type.
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 456
File: docs/src/content/docs/sdk/index.mdx:0-0
Timestamp: 2026-08-12T15:28:20.891Z
Learning: For `docs/src/content/docs/sdk/index.mdx`, the documented workaround for the undici idle-event-loop keep-alive stall is to upgrade to undici 8.10.0 or later. If a consumer is pinned to an affected version, `new Agent({ pipelining: 0 })` must be merged as `dispatcher` into the SDK-provided `RequestInit`; this disables keep-alive reuse. Configuring `keepAliveTimeout` does not mitigate this stall because the socket retirement timer is starved by the same idle event loop.
📚 Learning: 2026-08-12T21:55:01.697Z
Learnt from: CR
Repo: Wave-RF/WaveHouse PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-08-12T21:55:01.697Z
Learning: Applies to clients/ts/**/*.ts : The TypeScript SDK (`wavehouse/sdk` in `clients/ts/`) is the canonical client and ships from this repo.

Applied to files:

  • clients/ts/package.json
  • docs/src/content/docs/getting-started.md
  • AGENTS.md
  • README.md
  • tests/e2e/sdk/vitest.config.ts
  • docs/src/content/docs/index.mdx
  • docs/src/content/docs/why-wavehouse.md
  • clients/ts/README.md
  • clients/ts/src/client.ts
  • docs/src/content/docs/sdk/index.mdx
📚 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:

  • docs/src/content/docs/getting-started.md
  • AGENTS.md
  • README.md
  • docs/src/content/docs/why-wavehouse.md
  • clients/ts/README.md
  • docs/src/content/docs/sdk/reference.md
  • docs/src/content/docs/api.md
  • CHANGELOG.md
  • docs/src/content/docs/sdk/streaming.md
📚 Learning: 2026-08-12T15:28:20.891Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 456
File: docs/src/content/docs/sdk/index.mdx:0-0
Timestamp: 2026-08-12T15:28:20.891Z
Learning: For `docs/src/content/docs/sdk/index.mdx`, the documented workaround for the undici idle-event-loop keep-alive stall is to upgrade to undici 8.10.0 or later. If a consumer is pinned to an affected version, `new Agent({ pipelining: 0 })` must be merged as `dispatcher` into the SDK-provided `RequestInit`; this disables keep-alive reuse. Configuring `keepAliveTimeout` does not mitigate this stall because the socket retirement timer is starved by the same idle event loop.

Applied to files:

  • AGENTS.md
  • clients/ts/README.md
  • docs/src/content/docs/sdk/reference.md
  • docs/src/content/docs/api.md
  • docs/src/content/docs/reverse-proxy.mdx
  • CHANGELOG.md
  • docs/src/content/docs/sdk/streaming.md
  • docs/src/content/docs/sdk/index.mdx
📚 Learning: 2026-08-12T21:55:01.697Z
Learnt from: CR
Repo: Wave-RF/WaveHouse PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-08-12T21:55:01.697Z
Learning: Applies to internal/auth/**/*.go : **Auth: always on, fail-loud, decoupled from authz (security)**

Applied to files:

  • AGENTS.md
  • CHANGELOG.md
📚 Learning: 2026-08-12T21:55:01.697Z
Learnt from: CR
Repo: Wave-RF/WaveHouse PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-08-12T21:55:01.697Z
Learning: Applies to internal/policy/**/*.go : **Hasura-style access control: fail-closed (security)**

Applied to files:

  • AGENTS.md
📚 Learning: 2026-08-12T21:55:01.697Z
Learnt from: CR
Repo: Wave-RF/WaveHouse PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-08-12T21:55:01.697Z
Learning: Applies to internal/query/**/*.go : **Structured queries: column authz fail-closed (security)**

Applied to files:

  • AGENTS.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:

  • AGENTS.md
  • CHANGELOG.md
📚 Learning: 2026-08-11T15:22:47.380Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 448
File: docs/src/content/docs/sdk/index.mdx:330-334
Timestamp: 2026-08-11T15:22:47.380Z
Learning: In WaveHouse Go server authentication, `internal/auth/auth.go` `bearerToken` returns from the `Authorization` header path before modifying `r.URL`. It removes the `token` query parameter only when authentication uses the query parameter without an `Authorization` header. Documentation must state that this protects WaveHouse's own logs only; reverse proxies, CDNs, load balancers, and other upstream intermediaries require query-string redaction.

Applied to files:

  • AGENTS.md
  • docs/src/content/docs/api.md
  • docs/src/content/docs/reverse-proxy.mdx
  • CHANGELOG.md
  • docs/src/content/docs/sdk/index.mdx
📚 Learning: 2026-05-20T20:30:22.556Z
Learnt from: taitelee
Repo: Wave-RF/WaveHouse PR: 172
File: internal/api/pipes_test.go:106-118
Timestamp: 2026-05-20T20:30:22.556Z
Learning: In WaveHouse's pipes authorization fix (PR `#172`), fixing the AllowedRoles fail-open bug requires two changes: (1) remove the outer `if role != ""` guard in PipesHandler.Execute so empty roles are evaluated against the allowlist, AND (2) add an `ar != ""` guard inside the allowlist scan (i.e., `ar != "" && ar == role`) so a malformed allowlist containing empty strings (e.g., `[""]`) cannot match an empty role via `"" == ""`. Doing only (1) is insufficient and would make `[""]` fail-open.

Applied to files:

  • AGENTS.md
  • CHANGELOG.md
📚 Learning: 2026-07-07T12:38:15.328Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 378
File: internal/auth/auth.go:119-132
Timestamp: 2026-07-07T12:38:15.328Z
Learning: Repo: Wave-RF/WaveHouse. WaveHouse deliberately does not log or trace any client IP address anywhere in the codebase. `middleware.RealIP` was removed in PR `#332` due to IP-spoofing GHSAs, and trusted-proxy-aware client-IP extraction for logs/traces is tracked as a future cross-cutting effort in issue `#333`. Do not suggest adding `r.RemoteAddr` or naive `X-Forwarded-For`-derived IPs to logs (e.g., audit logs in internal/auth/auth.go for the operator-key path) until `#333` lands with proper trusted-proxy handling.

Applied to files:

  • AGENTS.md
  • CHANGELOG.md
📚 Learning: 2026-08-11T21:56:06.521Z
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:06.521Z
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:

  • AGENTS.md
  • CHANGELOG.md
📚 Learning: 2026-08-11T21:55:42.427Z
Learnt from: jfwoods
Repo: Wave-RF/WaveHouse PR: 434
File: clients/go/cmd/wavehouse-codegen/main.go:96-97
Timestamp: 2026-08-11T21:55:42.427Z
Learning: In `clients/go/cmd/wavehouse-codegen/main.go`, `fetchSchemas` is a self-contained CLI helper with one caller, `main`. The `AGENTS.md` convention to pass dependencies explicitly applies to package constructors, not to this type of CLI helper. Do not request HTTP-client injection unless HTTP-level test coverage or additional callers make that refactor necessary.

Applied to files:

  • AGENTS.md
📚 Learning: 2026-05-25T11:25:14.412Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 180
File: internal/observability/instruments.go:22-38
Timestamp: 2026-05-25T11:25:14.412Z
Learning: In WaveHouse's `internal/observability/instruments.go`, the `mustFloat64Histogram` and `mustInt64Counter` helpers intentionally panic at package init time if OTel instrument registration fails. This follows the `regexp.MustCompile`/`template.Must` Go idiom for build-time-constant invariants. The "return errors, don't panic" coding guideline applies to runtime/request-response paths only, not to init-time instrument registration. Do not flag this pattern as a violation.

Applied to files:

  • AGENTS.md
📚 Learning: 2026-08-12T21:55:01.697Z
Learnt from: CR
Repo: Wave-RF/WaveHouse PR: 0
File: AGENTS.md:0-0
Timestamp: 2026-08-12T21:55:01.697Z
Learning: Applies to tests/e2e/sdk/*.test.ts : Add new E2E scenarios as `tests/e2e/sdk/*.test.ts` files using helpers from `tests/e2e/sdk/helpers.ts`.

Applied to files:

  • tests/e2e/sdk/vitest.config.ts
  • clients/ts/src/stream/sse.test.ts
📚 Learning: 2026-08-11T15:22:23.813Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 448
File: clients/ts/src/types.ts:61-71
Timestamp: 2026-08-11T15:22:23.813Z
Learning: In the TypeScript SDK, `ClientConfig.baseURL` must be an absolute URL with a scheme and host. A relative `baseURL` causes `resolveURL` to throw a `TypeError` on the first request. REST requests reject because `resolveURL` runs outside `request()`'s retry `try` block. In `clients/ts/src/stream/sse.ts`, `SSETransport` catches this failure and reports `SSE_CONNECT_ERROR` through the optional `StreamSubscriber.error` callback, so a subscriber without that callback can observe no error.

Applied to files:

  • tests/e2e/sdk/vitest.config.ts
  • clients/ts/README.md
  • docs/src/content/docs/sdk/reference.md
  • CHANGELOG.md
  • docs/src/content/docs/sdk/streaming.md
  • docs/src/content/docs/sdk/index.mdx
📚 Learning: 2026-08-11T15:22:20.507Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 448
File: clients/ts/src/types.ts:61-71
Timestamp: 2026-08-11T15:22:20.507Z
Learning: In the TypeScript SDK, ensure `ClientConfig.baseURL` is validated or documented as requiring an absolute URL with both a scheme and host. Relative URLs cause `resolveURL` to throw a `TypeError` before REST retry handling runs. Ensure SSE connection failures are surfaced through `StreamSubscriber.error` and are not silently lost when that callback is absent.

Applied to files:

  • clients/ts/src/client.test.ts
  • clients/ts/src/client.ts
  • clients/ts/src/http.ts
  • clients/ts/src/stream/sse.test.ts
  • clients/ts/src/types.ts
  • clients/ts/src/stream/sse.ts
📚 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:

  • clients/ts/src/client.test.ts
  • clients/ts/README.md
  • CHANGELOG.md
  • clients/ts/src/stream/sse.test.ts
  • clients/ts/src/types.ts
📚 Learning: 2026-08-12T21:45:38.018Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 456
File: clients/ts/src/pipes.ts:0-0
Timestamp: 2026-08-12T21:45:38.018Z
Learning: In the TypeScript SDK, use the exported `PipeRequestOptions` type for `PipeRef.fetch` and shared pipe, table, or query-builder fetch options rather than `Pick<RequestOptions, "signal">` or `RequestOptions`. `PipeRequestOptions` declares `limit?: never`, ensuring object literals and named `RequestOptions` values containing `limit` fail type checking instead of silently dropping it. When only a signal is needed, use `PipeRequestOptions` or an inferred `{ signal }` object; do not expect a `RequestOptions` value to be assignable to `PipeRequestOptions`.

Applied to files:

  • clients/ts/src/client.test.ts
  • clients/ts/src/client.ts
  • clients/ts/src/http.ts
  • clients/ts/src/stream/sse.test.ts
  • clients/ts/src/types.ts
  • clients/ts/src/stream/sse.ts
📚 Learning: 2026-08-11T21:55:44.607Z
Learnt from: jfwoods
Repo: Wave-RF/WaveHouse PR: 434
File: clients/go/go.mod:3-6
Timestamp: 2026-08-11T21:55:44.607Z
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:

  • docs/src/content/docs/why-wavehouse.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:

  • docs/src/content/docs/why-wavehouse.md
  • CHANGELOG.md
📚 Learning: 2026-08-12T20:34:04.763Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 456
File: clients/ts/src/client.test.ts:0-0
Timestamp: 2026-08-12T20:34:04.763Z
Learning: In the TypeScript SDK, `clients/ts/src/types.ts` defines `FetchLike` as `(input: string | URL | Request, init?: RequestInit) => Promise<Response>`. This explicit standard fetch-compatible signature avoids `typeof fetch` differences when DOM library types are absent and allows fetch wrappers to be shared with SDKs that use the standard wide input type.

Applied to files:

  • clients/ts/README.md
  • CHANGELOG.md
  • clients/ts/src/types.ts
  • clients/ts/src/stream/sse.ts
  • docs/src/content/docs/sdk/index.mdx
📚 Learning: 2026-08-11T16:02:20.914Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 448
File: internal/auth/auth.go:0-0
Timestamp: 2026-08-11T16:02:20.914Z
Learning: In `internal/auth/auth.go`, `Middleware` must call `bearerToken(r)` before any authentication branch that can return early, including operator-key authentication. `bearerToken` removes a non-empty `token` query parameter from `r.URL.RawQuery` before selecting the Bearer-header or query-token credential, so WaveHouse handlers and logs do not retain an unused query token.

Applied to files:

  • docs/src/content/docs/api.md
  • 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:

  • docs/src/content/docs/reverse-proxy.mdx
  • docs/src/content/docs/sdk/streaming.md
📚 Learning: 2026-05-20T01:02:03.228Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 164
File: internal/api/router_test.go:289-350
Timestamp: 2026-05-20T01:02:03.228Z
Learning: In the WaveHouse project (`internal/api/**/*_test.go`), the convention for testing `RequireRole` middleware is to inject `ContextKeyRole` directly into the request context rather than using `testutil.MakeJWT`. JWT token parsing is covered separately in `middleware_test.go` (17 dedicated tests). Do not suggest switching role-gate tests to JWT-driven tests — the separation of concerns is intentional to keep failure surfaces isolated.

Applied to files:

  • CHANGELOG.md
📚 Learning: 2026-05-13T21:06:12.242Z
Learnt from: jfwoods
Repo: Wave-RF/WaveHouse PR: 135
File: deployments/signoz/clickhouse/users.xml:68-68
Timestamp: 2026-05-13T21:06:12.242Z
Learning: In the WaveHouse repository, `deployments/signoz/clickhouse/users.xml` intentionally configures the ClickHouse `default` user with an empty password and global network access (`::0`). This is acceptable because the `deployments/signoz/` stack is a local-dev-only SigNoz observability stack; ClickHouse ports are not published to the host in `deployments/signoz/compose.yaml`. Do not flag this as a security issue in future reviews.

Applied to files:

  • CHANGELOG.md
📚 Learning: 2026-07-08T12:46:29.364Z
Learnt from: taitelee
Repo: Wave-RF/WaveHouse PR: 381
File: internal/stream/hub.go:142-178
Timestamp: 2026-07-08T12:46:29.364Z
Learning: In `internal/stream/hub.go`, the per-subscriber `policy.Evaluate(...)` call in `Broadcast` (on the row-filtered path, when `perms.HasRowFilter()` is true) is intentionally not memoized per distinct claim set. Rationale from maintainer taitelee: the claims-independent fast path (`!HasRowFilter()`) already ensures high-fanout public streams without a row-filter pay no per-subscriber cost; for topics that do carry a row-filter, visibility is inherently per-connection (different JWT claims → different rows) so the per-subscriber evaluation can't be hoisted without losing correctness, and memoization by claim set would rarely hit since subscribers in a row-filtered bucket typically have distinct tenant claims (plus `map[string]any` claims aren't cheaply hashable). This tradeoff is intentional; don't flag it as a perf issue unless profiling on a real filtered-high-fanout topic shows it matters.

Applied to files:

  • CHANGELOG.md
📚 Learning: 2026-08-12T21:45:38.018Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 456
File: clients/ts/src/pipes.ts:0-0
Timestamp: 2026-08-12T21:45:38.018Z
Learning: In the TypeScript SDK, `PipeRef.fetch` uses the exported `PipeRequestOptions` type rather than `Pick<RequestOptions, "signal">`. `PipeRequestOptions` declares `limit?: never` so both object literals and named `RequestOptions` values that include `limit` fail type checking instead of silently dropping the limit. A value declared as `RequestOptions` is intentionally not assignable to `PipeRequestOptions`, even if it has no runtime `limit`; consumers can use `PipeRequestOptions` for shared pipe, table, and query-builder fetch options, or use an inferred `{ signal }` object.

Applied to files:

  • CHANGELOG.md
  • docs/src/content/docs/sdk/index.mdx
📚 Learning: 2026-08-12T20:33:30.744Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 456
File: clients/ts/src/pipes.ts:28-28
Timestamp: 2026-08-12T20:33:30.744Z
Learning: In the TypeScript SDK, `PipeRef.fetch` in `clients/ts/src/pipes.ts` accepts only a signal option. Pipe row limits are not generic request options. A pipe SQL definition can declare a `{{limit}}` parameter, and callers provide that parameter through `wh.pipe(name, { limit })`. The API binds the pipe request body as pipe parameters through `pipes.BindParams` in `internal/api/pipes.go`.

Applied to files:

  • CHANGELOG.md
  • docs/src/content/docs/sdk/streaming.md
📚 Learning: 2026-08-11T21:56:03.206Z
Learnt from: jfwoods
Repo: Wave-RF/WaveHouse PR: 434
File: clients/go/query_builder.go:278-291
Timestamp: 2026-08-11T21:56:03.206Z
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
🪛 LanguageTool
docs/src/content/docs/sdk/reference.md

[style] ~40-~40: A comma is missing here.
Context: ...OR| No | Stream could not be started (e.g. a non-absolutebaseURL) | | 0 | SSE_...

(EG_NO_COMMA)


[style] ~50-~50: Since ownership is already implied, this phrasing may be redundant.
Context: ...retryable after the SDK has exhausted its own attempts, so acting on it further is yo...

(PRP_OWN)


[style] ~52-~52: Consider an alternative for the overused word “exactly”.
Context: ...roxy. That silent-downgrade behavior is exactly why auth is re-read on every connecti...

(EXACTLY_PRECISELY)

docs/src/content/docs/sdk/streaming.md

[style] ~147-~147: Since ownership is already implied, this phrasing may be redundant.
Context: ...kfill seam), so key on timestamp plus your own row identity if duplicates matter. Rep...

(PRP_OWN)


[style] ~171-~171: Since ownership is already implied, this phrasing may be redundant.
Context: ...ed more of on this path; see Supplying your own fetch. ...

(PRP_OWN)

docs/src/content/docs/sdk/index.mdx

[style] ~419-~419: Since ownership is already implied, this phrasing may be redundant.
Context: ...row if it is set at all. See Supplying your own fetch for w...

(PRP_OWN)


[style] ~468-~468: This word has been used in one of the immediately preceding sentences. Using a synonym could make your text more interesting to read, unless the repetition is intentional.
Context: ...used with .stream() or .liveQuery() needs a different set: .ok, .status, `.ty...

(EN_REPEATEDWORDS_NEED)


[style] ~573-~573: Since ownership is already implied, this phrasing may be redundant.
Context: ... one underlying reason: undici declares its own request/response types, separate from t...

(PRP_OWN)


[style] ~575-~575: Consider using the typographical ellipsis character here instead.
Context: ...the two aren't structurally assignable. { ...init, dispatcher } as never covers the ...

(ELLIPSIS)


[style] ~577-~577: ‘whether or not’ might be wordy. Consider a shorter alternative.
Context: ...ither spelling, so one snippet compiles whether or not your lib includes DOM); and the retur...

(EN_WORDINESS_PREMIUM_WHETHER_OR_NOT)

🔇 Additional comments (26)
clients/ts/src/stream/sse.test.ts (5)

75-212: LGTM!


226-294: LGTM!


326-369: LGTM!

Also applies to: 397-538


542-751: LGTM!


753-886: LGTM!

clients/ts/src/client.test.ts (1)

361-384: LGTM!

AGENTS.md (1)

63-63: LGTM!

CHANGELOG.md (1)

14-14: LGTM!

Also applies to: 28-31

README.md (1)

63-63: LGTM!

clients/ts/README.md (1)

3-11: LGTM!

Also applies to: 47-47, 134-134

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

28-28: LGTM!

Also applies to: 586-587

docs/src/content/docs/getting-started.md (1)

108-108: LGTM!

docs/src/content/docs/index.mdx (1)

99-103: LGTM!

docs/src/content/docs/reverse-proxy.mdx (1)

180-180: LGTM!

docs/src/content/docs/sdk/index.mdx (1)

3-8: LGTM!

Also applies to: 57-58, 145-153, 319-333, 346-352, 397-425, 465-484, 494-582

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

28-49: LGTM!

Also applies to: 52-56, 103-103

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

14-18: LGTM!

Also applies to: 74-86, 125-125, 129-172, 222-222

docs/src/content/docs/why-wavehouse.md (1)

157-157: LGTM!

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

26-33: LGTM!

Also applies to: 135-165, 174-192

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

45-47: LGTM!

clients/ts/package.json (1)

43-45: LGTM!

clients/ts/src/stream/sse.ts (2)

1-4: LGTM!

Also applies to: 13-102, 113-192, 194-320, 322-354, 360-409, 432-455, 467-528


355-359: 🗄️ Data Integrity & Integration

No change is requested for the existing CORS allowance, Fetch/preflight explanation, or FetchLike documentation and intentional string narrowing; these compatibility details should remain as documented.

clients/ts/src/types.ts (1)

15-16: LGTM!

Also applies to: 146-157, 166-189

clients/ts/src/http.ts (1)

34-40: LGTM!

clients/ts/src/client.ts (1)

103-105: LGTM!

Comment thread clients/ts/src/stream/sse.test.ts Outdated
Comment thread clients/ts/src/stream/sse.test.ts
Comment thread clients/ts/src/stream/sse.test.ts
Comment thread clients/ts/src/stream/sse.ts
Comment thread clients/ts/src/stream/sse.ts
Comment thread docs/src/content/docs/reverse-proxy.mdx
Comment thread docs/src/content/docs/sdk/reference.md Outdated
Comment thread docs/src/content/docs/sdk/streaming.md Outdated
Comment thread docs/src/content/docs/sdk/streaming.md Outdated
Comment thread tests/e2e/sdk/streaming.test.ts Outdated
@github-project-automation github-project-automation Bot moved this from Backlog to In review in WaveHouse Task Board Aug 13, 2026
Nine of ten findings applied; one rejected on evidence (see the PR thread).

Code:
- backoff() jittered across the whole nominal range, so a server sending
  `retry: 5000` could be re-dialed after 2500ms. The SSE spec makes that
  field the reconnection time and the field is literally named
  _retryFloorMs. Jitter now spans [max(floor, nominal/2), nominal), which
  is unchanged when no retry: is sent.
- A buffer overflow terminates the parser, so the next feed() threw and
  surfaced as SSE_READ_ERROR — relabeling a cause already reported as
  SSE_PARSE_ERROR. The read loop now stops on the overflow it just
  reported and reconnects.

Tests:
- headersOf() cast made every negative header assertion vacuous: a
  Headers instance would produce an object with no string keys, so the
  Cache-Control, authorization and Last-Event-ID absence checks would all
  have passed without testing anything. The shape is asserted now.
- The backoff test checked only the attempt count, which a flat ~1.2s
  schedule also satisfies. It now asserts each gap strictly exceeds the
  last, which jitter guarantees since [n/2, n) ranges can't overlap as n
  doubles.
- Fake timers, global stubs and spies were restored at the end of each
  test body, which never runs when an assertion fails — one real failure
  leaked fake timers and hung every later test on flush(), burying the
  cause. Moved to a file-level afterEach.
- The e2e insert result was unchecked, so a failed write would have
  presented as two 10s stream timeouts.

Docs: the reference said REST "retries on it too" in a paragraph about
SSE_* codes, which never reach a Result; connected()'s "a rejection does
not stop the transport" was true only of the timeout case; and the
overflow chain above is documented as it now behaves.
coderabbitai[bot]
coderabbitai Bot previously approved these changes Aug 13, 2026
I applied CodeRabbit's backoff-floor and buffer-overflow fixes and ran
the suite green without mutation-testing either — the standard this PR's
own body sets, and the one its reviewer notes should apply hardest to new
early-exit paths in this transport. Both reverted cleanly with everything
passing.

- The only test touching _retryFloorMs pushed retry: 3600000 and checked
  a 120s window, which the clamp satisfies either way; it never exercised
  the floor. Now deterministic with Math.random pinned to 0: retry: 20000
  re-dialed at 10s before the fix and 20s after, asserted at 19_999ms.
- Nothing fed anywhere near the 16 MiB cap, so `overflowed` was never set
  and deleting the early return changed nothing observable. Now one
  oversized unterminated line asserts exactly one SSE_PARSE_ERROR — no
  trailing SSE_READ_ERROR — followed by a reconnect. MAX_BUFFER_CHARS is
  exported @internal so the test hits the cap exactly; it is not
  re-exported from index.ts.

Docs: the retryable comparison is now a four-cell matrix. Three previous
attempts each fixed the last objection and introduced a new error — first
implying REST ignores the flag, then that SSE_* codes reach REST, then
that every REST error costs maxRetries attempts (only retryable ones do;
a 4xx returns immediately). Stating all four cases outright ends that.
The "no second error" assertion was itself a tautology — it ran the
instant the first error landed, before anything could have produced a
second, so the mutant died on the attempt-count line while the named
assertion passed. That landed in the commit whose entire premise is that
an assertion which cannot fail is not coverage, and it is the same defect
class CodeRabbit flagged on headersOf and the backoff gaps, both of which
I accepted.

Now a second chunk is enqueued before the first read completes — it has
to be, since the early return makes _pump's finally cancel the reader —
and the error list is asserted only after the reconnect. Under the mutant
this fails with exactly ["SSE_PARSE_ERROR", "SSE_READ_ERROR"].

Docs: the four-cell matrix, written to end three rounds of getting this
comparison wrong, was itself wrong in two cells. SSE_PARSE_ERROR is
flagged retryable but neither re-dials nor closes, and ABORTED is
non-retryable yet can surface mid-backoff when an abort lands during a
retry sleep. Both exceptions are now stated under the table. Also fixes
"two things it does not cover", which contradicted itself — an overflow
*is* reported under that code; what differs is the behavior.

Merges the two adjacent JSDoc blocks on MAX_BUFFER_CHARS so quick-info
keeps the 16-MiB-vs-NATS-ceiling rationale alongside the @internal note.
Found by the docs reviewer while checking a sentence I had written about
ABORTED, and confirmed with a repro: `request()` threw instead of
returning. The network-error backoff is the one `sleep` that runs inside
the catch block, so its rejection had no handler and escaped as a raw
DOMException — the two sleeps in the try are caught and converted. No
caller wraps `request()`, so it reached the consumer as an unhandled
rejection, and the AbortController example in the SDK reference (a 5s
timeout against a server that is down) demonstrated a branch that could
not be taken.

Pre-existing and on the REST path, so out of this PR's nominal scope. I
fixed it rather than documenting it because the alternative was prose
describing a bug, then a follow-up undoing that prose. Six lines, and the
ABORTED result is now built in one place instead of two.

The regression test needed maxRetries > 0 — the default fixture is 0, so
my first version never reached the backoff at all and returned
NETWORK_ERROR. Mutation-verified in both directions.

Docs: three over-broad claims in the retryable section, all mine from the
previous commit. ABORTED is not "the one non-retryable error that may
reach you after a backoff" — any non-retryable error returned by a
post-backoff attempt does; what is unique is that it is raised *by* the
sleep. SSE_PARSE_ERROR does not "neither re-dial nor close" — the
buffer-cap overflow does both. And "two exceptions to that
skipped-in-place rule" mislabeled them: bad JSON is skipped but not
reported, an overflow is reported but not skipped — one exception to each
half.
Completes the previous commit, which fixed the escaping DOMException but
left the classification keyed off the error object — so the answer
depended on maxRetries. An implementation throwing something other than a
DOMException named AbortError (AbortSignal.timeout raises a TimeoutError,
node-fetch its own class) fell through to NETWORK_ERROR at the fetch
site, and was only reclassified as ABORTED if a retry remained for the
backoff sleep to notice the signal. Measured: same abort, NETWORK_ERROR
at maxRetries=0 and ABORTED at 2.

The outer check now also consults opts.signal?.aborted, which collapses
every case to ABORTED and makes the rule simpler to state: the SDK reads
the signal, not your error type. Pinned at both retry settings.

Three public docs asserted the opposite, all written or edited by this
branch — the FetchLike TSDoc that ships in dist/index.d.ts, the
supplying-your-own-fetch section, and the abort clause of the #456
CHANGELOG entry. All corrected.

Adds the ### Fixed CHANGELOG entry the abort fix needed: it is a
user-visible break of the SDK's never-throws contract on the REST path,
reachable with the default maxRetries and any AbortSignal, so it clears
the "any notable change" bar rather than riding along unmentioned.

Docs: anchors the closing SSE_AUTH_ERROR paragraph to the rule it
actually excepts — caller-side failures are terminal — instead of
claiming exception status without naming one.
EricAndrechek and others added 6 commits August 17, 2026 18:02
The BREAKING streaming entry still asserted what the rest of this PR spends
its length refuting: that a 4xx is terminal because "retrying cannot fix a
rejected token", and that "an expired token surfaces as HTTP_401". WaveHouse
leaves /v1/stream ungated and answers an expired token with a default_role
view, never a 401 — which consequence (1) says three sentences later in the
same entry, and which sse.ts:298 and sdk/reference.md both state correctly.

An operator reading the release notes would conclude stream expiry is now
enforced. That is the belief #239 exists to make true and that this PR
deliberately does not deliver, so the entry was advertising the opposite of
its own headline caveat.

This is the last surviving copy of a claim corrected everywhere else three
rounds ago, and it survived for a structural reason worth recording:
CHANGELOG.md is denylisted from the docs-prose set (scripts/docs-prose.sh:38,
"historical record, not instructions"), so the docs gate cannot see it. Every
sibling copy sat in a file that gate reads; this one did not.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ht2aYPYqByQ7L7Twqncr3J
Both reviewers caught the same thing: the sentence I wrote last round to fix a
too-narrow claim fixed it by removing the case set entirely. "Repeating the
request won't talk round whatever rejected it" is an unhedged universal, and it
is false for the one 4xx both docs pages deliberately carve out — a 429 or 408
from a fronting rate limiter is transient even though the stream still ends,
which is the whole subject of #469. Neither 429 nor #469 appeared anywhere in
the CHANGELOG, so an operator behind Cloudflare or nginx limit_req would read
the release notes and not implement the reopen-after-delay the docs prescribe.
Corrected a wrong case set by deleting the case set.

The code review then found a second one in the same sentence, and it is the
sharper of the two: "a fronting gateway's 401 arrives as HTTP_401" is true
server-side and same-origin, and false in the browser cross-origin deployment
the entry itself names as the motivation. The stream sends Authorization
whenever auth is set, so it preflights; per Fetch a preflight with a non-ok
status is a network error regardless of its CORS headers, and a 401 without
Access-Control-Allow-Origin fails the CORS check as a bare TypeError. Both land
as retryable SSE_NETWORK_ERROR and re-dial indefinitely — which is an opaque
retry loop, produced by the sentence's own exemplar.

Also corrected the baseline that sentence contrasted against. EventSource fails
the connection on a non-200 rather than looping, and the old transport's error
branch was unreachable (readyState is always one of the three values tested
above it), so a gateway 401 produced a status-free closed with no error callback
at all. Worse than what I claimed, and now stated.

And "WaveHouse raises only a 400" was true only for requests that reach the
route: router.go installs JSON handlers for chi's 404/405, so a baseURL path
prefix the proxy didn't strip is a 404 raised by WaveHouse itself — the same
shape as the defect the previous commit fixed, pointing a reader debugging
HTTP_404 at their gateway.

Fixed in all four copies (CHANGELOG, sdk/reference.md, sdk/streaming.md,
sse.ts) rather than one, since the two docs pages already hedged the 429 case
and fixing only the CHANGELOG would have re-created the drift.

Refs #469, #473, #478.

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

Both reviewers found the same thing, in the copies I had asked them to watch:
last round's consistency sweep added "which is what EventSource gave you for
everything" / "the way EventSource reported everything" to reference.md and
streaming.md — the same over-broad shape just deleted from the CHANGELOG,
relocated. False twice: EventSource fails the connection on a non-200 rather
than retrying (sse.ts:299 says so in the same commit), and the old transport's
error branch was unreachable, so no error callback fired at all. The CHANGELOG
and sse.ts said terminal-and-silent while the two docs pages said every failure
was a retryable network error — opposite baselines in one release.

Dropped the comparison rather than re-authoring it precisely. Both reviewers
offered a corrected version; the CHANGELOG already carries the accurate
baseline, and on this branch every clause I add to fix a clause has needed
fixing.

The rule behind the failure, worth recording: when reconciling copies, move the
already-reviewed sentence rather than writing fresh prose per site. Both pages
were incomplete rather than wrong when the reviewer cleared them; the sweep
made them wrong by authoring new text at each one.

Second finding, same class: "the gateway answered the `Authorization`
preflight" tells an uncredentialed reader the condition can't apply to them. It
can — Last-Event-ID is not CORS-safelisted, so an uncredentialed stream
preflights from its first resumption, which reverse-proxy.mdx already documents
as #471. Now names the whole trigger set in all three copies.

Also completes the entry's file list, which named 13 of the 27 files the entry
describes — omitting the e2e rewrite that is its own headline evidence, the new
live-query tests, and the six places the zero-dependency claim was retired.
Sibling entries in this file treat the list as exhaustive.

Refs #469, #471, #473, #478.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ht2aYPYqByQ7L7Twqncr3J
The Browser-first SDK distribution entry, also under [Unreleased], describes
the SDK as "(zero deps, native `fetch`/`EventSource`)". Both halves are
falsified by the streaming entry in the same section, so one release's notes
would have claimed zero dependencies and a first runtime dependency, and
native EventSource and its removal, in the same breath. Scoped to the moment
it describes rather than rewritten, since it is an accurate account of what
that change shipped against.

Leaves the `id:`/Last-Event-ID entry alone: "enabling native EventSource
automatic reconnection" is a statement about a server capability that is
unchanged, not about the SDK's transport.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ht2aYPYqByQ7L7Twqncr3J
The SDK reference's error table still said `401` means "Missing or invalid
JWT". A missing token is never a 401: writeAuthzDenied only returns 401 when
authErr is non-nil — a present-but-invalid or expired token — and a missing
one resolves to default_role, where a denial is 403 (internal/api/errors.go).

This is the last copy of the claim 188c2c1 was written to kill, and it sat
four rows above prose on the same page saying the opposite ("WaveHouse never
rejects a stream for authentication… an expired or missing token resolves to
default_role"). It is also the first thing a reader consults, so of the copies
this was the worst one to leave.

Two more from the same review:

- "isolated and logged to the console, matching what `EventSource` did" is the
  third relocation of that comparison, and it contradicts the bullet five lines
  below it. DOM dispatch reports a throwing listener and continues to the rest;
  StreamController's fan-out is a bare loop that unwinds at the thrower, which
  sse.ts:549 already says in a comment. Dropped here and in the CHANGELOG's
  matching clause, applying the same rule as the previous two rounds rather
  than relocating it a fourth time.

- `like`/`not_like` are documented as "the same FilterOp set .where() takes
  everywhere", but the client-side filter compiles the pattern to a regex with
  the `i` flag while ClickHouse's LIKE is case-sensitive. Inside one
  liveQuery() that means the backfill and the live frames apply different
  predicates across the seam, silently. Documented in streaming.md and mirrored
  in the queries.md operator table, which called it plain "SQL LIKE pattern".

Also rewraps a 172-char TSDoc line left by an earlier edit; it ships in
dist/index.d.ts and no linter reflows comments.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ht2aYPYqByQ7L7Twqncr3J
Both reviewers found the same defect in the divergence note added last commit.
It said `like` and `not_like` both match case-insensitively client-side against
a case-sensitive ClickHouse LIKE. True of `like`. `not_like` has no server-side
counterpart at all: filterToSQL has no case for it, so it falls through to
"unsupported operator" and structured_query.go renders that as a 400.

Inside a liveQuery() that is not a case-folding mismatch, it is a total
backfill failure — initial() fires with an error Result, live-query.ts:59
returns without flushing, and everything buffered during the fetch window is
dropped as well. I documented a dead backfill as a footnote about case
sensitivity.

queries.md:155 already said the backend rejects not_like, four lines from the
row this same commit rewrote, so the two pages contradicted each other on the
sharper of the two traps. The sentence also opened "One operator" and named
two.

Also scopes the 401 row further, per both reviewers from different directions:
it described only a WaveHouse-origin denial, but /v1/stream is ungated, so on a
stream every HTTP_401 comes from something in front — which is one of this
PR's headline wins and the case the row now names explicitly. WaveHouse itself
still never 401s a missing token.

And reflows the fetchOptions TSDoc paragraph the earlier rewrap left ragged; it
ships verbatim in dist/index.d.ts.

Refs #478.

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

"The stream re-dials forever without ever resuming" understates it, and the
ambiguity has a wrong action attached. _lastEventId is set once and never
cleared (sse.ts:459), so after the first event every attempt carries
Last-Event-ID, every attempt re-preflights, and every preflight is rejected —
the fetch never goes out and the stream never reconnects at all. #471's own
body says "The stream is permanently down."

The next bullet but one uses "resume" in the Last-Event-ID gap-fill sense
("so reconnects resume from the right point"), so a reader parses this as
"reconnects, but loses the gap" and goes looking for missing events instead of
a dead stream.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Ht2aYPYqByQ7L7Twqncr3J
@github-actions github-actions Bot added go Pull requests that update go code area/api HTTP handlers, routing, middleware labels Aug 18, 2026

@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: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
tests/e2e/sdk/streaming.test.ts (1)

250-267: 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Assert the insert results in the two new tests.

These insert() calls discard their Result. If a write fails, the following waitForCondition calls run to their 10s deadline and report a stream problem instead of the write failure. The same fix already landed at Lines 188-197 for the payload test. Apply it to the row-filter inserts here (Lines 252-253, 266-267) and to the metered inserts at Lines 313-314.

🧪 Proposed fix for the row-filter inserts
         const base = { page: "/scoped", user_id: "u", session_id: "s", duration_ms: 1 };
-        await inserter.from(T.clicks).insert({ ...base, event_id: usId, country: "US" });
-        await inserter.from(T.clicks).insert({ ...base, event_id: caId, country: "CA" });
+        for (const row of [
+          { ...base, event_id: usId, country: "US" },
+          { ...base, event_id: caId, country: "CA" },
+        ]) {
+          // A failed write would otherwise surface as a stream timeout below.
+          expect((await inserter.from(T.clicks).insert(row)).error).toBeNull();
+        }

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: fc2fd988-5069-4ad0-be0c-57eec5daeb0b

📥 Commits

Reviewing files that changed from the base of the PR and between 77feb9d and 297a6b2.

📒 Files selected for processing (20)
  • AGENTS.md
  • CHANGELOG.md
  • README.md
  • clients/ts/README.md
  • clients/ts/src/http.test.ts
  • clients/ts/src/http.ts
  • clients/ts/src/stream/live-query.test.ts
  • clients/ts/src/stream/live-query.ts
  • clients/ts/src/stream/sse.test.ts
  • clients/ts/src/stream/sse.ts
  • clients/ts/src/types.ts
  • docs/src/content/docs/api.md
  • docs/src/content/docs/index.mdx
  • docs/src/content/docs/reverse-proxy.mdx
  • docs/src/content/docs/sdk/index.mdx
  • docs/src/content/docs/sdk/queries.md
  • docs/src/content/docs/sdk/reference.md
  • docs/src/content/docs/sdk/streaming.md
  • internal/api/stream.go
  • tests/e2e/sdk/streaming.test.ts

Included review availability: Your plan includes up to 1 review per rolling hour; 0 remain after this review.

📜 Review details
⏰ Context from checks skipped due to timeout. (2)
  • GitHub Check: E2E tests
  • GitHub Check: Coverage
🧰 Additional context used
📓 Path-based instructions (6)
**/*.md

📄 CodeRabbit inference engine (AGENTS.md)

  • Comment the why, not the what. Add a comment only when the reason isn't obvious from the code; a line that matches the surrounding pattern needs none.

Files:

  • docs/src/content/docs/sdk/queries.md
  • README.md
  • AGENTS.md
  • CHANGELOG.md
  • docs/src/content/docs/api.md
  • clients/ts/README.md
  • docs/src/content/docs/sdk/reference.md
  • docs/src/content/docs/sdk/streaming.md
**/*.{md,mdx}

📄 CodeRabbit inference engine (AGENTS.md)

Every code change should update the corresponding docs in the same PR. A code change without its doc update is incomplete.

Files:

  • docs/src/content/docs/sdk/queries.md
  • README.md
  • docs/src/content/docs/index.mdx
  • AGENTS.md
  • CHANGELOG.md
  • docs/src/content/docs/api.md
  • clients/ts/README.md
  • docs/src/content/docs/reverse-proxy.mdx
  • docs/src/content/docs/sdk/reference.md
  • docs/src/content/docs/sdk/index.mdx
  • docs/src/content/docs/sdk/streaming.md
clients/ts/**/*.ts

📄 CodeRabbit inference engine (AGENTS.md)

The TypeScript SDK (@wavehouse/sdk in clients/ts/) is the canonical client and ships from this repo.

Files:

  • clients/ts/src/http.test.ts
  • clients/ts/src/stream/live-query.test.ts
  • clients/ts/src/stream/live-query.ts
  • clients/ts/src/http.ts
  • clients/ts/src/types.ts
  • clients/ts/src/stream/sse.test.ts
  • clients/ts/src/stream/sse.ts
tests/e2e/sdk/*.test.ts

📄 CodeRabbit inference engine (AGENTS.md)

  • E2E tests via SDK: The TypeScript SDK is the primary E2E test harness. Tests in tests/e2e/sdk/ exercise the full pipeline (ingest → ClickHouse → query) and simultaneously validate backend behavior and SDK correctness. Use make test-e2e to run. Add new E2E scenarios as tests/e2e/sdk/*.test.ts files using helpers from tests/e2e/sdk/helpers.ts.

Files:

  • tests/e2e/sdk/streaming.test.ts
**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

**/*.go: - Go 1.26, strict formatting (gofumpt, enforced by CI)

  • No global state: Dependencies are passed explicitly (constructor injection).
  • Package naming: Lowercase, single word (or abbreviated). internal/ enforces module privacy.
    Column-level access control is a hard cap on every read path. A role's allow_columns/deny_columns is enforced against every column a structured query references (projection, aggregations, filters, group_by, order_by, time_range) inside query.Build, and a select_all request expands to the role's allowed columns rather than SELECT *

Files:

  • internal/api/stream.go
internal/**/*.go

📄 CodeRabbit inference engine (AGENTS.md)

  • DRY — one source of truth. Before adding logic, look for an existing helper, type, or constant to reuse; before duplicating a rule, factor it into one place every caller reads.

Files:

  • internal/api/stream.go
🧠 Learnings (6)
📚 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:

  • docs/src/content/docs/sdk/queries.md
  • README.md
  • AGENTS.md
  • CHANGELOG.md
  • docs/src/content/docs/api.md
  • clients/ts/README.md
  • docs/src/content/docs/sdk/reference.md
  • docs/src/content/docs/sdk/streaming.md
📚 Learning: 2026-08-11T15:22:20.507Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 448
File: clients/ts/src/types.ts:61-71
Timestamp: 2026-08-11T15:22:20.507Z
Learning: In the TypeScript SDK, ensure `ClientConfig.baseURL` is validated or documented as requiring an absolute URL with both a scheme and host. Relative URLs cause `resolveURL` to throw a `TypeError` before REST retry handling runs. Ensure SSE connection failures are surfaced through `StreamSubscriber.error` and are not silently lost when that callback is absent.

Applied to files:

  • clients/ts/src/http.test.ts
  • clients/ts/src/stream/live-query.test.ts
  • clients/ts/src/stream/live-query.ts
  • clients/ts/src/http.ts
  • clients/ts/src/types.ts
  • clients/ts/src/stream/sse.test.ts
  • clients/ts/src/stream/sse.ts
📚 Learning: 2026-08-12T21:45:38.018Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 456
File: clients/ts/src/pipes.ts:0-0
Timestamp: 2026-08-12T21:45:38.018Z
Learning: In the TypeScript SDK, use the exported `PipeRequestOptions` type for `PipeRef.fetch` and shared pipe, table, or query-builder fetch options rather than `Pick<RequestOptions, "signal">` or `RequestOptions`. `PipeRequestOptions` declares `limit?: never`, ensuring object literals and named `RequestOptions` values containing `limit` fail type checking instead of silently dropping it. When only a signal is needed, use `PipeRequestOptions` or an inferred `{ signal }` object; do not expect a `RequestOptions` value to be assignable to `PipeRequestOptions`.

Applied to files:

  • clients/ts/src/http.test.ts
  • clients/ts/src/stream/live-query.test.ts
  • clients/ts/src/stream/live-query.ts
  • clients/ts/src/http.ts
  • clients/ts/src/types.ts
  • clients/ts/src/stream/sse.test.ts
  • clients/ts/src/stream/sse.ts
📚 Learning: 2026-08-13T12:17:52.620Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 470
File: docs/src/content/docs/reverse-proxy.mdx:137-144
Timestamp: 2026-08-13T12:17:52.620Z
Learning: For Wave-RF/WaveHouse documentation, verify claims about implementation control flow against the authoritative implementation source (for example, internal/auth/auth.go) rather than relying solely on docs/** content. Documentation may lag behind or paraphrase behavior, so control-flow claims should be confirmed in source code.

Applied to files:

  • docs/src/content/docs/index.mdx
  • docs/src/content/docs/reverse-proxy.mdx
  • docs/src/content/docs/sdk/index.mdx
📚 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:

  • internal/api/stream.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:

  • internal/api/stream.go
🪛 LanguageTool
CHANGELOG.md

[style] ~14-~14: Since ownership is already implied, this phrasing may be redundant.
Context: ...stream report measured ~450–465 ms, and our own runs against an instant-answering serve...

(PRP_OWN)


[grammar] ~14-~14: A noun may be missing here.
Context: ...mer's own tests without monkey-patching a global. fetch stays optional all the way thr...

(DT_JJ_NO_NOUN)


[style] ~14-~14: Since ownership is already implied, this phrasing may be redundant.
Context: ...nned by tests. Implementations shipping their own request/response declarations (undici, ...

(PRP_OWN)


[style] ~28-~28: Consider using a more formal/concise alternative here.
Context: ...nt an implementation throwing something other than a DOMException named AbortError — `...

(OTHER_THAN)


[style] ~28-~28: Since ownership is already implied, this phrasing may be redundant.
Context: ...raises aTimeoutError, node-fetchits own class — was reported asNETWORK_ERROR`...

(PRP_OWN)


[style] ~32-~32: This sentence is over 40 words long. Consider splitting it up, as shorter sentences make the text easier to read.
Context: ... are pinned by tests. ### Changed - BREAKING (SDK): streaming moves from EventSource to fetch, so the JWT rides in a header instead of ?token= (clients/ts/src/stream/sse.ts, clients/ts/src/stream/sse.test.ts, clients/ts/src/client.ts, clients/ts/src/http.ts, clients/ts/src/types.ts, clients/ts/package.json, tests/e2e/sdk/vitest.config.ts, tests/e2e/sdk/package.json, tests/e2e/sdk/polyfills.ts (deleted), tests/e2e/sdk/streaming.test.ts, clients/ts/src/stream/live-query.ts, clients/ts/src/stream/live-query.test.ts (new), clients/ts/src/client.test.ts, clients/ts/src/http.test.ts, pnpm-lock.yaml, internal/api/stream.go (comment only), docs/src/content/docs/sdk/index.mdx, docs/src/content/docs/sdk/streaming.md, docs/src/content/docs/sdk/reference.md, docs/src/content/docs/api.md, docs/src/content/docs/reverse-proxy.mdx, and the six places the zero-dependency claim was retired — README.md, clients/ts/README.md, AGENTS.md (invariant 14), docs/src/content/docs/index.mdx, docs/src/content/docs/getting-started.md, docs/src/content/docs/why-wavehouse.md): advances #203 — tasks 1 and 2 of 3; #468 tracks retiring ?token= server-side, which closes it. .stream() and .liveQuery()'s live c...

(TOO_LONG_SENTENCE)


[style] ~32-~32: This sentence is over 40 words long. Consider splitting it up, as shorter sentences make the text easier to read.
Context: because pinning in a published library duplicates the package in any consumer tree already resolving a 3.x and freezes them out of patch and security releases until we cut one, while our own lockfile still governs CI, the SDK's first runtime dependency, costing ~3.3 KB minified / ~1.4 KB gzipped in the CDN IIFE bundle (measured by bundling the parser alone; the SDK's own IIFE grew more than that, but the rest is the transport rewrite). Renting it rather than hand-rolling i...

(TOO_LONG_SENTENCE)


[style] ~32-~32: Consider an alternative for the overused word “exactly”.
Context: ...nst a malformed or hostile stream — are exactly what a naive implementation gets wrong,...

(EXACTLY_PRECISELY)


[style] ~32-~32: This sentence is over 40 words long. Consider splitting it up, as shorter sentences make the text easier to read.
Context: ...duced an authenticated stream anyway. Without either, redirects are followed normally, so CDN canonicalization, geo/LB indirection, and an http→https upgrade all work — note the test is that concrete pair rather than "is this authenticated", so cookies are not covered — a cookie is re-derived from the store at each hop rather than carried, so under the default same-origin credentials mode a redirect keeps the stream authenticated only while the hop stays inside both the request's origin and the cookie's Path, and a hop outside either (a subdomain redirect, an http→https upgrade, a same-origin rewrite off the cookie's Path) leaves it silently unauthenticated; with credentials: "include" the origin half is replaced rather than lifted — ordinary cookie scoping decides, so a cookie crosses a CORS-allowed cross-origin hop only if its own Domain covers the target, and a host-only cookie (the default) does not (#478); options.fetch can override redirect for anyone who needs the credentialed case followed regardless. manual rather than error so the out...

(TOO_LONG_SENTENCE)


[style] ~32-~32: Since ownership is already implied, this phrasing may be redundant.
Context: ...r` callback, so a handler that swallows its own failures fails silently. Four paths sit...

(PRP_OWN)


[style] ~32-~32: ‘by accident’ might be wordy. Consider a shorter alternative.
Context: ...fix the proxy; both previously "worked" by accident. - **BREAKING (SDK): options.fetch, ...

(EN_WORDINESS_PREMIUM_BY_ACCIDENT)


[style] ~42-~42: This sentence is over 40 words long. Consider splitting it up, as shorter sentences make the text easier to read.
Context: ...ly either omitted or quoted as 18. - Live SSE events are now projected and serialized once per role instead of once per subscriber (internal/stream/hub.go (new), internal/stream/{subscriber,bucket,heartbeat,metrics,doc}.go, internal/api/stream.go, internal/api/hub.go + internal/api/transform.go (both removed — the broadcast hub moves to internal/stream, and the orphaned test-only transformForClient is dropped), cmd/wavehouse/main.go, docs/src/content/docs/architecture.md, AGENTS.md, plus tests in internal/stream/{hub,filter,subscriber,bucket,heartbeat}_test.go and internal/api/{stream,transform,router,errors}_test.go): the first PR of the SSE delivery-path throughput epic (#294), building on the internal/stream primitives from #346. The broadcast hub moves into `int...

(TOO_LONG_SENTENCE)


[style] ~42-~42: Since ownership is already implied, this phrasing may be redundant.
Context: ...id: timestamp) on the same event in its own read loop — byte-identical work repeate...

(PRP_OWN)


[uncategorized] ~44-~44: The official name of this software platform is spelled with a capital “H”.
Context: ...Cloudflare token to PR-authored code** (.github/workflows/ci.yml, `.github/workflows/h...

(GITHUB)


[uncategorized] ~44-~44: The official name of this software platform is spelled with a capital “H”.
Context: ...red code** (.github/workflows/ci.yml, .github/workflows/housekeeping.yml, `.github/a...

(GITHUB)


[uncategorized] ~44-~44: The official name of this software platform is spelled with a capital “H”.
Context: ..., .github/workflows/housekeeping.yml, .github/actions/setup-env/action.yml, `Makefil...

(GITHUB)


[uncategorized] ~44-~44: The official name of this software platform is spelled with a capital “H”.
Context: ...The architecture is documented once, in .github/workflows/README.md (DAG diagram, desi...

(GITHUB)


[uncategorized] ~48-~48: The official name of this software platform is spelled with a capital “H”.
Context: ...KFILE CI failures on dependency PRs** (.github/dependabot.yml, docs/src/content/docs...

(GITHUB)


[typographical] ~56-~56: Consider using an em dash in dialogues and enumerations.
Context: - **Live SSE streams now apply a role's r...

(DASH_RULE)


[style] ~56-~56: This sentence is over 40 words long. Consider splitting it up, as shorter sentences make the text easier to read.
Context: - Live SSE streams now apply a role's row-filter per subscriber, closing a query/stream row-level-security drift (internal/stream/hub.go, internal/stream/subscriber.go, internal/stream/metrics.go, internal/policy/policy.go, internal/policy/{rowfilter,canonical,numeric}.go (new — predicate evaluation, operand rendering, and storage-domain comparison as three focused files), internal/discovery/validation.go, internal/discovery/timestamp.go, internal/api/stream.go, cmd/wavehouse/main.go, docs/src/content/docs/access-control.mdx, docs/src/content/docs/architecture.md, docs/src/content/docs/api.md, docs/src/content/docs/sdk/streaming.md, AGENTS.md, SECURITY.md, internal/stream/doc.go, plus tests in internal/policy/rowfilter_test.go (new), tests/integration/rowfilter_narrowing_test.go (new), internal/policy/policy_test.go, internal/discovery/validation_test.go, internal/discovery/timestamp_test.go, internal/stream/hub_test.go, tests/e2e/sdk/streaming.test.ts): closes #319. The SSE delivery path stripped de...

(TOO_LONG_SENTENCE)

docs/src/content/docs/sdk/reference.md

[style] ~28-~28: Since ownership is already implied, this phrasing may be redundant.
Context: ... or .liveQuery(), described under If your own callback throws below. | Status | Cod...

(PRP_OWN)


[style] ~61-~61: Consider an alternative for the overused word “exactly”.
Context: ...roxy. That silent-downgrade behavior is exactly why auth is re-read on every connecti...

(EXACTLY_PRECISELY)


[style] ~65-~65: Since ownership is already implied, this phrasing may be redundant.
Context: ...cts rather than feeding it again. If your own callback throws. For anything deliver...

(PRP_OWN)


[style] ~65-~65: Since ownership is already implied, this phrasing may be redundant.
Context: ...r` callback, so a handler that swallows its own failures fails silently. **Wrap your h...

(PRP_OWN)

docs/src/content/docs/sdk/streaming.md

[style] ~162-~162: Since ownership is already implied, this phrasing may be redundant.
Context: ...sues/449)) — so key on timestamp plus your own row identity if duplicates matter. Rep...

(PRP_OWN)


[style] ~199-~199: ‘On top of that’ might be wordy. Consider a shorter alternative.
Context: ...ect. ### Client-Side Stream Filtering On top of that, when a QueryBuilder with .where() ...

(EN_WORDINESS_PREMIUM_ON_TOP_OF_THAT)


[style] ~295-~295: Since ownership is already implied, this phrasing may be redundant.
Context: ...ers — treat initial() never firing as its own failure. Where auth rejects and the s...

(PRP_OWN)


[typographical] ~297-~297: The word ‘Where’ starts a question. Add a question mark (“?”) at the end of the sentence.
Context: ...es auth or the URL, never the backfill. Re-run the fetch then; you never have t...

(WRB_QUESTION_MARK)

🔇 Additional comments (19)
clients/ts/src/http.test.ts (1)

170-191: LGTM!

Also applies to: 193-214, 216-232

clients/ts/src/stream/live-query.test.ts (1)

1-120: LGTM!

clients/ts/src/stream/live-query.ts (1)

64-77: LGTM!

clients/ts/src/types.ts (1)

13-21: LGTM!

Also applies to: 105-109, 127-127, 133-159, 163-211

clients/ts/src/http.ts (1)

40-60: LGTM!

Also applies to: 63-70, 150-182

docs/src/content/docs/reverse-proxy.mdx (1)

140-144: LGTM!

Also applies to: 180-180

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

154-154: LGTM!

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

28-28: LGTM!

Also applies to: 30-74, 121-121

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

51-57: LGTM!

Also applies to: 96-105, 159-162, 172-176, 194-213, 257-322

internal/api/stream.go (1)

45-51: LGTM!

Also applies to: 83-89, 106-108

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

244-245: LGTM!

Also applies to: 286-286, 295-295, 586-591

clients/ts/src/stream/sse.test.ts (1)

11-23: LGTM!

Also applies to: 84-91, 405-411, 469-579, 788-814, 1021-1118

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

268-287: LGTM!

Also applies to: 289-322

AGENTS.md (1)

63-63: LGTM!

CHANGELOG.md (1)

14-14: LGTM!

Also applies to: 26-35, 132-132, 474-474

README.md (1)

63-63: LGTM!

clients/ts/README.md (1)

3-3: LGTM!

Also applies to: 11-11, 47-47, 134-134

docs/src/content/docs/index.mdx (1)

99-99: LGTM!

Also applies to: 133-133

docs/src/content/docs/sdk/index.mdx (1)

8-8: LGTM!

Also applies to: 144-153, 464-535, 564-564, 595-604

Comment thread clients/ts/src/stream/sse.ts Outdated
Two from CodeRabbit's review of 297a6b2.

disconnect() emitted onStatus("closed") outside the `if (!this._closed)`
guard, so a redundant call re-fired it. Moved inside. The emit deliberately
bypasses _emitStatus — which returns early once _closed is set, so the
terminal status would never get out — and that is why it sat outside the
guard in the first place; it only ever needed to be after the flag, not
outside the block. _closed is written in exactly one place, inside that
guard, so the first disconnect() still always emits and nothing observable
changes. Pinned by a test, mutation-verified: putting the emit back outside
the guard fails it.

CodeRabbit's stated consequence — "repeated .close() calls can run subscriber
cleanup twice" — does not hold through the SDK: StreamController.onStatus
dedupes on `status === this._status`, and SSETransport is not exported. The
change is still right, just for tidiness and any future direct consumer rather
than a live bug. Noted so the record isn't wrong.

The e2e streaming tests discarded their insert Results, so a failed write
surfaced as a 10s waitForCondition timeout blaming the stream for an event
that was never published. CodeRabbit flagged three sites; the same shape is in
five, including two it did not list, so all five now assert — leaving two of
five would be the exact N-1-of-N pattern this branch has been fighting. The
payload test already set the precedent.

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

Copy link
Copy Markdown
Member Author

@coderabbitai Addressing the outside-diff-range comment on tests/e2e/sdk/streaming.test.ts — taken in df20d7dc, and widened.

You listed three sites (the row-filter inserts at 252-253 and 266-267, and the metered pair at 313-314). The same shape was in five: the two you didn't list are the public-stream insert and the timestamp-canonicalization insert, both of which also feed a 10s waitForCondition, so a failed write there produced exactly the misleading timeout you describe. All five now assert; the payload test you cited as precedent was already the sixth.

Fixing three of five would have left the same defect in a subset — a pattern this branch has had six instances of, so it seemed worth closing completely rather than to the letter of the comment.

Two notes on the mechanics:

  • The paired inserts are wrapped in for...of with the await in the body, which is strictly sequential — so the metered test still writes duration_ms: 100 before 250. That ordering is the point of that test (the high row arriving proves the low row was withheld rather than merely late), so it mattered that the refactor preserve it.
  • The assertion has teeth here: every single-row /v1/ingest rejection path returns non-2xx, so a failure lands in the Result error arm rather than a 200 carrying ok: false.

Deliberately not changed: the admin.policy.set() results discarded in beforeAll/afterAll (lines 58 and 64). Same class, and arguably worse — a beforeAll failure yields five misleading timeouts rather than one — but every e2e suite in the repo does this, so it's a suite-wide cleanup rather than something to ride in on a transport PR. Worth its own issue if it ever bites.

🤖 Fixed by Claude Code

@coderabbitai

coderabbitai Bot commented Aug 18, 2026

Copy link
Copy Markdown

Tip

For best results, initiate chat on the files or code changes.

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

git show --stat --oneline df20d7dc
git show --format=fuller --find-renames -- clients/ts/../tests/e2e/sdk/streaming.test.ts df20d7dc 2>/dev/null || true

file="$(fd -a '^streaming\.test\.ts$' tests/e2e/sdk | head -n 1)"
if [ -z "$file" ]; then
  echo "streaming.test.ts was not found under tests/e2e/sdk" >&2
  exit 1
fi

echo "--- $file: relevant structure ---"
ast-grep outline "$file" --items all --type function --view compact || true
echo "--- $file: ingest calls and surrounding assertions ---"
rg -n -C 5 '(/v1/ingest|for\s*\(\s*const\s+.*\s+of)' "$file"

Length of output: 3166


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="$(fd -a '^streaming\.test\.ts$' tests/e2e/sdk | head -n 1)"

echo "--- df20d7dc diff for $file ---"
git show --format=fuller --find-renames df20d7dc -- "$file"

echo "--- all click insert call sites with context ---"
rg -n -C 4 'inserter\.from\(T\.clicks\)\.insert\s*\(' "$file"

Length of output: 7887


@EricAndrechek Thanks for the complete fix and the detail.

Verified df20d7dc. All five affected insert paths now assert that Result.error is null. The for...of loops preserve the required sequential write order for the metered test.

The unchanged admin.policy.set() calls are outside the scope of this transport PR. The finding is addressed.


✏️ Learnings added
Learnt from: EricAndrechek
URL: https://github.com/Wave-RF/WaveHouse/pull/470

Timestamp: 2026-08-18T13:46:01.151Z
Learning: In `tests/e2e/sdk/streaming.test.ts`, direct `/v1/ingest` writes that precede `waitForCondition` must assert that `Result.error` is `null`. A rejected single-row ingest returns a non-2xx response and otherwise appears as a misleading stream timeout. For paired inserts where event order is part of the test contract, use a `for...of` loop with `await` in the loop body to preserve sequential writes.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.


If you found this review helpful, would you consider giving us a shout-out on X?

Thank you for using CodeRabbit!

You are interacting with an AI system.

EricAndrechek and others added 3 commits August 18, 2026 15:45
Same class as de4beb0, found by the docs review of the main merge: two
[Unreleased] entries still describe the SDK as it shipped before this branch,
so one release's notes contradict themselves.

:219 told a reader streaming "needs an EventSource polyfill in Node" —
denied by consequence (3) of the streaming entry and by the shipped
sdk/index.mdx runtime-support note.

:197 said the token is transmitted "header on REST, ?token= on SSE". That is
the exact misreading this PR exists to remove: it invites the conclusion that
the released SDK puts the JWT in the stream URL, which is what the whole
change is about not doing.

Both scoped in place rather than rewritten, matching how de4beb0 handled the
"zero deps" sibling — and closing the inconsistency the reviewer noted, that
line 138 got both an "at that point" scope and a superseded marker while these
two got neither.

Left alone deliberately, per the same reviewer's calibration: :145 ("browser
EventSource clients still pass the JWT via ?token=") and :374 ("enabling
native EventSource automatic reconnection") are statements about a server
capability and a third-party client class, not about this SDK's transport.

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

Same class as de4beb0 and 2313601, from the local docs review of this
branch after the main merge.

:138 still said streaming under Node < 22 "still uses" the SDK's existing
"provide an EventSource polyfill" prompt. That prompt is gone -- client.ts
no longer throws on a missing EventSource, only on a missing fetch -- so
the sentence contradicted consequence (3) of the streaming entry it ships
alongside in the same unreleased block. Scoped in place rather than
rewritten, matching how de4beb0 handled the earlier clause of that very
sentence and how 2313601 handled the two siblings.

The streaming entry's file list also omitted sdk/queries.md, which this
branch changed: the 'like' row gained the case-sensitivity divergence
that .stream()/.liveQuery()'s client-side filter introduces. That list
annotates (deleted)/(new)/(comment only) and enumerates the six
zero-dependency sites, so the omission was the odd one out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014usznuPjSW5Af9iUBmZYyv
@EricAndrechek
EricAndrechek marked this pull request as ready for review August 18, 2026 20:21
@EricAndrechek
EricAndrechek requested review from a team and taitelee August 18, 2026 20:21

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
clients/ts/src/stream/sse.ts (1)

319-328: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Require an exact SSE media type.

Line 320 accepts values such as text/event-streaming. That is not an SSE media type. The transport can mark that response as live and silently consume a non-SSE body.

Compare the normalized media type with text/event-stream by equality.

Proposed fix
-    if (!contentType.toLowerCase().split(";")[0].trim().startsWith("text/event-stream")) {
+    if (contentType.toLowerCase().split(";")[0].trim() !== "text/event-stream") {
docs/src/content/docs/reverse-proxy.mdx (1)

239-241: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Align the proxy examples with the large-NDJSON guidance.

The body-limit section at Line 106 says NDJSON can carry batches larger than 16 MiB. These examples set a 16 MiB limit for every request, so copied configurations reject those uploads before WaveHouse receives them. Raise or remove the outer limit for deployments that use large NDJSON, or state that these examples intentionally cap all uploads at 16 MiB.

Also applies to: 274-276


ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 085a8be9-0e0e-4549-a275-2eb142487255

📥 Commits

Reviewing files that changed from the base of the PR and between 297a6b2 and 924d30d.

📒 Files selected for processing (15)
  • AGENTS.md
  • CHANGELOG.md
  • clients/ts/README.md
  • clients/ts/src/client.test.ts
  • clients/ts/src/client.ts
  • clients/ts/src/http.test.ts
  • clients/ts/src/stream/sse.test.ts
  • clients/ts/src/stream/sse.ts
  • docs/src/content/docs/api.md
  • docs/src/content/docs/getting-started.md
  • docs/src/content/docs/reverse-proxy.mdx
  • docs/src/content/docs/sdk/queries.md
  • docs/src/content/docs/sdk/reference.md
  • docs/src/content/docs/why-wavehouse.md
  • tests/e2e/sdk/streaming.test.ts
💤 Files with no reviewable changes (1)
  • CHANGELOG.md

Included review availability: Your plan provides up to 1 included review per hour; 0 remain after this review.

📜 Review details
⏰ Context from checks skipped due to timeout. (5)
  • GitHub Check: Unit tests
  • GitHub Check: Docs build
  • GitHub Check: Coverage
  • GitHub Check: Integration tests
  • GitHub Check: E2E tests
🧰 Additional context used
📓 Path-based instructions (2)
**/*.{ts,tsx}

📄 CodeRabbit inference engine (AGENTS.md)

  1. TypeScript SDK@wavehouse/sdk: typed query builder, real-time SSE over fetch, live queries (incrementable/decomposable/poll aggregation), codegen CLI. Exactly one runtime dependency — eventsource-parser (SSE framing, itself dependency-free); adding a second needs the same scrutiny the first got. The canonical client (see §SDK Sync).

Files:

  • clients/ts/src/client.ts
  • clients/ts/src/http.test.ts
  • clients/ts/src/client.test.ts
  • tests/e2e/sdk/streaming.test.ts
  • clients/ts/src/stream/sse.test.ts
  • clients/ts/src/stream/sse.ts
tests/e2e/sdk/*.test.ts

📄 CodeRabbit inference engine (AGENTS.md)

  • Per-suite table isolation: Each e2e test file owns its own ClickHouse tables — clicks_<suite> / events_<suite> / users_<suite>, generated from tests/e2e/sdk/tables.ts and created by setup.ts. A new test file must (1) add its suite name to SUITES in tables.ts and (2) get its names via const T = suiteTables("<suite>"), then reference T.clicks etc. — never a bare clicks.

Files:

  • tests/e2e/sdk/streaming.test.ts
🧠 Learnings (6)
📚 Learning: 2026-08-11T15:22:20.507Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 448
File: clients/ts/src/types.ts:61-71
Timestamp: 2026-08-11T15:22:20.507Z
Learning: In the TypeScript SDK, ensure `ClientConfig.baseURL` is validated or documented as requiring an absolute URL with both a scheme and host. Relative URLs cause `resolveURL` to throw a `TypeError` before REST retry handling runs. Ensure SSE connection failures are surfaced through `StreamSubscriber.error` and are not silently lost when that callback is absent.

Applied to files:

  • clients/ts/src/client.ts
  • clients/ts/src/http.test.ts
  • clients/ts/src/client.test.ts
  • clients/ts/src/stream/sse.test.ts
  • clients/ts/src/stream/sse.ts
📚 Learning: 2026-08-12T21:45:38.018Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 456
File: clients/ts/src/pipes.ts:0-0
Timestamp: 2026-08-12T21:45:38.018Z
Learning: In the TypeScript SDK, use the exported `PipeRequestOptions` type for `PipeRef.fetch` and shared pipe, table, or query-builder fetch options rather than `Pick<RequestOptions, "signal">` or `RequestOptions`. `PipeRequestOptions` declares `limit?: never`, ensuring object literals and named `RequestOptions` values containing `limit` fail type checking instead of silently dropping it. When only a signal is needed, use `PipeRequestOptions` or an inferred `{ signal }` object; do not expect a `RequestOptions` value to be assignable to `PipeRequestOptions`.

Applied to files:

  • clients/ts/src/client.ts
  • clients/ts/src/http.test.ts
  • clients/ts/src/client.test.ts
  • clients/ts/src/stream/sse.test.ts
  • clients/ts/src/stream/sse.ts
📚 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:

  • AGENTS.md
  • docs/src/content/docs/why-wavehouse.md
  • docs/src/content/docs/sdk/queries.md
  • docs/src/content/docs/getting-started.md
  • docs/src/content/docs/sdk/reference.md
  • docs/src/content/docs/api.md
  • clients/ts/README.md
📚 Learning: 2026-08-18T13:46:01.151Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 0
File: :0-0
Timestamp: 2026-08-18T13:46:01.151Z
Learning: In `tests/e2e/sdk/streaming.test.ts`, direct `/v1/ingest` writes that precede `waitForCondition` must assert that `Result.error` is `null`. A rejected single-row ingest returns a non-2xx response and otherwise appears as a misleading stream timeout. For paired inserts where event order is part of the test contract, use a `for...of` loop with `await` in the loop body to preserve sequential writes.

Applied to files:

  • tests/e2e/sdk/streaming.test.ts
📚 Learning: 2026-08-13T12:17:52.620Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 470
File: docs/src/content/docs/reverse-proxy.mdx:137-144
Timestamp: 2026-08-13T12:17:52.620Z
Learning: For Wave-RF/WaveHouse documentation, verify claims about implementation control flow against the authoritative implementation source (for example, internal/auth/auth.go) rather than relying solely on docs/** content. Documentation may lag behind or paraphrase behavior, so control-flow claims should be confirmed in source code.

Applied to files:

  • docs/src/content/docs/reverse-proxy.mdx
📚 Learning: 2026-08-18T13:45:38.941Z
Learnt from: EricAndrechek
Repo: Wave-RF/WaveHouse PR: 470
File: clients/ts/src/stream/sse.ts:0-0
Timestamp: 2026-08-18T13:45:38.941Z
Learning: In `clients/ts/src/stream/sse.ts`, `SSETransport.disconnect()` must set `_closed` and then directly invoke `onStatus?.("closed")` inside its `if (!this._closed)` state-transition guard. It must not use `SSETransport._emitStatus()` for this terminal status because `_emitStatus()` suppresses callbacks after `_closed` is set. `StreamController` deduplicates repeated status values, so repeated transport-level `"closed"` callbacks do not cause duplicate subscriber cleanup, but `SSETransport.disconnect()` must still be idempotent independently.

Applied to files:

  • clients/ts/src/stream/sse.ts
🪛 LanguageTool
docs/src/content/docs/api.md

[style] ~522-~522: Redundant conjunctions can lead to confusion; consider removing a conjunction here.
Context: ...meters can be supplied via query string and/or JSON body. Results are cached in the sh...

(AND_OR)

🔇 Additional comments (14)
clients/ts/src/http.test.ts (1)

79-79: LGTM!

Also applies to: 91-91, 103-103, 239-239, 264-266, 269-269, 277-277

clients/ts/src/client.ts (1)

98-105: LGTM!

AGENTS.md (1)

63-63: LGTM!

clients/ts/src/stream/sse.test.ts (1)

756-771: LGTM!

clients/ts/src/client.test.ts (1)

147-154: LGTM!

clients/ts/README.md (1)

3-11: LGTM!

Also applies to: 47-47, 134-134, 153-153

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

8-8: LGTM!

Also applies to: 26-28, 32-32, 190-195, 262-269, 286-295, 382-400, 448-451, 503-505, 522-539, 588-591, 606-610, 612-613, 633-635, 659-661, 685-687, 718-724, 758-760, 788-788, 834-834

docs/src/content/docs/getting-started.md (1)

50-50: LGTM!

Also applies to: 76-76, 98-98, 108-108

docs/src/content/docs/reverse-proxy.mdx (1)

21-21: LGTM!

Also applies to: 95-98, 135-144, 180-180, 203-205

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

72-76: LGTM!

Also applies to: 158-158

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

28-48: LGTM!

Also applies to: 50-61, 63-74, 121-121

docs/src/content/docs/why-wavehouse.md (1)

157-157: LGTM!

tests/e2e/sdk/streaming.test.ts (2)

87-95: LGTM!

Also applies to: 127-134, 159-219, 254-261, 274-279, 325-330


48-55: 🗄️ Data Integrity & Integration

No change needed: the policy uses T.events, and streaming is registered in SUITES.

			> Likely an incorrect or invalid review comment.

@EricAndrechek
EricAndrechek merged commit 74695a5 into main Aug 18, 2026
21 of 35 checks passed
@EricAndrechek
EricAndrechek deleted the sse-auth branch August 18, 2026 20:30
@github-project-automation github-project-automation Bot moved this from In review to Done in WaveHouse Task Board Aug 18, 2026
EricAndrechek added a commit that referenced this pull request Aug 18, 2026
#490 landed a better fix for the Dependabot composite-action gap than
mine, so main's version wins on every shared file:

- .github/dependabot.yml: taken wholesale. My two `updates:` entries are
  two independent Dependabot jobs and a group is scoped to its own job,
  so they would emit two actions-deps PRs every Monday -- the noise the
  group comment exists to prevent. main's `directories: [/, ...]` is one
  job, one PR. main's comment also carries the accuracy fix: actions/cache
  was uniformly v5.0.5 at every site, a major behind upstream rather than
  behind a caller.
- .github/actions/setup-env/action.yml: taken wholesale. My bumps were a
  strict subset and stale (cache still v5.0.5, pnpm 11.1.3).
- development.md Dependabot section: taken wholesale. My "four update
  configs" auto-merged silently and is wrong under the directories form
  -- it is three. main's also documents the typescript major hold (#487).
- My CHANGELOG entry describing the two-entry mechanism is dropped;
  #490's entry on main is the accurate record of the same fix.

Kept mine, reconciled by hand:
- README's `--signer-workflow` fix -- #490 deliberately avoided it.
- persist-credentials: false across the four release workflows.
- clients/ts/README.md and sdk/index.mdx: #470 changed streaming from
  EventSource to fetch. Took its wording, kept my `latest`-is-a-dev-
  snapshot caveat and the corrected anchor -- main still links
  #releasing-the-sdk, a section this branch renamed.
- CHANGELOG resolved with the same script as the #479 merge: main's entry
  text into this branch's structure. Verified 331 main entries + 29
  branch entries, none lost, none invented, no duplicates.

All action pins now match main exactly; no stale pnpm strings remain.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015bVwHtNakQgmnBhMcfW8pe
jfwoods added a commit that referenced this pull request Aug 21, 2026
Reconciles the Go SDK branch with main for the first time since
2026-08-11 (merge base e945ecc). All 14 conflicts were prose; no Go or
TypeScript source conflicted.

Ten of the conflicts share one cause: #489's WH001 rule unwrapped
hard-wrapped prose across the docs tree on main, so the branch's edits
sat on pre-reflow text. Each was resolved by taking main's reflowed
paragraph and re-applying the branch's semantic edit into it.

Resolutions of note:

- AGENTS.md #14, README.md, docs/index.mdx, why-wavehouse.md: the
  branch's "zero third-party runtime dependencies in both SDKs" is no
  longer true — main added eventsource-parser to the TypeScript SDK.
  Kept the branch's two-SDK structure with main's dependency facts:
  one runtime dependency in TypeScript, none in Go.
- CHANGELOG.md: main cut 0.1.0 on 2026-08-19, so the branch's Go SDK
  entry had landed inside a released section. Moved it to the top of
  main's new Unreleased/Added, and re-homed the branch-only TypeScript
  docs-corrections entry under Unreleased/Fixed.
- Makefile: unioned the verify-parallel leaf list (16 leaves) and kept
  main's per-leaf inventory comment.
- sdk/streaming.md: main's rewrite already covers both cautions the
  branch added — the projection-dedup caveat (#449) in step 3 and the
  like/not_like backfill-vs-live divergence (#451) in the operator
  section — so main's version supersedes it wholesale.
- sdk/queries.md: kept main's /v1/ops/query routing and operator rows,
  grafted on the branch's select_all carve-out (unrestricted/admin roles
  do get SELECT *), the not_like wire token, and the aggregation
  allowlist.
- sdk/index.mdx: dropped the branch's stale CDN paragraph — main's
  reflowed copy carries the post-#470 fetch wording and the correct
  /development#cutting-a-release anchor. The branch's dead
  #releasing-the-sdks link would have failed the docs build.
- reverse-proxy.mdx: #428 is closed and the fix shipped in 0.1.0, so
  main's deletion of the prefix caution stands; kept a Go example on
  main's renamed /api/wavehouse prefix.
- development.md: discarded the branch's release section entirely —
  main already documents the clients/go/vX.Y.Z tag scheme it was
  guessing at.

Also clears the forward-references main left for this PR: the
"(pending #434)" marker on make release-sdk-go, the paragraph saying it
refuses to run until clients/go/ exists, and the missing Go line in the
release example. Documents what a Go SDK release publishes (the module
proxy serves the tag; no workflow fires, so no GitHub Release).
jfwoods added a commit that referenced this pull request Aug 21, 2026
Three catch-up changes, all client-side. The server is untouched.

Routes: main merged every admin-gated endpoint under /v1/ops (#479) with
no aliases, so thirteen call sites were 404ing against a current server —
schema list/refresh, DLQ stats, raw SQL, policy get/put/validate, and
pipes CRUD, plus the codegen CLI's schema fetch. Rewrote them along with
the tests, the shared wire_cases.json fixture, and the Go SDK docs. The
fixture is replayed by both conformance runners, so the stale paths broke
the TypeScript half too; `make test-conformance-ts` is back to 45/45.

ClientOptions.Headers: the TypeScript SDK gained options.headers in #456
and Go had no equivalent. Headers now apply to every request the client
makes, REST and SSE alike — which is also how an operator sends the
server's non-JWT X-Operator-Key. The SDK's own headers are set afterwards
and win a collision; net/http canonicalizes names, so matching is
case-insensitive; the map is copied at construction so later mutation
can't reach into requests.

SSE robustness, mirroring main's fetch-based rewrite (#470). The Go SDK
already authenticated by header, so that part was never stale, but three
gaps were:

- A credentialed stream followed redirects. net/http drops Authorization
  on a cross-host hop while forwarding custom headers verbatim, so a
  redirect either downgraded the stream to default_role in silence or
  handed configured secrets to wherever it pointed. Now refused with a
  terminal SSE_REDIRECT. Uncredentialed streams still follow.
- A 200 with any content type was treated as an event stream, so an auth
  gateway's login page left the stream sitting in StatusLive delivering
  nothing. Now a terminal SSE_BAD_CONTENT_TYPE.
- Every failure collapsed into one retryable SSE_ERROR, and malformed
  frames came back as a bare fmt.Errorf, so errors.As and IsRetryable
  didn't work on them. Replaced with the taxonomy the TypeScript SDK
  uses — SSE_AUTH_ERROR, SSE_NETWORK_ERROR, SSE_CONNECT_ERROR,
  SSE_REDIRECT, SSE_BAD_CONTENT_TYPE, SSE_PARSE_ERROR, SSE_READ_ERROR —
  each with its own retryable flag, all delivered as *Error.

Also documents what main changed underneath the Go SDK without changing
its code: DateTime values arrive canonicalized to RFC 3339 UTC (#402),
SSE applies policy row-filters per subscriber and fails closed (#381,
#457), /v1/stream is ungated so WaveHouse never 401s a stream, and
/v1/ops/dlq/stats is absent (404) when the DLQ is disabled rather than
returning empty stats.

Tests: terminal-failure table (bad content type, missing content type,
credentialed redirect, non-HTTP scheme), redirect-followed-when-
uncredentialed, typed retryable parse errors, and header precedence and
copying on both transports.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/api HTTP handlers, routing, middleware 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.

feat(sdk): migrate SSE auth from ?token=JWT to Fetch EventSource

1 participant