docs(realtime): v3 API design proposal (RFC)#995
Draft
grdsdev wants to merge 6 commits into
Draft
Conversation
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>
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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,AsyncSequenceas 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:
ObservationTokencancellation races,subscribe/unsubscribeordering bugs (recently addressed in fix(realtime): resolve subscribe/unsubscribe lifecycle races via ChannelStateManager actor #974, feat(realtime): handle app lifecycle background/foreground transitions #967), ref-count grace periods that surprise users.JSONValue, errors areany Errorat every boundary.@Observableintegration requires bridging boilerplate every time.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:
Realtimeinstance. One server-side subscription per topic, ever.leave()only — no auto-unsubscribe.IssueReportingfires on leaked channels in debug.leave()— tears down for every holder of the topic. Topic ownership becomes a caller convention (namespace by feature).RealtimeErrorenum; Swift'sCancellationErrorfolded as.cancelled.channel.changes(...)returns aChangeRegistration<T, E>token;channel.subscribe()triggers the join with all pending tokens; consumption viaactive.events(for: token). Tokens reusable acrossleave()cycles; throw on register-after-join.RealtimeTransportprotocol +InMemoryTransport.pair()test helper +Clock<Duration>injection. Deterministic tests without real sockets or wall-clock.realtime.httpBroadcast(...)for one-shot sends; WSbroadcastthrows.channelNotJoinedif not joined — joining is a commitment.cancel(), multi-track support, channel-level key, auto re-track on reconnect.APIKeySource.dynamic(_:)+realtime.updateToken(_:). SDK never parses JWTs.withChannelscope sugar dropped — became a footgun under global-leave semantics.Status
Draft RFC, not ready to implement. Two open dependencies before merging or starting code:
realtime-v3-questions-for-backend.mdneed 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.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:
🤖 Generated with Claude Code