Skip to content

feat(_Realtime): Realtime v3 — idiomatic Swift 6 actor-based API#978

Draft
grdsdev wants to merge 26 commits intomainfrom
claude/zealous-gould-478cb1
Draft

feat(_Realtime): Realtime v3 — idiomatic Swift 6 actor-based API#978
grdsdev wants to merge 26 commits intomainfrom
claude/zealous-gould-478cb1

Conversation

@grdsdev
Copy link
Copy Markdown
Contributor

@grdsdev grdsdev commented Apr 27, 2026

Summary

  • Adds Packages/_Realtime/ — a standalone Swift 6.0 package implementing a ground-up redesign of the Realtime client targeting iOS 17+ / macOS 14+ with strict concurrency
  • Adds Packages/_RealtimeTableMacros/ — a companion macro package providing @RealtimeTable to synthesize RealtimeTable conformance at compile time
  • Provides InMemoryTransport.pair() as a public test helper for deterministic unit testing without real sockets

What's in _Realtime

Core architecture

  • Realtime actor — manages WebSocket connection, heartbeat, reconnection policy, channel registry
  • Channel actor — join/leave lifecycle, per-topic fan-out to AsyncThrowingStream subscribers
  • RealtimeTransport / RealtimeConnection protocols + URLSessionTransport production implementation
  • InMemoryTransport test double (ships in the main target so users can write their own tests)

Feature APIs (each via Channel extensions)

  • Broadcastchannel.broadcasts(), channel.broadcasts(of: T.self, event:), channel.broadcast(_:as:), channel.broadcast(_:as:) (binary), realtime.httpBroadcast(...) HTTP one-shot send
  • Presencechannel.presence.track(_:) → PresenceHandle, channel.presence.observe(T.self), channel.presence.diffs(T.self), auto re-track on reconnect
  • Postgres Changeschannel.changes(to: T.self, where: Filter<T>), inserts/updates/deletes convenience streams, untyped changes(schema:table:filter:) escape hatch

ConfigurationReconnectionPolicy (.never / .fixed / .exponentialBackoff), APIKeySource (.literal / .dynamic async), Configuration with clock injection for testing

Test plan

  • cd Packages/_Realtime && swift test — 45 tests pass, 1 known issue (intentional PresenceHandle leak warning test)
  • cd Packages/_RealtimeTableMacros && swift test — 2 macro expansion tests pass
  • swift test --filter RealtimeTests — 200 existing V2 tests unaffected
  • swift test --filter AuthTests — 167 existing Auth tests unaffected

Notes

  • Platform constraint: SupabaseClient.realtimeV3 is not wired into the root Supabase module yet — the root package still targets iOS 13+ while _Realtime requires iOS 17+. A ready-to-use extension template lives at docs/migrations/SupabaseClient+RealtimeV3.swift.template. This will be resolved when the main package bumps its platform floor.
  • Single coordinated release: Per the design doc, the _RealtimeRealtime rename and fold into main package happens at release when the platform floor is bumped.
  • Migration guide: docs/migrations/RealtimeV3 Migration Guide.md

🤖 Generated with Claude Code

grdsdev and others added 26 commits April 24, 2026 15:03
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…] and URLSession

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Adds a new standalone Swift 6.0 package at Packages/_Realtime with pure
foundation types: RealtimeError, CloseReason, RealtimeLogger, transport
protocol + URLSessionTransport, APIKeySource, ReconnectionPolicy, and
Configuration. All 4 ReconnectionPolicyTests pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…, Duration truncation

- Store and cancel the receive Task in URLSessionConnection to prevent a
  resource leak when close() is never called; also cancel in deinit.
- Add LocalizedError conformance to RealtimeError with human-readable
  descriptions for every case.
- Fix sub-second Duration truncation in ReconnectionPolicy.exponentialBackoff
  by including the attoseconds component in the Double conversion.
- Add regression tests for sub-second initial delay and unbounded fixed policy.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Add a prominent doc comment to InMemoryServer explaining the mutual
exclusion between receivedFrames and receive(), and document that
close(code:reason:) ignores its parameters for API compatibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…encodePush error message

- Assert payload content in decodeBinaryBroadcast test
- Add encodeBroadcastPushRoundTrip test verifying binary header layout and JSON payload
- Replace combined 255-byte guard in _encodePush with per-field guards that name the overflowing field

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…actor

