Skip to content

docs(realtime): v3 API design proposal (RFC)#995

Draft
grdsdev wants to merge 6 commits into
mainfrom
claude/charming-euler-49c5a1
Draft

docs(realtime): v3 API design proposal (RFC)#995
grdsdev wants to merge 6 commits into
mainfrom
claude/charming-euler-49c5a1

Conversation

@grdsdev
Copy link
Copy Markdown
Contributor

@grdsdev grdsdev commented May 7, 2026

Summary

Draft RFC for a greenfield Swift redesign of the Realtime module. No source code changes — adds two design docs under docs/design/:

  • realtime-v3.md — full API specification. Explicit lifecycle, typed throws (throws(RealtimeError)) throughout, AsyncSequence as the canonical surface, register-then-subscribe pattern for postgres_changes (the Phoenix wire requires filters in the join payload — the API can't pretend lazy join works for them), shared-by-topic channel identity, pluggable transport + clock for deterministic testing, 45+ locked decisions with rationale, and a V2 → V3 migration table.
  • realtime-v3-questions-for-backend.md — assumption-vs-question pairs across 16 topics (connection, channel lifecycle, broadcast WS + HTTP, replay, presence, postgres changes, auth, errors, rate limits, ordering, app lifecycle, protocol limits) for the Realtime backend team to validate before implementation begins.

Why

V2 has accumulated structural pain points that are hard to fix incrementally:

This RFC asks: if we were writing the module today with no backward-compat constraints, what would the API look like?

Headline decisions

The full 45-row matrix is in §12 of the spec. Highlights:

  1. Channels shared by topic within a Realtime instance. One server-side subscription per topic, ever.
  2. Explicit leave() only — no auto-unsubscribe. IssueReporting fires on leaked channels in debug.
  3. Global leave() — tears down for every holder of the topic. Topic ownership becomes a caller convention (namespace by feature).
  4. Typed throws everywhere. Flat RealtimeError enum; Swift's CancellationError folded as .cancelled.
  5. Postgres changes are register-then-subscribe. channel.changes(...) returns a ChangeRegistration<T, E> token; channel.subscribe() triggers the join with all pending tokens; consumption via active.events(for: token). Tokens reusable across leave() cycles; throw on register-after-join.
  6. Public RealtimeTransport protocol + InMemoryTransport.pair() test helper + Clock<Duration> injection. Deterministic tests without real sockets or wall-clock.
  7. Separate realtime.httpBroadcast(...) for one-shot sends; WS broadcast throws .channelNotJoined if not joined — joining is a commitment.
  8. Presence as a regular class with explicit cancel(), multi-track support, channel-level key, auto re-track on reconnect.
  9. APIKeySource.dynamic(_:) + realtime.updateToken(_:). SDK never parses JWTs.
  10. withChannel scope sugar dropped — became a footgun under global-leave semantics.

Status

Draft RFC, not ready to implement. Two open dependencies before merging or starting code:

  1. Backend assumption validation. ~60 wire/protocol assumptions in realtime-v3-questions-for-backend.md need answers from the Realtime team. Several headline decisions (e.g., postgres_changes filter constraint, presence multi-meta semantics, broadcast replay retention) hinge on confirmations there.
  2. SDK team feedback. Naming, missing use cases, platform floor (Swift 6.2 + iOS 17 is aggressive), migration cost.

Tracked in Linear with both docs attached as project documents:
https://linear.app/supabase/project/realtime-v3-idiomatic-swift-api-rfc-044c5935314f

Test plan

Docs-only — no test plan beyond:

  • Renders correctly on GitHub (tables, code fences)
  • Internal links between sections resolve
  • Code samples are syntactically plausible Swift 6.2 (compile-checked manually; not part of CI)

🤖 Generated with Claude Code

Greenfield Swift API redesign for the Realtime module, targeting Swift 6.2+.
Captures the locked design after grill-through review:

- realtime-v3.md — full API specification: explicit lifecycle, typed throws
  throughout, AsyncSequence as the canonical surface, register-then-subscribe
  pattern for postgres_changes (reflecting the Phoenix wire constraint that
  filters must be in the join payload), shared-by-topic channel identity,
  pluggable transport + clock for deterministic testing, 45+ locked decisions
  with rationale, and a V2 → V3 migration table.

- realtime-v3-questions-for-backend.md — assumption-vs-question pairs across
  16 topics (connection, channel lifecycle, broadcast WS + HTTP, replay,
  presence, postgres changes, auth, errors, rate limits, ordering, app
  lifecycle, protocol limits) for the Realtime backend team to validate
  before implementation begins.

Tracked in Linear:
https://linear.app/supabase/project/realtime-v3-idiomatic-swift-api-rfc-044c5935314f

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
@github-actions github-actions Bot added the Realtime Work related to realtime package label May 7, 2026
grdsdev and others added 5 commits May 7, 2026 14:22
Replace the two-type Channel + ActiveChannel split with a single post-join
type that conforms to AsyncSequence and owns broadcast send.

- subscribe() returns ChannelSubscription (was ActiveChannel)
- ChannelSubscription: AsyncSequence with Element = PhoenixMessage
- typed views: broadcasts(of:event:), events(for: token), presence
- broadcast send moves from Channel to ChannelSubscription — type-level
  gate replaces the runtime .channelNotJoined error
- presence accessor moves from Channel to ChannelSubscription
- subscribe() is now the only join path; no iteration-driven lazy-join
- 30-second tour, §2-§7, §11 migration table, §12 decisions, Appendix A
  all updated end-to-end

The new shape collapses asymmetry: Channel exposes registration + the join
verb; ChannelSubscription exposes everything else. Sending without a live
subscription is unrepresentable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…te accessor

- PhoenixMessage gains joinRef and ref fields for request/reply correlation
  visibility; raw iteration now includes internal phx_reply/phx_close/phx_error
  frames so advanced consumers can observe everything the SDK sees.
- Document raw iteration as the unfiltered escape hatch (the SDK still
  consumes these frames internally for ack correlation and lifecycle).
- Lock decisions 14k (raw PhoenixMessage shape) and 14l (defer
  ChannelSubscription.isAlive / state accessor — additive if needed).
- subscribe() remains async throws (no change); confirmed lock.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Including topic on the raw frame so consumers that pass PhoenixMessage
values across logging, debugging, or multi-topic aggregation surfaces
keep the routing key without threading it separately. Always matches the
ChannelSubscription's topic for in-iteration consumers.

Confirmed locks (no spec change needed):
- Socket-level RTT via ConnectionStatus.latency is sufficient (no per-channel
  / per-broadcast latency stream).
- ChannelSubscription stays as the type name.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…lidates-subscription

Three improvements after consistency sweep:

1. ChangeRegistration generics simplified.
   - Variants (Insert/Update/Delete/AnyEvent) are themselves generic over T
     and conform to a ChangeEventVariant protocol declaring Element.
   - ChangeRegistration drops to a single generic parameter (the variant
     carries T): ChangeRegistration<Insert<Message>> instead of
     ChangeRegistration<Message, Insert>.
   - sub.events(for:) becomes a single overload dispatched on the variant.
   - Fixes a real type-system bug in the previous shape, where
     ChangeEventVariant.Element referenced T but T wasn't in scope.

2. Filter split into Filter<T: RealtimeTable> + UntypedFilter.
   - Untyped path no longer requires JSONValue to conform to RealtimeTable.
   - Untyped factories (channel.changes(schema:table:filter:), etc.) return
     ChangeRegistration<E<JSONValue>>; identical type to typed registrations
     just with a different variant T. Mix freely.

3. Tighten subscription lifecycle.
   - §2.1 invariants now state that manual leave() invalidates the
     subscription value; methods throw .channelClosed(.userRequested);
     iteration terminates; reconnects do NOT invalidate.
   - Decision 14m captures this; 14n/14o capture the type split.

Also drops .channelNotJoined from §7 references (already removed earlier;
Decision 26 still mentioned it — fixed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Bugs fixed:
- §1.2 Configuration: replace fictional .iso8601 / .automaticDefault statics
  with proper SDK-provided constants (JSONDecoder.realtimeDefault,
  LifecyclePolicy.automaticDefault). Add `& Sendable` to the clock type.
- §1.2: rename `handleAppLifecycle: Bool` to `lifecycle: LifecyclePolicy`
  so the §9.3 enum is actually wired in (was dead code before).
- §6.1: declare realtime.connect() — was referenced but never declared.
- §4: define PresenceKey (typealias for String).
- §5.2: add RealtimePostgresFilterValue constraint to .in factory.
- Appendix A: pass `me: UUID` through init (Self.currentUserID was
  undefined); rename ChatMessage to ChatBroadcast and define the type.

Inconsistencies fixed:
- §2.1: drop "joins lazily on first subscribe" (subscribe IS the join).
- §2.3: pipelined re-acquire returns the same Channel actor in
  unsubscribed state, not a "fresh Channel" (was contradicting Decision 1).
- §4: docstring referenced channel.leave(); generalize to "leave on any
  holder of the topic".
- §9.2: stale channel.broadcast(...) → sub.broadcast(...).
- §6.2: split disconnect()'s close reason from policy giveup —
  add CloseReason.clientDisconnected. Reconnection policy applies only
  to UNEXPECTED closes.
- §12: refresh Decisions 12, 13, 22 to reflect the typed/untyped filter
  split and the broadcast Data overload. Decision 14g merged into 26.

Ambiguities tightened:
- §6.3: clarify "same token" = byte-equal returned string, no rotation
  attempted; propagate .authenticationFailed.
- §6.4: document `since` (state-entry timestamp) and `latency` semantics.
- §7: explain .disconnected vs .channelClosed distinction.
- §7: add .unknownToken for events(for:) called with cross-channel tokens.
- §2.1 PhoenixMessage.joinRef: document v1 (always nil) vs v2 behavior.

No semantic changes — all locked decisions stand.

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

Labels

Realtime Work related to realtime package

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant