Skip to content

feat(#81): SSE realtime events endpoint bridging LISTEN/NOTIFY#86

Merged
milmazz merged 13 commits into
mainfrom
feat/81-sse-realtime-events
Jul 17, 2026
Merged

feat(#81): SSE realtime events endpoint bridging LISTEN/NOTIFY#86
milmazz merged 13 commits into
mainfrom
feat/81-sse-realtime-events

Conversation

@milmazz

@milmazz milmazz commented Jul 16, 2026

Copy link
Copy Markdown
Owner

Closes #81.

What

A config-gated Server-Sent Events endpoint (GET /<events_path>?channel=...) that bridges PostgreSQL LISTEN/NOTIFY to streaming HTTP clients — bier's first deliberate differentiator vs PostgREST, where this has been requested since 2019 (PostgREST/postgrest#1388, #4746) and never landed.

  • Off by default: events_channels: [] disables everything; the frozen conformance surface is untouched (891 tests green, profile unchanged).
  • One LISTEN connection per instance (Bier.Events.Listener, cloned from SchemaCacheListener's connection-ownership pattern: trap-exit, 500ms→30s backoff) fans out through a duplicate-keys Bier.Events.Registry to per-subscriber Bandit connection processes.
  • Multiplexed wire shape: one connection, many channels — SSE's native event: field carries the channel, data: carries the NOTIFY payload verbatim.
  • Auth-first validation: the instance's standard JWT gate runs before channel checks (401 before 400/404 — prevents unauthenticated channel enumeration), with an access_token query fallback because browser EventSource cannot set headers.
  • Honest fire-and-forget contract: at-most-once delivery, heartbeats + retry: hint, token verified at connect, 8KB NOTIFY cap — all documented.
  • Errors use the PostgREST envelope with a new BIER* code namespace (BIER001/BIER002) and Proxy-Status: Bier; error=... branding.
  • Telemetry: [:bier, :events, :subscribe, :start|:stop], [:bier, :events, :notification], [:bier, :events, :listener].
  • Docs: new ExDoc guide (docs/guides/realtime_events.md) + brewery-tutorial chapter (docs/tutorials/realtime.md, live check-ins board).

Design & plan

  • Spec: docs/superpowers/specs/2026-07-16-sse-realtime-events-design.md
  • Plan: docs/superpowers/plans/2026-07-16-sse-realtime-events.md

Testing

  • mix precommit fully green: format, hex.audit, compile/credo/docs warnings-as-errors, 891 tests (5 excluded).
  • New coverage: config validation; pure SSE encoding; registry fan-out/cleanup; listener NOTIFY broadcast + reconnect/re-LISTEN; endpoint error paths (400/404/401/405/406); raw-socket streaming (multiplex, heartbeat, disconnect cleanup, JWT header + access_token); auth-first pinning test.
  • Also picks up a lock-only mint 1.9.2→1.9.3 bump (EEF-CVE-2026-59249) required for a green hex.audit.

Follow-ups (deliberately deferred)

  • Bound stream lifetime by the token's exp; per-subscriber mailbox guard; events_authorizer DB-function callback (config shaped for it).
  • Minor test hardening: validator boundary cases, SSE.frame trailing-newline pinning, boot-level events_children([]) case.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y8pKvGBR8U9oBzUzWmzdX4

milmazz and others added 12 commits July 16, 2026 06:48
Approved brainstorming outcome: raw NOTIFY->SSE bridge on the main API
behind a config-gated path, static channel allowlist + standard JWT gate
(with an access_token query fallback for EventSource), fire-and-forget
delivery, multiplexed frames via the SSE event: field, and a shared
per-instance listener fanning out through a duplicate-keys Registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8pKvGBR8U9oBzUzWmzdX4
Nine TDD tasks from the approved spec: config surface, SSE encoding,
duplicate-keys registry, telemetry, LISTEN fan-out listener, request
handler + routing + BIER* error envelopes, streaming edge-case tests,
docs (guide + brewery tutorial chapter), and the full precommit gate.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8pKvGBR8U9oBzUzWmzdX4
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8pKvGBR8U9oBzUzWmzdX4
… proxy-status

Applies the whole-branch review wave for the SSE realtime events feature:

- Reorder Bier.Events.handle/2's validation chain to authenticate before
  parsing/authorizing channels, so a tokenless request on a JWT-protected
  instance is always 401 instead of leaking channel existence via a
  404/401 split (unauthenticated channel-enumeration oracle). Spec doc and
  moduledoc updated to match; pinning test added.
- Brand Bier's own BIER-prefixed error codes with
  `Proxy-Status: Bier; error=<code>` instead of PostgREST's, leaving every
  PGRST* code (and the frozen conformance suite that depends on them)
  untouched.
- Make the realtime events guide honest about three shipped behaviors:
  tokens are verified once at connect (not re-checked against exp), OPTIONS
  never reaches the events handler, and slow-client backpressure piles up
  unbounded in that subscriber's own mailbox.
- Adopted minors: percent-decode the events path segment before comparing
  it (consistent with relation resolution), thread the chunk-write failure
  reason into the subscribe :stop telemetry event, dedupe the
  wait_until_listener_connected/2 test helper into
  Bier.SSETestClient, fix a stale comment on Listener.safe_listen/2, note
  the events listener's boot-ordering gap in lib/bier.ex, and fix a
  moduledoc heading's capitalization.
- Bump mint 1.9.2 -> 1.9.3 (mix.lock only) to clear a same-day security
  advisory (EEF-CVE-2026-59249) that was blocking `mix hex.audit`,
  unrelated to this branch's code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8pKvGBR8U9oBzUzWmzdX4
@milmazz

milmazz commented Jul 16, 2026

Copy link
Copy Markdown
Owner Author

@claude review once.

@claude

claude Bot commented Jul 16, 2026

Copy link
Copy Markdown

Claude finished @milmazz's task in 4m 14s —— View job


Review: SSE realtime events endpoint (#81)

  • Gather context (PR description, changed files list)
  • Read diff against origin/main
  • Review core implementation: lib/bier/events.ex, lib/bier/events/listener.ex, lib/bier/events/registry.ex, lib/bier/events/sse.ex
  • Review controller/auth wiring: lib/bier/plugs/action_controller.ex, lib/bier/plugs/fallback_controller.ex
  • Review config surface: lib/bier.ex, lib/bier/config.ex
  • Review test coverage
  • Post final review feedback

Solid piece of work — the connection-ownership pattern is correctly cloned from SchemaCacheListener (trap_exit, all-or-retry LISTEN, exponential backoff), the auth-first ordering genuinely closes the channel-enumeration oracle (verified handle/2's with chain runs maybe_auth before parse_channels/authorize, and there's a pinning test for it in events_stream_test.exs:87), the feature is fully opt-in (events_channels: [] default, confirmed Bier.ConformanceServer.base_opts/0 never sets it, so the frozen conformance suite is untouched), and the docs are honestly scoped (exp not re-checked, unbounded per-subscriber mailbox, 8KB cap is Postgres's limit not Bier's).

A couple of things worth a look, none blocking:

1. Bier.Events.SSE.frame/2 only splits payloads on \n, not \r or \r\n (lib/bier/events/sse.ex:24-31). The SSE spec treats a bare CR as a line terminator too. A NOTIFY payload containing a standalone \r (no \n) would produce a data: line containing a literal CR that a browser's EventSource parser will treat as an extra line break — the tail after the CR loses its data: prefix and (if it doesn't parse as a recognized field) gets silently dropped, truncating the delivered payload. Vanishingly rare for JSON payloads, but since payloads are attacker/application-influenced NOTIFY content (not config), splitting on ~r/\r\n|\r|\n/ would be more spec-faithful.

2. CLAUDE.md's architecture section says Bier.Application "starts only Bier.Registry" — that's now stale: lib/bier/application.ex:12 also starts Bier.Events.Registry as shared cross-instance infrastructure (which is architecturally the right call, mirroring Bier.Registry's role — just flagging that the doc comment describing the two-layer supervision should probably get a one-line update in a follow-up so it doesn't mislead the next reader.

3. Minor asymmetry: the events endpoint's error-precedence is 401 → 400 (missing channel) → 404 (unknown channel) → 406 (negotiate), whereas the regular relation path checks 406 (reject_openapi_media) before 404 (resolve_relation) in action_controller.ex. Both orderings are internally consistent and separately tested/documented, so this is just a note that the two "406 vs 404" precedence rules diverge across the two endpoints — worth a one-line mention in the guide if that's ever surprising to an integrator.

Everything else checked out: config validation mirrors the db_channel/identifier rules appropriately (63-byte cap, no null bytes, no double quotes — matching how Postgrex.Notifications.listen/3 quotes the name), the registry's duplicate-keys fan-out and cleanup-on-death are correct and tested, telemetry spans are wired consistently with the rest of the codebase's Bier.Telemetry conventions, and the BIER*-vs-PGRST* Proxy-Status branding in fallback_controller.ex:537-543 is a nice touch that doesn't touch any existing PostgREST-owned code path.

Note on verification: I wasn't able to run mix compile/mix precommit/mix test in this environment — mix commands require approval that isn't available in this non-interactive review context. This review is based on static reading of the diff and cross-referencing against the existing SchemaCacheListener/Bier.Auth/FallbackController patterns it's modeled on. If you'd like the suite actually run, that'll need to happen through CI or a follow-up invocation with broader tool permissions.

…gistry note

PR #86 review follow-up: a NOTIFY payload containing a bare CR previously
left a raw CR inside a data: line, which an EventSource parser treats as a
line break - silently truncating the payload after it. Split on all three
SSE line terminators instead. Also updates CLAUDE.md's supervision note,
stale since Bier.Application gained Bier.Events.Registry.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y8pKvGBR8U9oBzUzWmzdX4
@milmazz

milmazz commented Jul 16, 2026

Copy link
Copy Markdown
Owner Author

Review follow-up, addressed in f2cd0dc:

  1. CR/CRLF line terminators — fixed. SSE.frame/2 now splits on \r\n|\r|\n, with a pinning test for both bare-CR and CRLF payloads. Good catch: a raw CR inside a data: line really would truncate the payload at the client's parser.
  2. Stale CLAUDE.md — fixed; the two-layer supervision note now names both Bier.Registry and Bier.Events.Registry.
  3. 406-vs-404 precedence asymmetry — intentional, leaving as-is. The events endpoint's ordering (auth → 400 → 404 → 406) was a deliberate design decision: authentication runs first to close an unauthenticated channel-enumeration oracle (see the spec's "Validation order" rationale and the pinning test in events_stream_test.exs). Aligning with the relation path's 406-before-404 would reorder checks for symmetry alone; both orderings are documented and pinned by tests.

@milmazz
milmazz merged commit f5b3bca into main Jul 17, 2026
3 checks passed
@milmazz
milmazz deleted the feat/81-sse-realtime-events branch July 17, 2026 01:34
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[lib] Realtime change notifications: SSE endpoint bridging LISTEN/NOTIFY

1 participant