- Add ConnectionStatus with State enum (idle/connecting/connected/reconnecting/closed)
- Add Realtime public final actor with connect/disconnect/channel registry
- Add Channel stub (topic, options, realtime weak ref) and ChannelOptions/ChannelState stubs
- Add withRealtimeTimeout helper for racing operations against a deadline
- Add Clocks to test target dependencies for TestClock usage
- All 17 tests pass (4 new RealtimeClientTests + 13 pre-existing)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ream initial value

- Extract _connect() so attemptReconnect bypasses the idle/closed guard
- Remove pre-assigned ref in updateToken (sendAndAwait always mints its own)
- Yield current status immediately on status stream subscription
- Make refCounter and _currentStatus private

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implement full Channel actor with join/leave state machine, fan-out
continuation infrastructure for broadcast/presence/postgres streams,
and internal routing hooks called by the Realtime actor. Add Equatable
conformance to RealtimeError and ChannelTests covering join, identity,
options locking, and stream completion on leave.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Implements the full broadcast surface for the _Realtime package:
- BroadcastMessage type with event, payload (JSONValue), and receivedAt
- Channel.broadcasts() — untyped AsyncThrowingStream with auto-join and fan-out
- Channel.broadcasts(of:event:decoder:) — typed, filtered, decoded stream
- Channel.broadcast(_:as:) — typed JSON send (throws channelNotJoined if not joined)
- Channel.broadcastBinary(_:as:) — binary-frame send via PhoenixSerializer
- Realtime.httpBroadcast — single and batch HTTP POST broadcast without WebSocket
- HttpBroadcastMessage value type for batch HTTP sends
- 6 new BroadcastTests covering delivery, fan-out, typed decoding, binary frames,
  not-joined guard, and stream termination on leave

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…n config, JSONValue payload type

- Rename `broadcastBinary(_:as:)` to `broadcast(_:as:)` for spec-correct call-site syntax
- Add `urlSession: URLSession = .shared` to `Configuration` so HTTP broadcast uses the configured session
- Update `httpBroadcast` to use `configuration.urlSession` instead of `URLSession.shared`
- Change `HttpBroadcastMessage.payload` from existential `any Encodable & Sendable` to concrete `JSONValue`
- Update generic `httpBroadcast<T>` convenience overload to encode `T` into `JSONValue` before building the message
- Fix `join()` and `joinIfNeeded()` to allow re-joining from any `.closed(...)` state, not just `.closed(.userRequested)`

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…track

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…ce tests

- Move trackedStates.removeAll() out of finishAllContinuations so transient
  errors preserve tracked states for re-track after reconnect
- Clear trackedStates on permanent closes: leave() and _join() rejection
- rejoin() now iterates trackedStates and re-sends presence track messages
- Add FLAG: trackSendsPresenceEvent verifies trackedStates is empty after cancel
- Add Test: autoRetrackOnRejoin — verifies re-track after disconnect/rejoin
- Add Test: multiTrackMultipleMetasPerKey — verifies count/cleanup of 2 handles
- Add Test: handleLeakWarningDoesNotCrash — verifies deinit without cancel
  fires reportIssue but does not trap (marked as withKnownIssue)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…untyped escape hatch

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…nce stream tests

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
- Add disabled-by-default integration test scaffold under _Realtime tests (requires
  local Supabase instance; enable by removing .disabled).
- Add migration guide documenting V2 → V3 API mapping and behavioural differences.
- Include a template SupabaseClient extension for consumers on iOS 17+; it cannot
  live inside the main Supabase target because _Realtime requires iOS 17+ while
  Supabase still supports iOS 13+ and SwiftPM applies package-level platforms to
  every target.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…wait, fix stale joinRef in track

- Extract _Atomic<T> from PresenceHandle.swift into Internal/Atomic.swift
- Add OnceResumingContinuation<T> that guards a CheckedContinuation so only
  the first resume call (timeout or server reply) wins, preventing a trap in
  debug and UB in release when both fire concurrently
- Change pendingReplies to [String: OnceResumingContinuation<PhoenixMessage>]
  and withRealtimeTimeout to pass the guarded wrapper to both the timeout
  branch and the sendAndAwait closure, ensuring a single shared guard covers
  both racing paths
- Add Channel.presenceTrackInfo() that returns (topic, joinRef) atomically;
  Presence.track(_:) now reads both in one await instead of two separate
  awaits, eliminating the stale-joinRef window across a channel rejoin

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
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.

1 participant