diff --git a/.superpowers/sdd/ie-flows-report.md b/.superpowers/sdd/ie-flows-report.md new file mode 100644 index 000000000..a6c179059 --- /dev/null +++ b/.superpowers/sdd/ie-flows-report.md @@ -0,0 +1,101 @@ +# IE-3..6 E2E Integration Test Report + +## Summary + +All 13 integration tests (IE-1..6, 6 suites) pass GREEN against the live local Supabase instance (Docker, project `realtimev3`, `realtime:v2.107.5`). + +**Branch**: `claude/charming-euler-49c5a1` +**HEAD after commit**: see `git log --oneline -1` +**Test run**: `swift test --filter RealtimeV3IntegrationTests` +**Unit suite**: `swift test --filter RealtimeV3Tests` — 132 tests pass, 1 pre-existing known issue (unchanged) +**Supabase instance**: LEFT RUNNING (not stopped) + +--- + +## IE-3 Broadcast (BroadcastE2ETests.swift) — GREEN + +### IE-3a: WS round-trip between two clients — PASS +Two separate `Realtime` clients join the same topic. B opens a `broadcasts(of:event:)` stream before subscribing. A broadcasts a `ChatMsg`. B receives it within the timeout. Confirmed against live server. + +### IE-3b: HTTP broadcast received by WS subscriber — PASS (with SDK GAP noted) +B subscribes via WebSocket. HTTP broadcast via `Realtime.httpBroadcastBatch` delivers the message. **SDK GAP discovered and documented** (see Concerns below). Test uses `httpBroadcastBatch` directly with the correct short topic as a workaround. + +### IE-3c: `acknowledge=true` returns without timeout — PASS +Channel created with `broadcast.acknowledge = true`. `broadcast(...)` returns cleanly; server ACKs the push. + +--- + +## IE-4 Presence (PresenceE2ETests.swift) — GREEN + +### IE-4a: presence sync between two clients — PASS +A and B join same topic with `presence.enabled = true`. A tracks `UserPresence(userId: "user-a", status: "active")`. B's `presence.observe(UserPresence.self)` stream sees A appear in the active map. A cancels the track handle; B sees A's leave. Handle correctly cancelled to suppress leak warning. + +--- + +## IE-5 Postgres Changes (PostgresChangesE2ETests.swift) — GREEN + +### IE-5a: INSERT delivers postgres change — PASS +Registered `channel.inserts(schema:table:filter:)` with a unique `room_id` UUID before `subscribe()`. After join, inserted a row via `PostgrestClient`. The `postgresChanges(for:)` stream yielded the row within 15 seconds. `record["content"]` matched expected value. Server-id routing (`_buildServerIDRouting`) works correctly — join reply includes `postgres_changes: [{id: , ...}]` which maps to the registration UUID. + +### IE-5b: UPDATE and DELETE deliver old_record — PASS (with server behavior note) +UPDATE: `old_record` contains the full original row (REPLICA IDENTITY FULL working). New `record` contains updated values. DELETE: `old_record` contains **only the primary key**, not all columns. This is **not an SDK bug** — it is intentional Realtime server v2.x behavior: for DELETE, the deleted row no longer exists for RLS evaluation, so the server returns only the PK in `old_record`. Test updated to assert `old_record.id` matches the deleted row's UUID. + +--- + +## IE-6 Reconnection (ReconnectionE2ETests.swift) — GREEN (with SDK GAP noted) + +### IE-6a: leave → disconnect → connect → subscribe — PASS +Channel leaves cleanly, transitions to `.closed`. After `disconnect()` + `subscribe()` (which internally calls `connect()`), the channel rejoins and reaches `.joined`. + +### IE-6b: broadcast stream after leave → disconnect → connect cycle — PASS +Pre-disconnect: receiver gets `Ping(seq: 1)`. After `leave()` + `disconnect()` + `subscribe()`: receiver re-joins, gets `Ping(seq: 2)` from the post-reconnect stream. + +**SDK GAP discovered**: After `disconnect()` WITHOUT a prior `leave()`, the channel state remains `.joined` in the SDK (transport severed, but logical state preserved for unclean-drop transparent rejoin). Calling `subscribe()` on a `.joined` channel is idempotent (returns immediately). Callers that want to reuse a channel after an intentional disconnect must call `leave()` before `disconnect()`. Forced-drop (unclean) reconnection is covered deterministically by `RealtimeV3Tests/RejoinTests` using `InMemoryTransport`. + +--- + +## Real-Server Findings and SDK Gaps + +### SDK GAP 1 — `Channel.httpBroadcast` topic format + +**File**: `Sources/RealtimeV3/HTTP/HttpBroadcast.swift`, `Channel.httpBroadcast` +**Symptom**: `Channel.httpBroadcast(event:payload:)` sends HTTP 202 but the message is **not delivered** to WebSocket subscribers. +**Root cause**: `Channel.httpBroadcast` passes `topic` (the full `realtime:` string) as the topic in the HTTP broadcast body. The Realtime server's `/api/broadcast` endpoint expects the **short topic without the `realtime:` prefix** to route to WS subscribers. Using the full prefixed topic causes a routing mismatch — accepted (202) but not delivered. +**Evidence**: `curl` tests confirmed that `"topic":"room:foo"` delivers; `"topic":"realtime:room:foo"` does not. +**Workaround in tests**: Uses `Realtime.httpBroadcastBatch` directly with the short topic. +**Fix needed**: Strip the `realtime:` prefix when building `HttpBroadcastMessage.topic` in `Channel.httpBroadcast`. + +### SDK GAP 2 — HTTP broadcast requires service-role JWT + +**File**: `Sources/RealtimeV3/HTTP/HttpBroadcast.swift` +**Symptom**: HTTP broadcast with `apikey: ` returns HTTP 500 from the Realtime server. +**Root cause**: The Realtime `/api/broadcast` endpoint requires a JWT with `service_role` role (Bearer auth), not just the anon key header. The SDK sends `apikey: ` when no `accessToken` provider is configured, which is rejected. +**Evidence**: `curl` with `Authorization: Bearer ` → 202; with `apikey: ` → 500. +**Fix needed**: Document that `httpBroadcast` requires the Realtime client to be initialized with a service-role `accessToken` provider, or add a specific error message when the server returns 500 for this call. + +### SDK GAP 3 — Intentional disconnect does not clear channel state + +**File**: `Sources/RealtimeV3/Realtime.swift`, `disconnect()` +**Symptom**: After `disconnect()`, channels remain in `.joined` state. Calling `subscribe()` is a no-op. +**Root cause**: `disconnect()` does not cascade state transitions to channels — this is by design (channels preserve state for unclean-drop transparent rejoin). But it creates a footgun for intentional disconnect+reconnect scenarios. +**Fix needed**: Either document explicitly that `leave()` must be called before `disconnect()` when the caller wants to resubscribe, or add a `disconnectMode` parameter to `disconnect()` that optionally leaves all channels first. + +### Server behavior note — DELETE old_record + +The Realtime server v2.107.5 returns only the PK columns in `old_record` for DELETE events, even with `REPLICA IDENTITY FULL` and permissive RLS. This is intentional security behavior (deleted row can't be evaluated against RLS). The SDK correctly surfaces what the server sends. No SDK fix needed; documentation note added in the test. + +--- + +## Warnings Check + +Clean: `swift build --build-tests 2>&1 | grep "warning:" | grep -E "BroadcastE2E|PresenceE2E|PostgresChanges|Reconnection|IntegrationTests"` → no output. + +--- + +## Files + +- `Tests/RealtimeV3IntegrationTests/BroadcastE2ETests.swift` (IE-3, 3 tests) +- `Tests/RealtimeV3IntegrationTests/PresenceE2ETests.swift` (IE-4, 1 test) +- `Tests/RealtimeV3IntegrationTests/PostgresChangesE2ETests.swift` (IE-5, 2 tests) +- `Tests/RealtimeV3IntegrationTests/ReconnectionE2ETests.swift` (IE-6, 2 tests) +- `Tests/RealtimeV3IntegrationTests/Support/IntegrationEnv.swift` (added `serviceRoleKey` + `makeRealtimeWithServiceRole()`) diff --git a/Makefile b/Makefile index 47d217cfd..c04f3b912 100644 --- a/Makefile +++ b/Makefile @@ -53,6 +53,15 @@ test-integration: swift test --filter IntegrationTests cd Tests/IntegrationTests && supabase stop +# Run RealtimeV3 integration tests against a live local Supabase instance. +# Starts the dedicated realtimev3 project, resets the DB (applies migrations + seed), +# then runs the integration suite. The instance is intentionally left running after +# the first invocation so subsequent runs skip the slow start step. +# To stop: cd Tests/RealtimeV3IntegrationTests/supabase && supabase stop +test-realtime-v3-integration: + cd Tests/RealtimeV3IntegrationTests/supabase && supabase start && supabase db reset --local + swift test --filter RealtimeV3IntegrationTests + build-for-library-evolution: swift build \ -q \ @@ -81,7 +90,7 @@ format: -not -path '*/.*' -print0 \ | xargs -0 swift format --ignore-unparsable-files --in-place -.PHONY: build-for-library-evolution format warm-simulator xcodebuild test-docs test-integration +.PHONY: build-for-library-evolution format warm-simulator xcodebuild test-docs test-integration test-realtime-v3-integration .PHONY: coverage coverage: diff --git a/Package.swift b/Package.swift index 08bfae2ae..93ff829d3 100644 --- a/Package.swift +++ b/Package.swift @@ -18,6 +18,7 @@ let package = Package( .library(name: "Functions", targets: ["Functions"]), .library(name: "PostgREST", targets: ["PostgREST"]), .library(name: "Realtime", targets: ["Realtime"]), + .library(name: "RealtimeV3", targets: ["RealtimeV3"]), .library(name: "Storage", targets: ["Storage"]), .library(name: "Supabase", targets: ["Supabase"]), ], @@ -159,6 +160,40 @@ let package = Package( "TestHelpers", ] ), + .target( + name: "RealtimeV3", + dependencies: [ + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "HTTPTypes", package: "swift-http-types"), + .product(name: "Clocks", package: "swift-clocks"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), + "Helpers", + ] + ), + .testTarget( + name: "RealtimeV3Tests", + dependencies: [ + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "Clocks", package: "swift-clocks"), + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + "Mocker", + "RealtimeV3", + "TestHelpers", + ] + ), + .testTarget( + name: "RealtimeV3IntegrationTests", + dependencies: [ + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + "RealtimeV3", + "PostgREST", + "Helpers", + ], + exclude: [ + "supabase", + ] + ), .target( name: "Storage", dependencies: [ diff --git a/Sources/RealtimeV3/Channel+Broadcast.swift b/Sources/RealtimeV3/Channel+Broadcast.swift new file mode 100644 index 000000000..cac2abb88 --- /dev/null +++ b/Sources/RealtimeV3/Channel+Broadcast.swift @@ -0,0 +1,160 @@ +// +// Channel+Broadcast.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import Helpers + +// MARK: - Channel + broadcast send + +extension Channel { + + /// Sends a broadcast message with an `Encodable` payload. + /// + /// ## State gating + /// - `.unsubscribed` / `.joining` → throws `.notSubscribed` + /// - `.leaving` / `.closed` → throws `.channelClosed(reason)` + /// - `.joined` → encodes payload and sends the binary push frame + /// + /// ## Ack mode + /// When `options.broadcast.acknowledge == true`, the call suspends until the server + /// sends a `phx_reply` for this push, or `configuration.broadcastAckTimeout` elapses + /// (throws `.broadcastAckTimeout`). When `acknowledge == false` the frame is sent + /// fire-and-forget. + /// + /// ## Wire format + /// The binary frame is a Phoenix 2.0.0 broadcast push (kind byte `0x03`). The JSON + /// payload inside the frame is: + /// ```json + /// {"type": "broadcast", "event": "", "payload": } + /// ``` + /// This is symmetric with the receive side (`broadcasts(of:event:)`). + /// + /// - Parameters: + /// - payload: The message payload. Encoded to JSON before sending. + /// - event: The broadcast event name (e.g. `"chat"`). + /// - Throws: `RealtimeError` + public func broadcast(_ payload: T, as event: String) + async throws(RealtimeError) + { + // Guard: ensure the owning Realtime is still alive (needed for the ack timeout below). + guard let realtime else { throw .channelClosed(.clientDisconnected) } + try _requireJoinedForSend() + + // Build the inner broadcast envelope (user payload encoded via Configuration.encoder). + let envelope: JSONObject = [ + "type": .string("broadcast"), + "event": .string(event), + "payload": try _encodeToJSON(payload), + ] + + try await _push( + .broadcast, .broadcastJSON(envelope), + ack: options.broadcast.acknowledge + ? .require(timeout: realtime.configuration.broadcastAckTimeout, error: .broadcastAckTimeout) + : .none + ) + } + + /// Sends a broadcast message with a raw binary payload. + /// + /// The binary data is shipped as-is inside the Phoenix 2.0.0 broadcast push frame + /// (kind byte `0x03`, encoding byte `0x00`). Ack semantics are identical to the + /// `Encodable` overload. + /// + /// - Parameters: + /// - data: The raw binary payload. + /// - event: The broadcast event name. + /// - Throws: `RealtimeError` + public func broadcast(_ data: Data, as event: String) async throws(RealtimeError) { + // Guard: ensure the owning Realtime is still alive (needed for the ack timeout below). + guard let realtime else { throw .channelClosed(.clientDisconnected) } + try _requireJoinedForSend() + + try await _push( + .broadcast, .broadcastData(data), + ack: options.broadcast.acknowledge + ? .require(timeout: realtime.configuration.broadcastAckTimeout, error: .broadcastAckTimeout) + : .none + ) + } +} + +// MARK: - Channel + broadcasts(of:event:) + +extension Channel { + /// Returns an `AsyncThrowingStream` that yields every broadcast message for the + /// given `event` name, decoded to `T`. + /// + /// ## Wire shape + /// A broadcast Phoenix frame has `event == "broadcast"` and a JSON payload of the form: + /// ```json + /// { "type": "broadcast", "event": "", "payload": { ... } } + /// ``` + /// This method filters frames whose inner `event` matches the requested name and + /// decodes the inner `payload` object to `T` using `Configuration.decoder`. + /// + /// ## Per-call fan-out (Decision 8) + /// Each call mints an independent stream. N concurrent calls each receive a copy + /// of every matching message. Streams created before `subscribe()` are valid — + /// they start producing once frames arrive after the join. + /// + /// ## Decode failure + /// If the inner `payload` cannot be decoded to `T`, the stream terminates by + /// throwing `RealtimeError.decoding(type:underlying:)`. Non-matching events are + /// silently ignored. + /// + /// ## Terminal close + /// When the channel transitions to `.closed(reason)` (e.g. via `leave()`), the + /// stream terminates by throwing `RealtimeError.channelClosed(reason)`. + /// + /// - Note: The thrown error is always a `RealtimeError`. Cast with `as? RealtimeError` + /// or use `if case` matching on the caught `any Error`. + public func broadcasts( + of type: T.Type, + event: String + ) -> AsyncThrowingStream { + // Capture the decoder from realtime configuration, falling back to the default if + // realtime has already been deallocated (e.g. stream registered after client teardown). + let decoder = realtime?.configuration.decoder ?? .realtimeDefault + + // The helper handles terminal close (→ `.channelClosed`) and task lifecycle; the body + // filters for the requested broadcast event and decodes, throwing on decode failure. + return _makeThrowingStream(initialState: ()) { _, message, continuation in + // Only handle broadcast Phoenix events matching the requested inner event. + guard message.event == .broadcast, + case .json(let jsonValue) = message.payload, + let obj = jsonValue.objectValue, + let innerEvent = obj["event"]?.stringValue, innerEvent == event + else { return } + + // Extract the inner "payload" value. + guard let innerPayload = obj["payload"] else { + // No payload key — decode failure terminates the stream. + throw RealtimeError.decoding( + type: String(describing: T.self), + underlying: MissingPayloadError() + ) + } + + // Re-encode the JSONValue to Data, then decode T using the configured decoder. + // A decode failure throws and terminates the stream (spec: decode failure throws). + do { + let data = try JSONEncoder().encode(innerPayload) + continuation.yield(try decoder.decode(T.self, from: data)) + } catch { + throw RealtimeError.decoding(type: String(describing: T.self), underlying: error) + } + } + } +} + +// MARK: - MissingPayloadError + +/// Sentinel error used when a broadcast frame has no inner `payload` key. +private struct MissingPayloadError: Error, Sendable { + var localizedDescription: String { "Broadcast frame is missing the inner 'payload' key." } +} diff --git a/Sources/RealtimeV3/Channel+Postgres.swift b/Sources/RealtimeV3/Channel+Postgres.swift new file mode 100644 index 000000000..cb6bc2338 --- /dev/null +++ b/Sources/RealtimeV3/Channel+Postgres.swift @@ -0,0 +1,340 @@ +// +// Channel+Postgres.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation + +// MARK: - Pending registration storage + +extension Channel { + /// Appends a `ChangeRegistrationConfig` to the channel's pending-registration set. + /// + /// Called by every untyped factory after the state guard passes. + func _appendRegistration(_ config: ChangeRegistrationConfig) { + pendingRegistrations.append(config) + } +} + +// MARK: - Untyped factories (§5.3 / §5.4) + +extension Channel { + // ----------------------------------------------------------------------- + // All factories are isolated (they mutate `pendingRegistrations`) so + // callers must `await` them. They are `async throws(RealtimeError)` so + // they can throw `.cannotRegisterAfterJoin` when the channel is already + // `.joined` or `.joining`. + // + // Design note: the spec signature (§5.3) shows these as returning + // `ChangeRegistration` directly (no explicit `async throws`), but the + // spec prose (§2.1, §5.3) states they are isolated and throw after join. + // We resolve toward `async throws(RealtimeError)` to satisfy the isolation + // contract and the typed-throw requirement — the `await` is mandatory when + // calling any isolated actor method in Swift 6 anyway. + // ----------------------------------------------------------------------- + + /// Registers a subscription for ALL postgres change events (`INSERT`, `UPDATE`, + /// `DELETE`) on the given schema+table. + /// + /// Must be called before `subscribe()`. Throws `.cannotRegisterAfterJoin` + /// if the channel is already `.joined` or `.joining`. + /// + /// - Parameters: + /// - schema: Postgres schema (e.g. `"public"`). + /// - table: Postgres table name. + /// - filter: Optional row filter (`UntypedFilter`). `nil` means no filter. + /// - Returns: An opaque `ChangeRegistration>` token. + public func changes( + schema: String, + table: String, + filter: UntypedFilter? = nil + ) async throws(RealtimeError) -> ChangeRegistration> { + try _guardCanRegister() + let config = ChangeRegistrationConfig( + event: .all, + schema: schema, + table: table, + filter: filter?.serialized, + id: UUID(), + channelID: ObjectIdentifier(self), + variantKind: .anyEvent + ) + _appendRegistration(config) + return ChangeRegistration(config: config) + } + + /// Registers a subscription for INSERT events on the given schema+table. + /// + /// Must be called before `subscribe()`. Throws `.cannotRegisterAfterJoin` + /// if the channel is already `.joined` or `.joining`. + /// + /// - Parameters: + /// - schema: Postgres schema (e.g. `"public"`). + /// - table: Postgres table name. + /// - filter: Optional row filter (`UntypedFilter`). `nil` means no filter. + /// - Returns: An opaque `ChangeRegistration>` token. + public func inserts( + schema: String, + table: String, + filter: UntypedFilter? = nil + ) async throws(RealtimeError) -> ChangeRegistration> { + try _guardCanRegister() + let config = ChangeRegistrationConfig( + event: .insert, + schema: schema, + table: table, + filter: filter?.serialized, + id: UUID(), + channelID: ObjectIdentifier(self), + variantKind: .insert + ) + _appendRegistration(config) + return ChangeRegistration(config: config) + } + + /// Registers a subscription for UPDATE events on the given schema+table. + /// + /// Must be called before `subscribe()`. Throws `.cannotRegisterAfterJoin` + /// if the channel is already `.joined` or `.joining`. + /// + /// - Parameters: + /// - schema: Postgres schema (e.g. `"public"`). + /// - table: Postgres table name. + /// - filter: Optional row filter (`UntypedFilter`). `nil` means no filter. + /// - Returns: An opaque `ChangeRegistration>` token. + public func updates( + schema: String, + table: String, + filter: UntypedFilter? = nil + ) async throws(RealtimeError) -> ChangeRegistration> { + try _guardCanRegister() + let config = ChangeRegistrationConfig( + event: .update, + schema: schema, + table: table, + filter: filter?.serialized, + id: UUID(), + channelID: ObjectIdentifier(self), + variantKind: .update + ) + _appendRegistration(config) + return ChangeRegistration(config: config) + } + + /// Registers a subscription for DELETE events on the given schema+table. + /// + /// Must be called before `subscribe()`. Throws `.cannotRegisterAfterJoin` + /// if the channel is already `.joined` or `.joining`. + /// + /// - Parameters: + /// - schema: Postgres schema (e.g. `"public"`). + /// - table: Postgres table name. + /// - filter: Optional row filter (`UntypedFilter`). `nil` means no filter. + /// - Returns: An opaque `ChangeRegistration>` token. + public func deletes( + schema: String, + table: String, + filter: UntypedFilter? = nil + ) async throws(RealtimeError) -> ChangeRegistration> { + try _guardCanRegister() + let config = ChangeRegistrationConfig( + event: .delete, + schema: schema, + table: table, + filter: filter?.serialized, + id: UUID(), + channelID: ObjectIdentifier(self), + variantKind: .delete + ) + _appendRegistration(config) + return ChangeRegistration(config: config) + } +} + +// MARK: - postgresChanges(for:) (Task 28) + +// Small sendable error carrying a plain string description, used as the `underlying` +// value in `RealtimeError.decoding` when no root-cause `Error` is available. +struct PostgresDecodeError: Error, Sendable, CustomStringConvertible { + let description: String +} + +extension Channel { + /// Returns an `AsyncThrowingStream` that yields every postgres change event that + /// matches the given registration token. + /// + /// ## Server-id routing + /// The server assigns integer ids to each `postgres_changes` entry in the join reply + /// (in the same order as the client's `postgres_changes` array). An incoming + /// `postgres_changes` frame carries an `ids` array; this method routes the frame to + /// all tokens whose registration UUID is mapped to one of those server ids. + /// + /// ## Per-call fan-out (Decision 12 OR semantics) + /// Multiple `postgresChanges(for:)` calls for tokens sharing a server id each receive + /// a copy of every matching frame. + /// + /// ## `.unknownToken` + /// If `token` was created on a different channel, the returned stream immediately + /// finishes throwing `.unknownToken`. + /// + /// ## Decode failure + /// If the frame cannot be decoded into `E.Element`, the stream terminates by + /// throwing `RealtimeError.decoding(type:underlying:)`. + /// + /// ## Terminal close + /// When the channel transitions to `.closed(reason)`, the stream terminates by + /// throwing `RealtimeError.channelClosed(reason)`. + /// + /// ## Async setup errors (H6) + /// When a `system` event signals a postgres_changes failure, the stream terminates + /// by throwing `RealtimeError.postgresSubscriptionFailed(reason:)`. + /// + /// - Note: The thrown error is always a `RealtimeError`. The stream's `Failure` is + /// `any Error` (not `RealtimeError`) because `AsyncThrowingStream<_, RealtimeError>` + /// requires iOS 17+; on the iOS 16 deployment target, only `Failure == any Error` is + /// available. Cast with `as? RealtimeError` or use `if case` matching. + public func postgresChanges( + for token: ChangeRegistration + ) -> AsyncThrowingStream { + let config = token.config + + // Guard: token must belong to this channel. + guard config.channelID == ObjectIdentifier(self) else { + let (stream, continuation) = AsyncThrowingStream.makeStream() + continuation.finish(throwing: RealtimeError.unknownToken) + return stream + } + + // Capture the variantKind + registration id at registration time. + let variantKind = config.variantKind + let registrationID = config.id + + // The helper handles terminal close (→ `.channelClosed`) and task lifecycle. The body + // self-filters the feed: a `system` postgres error fails the stream (H6); a matching + // `postgres_changes` frame is decoded and yielded; a malformed frame throws `.decoding`. + return _makeThrowingStream(initialState: ()) { [weak self] _, message, continuation in + switch message.event { + case .system: + // postgres_changes subscription error (H6) → fail this stream. + guard let system = SystemEventPayload(message), + system.isPostgresChanges, system.status == "error" + else { return } + let reason = system.message ?? "Unknown postgres subscription error" + throw RealtimeError.postgresSubscriptionFailed(reason: reason) + + case .postgresChanges: + // Does this frame target our registration? Match the frame's `ids` against the + // server ids currently mapped to our registration (read live, since the mapping + // is rebuilt on every rejoin). + guard case .json(let jsonValue) = message.payload, + let obj = jsonValue.objectValue, + let idsArray = obj["ids"]?.arrayValue, + let dataObj = obj["data"]?.objectValue + else { return } + + let frameIDs = Set(idsArray.compactMap { $0.intValue }) + guard !frameIDs.isEmpty else { return } + + let myServerIDs = await self?.postgresServerIDs(for: registrationID) ?? [] + guard !myServerIDs.isDisjoint(with: frameIDs) else { return } + + // Decode E.Element from the data object. Safe `as!`: the variant kind is immutably + // tied to the generic parameter E at factory call time. A malformed frame throws. + let type_ = dataObj["type"]?.stringValue ?? "" + let element: E.Element + switch variantKind { + case .insert: + // E == Insert, E.Element == JSONValue + guard let record = dataObj["record"] else { + throw decodeError("Insert: missing record") + } + element = try postgresElement(record, for: E.self) + + case .update: + // E == Update, E.Element == PostgresUpdate + guard let record = dataObj["record"] else { + throw decodeError("Update: missing record") + } + let oldRecord: JSONValue? = dataObj["old_record"] + let update = PostgresUpdate(record: record, oldRecord: oldRecord) + element = try postgresElement(update, for: E.self) + + case .delete: + // E == Delete, E.Element == PostgresDelete + // old_record is NON-optional for DELETE; absence is a decode failure. + guard let oldRecord = dataObj["old_record"] else { + throw decodeError("Delete: missing old_record") + } + let del = PostgresDelete(oldRecord: oldRecord) + element = try postgresElement(del, for: E.self) + + case .anyEvent: + // E == AnyEvent, E.Element == PostgresChange + let change: PostgresChange + switch type_ { + case "INSERT": + let record: JSONValue = dataObj["record"] ?? .object([:]) + change = .insert(record) + case "UPDATE": + let record: JSONValue = dataObj["record"] ?? .object([:]) + let oldRecord: JSONValue? = dataObj["old_record"] + let update = PostgresUpdate(record: record, oldRecord: oldRecord) + change = .update(update) + case "DELETE": + let oldRecord: JSONValue = dataObj["old_record"] ?? .object([:]) + let del = PostgresDelete(oldRecord: oldRecord) + change = .delete(del) + default: + // Unrecognized event type is a decode failure for this stream. + throw decodeError("AnyEvent: unknown type '\(type_)'") + } + element = try postgresElement(change, for: E.self) + } + continuation.yield(element) + + default: + return + } + } + } +} + +/// Builds a `.decoding` error for a malformed `postgres_changes` frame. +private func decodeError(_ typeDescription: String) -> RealtimeError { + RealtimeError.decoding( + type: typeDescription, + underlying: PostgresDecodeError(description: "malformed postgres_changes data") + ) +} + +/// Casts a constructed variant payload to `E.Element`. The cast holds by construction — +/// `variantKind` is bound to `E` at registration — but throwing instead of force-casting honors +/// the never-crash policy should that invariant ever be violated. +private func postgresElement( + _ value: Any, for _: E.Type +) throws(RealtimeError) -> E.Element { + guard let element = value as? E.Element else { + throw decodeError("postgres_changes element type mismatch for \(E.Element.self)") + } + return element +} + +// MARK: - State guard (internal) + +extension Channel { + /// Throws `.cannotRegisterAfterJoin` if the channel is in a state where + /// postgres-changes registration is no longer allowed. + /// + /// Registration is permitted in `.unsubscribed` and `.closed` states — i.e. + /// any state where a `phx_join` has not yet been sent or is no longer active. + func _guardCanRegister() throws(RealtimeError) { + switch channelState { + case .unsubscribed, .closed: + return // Allowed. + case .joining, .joined, .leaving: + throw .cannotRegisterAfterJoin + } + } +} diff --git a/Sources/RealtimeV3/Channel+Presence.swift b/Sources/RealtimeV3/Channel+Presence.swift new file mode 100644 index 000000000..d1f2d72a1 --- /dev/null +++ b/Sources/RealtimeV3/Channel+Presence.swift @@ -0,0 +1,549 @@ +// +// Channel+Presence.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import Helpers +import IssueReporting + +// MARK: - PresenceKey + +/// The presence key string the server attaches to each meta. Comes from +/// `ChannelOptions.presence.key` if set, otherwise server-generated. +public typealias PresenceKey = String + +// MARK: - PresenceState + +/// A snapshot of all presences on a channel, plus the incremental diff that produced it. +/// +/// `active` maps each presence key to the list of decoded meta objects for that key. +/// `lastDiff` is `nil` on the initial `presence_state` snapshot and non-nil on every +/// subsequent `presence_diff` update. +public struct PresenceState: Sendable { + public let active: [PresenceKey: [T]] + public let lastDiff: PresenceDiff? +} + +// MARK: - PresenceDiff + +/// An incremental presence change: who joined and who left since the last snapshot. +/// +/// Each element is `(PresenceKey, T)` — the presence key and the decoded meta. +/// Multiple metas per key are flattened into the array in the order they appear in the +/// server payload. +public struct PresenceDiff: Sendable { + public let joined: [(PresenceKey, T)] + public let left: [(PresenceKey, T)] +} + +// MARK: - PresenceHandle + +/// Represents a single tracked presence slot returned by `Presence.track(_:)`. +/// +/// - `update(_:)` replaces the meta for this slot without creating an additional meta. +/// - `cancel()` untracks and awaits server ACK. +/// +/// The handle should be explicitly cancelled when done to cleanly untrack from the server. +/// If a handle is deinited without cancelling while the channel is still joined, a debug +/// warning is emitted via `IssueReporting.reportIssue`. +/// +/// ## Leak Warning (Decision 15) +/// A `LockIsolated` `cancelled` flag is used to track whether `cancel()` was called. +/// In `deinit` (which is nonisolated/synchronous), we cannot reliably hop to the `Channel` +/// actor to check its state. The simplest correct approach is: fire the warning whenever a +/// non-cancelled handle deinits. This may fire after `leave()` tears down the server slot +/// implicitly, but it is always safe (never a crash) and correctly catches genuine leaks. +/// +/// ## Sendable +/// `PresenceHandle` is `Sendable` because all mutable state is wrapped in `LockIsolated`. +/// The `channel` reference is to the actor-isolated `Channel`, which is itself `Sendable`. +public final class PresenceHandle: Sendable { + /// The owning channel. Strong ref is intentional (Channel does not hold handles → no cycle). + let channel: Channel + + /// Whether `cancel()` has been called. Protected by `LockIsolated` for nonisolated deinit. + let cancelled: LockIsolated + + init(channel: Channel) { + self.channel = channel + self.cancelled = LockIsolated(false) + } + + deinit { + let alreadyCancelled = cancelled.value + if !alreadyCancelled { + reportIssue( + "PresenceHandle deinited without cancel() being called. " + + "Call handle.cancel() when done tracking to cleanly untrack from the server. " + + "If the channel was left via channel.leave(), the server slot is implicitly " + + "torn down, but the handle should still be cancelled to suppress this warning." + ) + } + } + + /// Update the current presence meta for this slot. + /// + /// Sends a fresh presence track frame with the new state. This replaces the existing + /// meta on the server without creating an additional meta entry (Decision 16). + /// + /// - Throws: `RealtimeError.notSubscribed` if the channel is not yet subscribed. + /// - Throws: `RealtimeError.channelClosed` if the channel has been closed. + /// - Throws: `RealtimeError.broadcastAckTimeout` if the server does not acknowledge + /// within `configuration.broadcastAckTimeout`. + public func update(_ state: T) async throws(RealtimeError) { + try await channel.sendPresenceTrack(state) + } + + /// Untracks presence for this slot; awaits server ACK. + /// + /// Idempotent: a second call is a no-op and returns immediately without sending any frame. + /// After a successful cancel, the deinit leak-warning will not fire. + /// + /// - Throws: `RealtimeError.notSubscribed` if the channel is not yet subscribed. + /// - Throws: `RealtimeError.channelClosed` if the channel has been closed. + /// - Throws: `RealtimeError.broadcastAckTimeout` if the server does not acknowledge. + public func cancel() async throws(RealtimeError) { + // Idempotent: return immediately if already cancelled. + let alreadyCancelled = cancelled.withValue { val -> Bool in + if val { return true } + val = true + return false + } + guard !alreadyCancelled else { return } + + try await channel.sendPresenceUntrack() + } +} + +// MARK: - Presence + +/// Provides presence operations for the owning `Channel`. +/// +/// Obtain via `Channel.presence`. Methods `track`, `observe`, and `diffs` are +/// implemented in Tasks 24/25. Only the decoder utilities are live in this task. +public struct Presence: Sendable { + /// Strong reference to the owning channel. `Presence` is a lightweight value + /// wrapper handed out by `Channel.presence`; holding the channel strongly is + /// safe (no retain cycle — `Channel` never references `Presence`, and `Channel` + /// itself holds `Realtime` weakly) and avoids a use-after-free if a `Presence` + /// value outlives the `Channel`'s entry in the registry. + let channel: Channel + + /// Begin tracking, or update the existing tracked state, for this channel process. + /// + /// Sends a `presence` channel event with payload `{ "event": "track", "payload": }` + /// and awaits the server ACK. Returns a `PresenceHandle` that can be used to update or cancel + /// the presence tracking. + /// + /// One meta per channel process (Decision 16): repeated `track` calls update the same slot, + /// not create additional entries. + /// + /// - Parameter state: The presence meta to track. Must be `Codable & Sendable`. + /// - Returns: A `PresenceHandle` bound to this channel. + /// - Throws: `RealtimeError.notSubscribed` if the channel is not yet subscribed (`.unsubscribed` + /// or `.joining` state). + /// - Throws: `RealtimeError.channelClosed` if the channel is leaving or closed. + /// - Throws: `RealtimeError.broadcastAckTimeout` if the server does not ACK in time. + public func track( + _ state: T + ) async throws(RealtimeError) -> PresenceHandle { + // Delegate gating + wire send to the actor-isolated Channel seam. + try await channel.sendPresenceTrack(state) + // Return a handle bound to the owning channel. + return PresenceHandle(channel: channel) + } + + /// Snapshot + diff stream of all presences, keyed by presence key. + /// + /// Each call mints an independent `AsyncStream`. The consumer receives: + /// - A `PresenceState` with `lastDiff == nil` on each `presence_state` frame (full snapshot). + /// - A `PresenceState` with `lastDiff != nil` on each `presence_diff` frame (incremental update). + /// + /// The consumer's `active` map accumulates state per-consumer: `presence_state` replaces the + /// map; `presence_diff` applies joins/leaves on top. Decode failures are swallowed so the + /// stream stays open. + /// + /// The stream finishes cleanly (no throw) when the channel closes. + public func observe( + _ type: T.Type + ) async -> AsyncStream> { + await channel.registerPresenceObserver(T.self) + } + + /// Incremental diffs only. + /// + /// Each call mints an independent `AsyncStream`. The consumer receives a `PresenceDiff` only + /// on `presence_diff` frames. `presence_state` frames are ignored. + /// + /// Decode failures are swallowed so the stream stays open. + /// + /// The stream finishes cleanly (no throw) when the channel closes. + public func diffs( + _ type: T.Type + ) async -> AsyncStream> { + await channel.registerPresenceDiffs(T.self) + } +} + +// MARK: - Channel + presence accessor + +extension Channel { + /// The presence interface for this channel. + /// + /// `nonisolated` — creating a `Presence` shell is always safe; it only stores + /// a back-reference to `self` with no actor-isolated state access. + public nonisolated var presence: Presence { + Presence(channel: self) + } +} + +// MARK: - Channel + presence send seam (Task 24) + +extension Channel { + /// Encodes and sends a presence track frame, then awaits the server ACK. + /// + /// ## State gating + /// - `.unsubscribed` / `.joining` → throws `.notSubscribed` + /// - `.leaving` / `.closed` → throws `.channelClosed(reason)` + /// - `.joined` → encodes `state` as `{ "event": "track", "payload": }`, + /// sends as a `"presence"` channel event (text frame), and awaits the phx_reply. + /// + /// ## Tracked flag + /// `isPresenceTracked` is set to `true` so a later `untrack` is not a no-op. + /// + /// ## Ack timeout + /// Uses `broadcastAckTimeout` — presence track semantics are analogous to an acked push. + func sendPresenceTrack(_ state: T) async throws(RealtimeError) { + guard let realtime else { throw .channelClosed(.clientDisconnected) } + try _requireJoinedForSend() + + // Build the presence track outer payload (user state encoded via Configuration.encoder). + let outerPayload: JSONObject = [ + "event": .string("track"), + "payload": try _encodeToJSON(state), + ] + + // Send and await the server ACK. + _ = try await _push( + .presence, .text(outerPayload), + ack: .require( + timeout: realtime.configuration.broadcastAckTimeout, error: .broadcastAckTimeout) + ) + + log(.debug, .presence, "Presence tracked", metadata: ["topic": topic]) + isPresenceTracked = true + } + + /// Sends a presence untrack frame, awaits the server ACK, and clears tracked state. + /// + /// Idempotent: if `isPresenceTracked` is already `false`, returns immediately (no-op). + func sendPresenceUntrack() async throws(RealtimeError) { + // Idempotent guard. + guard isPresenceTracked else { return } + + guard let realtime else { throw .channelClosed(.clientDisconnected) } + try _requireJoinedForSend() + + // Send the presence untrack frame and await the server ACK. + _ = try await _push( + .presence, .text(["event": .string("untrack")]), + ack: .require( + timeout: realtime.configuration.broadcastAckTimeout, error: .broadcastAckTimeout) + ) + + // Clear tracked state. + log(.debug, .presence, "Presence untracked", metadata: ["topic": topic]) + isPresenceTracked = false + } +} + +// MARK: - Channel + presence stream registration + +extension Channel { + /// Registers a per-call presence observe stream. + /// + /// Each consumer maintains its own running `active` roster, threaded as the helper's + /// per-subscription `State` (task-local, no lock). On each `presence_state` frame the + /// roster is replaced; on each `presence_diff` frame it is updated incrementally using + /// phx_ref matching to identify leaving metas. Decode failures are swallowed to keep the + /// stream open; the stream finishes cleanly on channel close. + func registerPresenceObserver( + _ type: T.Type + ) -> AsyncStream> { + // Per-consumer accumulated active map. Value: list of (phx_ref, decoded T) pairs to + // allow phx_ref-based leave matching. + let initial: [PresenceKey: [(phxRef: String?, value: T)]] = [:] + return _makeStream(initialState: initial) { active, message, continuation in + guard case .json(let jsonValue) = message.payload else { return } + + if message.event == .presenceState { + // Full snapshot: decode with raw refs and replace the accumulated map. + guard let rawMap = try? decodePresenceStateWithRefs(jsonValue, as: T.self) else { return } + active = rawMap + let snapshot = active.mapValues { $0.map(\.value) } + continuation.yield(PresenceState(active: snapshot, lastDiff: nil)) + + } else if message.event == .presenceDiff { + // Incremental diff: decode once (ref-aware) and derive the public `PresenceDiff` + // by stripping the phx_refs, avoiding a redundant second decode pass. + guard let rawDiff = try? decodePresenceDiffWithRefs(jsonValue, as: T.self) else { return } + let diff = PresenceDiff( + joined: rawDiff.joined.flatMap { key, pairs in pairs.map { (key, $0.value) } }, + left: rawDiff.left.flatMap { key, pairs in pairs.map { (key, $0.value) } } + ) + + // Apply leaves: remove metas matching by phx_ref. + for (key, leftPairs) in rawDiff.left { + guard var existing = active[key] else { continue } + for (leftRef, _) in leftPairs { + if let ref = leftRef, let idx = existing.firstIndex(where: { $0.phxRef == ref }) { + existing.remove(at: idx) + } else if leftRef == nil, !existing.isEmpty { + // No phx_ref: fall back to removing the first entry. + existing.removeFirst() + } + } + if existing.isEmpty { + active.removeValue(forKey: key) + } else { + active[key] = existing + } + } + + // Apply joins: add new metas. + for (key, joinPairs) in rawDiff.joined { + active[key, default: []].append(contentsOf: joinPairs) + } + + let snapshot = active.mapValues { $0.map(\.value) } + continuation.yield(PresenceState(active: snapshot, lastDiff: diff)) + } + } + } + + /// Registers a per-call presence diffs-only stream. + /// + /// Emits only on `presence_diff` frames. `presence_state` frames are ignored. + /// Decode failures are swallowed to keep the stream open. + func registerPresenceDiffs( + _ type: T.Type + ) -> AsyncStream> { + _makeStream(initialState: ()) { _, message, continuation in + guard message.event == .presenceDiff, + case .json(let jsonValue) = message.payload, + let diff = try? decodePresenceDiff(jsonValue, as: T.self) + else { return } + continuation.yield(diff) + } + } +} + +// MARK: - Internal decoders + +/// Decodes a `presence_state` wire payload into a keyed dictionary. +/// +/// ## Wire shape +/// ```json +/// { +/// "": { +/// "metas": [ { "phx_ref": "...", } ] +/// } +/// } +/// ``` +/// Each meta object is decoded whole as `T`; extra fields (e.g. `phx_ref`) are ignored +/// by the decoder if `T` does not declare them. +/// +/// An empty object `{}` decodes to an empty dictionary. +/// +/// - Parameters: +/// - json: The raw `JSONValue` from the `presence_state` Phoenix event payload. +/// - type: The concrete `Decodable` type to decode each meta into. +/// - Returns: A dictionary mapping each presence key to the list of decoded metas. +/// - Throws: `RealtimeError.decoding` if the overall structure is wrong or any meta +/// fails to decode as `T`. +func decodePresenceState( + _ json: JSONValue, + as type: T.Type +) throws -> [PresenceKey: [T]] { + guard let topObject = json.objectValue else { + throw RealtimeError.decoding( + type: String(describing: T.self), + underlying: PresenceDecodeError.invalidShape( + "presence_state root must be a JSON object" + ) + ) + } + + var result: [PresenceKey: [T]] = [:] + + for (key, keyValue) in topObject { + guard let keyObject = keyValue.objectValue, + let metasArray = keyObject["metas"]?.arrayValue + else { + throw RealtimeError.decoding( + type: String(describing: T.self), + underlying: PresenceDecodeError.invalidShape( + "presence_state entry '\(key)' must have a 'metas' array" + ) + ) + } + + var decoded: [T] = [] + for meta in metasArray { + do { + let data = try JSONEncoder().encode(meta) + let value = try JSONDecoder().decode(T.self, from: data) + decoded.append(value) + } catch { + throw RealtimeError.decoding( + type: String(describing: T.self), + underlying: error + ) + } + } + + result[key] = decoded + } + + return result +} + +/// Decodes a `presence_diff` wire payload into a `PresenceDiff`. +/// +/// ## Wire shape +/// ```json +/// { +/// "joins": { "": { "metas": [ ... ] } }, +/// "leaves": { "": { "metas": [ ... ] } } +/// } +/// ``` +/// Each key's metas are flattened into the `joined`/`left` arrays as `(key, T)` pairs +/// in the order they appear. Missing `joins` or `leaves` keys are treated as empty. +/// +/// - Parameters: +/// - json: The raw `JSONValue` from the `presence_diff` Phoenix event payload. +/// - type: The concrete `Decodable` type to decode each meta into. +/// - Returns: A `PresenceDiff` with flattened joined and left arrays. +/// - Throws: `RealtimeError.decoding` on structural or decode errors. +func decodePresenceDiff( + _ json: JSONValue, + as type: T.Type +) throws -> PresenceDiff { + guard let topObject = json.objectValue else { + throw RealtimeError.decoding( + type: String(describing: T.self), + underlying: PresenceDecodeError.invalidShape( + "presence_diff root must be a JSON object" + ) + ) + } + + let joinsValue = topObject["joins"] ?? .object([:]) + let leavesValue = topObject["leaves"] ?? .object([:]) + + let joinsMap = try decodePresenceState(joinsValue, as: T.self) + let leavesMap = try decodePresenceState(leavesValue, as: T.self) + + let joined: [(PresenceKey, T)] = joinsMap.flatMap { key, values in + values.map { (key, $0) } + } + let left: [(PresenceKey, T)] = leavesMap.flatMap { key, values in + values.map { (key, $0) } + } + + return PresenceDiff(joined: joined, left: left) +} + +// MARK: - Internal ref-aware decoders + +/// Decodes a `presence_state` payload retaining the `phx_ref` of each meta. +/// +/// Returns a map from presence key → list of `(phxRef: String?, value: T)` pairs. +/// Used by `registerPresenceObserver` for leave-matching. +func decodePresenceStateWithRefs( + _ json: JSONValue, + as type: T.Type +) throws -> [PresenceKey: [(phxRef: String?, value: T)]] { + guard let topObject = json.objectValue else { + throw RealtimeError.decoding( + type: String(describing: T.self), + underlying: PresenceDecodeError.invalidShape( + "presence_state root must be a JSON object" + ) + ) + } + + var result: [PresenceKey: [(phxRef: String?, value: T)]] = [:] + + for (key, keyValue) in topObject { + guard let keyObject = keyValue.objectValue, + let metasArray = keyObject["metas"]?.arrayValue + else { + throw RealtimeError.decoding( + type: String(describing: T.self), + underlying: PresenceDecodeError.invalidShape( + "presence_state entry '\(key)' must have a 'metas' array" + ) + ) + } + + var pairs: [(phxRef: String?, value: T)] = [] + for meta in metasArray { + // Extract phx_ref (if present) before decoding T. + let phxRef = meta.objectValue?["phx_ref"]?.stringValue + let data = try JSONEncoder().encode(meta) + let value = try JSONDecoder().decode(T.self, from: data) + pairs.append((phxRef: phxRef, value: value)) + } + + result[key] = pairs + } + + return result +} + +/// Decoded presence diff with raw phx_ref information retained. +struct PresenceDiffWithRefs: Sendable { + let joined: [PresenceKey: [(phxRef: String?, value: T)]] + let left: [PresenceKey: [(phxRef: String?, value: T)]] +} + +/// Decodes a `presence_diff` payload retaining phx_ref for each meta. +func decodePresenceDiffWithRefs( + _ json: JSONValue, + as type: T.Type +) throws -> PresenceDiffWithRefs { + guard let topObject = json.objectValue else { + throw RealtimeError.decoding( + type: String(describing: T.self), + underlying: PresenceDecodeError.invalidShape( + "presence_diff root must be a JSON object" + ) + ) + } + + let joinsValue = topObject["joins"] ?? .object([:]) + let leavesValue = topObject["leaves"] ?? .object([:]) + + let joinsMap = try decodePresenceStateWithRefs(joinsValue, as: T.self) + let leavesMap = try decodePresenceStateWithRefs(leavesValue, as: T.self) + + return PresenceDiffWithRefs(joined: joinsMap, left: leavesMap) +} + +// MARK: - PresenceDecodeError + +/// Internal sentinel errors for presence payload shape violations. +private enum PresenceDecodeError: Error, Sendable { + case invalidShape(String) + + var localizedDescription: String { + switch self { + case .invalidShape(let msg): "Invalid presence payload shape: \(msg)" + } + } +} diff --git a/Sources/RealtimeV3/Channel+Routing.swift b/Sources/RealtimeV3/Channel+Routing.swift new file mode 100644 index 000000000..f228da3fe --- /dev/null +++ b/Sources/RealtimeV3/Channel+Routing.swift @@ -0,0 +1,211 @@ +// +// Channel+Routing.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import Helpers + +// MARK: - Frame router entry point + +extension Channel { + /// Called by the frame router when a message arrives for this channel's topic. + /// + /// Yields the frame to every event-feed subscriber; each per-call stream + /// (`messages()`, `broadcasts`, presence, postgres) filters and decodes from there. + /// + /// Also handles server-initiated terminal events (`phx_close`, `phx_error`, + /// and non-postgres `system` error frames) by transitioning to the appropriate + /// `.closed` state. These routes guard on the current channel state so that a + /// trailing `phx_close` from the server after our own `leave()` does NOT + /// overwrite the already-set `.closed(.userRequested)` reason (idempotent). + func receive(_ message: PhoenixMessage) { + for continuation in eventContinuations.values { + continuation.yield(.message(message)) + } + // Channel-level reactions. `postgres_changes` frames and `system` + // postgres-subscription errors are handled by the postgres transforms + // themselves (they self-filter the feed), so they are not routed here. + switch message.event { + case .system: + _routeSystemEvent(message) + case .close: + _handleServerClose(message) + case .error: + _handleServerError(message) + default: + break + } + } + + // MARK: - Server-initiated terminal event handlers + + /// Handles an unsolicited `phx_close` frame from the server. + /// + /// If the channel is already `.closed` (e.g. from our own `leave()`) or `.leaving` + /// (our own leave is in progress), the frame is ignored so we never overwrite a + /// user-requested close reason. Only unsolicited closes trigger a state transition. + private func _handleServerClose(_ message: PhoenixMessage) { + // Idempotency guard: ignore if already terminal or our own leave is in progress. + switch channelState { + case .closed, .leaving: + return + default: + break + } + + // Extract an optional message from the payload. + let closeMessage: String? + if case .json(let jsonValue) = message.payload, + let obj = jsonValue.objectValue + { + closeMessage = obj["message"]?.stringValue + } else { + closeMessage = nil + } + + log( + .warn, .channel, + "Server closed channel: \(closeMessage ?? "(no message)")", + metadata: ["topic": topic] + ) + + // Clear shouldRejoin — a server-closed channel must not be auto-rejoined. + shouldRejoin = false + transition(to: .closed(.serverClosed(code: nil, message: closeMessage))) + } + + /// Handles a `phx_error` frame from the server. + /// + /// Same idempotency guard as `_handleServerClose`: ignored when already terminal. + private func _handleServerError(_ message: PhoenixMessage) { + switch channelState { + case .closed, .leaving: + return + default: + break + } + + let reason: String? + if case .json(let jsonValue) = message.payload, + let obj = jsonValue.objectValue + { + reason = obj["reason"]?.stringValue ?? obj["message"]?.stringValue + } else { + reason = nil + } + + log( + .error, .channel, + "Server sent phx_error: \(reason ?? "(no reason)")", + metadata: ["topic": topic] + ) + + shouldRejoin = false + transition(to: .closed(.serverClosed(code: nil, message: reason))) + } + + // MARK: - Postgres routing (Task 28) + + /// Returns the set of server-assigned subscription ids currently mapped to the given + /// client registration UUID. + /// + /// Built from `serverIDRouting`, which is (re)built on every successful join/rejoin. + /// A `postgresChanges(for:)` transform reads this live (per frame) to decide whether an + /// incoming `postgres_changes` frame's `ids` array targets its registration. + func postgresServerIDs(for registrationID: UUID) -> Set { + var ids: Set = [] + for (serverID, registrationUUIDs) in serverIDRouting + where registrationUUIDs.contains(registrationID) { + ids.insert(serverID) + } + return ids + } + + /// Routes an incoming `system` event. + /// + /// - If `extension == "postgres_changes"` and `status == "error"`: ignored here — the + /// postgres transforms self-filter this frame off the event feed and finish their own + /// streams with `.postgresSubscriptionFailed(reason:)`. The channel stays open. + /// - Otherwise, if `status == "error"` and the message indicates an auth/token failure: + /// transitions the channel to `.closed(.unauthorized)` (server-initiated auth failure). + /// - Otherwise, if `status == "error"` for any other reason: + /// transitions to `.closed(.serverClosed(code:message:))`. + /// + /// The channel-close path guards on the current state so it is idempotent when the + /// channel is already `.closed` or `.leaving`. + private func _routeSystemEvent(_ message: PhoenixMessage) { + guard let system = SystemEventPayload(message) else { return } + let msgText = system.message + + // postgres_changes subscription error → handled by the postgres transforms; channel stays open. + if system.isPostgresChanges, system.status == "error" { + let reason = msgText ?? "Unknown postgres subscription error" + log(.error, .postgres, "Postgres subscription error: \(reason)", metadata: ["topic": topic]) + return + } + + // Non-postgres system error → close the whole channel. + guard system.status == "error" else { return } + + // Idempotency guard: if already terminal/leaving, do nothing. + switch channelState { + case .closed, .leaving: + return + default: + break + } + + let reason = msgText ?? "Unknown system error" + log(.error, .channel, "System error: \(reason)", metadata: ["topic": topic]) + + // Detect auth/token failures by looking for common keywords in the message. + let lowerReason = reason.lowercased() + let isAuthError = + lowerReason.contains("token") + || lowerReason.contains("auth") + || lowerReason.contains("unauthorized") + || lowerReason.contains("unauthenticated") + || lowerReason.contains("forbidden") + || lowerReason.contains("jwt") + + shouldRejoin = false + if isAuthError { + transition(to: .closed(.unauthorized)) + } else { + transition(to: .closed(.serverClosed(code: nil, message: msgText))) + } + } + + /// Builds the server-id routing map from the join reply's `postgres_changes` response array. + /// + /// The server returns an array of objects in the same order as the client's `postgres_changes` + /// entries. Each object has an `id` integer key. Multiple entries may share the same integer id + /// (identical subscriptions collapse). We map `serverID -> [registrationUUID]`. + func _buildServerIDRouting(from response: JSONValue) { + var routing: [Int: [UUID]] = [:] + guard let responseObj = response.objectValue, + let changesArray = responseObj["postgres_changes"]?.arrayValue + else { + serverIDRouting = [:] + return + } + + // The changesArray indices correspond to pendingRegistrations indices. + for (index, entry) in changesArray.enumerated() { + guard let serverID = entry.objectValue?["id"]?.intValue, + index < pendingRegistrations.count + else { continue } + let regUUID = pendingRegistrations[index].id + if routing[serverID] == nil { + routing[serverID] = [regUUID] + } else { + routing[serverID]?.append(regUUID) + } + } + + serverIDRouting = routing + } +} diff --git a/Sources/RealtimeV3/Channel.swift b/Sources/RealtimeV3/Channel.swift new file mode 100644 index 000000000..2e2f333c8 --- /dev/null +++ b/Sources/RealtimeV3/Channel.swift @@ -0,0 +1,765 @@ +// +// Channel.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import Helpers + +/// An item delivered by a channel's internal event feed (`_subscribeEvents()`). +/// +/// Every per-call stream (`messages()`, `broadcasts`, presence, postgres) is a +/// transform over this feed. `.message` carries a routed frame; `.terminated` +/// carries the close reason once, immediately before the feed finishes, so a +/// throwing stream can finish with `.channelClosed(reason)` without racing a read +/// of `channelState`. +enum ChannelEvent: Sendable { + case message(PhoenixMessage) + case terminated(CloseReason) +} + +/// A Realtime channel that represents a named topic on the server. +/// +/// Obtain a `Channel` by calling `Realtime.channel(_:configure:)`. The channel's +/// `topic` and `options` are immutable after creation. +public actor Channel { + /// The Phoenix topic this channel is subscribed to (e.g. `"realtime:public:messages"`). + public nonisolated let topic: String + + /// The options applied at channel creation. Immutable after creation (Decision 33). + public nonisolated let options: ChannelOptions + + /// Weak back-reference to the owning client, abstracted behind ``ChannelHost`` so `Channel` + /// is decoupled from the concrete `Realtime` actor (and exercisable against a test double). + /// + /// **Cycle-break (Task 16):** the host's registry holds the `Channel` strongly. If `Channel` + /// also held the host strongly, neither could ever deinit — which would defeat Task 32's + /// leaked-channel `deinit` warning. Storing it weakly breaks the cycle; channel methods + /// guard-unwrap it and throw `.channelClosed(.clientDisconnected)` if the host is gone. + weak var realtime: (any ChannelHost)? + + // MARK: - Channel state + + /// The current channel state. Drives `state` stream emissions. + /// Internal (not private) so Channel+Broadcast.swift (same module, separate file) can read it. + var channelState: ChannelState = .unsubscribed + + /// Broadcast list of `state` stream continuations. + /// Mirrors the pattern used by `Realtime.statusContinuations`. + private var stateContinuations: [UUID: AsyncStream.Continuation] = [:] + + /// The single fan-out table backing every per-call stream on this channel. + /// + /// `messages()`, `broadcasts(of:event:)`, `presence.observe`/`diffs`, and + /// `postgresChanges(for:)` are all transforms over a fresh subscription to this + /// feed (see `_subscribeEvents()`): each filters and decodes the frames it cares + /// about. `receive(_:)` yields `.message(_)` to every subscriber; a terminal close + /// yields `.terminated(reason)` and then finishes them. + /// + /// Carrying the close reason in-band lets throwing streams finish with + /// `.channelClosed(reason)` race-free, without reading `channelState` after the loop. + /// Internal (not private) so `receive(_:)` in `Channel+Routing.swift` can fan out to it. + var eventContinuations: [UUID: AsyncStream.Continuation] = [:] + + // MARK: - Postgres change registrations (Task 27) + + /// Ordered list of pending postgres-changes registrations. + /// + /// Populated by the `inserts`/`updates`/`deletes`/`changes` factories in + /// `Channel+Postgres.swift` before `subscribe()` is called. Each entry is + /// baked into `config.postgres_changes` during `_performJoin`. + /// + /// **Reusability (Decision 14c):** registrations are NOT cleared on `leave()`; + /// they persist and replay on the next `subscribe()`. New registrations may be + /// added between `leave()` and resubscribe as long as the state is `.closed`. + var pendingRegistrations: [ChangeRegistrationConfig] = [] + + /// Routing map from server-assigned postgres subscription ID (integer, from the phx_reply + /// `postgres_changes` array) to the set of client registration UUIDs that share that id. + /// + /// Built (or rebuilt) in `_performJoin` after each successful ok reply. The server assigns + /// one integer id per entry in the join's `postgres_changes` array, in the same order. + /// Multiple client registrations may share the same server id (identical subscriptions). + /// An incoming `postgres_changes` frame with `ids:[0,2]` fans out to all UUIDs in those slots. + var serverIDRouting: [Int: [UUID]] = [:] + + /// The joinRef assigned during the most recent successful (or in-progress) `subscribe()`. + /// Stored so subsequent frames for this channel (which carry the joinRef) can be validated. + private(set) var joinRef: String? + + /// In-flight join task — coalesces concurrent `subscribe()` callers (Decision 14h). + private var joinTask: Task? + + // MARK: - Rejoin eligibility (Task 29) + + /// Tracks whether this channel should be automatically re-joined after a transport reconnect. + /// + /// Set to `true` when `subscribe()` completes successfully (channel transitions to `.joined`). + /// Cleared to `false` when the user explicitly calls `leave()`. + /// + /// Channels that were transport-dropped (not user-left) and had `.joined` state are eligible + /// for transparent re-join (Decision 6 / Decision 18). + var shouldRejoin: Bool = false + + // MARK: - Presence tracking state (Task 24) + + /// Whether presence is currently being tracked on this channel. Set to `true` by + /// `sendPresenceTrack` and `false` by `sendPresenceUntrack`; used to make untrack + /// idempotent. + var isPresenceTracked: Bool = false + + // MARK: - Init + + init(topic: String, options: ChannelOptions, realtime: any ChannelHost) { + self.topic = topic + self.options = options + self.realtime = realtime + } + + // MARK: - State stream + + /// Returns a fresh `AsyncStream` seeded with the current state. + /// + /// Each call mints an independent stream. The seeded value is delivered + /// synchronously before the caller's first `await it.next()`. The stream + /// completes only when its task is cancelled. + public var state: AsyncStream { + let id = UUID() + let current = channelState + let (stream, continuation) = AsyncStream.makeStream() + continuation.yield(current) + stateContinuations[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { [weak self] in await self?.removeStateContinuation(id: id) } + } + return stream + } + + // MARK: - Messages feed + + /// Returns a fresh `AsyncStream` that receives every frame routed + /// to this channel by the frame router. + /// + /// ## Per-call fan-out + /// Each call mints an independent stream. All registered streams receive a copy of + /// every frame delivered via `receive(_:)`. Streams created before `subscribe()` are + /// valid — they start producing once frames arrive after the join. + /// + /// ## Termination + /// The stream finishes automatically when `leave()` (or any terminal close) is called. + /// Consumers' `for await` loops will end cleanly without an error. + public func messages() -> AsyncStream { + _makeStream(initialState: ()) { _, message, continuation in + continuation.yield(message) + } + } + + // MARK: - Event feed (internal) + + /// Registers a fresh subscriber to this channel's event feed and returns its stream. + /// + /// Every per-call stream is built on top of this. The returned stream yields + /// `.message(_)` for each routed frame and a final `.terminated(reason)` when the + /// channel closes. If the channel is already `.closed`, the stream is seeded with the + /// terminal event immediately so late subscribers don't hang. + /// + /// The subscription removes itself from the fan-out table when the returned stream is + /// finished or its consumer is cancelled (via `onTermination`). + func _subscribeEvents() -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream() + + // Late subscriber after a terminal close: deliver the reason and finish immediately. + if case .closed(let reason) = channelState { + continuation.yield(.terminated(reason)) + continuation.finish() + return stream + } + + let id = UUID() + eventContinuations[id] = continuation + continuation.onTermination = { [weak self] _ in + Task { [weak self] in await self?.removeEventContinuation(id: id) } + } + return stream + } + + // MARK: - Stream-transform helpers (internal) + // + // Every per-call stream is a transform over a fresh `_subscribeEvents()` subscription. + // These two helpers centralize the boilerplate that would otherwise repeat in each + // factory: subscribing on-actor, draining the feed in a task, finishing the output on + // terminal close, and cancelling the task when the consumer goes away. + // + // The `body` only ever sees `.message` frames — the `.terminated` event is handled + // here (clean finish for non-throwing streams, `.channelClosed(reason)` for throwing + // streams). `State` is a per-subscription value threaded `inout` through every call so + // stateful transforms (presence's running roster) need no external storage or lock. + + /// Builds a non-throwing `AsyncStream` transform over the channel event feed. + /// + /// `body` is invoked for each `.message` frame with the running `state` and the output + /// continuation; it may yield zero or more values. The stream finishes cleanly when the + /// channel closes. Pass `initialState: ()` for stateless transforms. + func _makeStream( + initialState: State, + _ body: @escaping @Sendable (inout State, PhoenixMessage, AsyncStream.Continuation) -> Void + ) -> AsyncStream { + let base = _subscribeEvents() + return AsyncStream { continuation in + let task = Task { + var state = initialState + for await event in base { + switch event { + case .terminated: + continuation.finish() + return + case .message(let message): + body(&state, message, continuation) + } + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + } + } + + /// Builds an `AsyncThrowingStream` transform over the channel event feed. + /// + /// `body` is invoked (with `await`) for each `.message` frame; it may yield values or + /// `throw` to terminate the stream (e.g. a decode failure or a postgres subscription + /// error). A terminal channel close finishes the stream throwing + /// `RealtimeError.channelClosed(reason)`. Pass `initialState: ()` for stateless transforms. + func _makeThrowingStream( + initialState: State, + _ body: + @escaping @Sendable ( + inout State, PhoenixMessage, AsyncThrowingStream.Continuation + ) async throws -> Void + ) -> AsyncThrowingStream { + let base = _subscribeEvents() + return AsyncThrowingStream { continuation in + let task = Task { + var state = initialState + do { + for await event in base { + switch event { + case .terminated(let reason): + throw RealtimeError.channelClosed(reason) + case .message(let message): + try await body(&state, message, continuation) + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Private helpers + + private func removeStateContinuation(id: UUID) { + stateContinuations.removeValue(forKey: id) + } + + private func removeEventContinuation(id: UUID) { + eventContinuations.removeValue(forKey: id) + } + + /// Transitions the channel to `newState` and broadcasts to all state observers. + /// When transitioning to a terminal `.closed` state, all `messages()` streams are + /// finished so consumers' `for await` loops end cleanly. + /// + /// ## Joined-topic registry (Task 32) + /// - On `.joined`: adds `topic` to `Realtime.joinedTopics` via the nonisolated `_markJoined`. + /// - On `.closed`: removes `topic` from `Realtime.joinedTopics` via `_markLeft`. + /// This fires for every close reason (userRequested, unauthorized, transportFailure, etc.) + /// so the deinit warning only fires for channels that are still in `.joined` state. + func transition(to newState: ChannelState) { + channelState = newState + for continuation in stateContinuations.values { + continuation.yield(newState) + } + + // Update the nonisolated joinedTopics registry on the owning Realtime actor. + // Both _markJoined and _markLeft are nonisolated on Realtime, so no await is needed. + switch newState { + case .joined: + realtime?._markJoined(topic) + case .closed: + realtime?._markLeft(topic) + default: + break + } + + if case .closed(let reason) = newState { + // Deliver the close reason in-band to every subscriber, then finish the feed. + // Each transform decides what this means for its stream: messages()/presence + // finish cleanly, broadcasts()/postgres finish throwing `.channelClosed(reason)`. + for continuation in eventContinuations.values { + continuation.yield(.terminated(reason)) + continuation.finish() + } + eventContinuations.removeAll() + } + } + + // MARK: - Outgoing push (internal) + + /// Encodes and sends a single Phoenix frame for this channel, optionally awaiting its + /// `phx_reply`. + /// + /// Thin wrapper over ``Realtime/_push(topic:_:_:ref:joinRef:lazyConnect:ack:)`` that + /// supplies this channel's `topic` and defaults `joinRef` to the channel's current + /// `joinRef`. All send paths (broadcast, presence, join, leave, access_token) funnel + /// through here. + /// + /// - Parameters: + /// - event: The Phoenix event for the frame. + /// - body: The wire payload (text JSON, or a binary broadcast frame). + /// - ref: The push ref. Defaults to a fresh ref; `join` passes its own so `ref == joinRef`. + /// - joinRef: The join ref stamped on the frame. Defaults to the channel's current `joinRef`. + /// - ack: Whether to await a reply. + /// - Returns: The `phx_reply` when `ack == .require`, otherwise `nil`. + @discardableResult + func _push( + _ event: PhoenixEvent, + _ body: PushBody, + ref: String? = nil, + joinRef: String? = nil, + ack: AckPolicy = .none + ) async throws(RealtimeError) -> PushReply? { + guard let realtime else { throw .channelClosed(.clientDisconnected) } + return try await realtime._push( + topic: topic, event, body, ref: ref, joinRef: joinRef ?? self.joinRef, + lazyConnect: true, ack: ack) + } + + /// Gates a send on the channel being `.joined`, mapping other states to the + /// appropriate error: `.notSubscribed` while pre-join, `.channelClosed(reason)` once + /// leaving or closed. + func _requireJoinedForSend() throws(RealtimeError) { + switch channelState { + case .joined: + return + case .unsubscribed, .joining: + throw .notSubscribed + case .leaving: + throw .channelClosed(.userRequested) + case .closed(let reason): + throw .channelClosed(reason) + } + } + + /// Encodes an `Encodable` value to `AnyJSON` using the configured encoder, mapping any + /// failure to `.encoding`. Used to embed user payloads in broadcast/presence frames. + /// Thin wrapper over ``Realtime/_encodeToJSON(_:)``. + func _encodeToJSON(_ value: T) throws(RealtimeError) -> AnyJSON { + guard let realtime else { throw .channelClosed(.clientDisconnected) } + return try realtime._encodeToJSON(value) + } + + // MARK: - Subscribe + + /// Subscribes the channel to its topic on the server by performing the `phx_join` handshake. + /// + /// ## Idempotency + coalescing (Decision 14h) + /// - Already `.joined`: returns immediately. + /// - Join in flight: awaits the same in-flight task (concurrent callers coalesce). + /// - `.unsubscribed` / `.closed`: sends a fresh `phx_join`. + /// + /// ## State machine + /// `.unsubscribed`/`.closed` → `.joining` → `.joined` (on ok reply) + /// → throws `.channelJoinRejected` (on non-ok reply) + /// → throws `.channelJoinTimeout` (on timeout) + /// + /// - Throws: `RealtimeError.channelClosed` if the owning `Realtime` has been deallocated. + /// - Throws: `RealtimeError.channelJoinTimeout` if no reply arrives within `joinTimeout`. + /// - Throws: `RealtimeError.channelJoinRejected` if the server responds with a non-ok status. + public func subscribe() async throws(RealtimeError) { + // Guard: ensure the owning Realtime is still alive. + guard let realtime else { throw .channelClosed(.clientDisconnected) } + + // Idempotent: already joined — no-op. + if channelState == .joined { return } + + // Coalesce: if a join is already in flight, await that task. + if let existing = joinTask { + do { + try await existing.value + } catch let error as RealtimeError { + throw error + } catch { + throw .channelJoinTimeout + } + return + } + + // Kick off the join as an unstructured task so concurrent callers can coalesce. + let task = Task { + try await self._performJoin(realtime: realtime) + } + joinTask = task + + do { + try await task.value + joinTask = nil + } catch let error as RealtimeError { + joinTask = nil + throw error + } catch { + joinTask = nil + throw .channelJoinTimeout + } + } + + /// Performs the actual `phx_join` wire handshake. Called exclusively from `subscribe()`. + /// + /// Failures throw (and leave the channel in `.joining` for the transport/timeout cases, so the + /// caller may retry); a server rejection transitions to `.closed(.unauthorized)` and throws + /// `.channelJoinRejected`. + private func _performJoin(realtime: any ChannelHost) async throws(RealtimeError) { + // Guard: only start a fresh join from a quiescent state. + // (.joining / .leaving with no in-flight task should not happen given the + // coalescing in subscribe(), but guard defensively to avoid duplicate + // .joining emissions and state-machine confusion.) + switch channelState { + case .unsubscribed: break + case .closed: break + default: return + } + + // Generate a joinRef (Phoenix uses ref == joinRef for the join push). + // `nextRef()` is nonisolated — no actor hop needed. + let ref = realtime.nextRef() + joinRef = ref + + // Transition to .joining BEFORE awaiting the access token so the channel + // reflects the correct state during the entire join handshake. + log(.info, .channel, "Joining channel", metadata: ["topic": topic]) + transition(to: .joining) + + switch try await _sendJoin(ref: ref, realtime: realtime) { + case .joined: + log(.info, .channel, "Channel joined", metadata: ["topic": topic]) + transition(to: .joined) + case .rejected(let reason): + log(.warn, .channel, "Channel join rejected: \(reason)", metadata: ["topic": topic]) + transition(to: .closed(.unauthorized)) + throw RealtimeError.channelJoinRejected(reason: reason) + } + } + + // MARK: - Rejoin (Task 29) + + /// Re-sends `phx_join` after a transparent transport reconnect. + /// + /// Called exclusively from the `Realtime` reconnection loop after a successful reconnect. + /// Unlike `subscribe()`, this method: + /// - Does NOT check `shouldRejoin` — the caller is responsible for the eligibility check. + /// - Does NOT guard on `.joined` idempotency (the channel may still appear `.joined` + /// from the previous connection's state, so we always re-send). + /// - Does NOT terminate any open streams (Decision 6 — streams survive the transport gap). + /// + /// On success, the channel remains / transitions to `.joined`. + /// On failure (timeout or rejection), the channel transitions to `.closed(...)`. + func rejoin() async { + guard let realtime else { + transition(to: .closed(.clientDisconnected)) + return + } + + // Reset the joinTask so a concurrent subscribe() doesn't coalesce onto a stale task. + joinTask = nil + + // Generate a fresh joinRef for this connection's join. + let ref = realtime.nextRef() + joinRef = ref + + // Transition to .joining. We call transition(to:) which emits to state observers. + // .joining is not a terminal state so no finishers are invoked — streams stay open. + transition(to: .joining) + + do { + switch try await _sendJoin(ref: ref, realtime: realtime) { + case .joined: + // shouldRejoin stays true — still eligible for future reconnects. + transition(to: .joined) + case .rejected: + // Server rejected the rejoin (e.g. revoked auth). This is terminal: clear + // `shouldRejoin` so the channel is NOT re-attempted on every subsequent reconnect + // (which would loop indefinitely). The caller must explicitly `subscribe()` again. + shouldRejoin = false + transition(to: .closed(.unauthorized)) + } + } catch { + // Wire failure. An auth failure is treated as `.unauthorized`; transport/timeout/encode + // are recoverable drops — `shouldRejoin` stays true so the next reconnect retries. + if case .authenticationFailed = error { + transition(to: .closed(.unauthorized)) + } else { + transition(to: .closed(.transportFailure)) + } + } + } + + // MARK: - Join wire handshake (shared) + + /// The outcome of a `phx_join` reply: the server accepted the join, or rejected it. + private enum JoinOutcome: Sendable { + case joined + case rejected(reason: String) + } + + /// Shared `phx_join` wire handshake used by both `subscribe()` (`_performJoin`) and `rejoin()`. + /// + /// Assumes the channel is already `.joining` with `joinRef == ref`. Builds the join payload + /// (baking in any pending postgres registrations), sends it, and awaits the reply. On an `ok` + /// reply it builds the server-id routing map, marks the channel rejoin-eligible, and — when the + /// join carried registrations — waits for the postgres `system` confirmation before returning + /// `.joined`. A non-ok reply returns `.rejected`. + /// + /// Throws on any wire failure (auth, encode, transport, timeout, postgres subscription error). + /// Performs no terminal state transition itself — each caller maps the outcome (and any thrown + /// error) to its own state-machine policy. + private func _sendJoin(ref: String, realtime: any ChannelHost) async throws(RealtimeError) + -> JoinOutcome + { + // If this join carries postgres_changes registrations, the server activates replication + // asynchronously and confirms via a `system` event — the phx_reply alone is premature + // (early changes would be missed). Subscribe to the event feed *before* sending the join so + // that confirmation cannot be missed. + let postgresEvents: AsyncStream? = + pendingRegistrations.isEmpty ? nil : _subscribeEvents() + + // Build the join payload, baking in any pending postgres-changes registrations. + let accessToken = try await realtime.accessTokenForJoin() + let joinPayload = JoinPayload.make( + from: options, accessToken: accessToken, registrations: pendingRegistrations + ) + + let payloadObject: JSONObject + do { + payloadObject = try joinPayload.toJSONObject() + } catch { + throw .encoding(underlying: error) + } + + // Encode + send the phx_join (ref == joinRef) and await the reply. + let reply = try await _push( + .join, .text(payloadObject), ref: ref, joinRef: ref, + ack: .require(timeout: realtime.configuration.joinTimeout, error: .channelJoinTimeout) + )! + + guard reply.status == "ok" else { + let reason = + reply.response.objectValue?["reason"]?.stringValue + ?? "Server rejected the channel join (status: \(reply.status))." + return .rejected(reason: reason) + } + + // Build the server-id routing map from the join reply's postgres_changes array (the server + // assigns integer ids in the same order as the client's entries). Mark rejoin-eligible + // BEFORE the (possibly slow) postgres wait so a confirmation failure still leaves the channel + // eligible for a future reconnect. + _buildServerIDRouting(from: reply.response) + shouldRejoin = true + + if let postgresEvents { + try await _awaitPostgresSubscribed( + postgresEvents, + timeout: realtime.configuration.joinTimeout, + clock: realtime.configuration.clock + ) + } + return .joined + } + + // MARK: - Postgres subscription confirmation + + /// Awaits the server's `system` confirmation that this channel's `postgres_changes` + /// subscription is live, racing against `timeout`. + /// + /// A join whose payload carried registrations is only really subscribed once the server + /// finishes setting up replication and emits a `system` event + /// (`extension == "postgres_changes"`): `status == "ok"` succeeds, `status == "error"` + /// throws `.postgresSubscriptionFailed`. The phx_reply arrives earlier and merely echoes + /// the requested subscriptions, so relying on it alone drops the first changes. + /// + /// `events` must be a feed subscription created *before* the join was sent, so the + /// confirmation cannot arrive in the gap before we start listening. + private func _awaitPostgresSubscribed( + _ events: AsyncStream, + timeout: Duration, + clock: any Clock & Sendable + ) async throws(RealtimeError) { + let outcome: Result = await withTaskGroup( + of: Result.self + ) { group in + group.addTask { + for await event in events { + switch event { + case .terminated(let reason): + return .failure(.channelClosed(reason)) + case .message(let message): + guard let system = SystemEventPayload(message), system.isPostgresChanges + else { continue } + switch system.status { + case "ok": + return .success(()) + case "error": + return .failure( + .postgresSubscriptionFailed( + reason: system.message ?? "postgres_changes subscription failed")) + default: + continue + } + } + } + // Feed ended before confirmation (channel deallocated) — treat as a join timeout. + return .failure(.channelJoinTimeout) + } + group.addTask { + try? await clock.sleep(for: timeout) + return .failure(.channelJoinTimeout) + } + let first = await group.next() ?? .failure(.channelJoinTimeout) + group.cancelAll() + return first + } + + switch outcome { + case .success: + return + case .failure(let error): + throw error + } + } + + // MARK: - Leave + + /// Unsubscribes the channel from its topic by performing the `phx_leave` handshake. + /// + /// ## State machine + /// `.joined` → `.leaving` → `.closed(.userRequested)` (on ok reply) + /// + /// ## Idempotency + /// If the channel is already `.closed` or `.unsubscribed`, this is a no-op. + /// Calling `leave()` a second time on an already-closed channel returns immediately. + /// + /// ## In-flight join + /// If a `subscribe()` join is currently in flight, `leave()` awaits its completion + /// first (best-effort), then proceeds to leave. This ensures the join/leave handshake + /// is always well-ordered on the server. + /// + /// ## Error semantics + /// On transport failure or timeout the channel's local state is set to + /// `.closed(.userRequested)` regardless — the local handle is torn down. The + /// error is then rethrown so the caller can detect that the server may not have + /// confirmed the leave. + /// + /// ## Re-subscribe + /// After leave, the channel is `.closed(.userRequested)`. Calling `subscribe()` + /// again from this state performs a fresh `phx_join` (the state machine guard in + /// `subscribe()` already permits `.closed`). + /// + /// - Throws: `RealtimeError.channelJoinTimeout` if no reply arrives within `leaveTimeout`. + public func leave() async throws(RealtimeError) { + // If the owning Realtime is gone, there is nothing to leave. + guard let realtime else { + transition(to: .closed(.userRequested)) + return + } + + // Idempotent: already closed/unsubscribed — no-op. + switch channelState { + case .closed, .unsubscribed: + return + default: + break + } + + // If a join is in flight, await it first so leave is well-ordered on the server. + if let existing = joinTask { + // Best-effort: ignore any join error; we are leaving regardless. + try? await existing.value + // After the join resolves the state is either .joined or .closed(rejected/timeout). + // If it ended up closed, we are done — nothing to leave. + switch channelState { + case .closed, .unsubscribed: + return + default: + break + } + } + + // Clear the rejoin flag: a user-initiated leave must NOT trigger transparent re-join (Task 29). + shouldRejoin = false + + log(.info, .channel, "Leaving channel", metadata: ["topic": topic]) + // Transition to .leaving to signal in-progress leave. + transition(to: .leaving) + + // Encode + send the phx_leave frame and await its reply. On any failure (encode, + // transport, or timeout) tear down locally and rethrow so the caller knows the server + // may not have confirmed. + do { + _ = try await _push( + .leave, .text([:]), + ack: .require(timeout: realtime.configuration.leaveTimeout, error: .channelJoinTimeout) + ) + } catch { + transition(to: .closed(.userRequested)) + joinRef = nil + throw error + } + + // Success: transition to closed and reset joinRef so a future subscribe() gets a fresh ref. + log(.info, .channel, "Channel left", metadata: ["topic": topic]) + transition(to: .closed(.userRequested)) + joinRef = nil + } + + // MARK: - Token push + + /// Sends an `access_token` Phoenix event for this channel if it is currently `.joined`. + /// + /// Called by `Realtime.updateToken(_:)` for every channel in the registry. + /// + /// ## No-ACK (Finding I1) + /// The backend does not reply to `access_token` events, so this method does NOT + /// register the frame in the in-flight registry and does NOT await a reply. + /// It returns immediately after queueing the send. + /// + /// ## Failure semantics + /// If the channel is not `.joined` or has no `joinRef`, this is a no-op. + /// Transport failures from `sendText` are swallowed — the token is already stored + /// on the `Realtime` actor for the next reconnect/rejoin. + func pushAccessToken(_ newToken: String) async { + guard channelState == .joined, joinRef != nil else { return } + + // Best-effort, no ACK (the backend does not reply to access_token): swallow encode and + // transport errors — the token is already stored on Realtime for future joins. + _ = try? await _push( + .accessToken, .text(["access_token": .string(newToken)]), ack: .none) + } + + // MARK: - Logging helper + + /// Emits a log event via the owning `Realtime` actor's logger. + /// No-ops if the `Realtime` reference has been deallocated. + /// Internal (not private) so the same-module extension files can log. + func log( + _ level: LogLevel, + _ category: Category, + _ message: String, + metadata: [String: String] = [:] + ) { + realtime?.log(level, category, message, metadata: metadata) + } + +} diff --git a/Sources/RealtimeV3/ChannelHost.swift b/Sources/RealtimeV3/ChannelHost.swift new file mode 100644 index 000000000..384a6a3be --- /dev/null +++ b/Sources/RealtimeV3/ChannelHost.swift @@ -0,0 +1,63 @@ +// +// ChannelHost.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 30/06/26. +// + +import Foundation +import Helpers + +/// The capabilities a `Channel` needs from its owning client. +/// +/// `Channel` depends on this protocol rather than the concrete `Realtime` actor, so the two +/// are decoupled: the channel can be exercised against a test double, and the identity of the +/// host (today `Realtime`, potentially a dedicated connection controller later) is an +/// implementation detail. The surface is exactly what `Channel` consumes — no more. +/// +/// `AnyObject`-constrained so `Channel` can hold the host `weak` (cycle-break, see +/// `Channel.realtime`). The synchronous members are `nonisolated` because `Channel` calls them +/// without hopping; the two `async` members run on the host's executor. +protocol ChannelHost: AnyObject, Sendable { + /// The client configuration (encoder/decoder, timeouts, clock, …). + nonisolated var configuration: Configuration { get } + + /// Returns the next monotonic ref string from the shared generator. + nonisolated func nextRef() -> String + + /// Emits a structured log event (no-op if no logger is configured). + nonisolated func log( + _ level: LogLevel, _ category: Category, _ message: String, metadata: [String: String]) + + /// Records that `topic` has joined (drives the leaked-channel deinit warning + idle timer). + nonisolated func _markJoined(_ topic: String) + + /// Records that `topic` has been left or terminally evicted. + nonisolated func _markLeft(_ topic: String) + + /// Encodes an `Encodable` value to `AnyJSON` using the configured encoder. + nonisolated func _encodeToJSON(_ value: T) throws(RealtimeError) + -> AnyJSON + + /// Vends the access token to use for a channel join (`nil` for anonymous channels). + func accessTokenForJoin() async throws(RealtimeError) -> String? + + /// Encodes and sends a single Phoenix frame for `topic`, optionally awaiting its reply. + @discardableResult + func _push( + topic: String, + _ event: PhoenixEvent, + _ body: PushBody, + ref: String?, + joinRef: String?, + lazyConnect: Bool, + ack: AckPolicy + ) async throws(RealtimeError) -> PushReply? + + /// Sends one or more broadcast messages over HTTP (no WebSocket required). + func _httpBroadcastBatch(_ messages: [HttpBroadcastMessage]) async throws(RealtimeError) +} + +// `Realtime` already provides every member with matching isolation — the conformance is +// declarative only. +extension Realtime: ChannelHost {} diff --git a/Sources/RealtimeV3/ChannelOptions.swift b/Sources/RealtimeV3/ChannelOptions.swift new file mode 100644 index 000000000..211f481c8 --- /dev/null +++ b/Sources/RealtimeV3/ChannelOptions.swift @@ -0,0 +1,78 @@ +// +// ChannelOptions.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation + +// MARK: - ChannelOptions + +/// Options applied at channel creation time. Options are locked on the first `channel(_:configure:)` +/// call — subsequent calls for the same topic with different options are ignored and a debug warning +/// is emitted (Decision 33: first-call-wins). +public struct ChannelOptions: Sendable, Equatable { + /// Whether the channel is a private channel. Private channels require a valid JWT and support + /// backend replay. + public var isPrivate: Bool = false + + /// Broadcast behaviour options. + public var broadcast: BroadcastOptions = .init() + + /// Presence behaviour options. + public var presence: PresenceOptions = .init() + + public init() {} +} + +// MARK: - BroadcastOptions + +/// Options controlling broadcast behaviour for a channel. +public struct BroadcastOptions: Sendable, Equatable { + /// When `true`, `broadcast()` calls wait for a server acknowledgement before returning. + /// Default: `false` (fire-and-forget). + public var acknowledge: Bool = false + + /// When `true`, the client receives its own broadcast messages. Default: `false`. + public var receiveOwnBroadcasts: Bool = false + + /// Backend replay configuration. Only valid on private channels (`isPrivate == true`). + /// Public-channel replay is rejected by the backend. Default: `nil` (no replay). + public var replay: ReplayOption? = nil + + public init() {} +} + +// MARK: - ReplayOption + +/// Configures backend replay for a private broadcast channel. +public struct ReplayOption: Sendable, Equatable { + /// Replay messages sent at or after this date. + public var since: Date + + /// Maximum number of messages to replay. Clamped server-side to 1...25, defaulting to 25 + /// when `nil`. + public var limit: Int? + + public init(since: Date, limit: Int? = nil) { + self.since = since + self.limit = limit + } +} + +// MARK: - PresenceOptions + +/// Options controlling presence behaviour for a channel. +public struct PresenceOptions: Sendable, Equatable { + /// Sends `presence.enabled = true` in the join config. Required for an initial + /// `presence_state` snapshot on join. If `false`, `track` can still create/update presence + /// later, but observers cannot retroactively get the initial snapshot. + public var enabled: Bool = false + + /// Presence key for this channel process. If `nil` or empty, the server generates a fresh + /// UUID per join. + public var key: String? = nil + + public init() {} +} diff --git a/Sources/RealtimeV3/ChannelState.swift b/Sources/RealtimeV3/ChannelState.swift new file mode 100644 index 000000000..c98ab0a12 --- /dev/null +++ b/Sources/RealtimeV3/ChannelState.swift @@ -0,0 +1,24 @@ +// +// ChannelState.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 27/06/26. +// + +public enum ChannelState: Sendable, Equatable { + case unsubscribed + case joining + case joined + case leaving + case closed(CloseReason) +} + +public enum CloseReason: Sendable, Equatable { + case userRequested + case clientDisconnected + case serverClosed(code: Int?, message: String?) + case timeout + case unauthorized + case policyViolation(String) + case transportFailure +} diff --git a/Sources/RealtimeV3/Configuration.swift b/Sources/RealtimeV3/Configuration.swift new file mode 100644 index 000000000..b849cc2ae --- /dev/null +++ b/Sources/RealtimeV3/Configuration.swift @@ -0,0 +1,90 @@ +// +// Configuration.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Clocks +import Foundation +import Helpers + +// MARK: - RealtimeProtocolVersion + +/// The Phoenix channel protocol version used for the WebSocket connection. +public enum RealtimeProtocolVersion: String, Sendable { + case v1 = "1.0.0" + case v2 = "2.0.0" +} + +// MARK: - Configuration + +/// Configuration for the Realtime connection. +public struct Configuration: Sendable { + /// Interval between heartbeat pings. Default: 25 seconds. + public var heartbeat: Duration = .seconds(25) + + /// Maximum time to wait for a channel join acknowledgement. Default: 10 seconds. + public var joinTimeout: Duration = .seconds(10) + + /// Maximum time to wait for a channel leave acknowledgement. Default: 10 seconds. + public var leaveTimeout: Duration = .seconds(10) + + /// Maximum time to wait for a broadcast acknowledgement. Default: 5 seconds. + public var broadcastAckTimeout: Duration = .seconds(5) + + /// Reconnection policy applied when the connection drops. Default: exponential backoff. + public var reconnection: ReconnectionPolicy = .exponentialBackoff( + initial: .seconds(1), max: .seconds(30), jitter: 0.2 + ) + + /// How long to keep an idle socket open after the last channel has left, + /// to avoid reconnect churn when a new channel joins shortly after. + /// `.zero` means close immediately. Default: 50 seconds. + public var disconnectOnEmptyChannelsAfter: Duration = .seconds(50) + + /// Whether the SDK manages connection lifecycle based on app lifecycle events. + /// Default: `.automatic` on iOS/macOS/tvOS/visionOS, `.manual` on watchOS/Linux. + public var lifecycle: LifecyclePolicy = .automaticDefault + + /// Phoenix protocol version used for the WebSocket connection. Default: `.v2`. + public var protocolVersion: RealtimeProtocolVersion = .v2 + + /// Clock used for timers and scheduling. Default: `ContinuousClock`. + public var clock: any Clock & Sendable = ContinuousClock() + + /// Additional HTTP headers sent with the WebSocket upgrade request. + public var headers: [String: String] = [:] + + /// Optional logger that receives structured `LogEvent`s from the SDK. + /// + /// Set to `nil` (the default) to disable logging entirely. Provide an `OSLogLogger`, + /// `StdoutLogger`, or any custom `RealtimeLogger` implementation to receive events. + public var logger: (any RealtimeLogger)? = nil + + /// JSON decoder used to decode messages from the server. Default: ISO 8601 date strategy. + public var decoder: JSONDecoder = .realtimeDefault + + /// JSON encoder used to encode messages to the server. Default: ISO 8601 date strategy. + public var encoder: JSONEncoder = .realtimeDefault + + public init() {} + + /// Default configuration matching the specification. + public static let `default` = Configuration() +} + +// MARK: - JSONDecoder + realtimeDefault + +extension JSONDecoder { + /// SDK-provided decoder configured with ISO 8601 date strategy. + /// Replace via `Configuration.decoder` for custom needs. + public static let realtimeDefault: JSONDecoder = .supabase() +} + +// MARK: - JSONEncoder + realtimeDefault + +extension JSONEncoder { + /// SDK-provided encoder configured with ISO 8601 date strategy. + public static let realtimeDefault: JSONEncoder = .supabase() +} diff --git a/Sources/RealtimeV3/ConnectionStatus.swift b/Sources/RealtimeV3/ConnectionStatus.swift new file mode 100644 index 000000000..13f17fa94 --- /dev/null +++ b/Sources/RealtimeV3/ConnectionStatus.swift @@ -0,0 +1,39 @@ +// +// ConnectionStatus.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation + +/// Represents the current connection state and associated metadata. +public struct ConnectionStatus: Sendable { + /// The discrete connection states. + // Note: `Equatable` synthesis is intentionally omitted because `.reconnecting` and + // `.closed` carry `(any Error & Sendable)?` associated values, which do not conform + // to `Equatable` and would prevent automatic synthesis for the entire enum. + public enum State: Sendable { + case idle + case connecting(attempt: Int) + case connected + case reconnecting(attempt: Int, lastError: (any Error & Sendable)?) + case closed(CloseReason) + } + + /// The current connection state. + public let state: State + + /// When the current `state` was entered. Reset on every state transition. + public let since: Date + + /// Last successful heartbeat round-trip time, if any. `nil` before the + /// first heartbeat reply or after the connection drops. + public let latency: Duration? + + public init(state: State, since: Date, latency: Duration?) { + self.state = state + self.since = since + self.latency = latency + } +} diff --git a/Sources/RealtimeV3/HTTP/HttpBroadcast.swift b/Sources/RealtimeV3/HTTP/HttpBroadcast.swift new file mode 100644 index 000000000..e1f775cdc --- /dev/null +++ b/Sources/RealtimeV3/HTTP/HttpBroadcast.swift @@ -0,0 +1,205 @@ +// +// HttpBroadcast.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import Helpers + +// MARK: - HttpBroadcastMessage + +/// A broadcast message for use with ``Realtime/httpBroadcastBatch(_:)``. +/// +/// Each message carries a `topic`, an `event`, an `Encodable` payload, +/// and an optional `isPrivate` flag. Multiple messages may span different topics +/// in a single batch POST to the broadcast endpoint. +public struct HttpBroadcastMessage: Sendable { + public let topic: String + public let event: String + public let payload: any Encodable & Sendable + public let isPrivate: Bool + + public init( + topic: String, + event: String, + payload: any Encodable & Sendable, + isPrivate: Bool = false + ) { + self.topic = topic + self.event = event + self.payload = payload + self.isPrivate = isPrivate + } +} + +// MARK: - Wire-format helpers (internal) + +/// A single message element in the broadcast request body. +/// Encodable so the whole array can be serialized by `JSONEncoder`. +private struct BroadcastMessageBody: Encodable { + let topic: String + let event: String + let payload: AnyJSON + let `private`: Bool +} + +/// Top-level request body: `{ "messages": [...] }`. +private struct BroadcastRequestBody: Encodable { + let messages: [BroadcastMessageBody] +} + +// MARK: - Realtime + httpBroadcastBatch + +extension Realtime { + + /// Sends multiple broadcast messages across one or more topics in a single HTTP POST. + /// + /// Does **not** require an open WebSocket connection. Auth is injected via the + /// `_HTTPClient` token provider when available; otherwise the `apikey` header is set. + /// + /// - Important: The `/api/broadcast` endpoint requires a **service-role** Bearer token + /// (or an access token with broadcast privileges). An anon JWT is rejected by the + /// server with HTTP 500 — surfaced here as ``RealtimeError/serverError(code:message:)``. + /// + /// - Parameter messages: One or more ``HttpBroadcastMessage`` values. Each `topic` + /// must be the **short** topic (without the `realtime:` prefix). + /// - Throws: ``RealtimeError`` + public func httpBroadcastBatch(_ messages: [HttpBroadcastMessage]) async throws(RealtimeError) { + try await _httpBroadcastBatch(messages) + } + + /// Internal workhorse called by both `httpBroadcastBatch` and `Channel.httpBroadcast`. + func _httpBroadcastBatch(_ messages: [HttpBroadcastMessage]) async throws(RealtimeError) { + // Build the wire-format message array, encoding each payload via the shared helper + // (honours Configuration.encoder's date/key strategies). + var bodyMessages: [BroadcastMessageBody] = [] + for msg in messages { + bodyMessages.append( + BroadcastMessageBody( + topic: msg.topic, + event: msg.event, + payload: try _encodeToJSON(msg.payload), + private: msg.isPrivate + ) + ) + } + + let requestBody = BroadcastRequestBody(messages: bodyMessages) + + // Determine whether a token is available for this call. + // We can't call the actor-isolated accessTokenForJoin here because we are already + // on the actor. Call the token logic directly (inline, actor-isolated). + let currentToken: String? + if let override = _overrideToken { + currentToken = override + } else if let provider = accessTokenProvider { + do { + currentToken = try await provider() + } catch { + throw .authenticationFailed( + reason: "Access token provider threw an error.", underlying: error) + } + } else { + currentToken = nil + } + + // Auth header selection (spec §3.3, Finding E2): + // • Token available → "Authorization: Bearer " (standard bearer auth) + // • No token → "apikey: " (anon/public channel access) + // The _HTTPClient is built without a tokenProvider, so we inject auth explicitly. + let headers: [String: String] + if let token = currentToken { + headers = ["Authorization": "Bearer \(token)"] + } else { + headers = ["apikey": apiKey] + } + + // Build the absolute URL by appending "api/broadcast" to the HTTP base URL. + // We use the absolute-URL overload of fetchData to preserve the full path prefix + // (e.g. /realtime/v1) — the path-string overload replaces the path entirely. + let broadcastURL = httpClient.host.appendingPathComponent("api/broadcast") + + do { + _ = try await httpClient.fetchData( + .post, + url: broadcastURL, + body: .encodable(requestBody), + headers: headers + ) + } catch let clientError as HTTPClientError { + throw mapHTTPClientError(clientError) + } catch { + throw .transportFailure(underlying: error) + } + } +} + +// MARK: - Channel + httpBroadcast + +extension Channel { + + /// Sends a single broadcast message via HTTP POST (no WebSocket required). + /// + /// Delegates to ``Realtime/httpBroadcastBatch(_:)`` with a single-element batch + /// whose topic is this channel's topic. The SDK-internal `realtime:` prefix is + /// stripped before building the HTTP body (the endpoint matches WebSocket + /// subscribers on the short topic). + /// + /// - Important: Like ``Realtime/httpBroadcastBatch(_:)``, the `/api/broadcast` + /// endpoint requires a **service-role** Bearer token; an anon JWT yields HTTP 500. + /// + /// - Parameters: + /// - event: The broadcast event name. + /// - payload: An `Encodable & Sendable` payload. + /// - isPrivate: When `true`, the message is restricted to authenticated subscribers. + /// - Throws: ``RealtimeError`` + public func httpBroadcast( + event: String, + payload: T, + isPrivate: Bool = false + ) async throws(RealtimeError) { + guard let realtime else { throw .channelClosed(.clientDisconnected) } + // `topic` is the WS channel topic, which is `realtime:`-prefixed. The HTTP + // `/api/broadcast` endpoint expects the SHORT topic (no `realtime:` prefix) — + // otherwise the message is accepted (202) but never delivered to subscribers. + let shortTopic = + topic.hasPrefix("realtime:") ? String(topic.dropFirst("realtime:".count)) : topic + let msg = HttpBroadcastMessage( + topic: shortTopic, + event: event, + payload: payload, + isPrivate: isPrivate + ) + try await realtime._httpBroadcastBatch([msg]) + } +} + +// MARK: - Error mapping + +/// Maps an ``HTTPClientError`` returned by the broadcast endpoint to a ``RealtimeError``. +private func mapHTTPClientError(_ error: HTTPClientError) -> RealtimeError { + switch error { + case .responseError(let response, let data): + let body = String(decoding: data, as: UTF8.self) + switch response.statusCode { + case 401, 403: + return .authenticationFailed(reason: body, underlying: nil) + case 429: + return .rateLimited(retryAfter: nil) + case 500...599: + return .serverError(code: response.statusCode, message: body) + default: + return .broadcastFailed(reason: "HTTP \(response.statusCode): \(body)") + } + case .decodingError(_, let detail): + return .broadcastFailed(reason: detail) + case .unexpectedError(let msg): + return .transportFailure( + underlying: URLError( + .cannotConnectToHost, + userInfo: [NSLocalizedDescriptionKey: msg] + )) + } +} diff --git a/Sources/RealtimeV3/Internal/ChannelRegistry.swift b/Sources/RealtimeV3/Internal/ChannelRegistry.swift new file mode 100644 index 000000000..3336d27bd --- /dev/null +++ b/Sources/RealtimeV3/Internal/ChannelRegistry.swift @@ -0,0 +1,45 @@ +// +// ChannelRegistry.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 30/06/26. +// + +import Foundation + +/// The topic → `Channel` collection owned by `Realtime`. +/// +/// A value type held as an actor-isolated stored property of `Realtime`; every method runs on +/// `Realtime`'s executor, so no internal locking is needed. Encapsulates the keyed lookup, +/// insertion, eviction, and snapshotting that the connect, reconnect, frame-routing, and +/// token-update paths all need — keeping the raw dictionary out of those call sites. +/// +/// Topic strings are the `realtime:`-prefixed form (see `Realtime.channel(_:)`). +struct ChannelRegistry { + private var channels: [String: Channel] = [:] + + /// The channel currently registered for `topic`, if any. + func channel(for topic: String) -> Channel? { + channels[topic] + } + + /// Registers `channel` under `topic` (first-call-wins is enforced by the caller). + mutating func insert(_ channel: Channel, for topic: String) { + channels[topic] = channel + } + + /// Removes the channel registered for `topic`, if any. + mutating func remove(topic: String) { + channels.removeValue(forKey: topic) + } + + /// A snapshot of all registered channels (for concurrent rejoin / token push). + var all: [Channel] { + Array(channels.values) + } + + /// A snapshot of all registered `(topic, channel)` pairs (for give-up eviction). + var allByTopic: [(topic: String, channel: Channel)] { + channels.map { (topic: $0.key, channel: $0.value) } + } +} diff --git a/Sources/RealtimeV3/Internal/ConnectionStatusBroadcaster.swift b/Sources/RealtimeV3/Internal/ConnectionStatusBroadcaster.swift new file mode 100644 index 000000000..6a48cd350 --- /dev/null +++ b/Sources/RealtimeV3/Internal/ConnectionStatusBroadcaster.swift @@ -0,0 +1,52 @@ +// +// ConnectionStatusBroadcaster.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 30/06/26. +// + +import Foundation + +/// Holds the current `ConnectionStatus` and fans transitions out to all `status` stream +/// subscribers. +/// +/// A value type held as an actor-isolated stored property of `Realtime`; every method runs on +/// `Realtime`'s executor, so no internal locking is needed. The single source of truth for the +/// client's connection status, separated from the socket mechanism that drives it. +struct ConnectionStatusBroadcaster { + /// The most recent status. New subscribers are seeded with this value. + private(set) var current: ConnectionStatus + private var continuations: [UUID: AsyncStream.Continuation] = [:] + + init(initial: ConnectionStatus) { + self.current = initial + } + + /// Registers a new subscriber, seeded with `current`, and returns its stream. + /// + /// `onTerminate` is invoked (with the subscriber's id) when the stream is cancelled or + /// finished, so the owner can hop back onto its executor and call `remove(_:)`. + mutating func makeStream( + onTerminate: @escaping @Sendable (UUID) -> Void + ) -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream() + continuation.yield(current) + continuations[id] = continuation + continuation.onTermination = { _ in onTerminate(id) } + return stream + } + + /// Updates `current` and yields it to every subscriber. + mutating func emit(_ status: ConnectionStatus) { + current = status + for continuation in continuations.values { + continuation.yield(status) + } + } + + /// Removes a finished/cancelled subscriber. + mutating func remove(_ id: UUID) { + continuations.removeValue(forKey: id) + } +} diff --git a/Sources/RealtimeV3/Internal/Heartbeater.swift b/Sources/RealtimeV3/Internal/Heartbeater.swift new file mode 100644 index 000000000..c49e25c52 --- /dev/null +++ b/Sources/RealtimeV3/Internal/Heartbeater.swift @@ -0,0 +1,67 @@ +// +// Heartbeater.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Clocks +import Foundation + +/// Drives the periodic heartbeat for an active WebSocket connection. +/// +/// The loop sleeps `heartbeat` on `clock`, then invokes `beat` — which sends the heartbeat +/// frame and awaits its reply via `Realtime._sendHeartbeat()`. If `beat` throws (send failure +/// or reply timeout), `onConnectionLost` is called so the actor can react. +/// +/// Start one instance after a successful `connect()` and cancel its task on disconnect. +struct Heartbeater: Sendable { + typealias Beat = @Sendable () async throws -> Void + typealias OnConnectionLost = @Sendable (RealtimeError) async -> Void + + private let heartbeat: Duration + private let clock: any Clock & Sendable + private let beat: Beat + private let onConnectionLost: OnConnectionLost + + init( + heartbeat: Duration, + clock: any Clock & Sendable, + beat: @escaping Beat, + onConnectionLost: @escaping OnConnectionLost + ) { + self.heartbeat = heartbeat + self.clock = clock + self.beat = beat + self.onConnectionLost = onConnectionLost + } + + /// Starts the heartbeat loop as an unstructured `Task`. Returns the task handle + /// so the caller can cancel it on disconnect or connection loss. + func start() -> Task { + Task { + await run() + } + } + + private func run() async { + while !Task.isCancelled { + // Sleep for the heartbeat interval before the first send, matching V2 behaviour. + do { + try await clock.sleep(for: heartbeat) + } catch { + // CancellationError: task was cancelled — clean exit. + return + } + guard !Task.isCancelled else { return } + + do { + try await beat() + } catch let error as RealtimeError { + await onConnectionLost(error) + } catch { + await onConnectionLost(.cancelled) + } + } + } +} diff --git a/Sources/RealtimeV3/Internal/InflightPush.swift b/Sources/RealtimeV3/Internal/InflightPush.swift new file mode 100644 index 000000000..6c8ed3301 --- /dev/null +++ b/Sources/RealtimeV3/Internal/InflightPush.swift @@ -0,0 +1,162 @@ +// +// InflightPush.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 27/06/26. +// + +import Clocks +import ConcurrencyExtras +import Foundation + +/// Reply value for a Phoenix push. +typealias PushReply = (status: String, response: JSONValue) + +/// Tracks in-flight pushes that are waiting for a `phx_reply` from the server. +/// +/// Callers register a push via `awaitReply`, which suspends until the matching +/// reply arrives or the timeout fires. The frame router calls `resolve` when a +/// `phx_reply` frame arrives; `failAll` is called on disconnect to drain the +/// pending map immediately. +/// +/// ## Design Notes +/// +/// The pending-continuation map is stored in a `LockIsolated` dict rather than +/// plain actor state. This lets the `withCheckedThrowingContinuation` setup +/// closure (which is `nonisolated`) store the continuation atomically without +/// having to hop onto the actor, eliminating the registration race that would +/// exist if we used `Task { await self._register(...) }`. +/// +/// Double-resume is prevented by removing the entry from the map at the moment +/// the continuation is resumed in all three paths (resolve, timeout, failAll). +/// +/// ## Early-reply buffering +/// +/// `resolve` can be called before `awaitReply` has registered the continuation +/// (e.g. a very fast server response arriving before the caller has reached the +/// `withCheckedThrowingContinuation` setup closure). Without buffering the reply +/// would be silently dropped and the caller would hang until the timeout. +/// +/// Both `pendingEntries` and `earlyReplies` live in the same `LockIsolated` +/// state struct so all three paths — resolve-before-register, timeout, and +/// register-before-resolve — are mutually exclusive under the same lock. +actor InflightPushRegistry { + private struct Entry: @unchecked Sendable { + let continuation: CheckedContinuation + let timeoutError: RealtimeError + } + + private struct State { + var pendingEntries: [String: Entry] = [:] + var earlyReplies: [String: PushReply] = [:] + } + + /// All mutable state held under a single lock so `resolve` and the + /// `withCheckedThrowingContinuation` setup closure are mutually exclusive. + private let stateLock = LockIsolated(State()) + + /// Number of pushes currently awaiting a reply. Used by tests to + /// deterministically observe registration before advancing a test clock. + nonisolated var pendingCount: Int { + stateLock.withValue { $0.pendingEntries.count } + } + + /// Suspends until the `phx_reply` for `ref` arrives, or until `timeout` elapses + /// on `clock`. On timeout, `timeoutError` is thrown. + func awaitReply( + ref: String, + timeout: Duration, + clock: any Clock, + timeoutError: RealtimeError + ) async throws(RealtimeError) -> PushReply { + do { + return try await _awaitReply( + ref: ref, timeout: timeout, clock: clock, timeoutError: timeoutError) + } catch let error as RealtimeError { + throw error + } catch { + throw .cancelled + } + } + + private func _awaitReply( + ref: String, + timeout: Duration, + clock: any Clock, + timeoutError: RealtimeError + ) async throws -> PushReply { + // Spawn a timeout task before registering the continuation, so the timer + // starts as close to the send moment as possible. + let timeoutTask = Task { [stateLock] in + do { + try await clock.sleep(for: timeout) + } catch { + // CancellationError: reply arrived first; nothing to do. + return + } + // Timeout fired: remove and resume the continuation if it's still pending. + stateLock.withValue { state in + guard let entry = state.pendingEntries.removeValue(forKey: ref) else { return } + entry.continuation.resume(throwing: entry.timeoutError) + } + } + + do { + let result = try await withCheckedThrowingContinuation { + (continuation: CheckedContinuation) in + // This closure runs synchronously before the outer function suspends. + // LockIsolated.withValue is safe to call from nonisolated context. + stateLock.withValue { state in + if Task.isCancelled { + continuation.resume(throwing: CancellationError()) + return + } + // Check whether a reply arrived before we registered — if so, resolve + // immediately without storing a pending entry. The timeout task will + // be cancelled in the code path after `withCheckedThrowingContinuation` + // returns. + if let early = state.earlyReplies.removeValue(forKey: ref) { + continuation.resume(returning: early) + } else { + state.pendingEntries[ref] = Entry( + continuation: continuation, timeoutError: timeoutError) + } + } + } + timeoutTask.cancel() + return result + } catch { + timeoutTask.cancel() + throw error + } + } + + /// Called by the frame router when a `phx_reply` frame arrives. + /// Resolving an unknown ref is a no-op (double-resolve guard). + /// If no continuation is registered yet, the reply is buffered in + /// `earlyReplies` so `awaitReply` can pick it up when it registers. + nonisolated func resolve(ref: String, status: String, response: JSONValue) { + stateLock.withValue { state in + if let entry = state.pendingEntries.removeValue(forKey: ref) { + entry.continuation.resume(returning: (status: status, response: response)) + } else { + // Buffer the reply; awaitReply will consume it when it registers. + state.earlyReplies[ref] = (status: status, response: response) + } + } + } + + /// Fails all outstanding pushes immediately with `error`. + /// Also clears any buffered early replies that have not yet been consumed. + func failAll(_ error: RealtimeError) { + let entries = stateLock.withValue { state -> [Entry] in + let all = Array(state.pendingEntries.values) + state.pendingEntries.removeAll() + state.earlyReplies.removeAll() + return all + } + for entry in entries { + entry.continuation.resume(throwing: error) + } + } +} diff --git a/Sources/RealtimeV3/Internal/JoinPayload.swift b/Sources/RealtimeV3/Internal/JoinPayload.swift new file mode 100644 index 000000000..c83fcdcd2 --- /dev/null +++ b/Sources/RealtimeV3/Internal/JoinPayload.swift @@ -0,0 +1,128 @@ +// +// JoinPayload.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import Helpers + +// MARK: - JoinPayload + +/// The wire-format payload for a `phx_join` frame. +/// +/// Shape mirrors `RealtimeJoinPayload` / `RealtimeJoinConfig` from the v2 module. +/// For Phase 3, `postgresChanges` is always an empty array. +struct JoinPayload: Codable { + var config: JoinConfig + var accessToken: String? + + enum CodingKeys: String, CodingKey { + case config + case accessToken = "access_token" + } +} + +// MARK: - JoinConfig + +struct JoinConfig: Codable { + var broadcast: BroadcastJoinConfig + var presence: PresenceJoinConfig + var postgresChanges: [JSONObject] + var isPrivate: Bool + + enum CodingKeys: String, CodingKey { + case broadcast + case presence + case postgresChanges = "postgres_changes" + case isPrivate = "private" + } +} + +// MARK: - BroadcastJoinConfig + +struct BroadcastJoinConfig: Codable { + var ack: Bool + var `self`: Bool + var replay: BroadcastReplayConfig? +} + +// MARK: - BroadcastReplayConfig + +struct BroadcastReplayConfig: Codable { + /// Unix timestamp in milliseconds. + var since: Int + var limit: Int? +} + +// MARK: - PresenceJoinConfig + +struct PresenceJoinConfig: Codable { + var key: String + var enabled: Bool +} + +// MARK: - Factory + +extension JoinPayload { + /// Builds a `JoinPayload` from `ChannelOptions`, an optional access token, and + /// the channel's pending postgres-changes registrations. + /// + /// Each `ChangeRegistrationConfig` in `registrations` is serialised to a + /// `JSONObject` with keys `event`, `schema`, `table`, and (if non-nil) `filter`, + /// matching the wire shape Phoenix expects. + static func make( + from options: ChannelOptions, + accessToken: String?, + registrations: [ChangeRegistrationConfig] = [] + ) -> JoinPayload { + let replayConfig: BroadcastReplayConfig? + if let replay = options.broadcast.replay { + replayConfig = BroadcastReplayConfig( + since: Int(replay.since.timeIntervalSince1970 * 1000), + limit: replay.limit + ) + } else { + replayConfig = nil + } + + let broadcastConfig = BroadcastJoinConfig( + ack: options.broadcast.acknowledge, + self: options.broadcast.receiveOwnBroadcasts, + replay: replayConfig + ) + + let presenceConfig = PresenceJoinConfig( + key: options.presence.key ?? "", + enabled: options.presence.enabled + ) + + // Serialise each registration into a JSONObject entry. + let postgresChanges: [JSONObject] = registrations.map { reg in + var entry: JSONObject = [ + "event": .string(reg.event.rawValue), + "schema": .string(reg.schema), + "table": .string(reg.table), + ] + if let filter = reg.filter { + entry["filter"] = .string(filter) + } + return entry + } + + let config = JoinConfig( + broadcast: broadcastConfig, + presence: presenceConfig, + postgresChanges: postgresChanges, + isPrivate: options.isPrivate + ) + + return JoinPayload(config: config, accessToken: accessToken) + } + + /// Encodes the payload to a `JSONObject` suitable for embedding in the phx_join frame. + func toJSONObject() throws -> JSONObject { + try JSONObject(self) + } +} diff --git a/Sources/RealtimeV3/Internal/RefGenerator.swift b/Sources/RealtimeV3/Internal/RefGenerator.swift new file mode 100644 index 000000000..3b9ba0eed --- /dev/null +++ b/Sources/RealtimeV3/Internal/RefGenerator.swift @@ -0,0 +1,22 @@ +// +// RefGenerator.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 27/06/26. +// + +import ConcurrencyExtras + +/// A thread-safe monotonic counter that produces string-valued refs for push/reply +/// correlation (Phoenix protocol "ref" field). +struct RefGenerator: Sendable { + private let counter = LockIsolated(0) + + /// Returns the next ref, starting at "1" and incrementing by 1. + func next() -> String { + counter.withValue { + $0 += 1 + return $0.description + } + } +} diff --git a/Sources/RealtimeV3/Internal/SystemEventPayload.swift b/Sources/RealtimeV3/Internal/SystemEventPayload.swift new file mode 100644 index 000000000..f7ef01fd0 --- /dev/null +++ b/Sources/RealtimeV3/Internal/SystemEventPayload.swift @@ -0,0 +1,35 @@ +// +// SystemEventPayload.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 30/06/26. +// + +import Foundation +import Helpers + +/// The decoded payload of a Phoenix `system` event: `{ extension, status, message }`. +/// +/// Centralizes the wire-key strings (notably the `"postgres_changes"` extension name) and the +/// parse shape shared by the join-confirmation wait (`Channel._awaitPostgresSubscribed`), the +/// channel system router (`Channel._routeSystemEvent`), and the postgres transforms +/// (`Channel.postgresChanges(for:)`). +struct SystemEventPayload { + let extensionName: String? + let status: String? + let message: String? + + /// Parses `message` when it is a `system` event carrying a JSON object payload; otherwise `nil`. + init?(_ message: PhoenixMessage) { + guard message.event == .system, + case .json(let json) = message.payload, + let obj = json.objectValue + else { return nil } + self.extensionName = obj["extension"]?.stringValue + self.status = obj["status"]?.stringValue + self.message = obj["message"]?.stringValue + } + + /// Whether this system event concerns the `postgres_changes` extension. + var isPostgresChanges: Bool { extensionName == "postgres_changes" } +} diff --git a/Sources/RealtimeV3/JSONValue.swift b/Sources/RealtimeV3/JSONValue.swift new file mode 100644 index 000000000..79f748751 --- /dev/null +++ b/Sources/RealtimeV3/JSONValue.swift @@ -0,0 +1,12 @@ +// +// JSONValue.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 27/06/26. +// + +import Helpers + +/// The SDK's untyped JSON value, aliased to the shared `AnyJSON` type so that +/// payloads interoperate with the rest of the Supabase SDK. +public typealias JSONValue = AnyJSON diff --git a/Sources/RealtimeV3/Lifecycle/LifecycleObserver.swift b/Sources/RealtimeV3/Lifecycle/LifecycleObserver.swift new file mode 100644 index 000000000..e7c0d58f1 --- /dev/null +++ b/Sources/RealtimeV3/Lifecycle/LifecycleObserver.swift @@ -0,0 +1,184 @@ +// +// LifecycleObserver.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation + +#if canImport(UIKit) + import UIKit +#elseif canImport(AppKit) + import AppKit +#endif + +// MARK: - LifecycleEventSource + +/// A source of app lifecycle events (background / foreground) that the `Realtime` actor +/// observes when `Configuration.lifecycle == .automatic`. +/// +/// The production implementation wraps platform `NotificationCenter` notifications. +/// Inject a `TestLifecycleEventSource` in tests for deterministic control. +public protocol LifecycleEventSource: Sendable { + /// Emits a `Void` each time the app enters the background. + var didEnterBackground: AsyncStream { get } + /// Emits a `Void` each time the app is about to enter the foreground. + var willEnterForeground: AsyncStream { get } +} + +// MARK: - NotificationCenterLifecycleEventSource + +#if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) + + /// Production `LifecycleEventSource` backed by `NotificationCenter`. + /// + /// - On iOS / tvOS / visionOS: uses `UIApplication.didEnterBackgroundNotification` + /// and `UIApplication.willEnterForegroundNotification`. + /// - On macOS: uses `NSApplication.didResignActiveNotification` and + /// `NSApplication.willBecomeActiveNotification`. + final class NotificationCenterLifecycleEventSource: LifecycleEventSource, @unchecked Sendable { + + let didEnterBackground: AsyncStream + let willEnterForeground: AsyncStream + + private let backgroundContinuation: AsyncStream.Continuation + private let foregroundContinuation: AsyncStream.Continuation + // `@unchecked Sendable` safety: assigned exactly once at the end of `init` + // (the self-capturing observer closures preclude a stored `let`) and only read + // in `deinit` — no post-init mutation, no concurrent access. + private var observers: [any NSObjectProtocol] = [] + + init() { + let (bgStream, bgCont) = AsyncStream.makeStream() + let (fgStream, fgCont) = AsyncStream.makeStream() + self.didEnterBackground = bgStream + self.willEnterForeground = fgStream + self.backgroundContinuation = bgCont + self.foregroundContinuation = fgCont + + let center = NotificationCenter.default + + #if canImport(UIKit) + let backgroundNotification = UIApplication.didEnterBackgroundNotification + let foregroundNotification = UIApplication.willEnterForegroundNotification + #elseif canImport(AppKit) + let backgroundNotification = NSApplication.didResignActiveNotification + let foregroundNotification = NSApplication.willBecomeActiveNotification + #endif + + let bgObserver = center.addObserver( + forName: backgroundNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.backgroundContinuation.yield(()) + } + + let fgObserver = center.addObserver( + forName: foregroundNotification, + object: nil, + queue: nil + ) { [weak self] _ in + self?.foregroundContinuation.yield(()) + } + + observers = [bgObserver, fgObserver] + } + + deinit { + let center = NotificationCenter.default + for observer in observers { + center.removeObserver(observer) + } + backgroundContinuation.finish() + foregroundContinuation.finish() + } + } + +#endif + +// MARK: - LifecycleObserver (actor-internal helper) + +/// Observes a `LifecycleEventSource` and drives reconnect on foreground when the +/// connection was dropped while backgrounded and no intentional disconnect was set. +/// +/// Lifecycle: +/// - Created and started when `Realtime` connects (or at init for `.automatic`). +/// - `cancel()` is called on `disconnect()` and on deinit cleanup. +final class LifecycleObserver: Sendable { + private let task: Task + + /// Creates the observer and immediately starts listening to `source`. + /// + /// - Parameters: + /// - source: The `LifecycleEventSource` to observe. + /// - client: The `Realtime` actor to callback on foreground. + init(source: any LifecycleEventSource, client: Realtime) { + let backgroundStream = source.didEnterBackground + let foregroundStream = source.willEnterForeground + self.task = Task { [weak client] in + await LifecycleObserver._run( + backgroundStream: backgroundStream, + foregroundStream: foregroundStream, + client: client + ) + } + } + + /// Cancels the observation task. + func cancel() { + task.cancel() + } + + private static func _run( + backgroundStream: AsyncStream, + foregroundStream: AsyncStream, + client: Realtime? + ) async { + // Merge both event types into a single ordered stream using a shared channel. + // We use a simple approach: run two child tasks, one for each stream, that feed + // a common continuation. + enum Event { + case background + case foreground + } + + let (eventStream, eventCont) = AsyncStream.makeStream() + + let bgTask = Task { + for await _ in backgroundStream { + eventCont.yield(.background) + } + } + let fgTask = Task { + for await _ in foregroundStream { + eventCont.yield(.foreground) + } + } + defer { + bgTask.cancel() + fgTask.cancel() + eventCont.finish() + } + + var didBackgroundWhileDropped = false + + for await event in eventStream { + if Task.isCancelled { break } + guard let client else { break } + switch event { + case .background: + // Record that we entered the background. We don't know yet if the socket + // will survive — we check on foreground. + didBackgroundWhileDropped = true + case .foreground: + guard didBackgroundWhileDropped else { continue } + didBackgroundWhileDropped = false + // Delegate the reconnect decision to the actor (it checks intentionalDisconnect + // and current connection state). + await client.handleAppForeground() + } + } + } +} diff --git a/Sources/RealtimeV3/LifecyclePolicy.swift b/Sources/RealtimeV3/LifecyclePolicy.swift new file mode 100644 index 000000000..d8ac54905 --- /dev/null +++ b/Sources/RealtimeV3/LifecyclePolicy.swift @@ -0,0 +1,26 @@ +// +// LifecyclePolicy.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +/// Controls how the Realtime connection responds to app lifecycle events. +public enum LifecyclePolicy: Sendable { + /// The caller manages connection/disconnection manually. + case manual + /// The SDK automatically manages the connection based on app lifecycle events. + case automatic +} + +extension LifecyclePolicy { + /// `.automatic` on iOS/macOS/tvOS/visionOS; `.manual` on watchOS and Linux + /// where lifecycle observation is not supported. + public static let automaticDefault: LifecyclePolicy = { + #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) + return .automatic + #else + return .manual + #endif + }() +} diff --git a/Sources/RealtimeV3/Logging/RealtimeLogger.swift b/Sources/RealtimeV3/Logging/RealtimeLogger.swift new file mode 100644 index 000000000..8c386f83c --- /dev/null +++ b/Sources/RealtimeV3/Logging/RealtimeLogger.swift @@ -0,0 +1,191 @@ +// +// RealtimeLogger.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation + +// MARK: - RealtimeLogger + +/// A sink that receives structured log events from the Realtime SDK. +/// +/// Implement this protocol to route events to any destination — os.Logger, file, +/// remote telemetry, etc. Implementations must be `Sendable` and must never throw. +public protocol RealtimeLogger: Sendable { + func log(_ event: LogEvent) +} + +// MARK: - LogEvent + +/// A structured log event emitted by the Realtime SDK. +/// +/// `metadata` carries auxiliary key-value pairs. Numeric metrics (latency, attempt +/// counters) are encoded as decimal string values under well-known keys: +/// - `"heartbeat.rtt_ms"` — heartbeat round-trip time in milliseconds. +/// - `"reconnect.attempt"` — reconnect attempt number (1-based). +/// - `"broadcast.ack_latency_ms"` — acked broadcast round-trip time in milliseconds. +public struct LogEvent: Sendable { + /// Severity level of the event. + public let level: LogLevel + /// Functional category the event belongs to. + public let category: Category + /// Human-readable description of what happened. + public let message: String + /// Auxiliary key-value pairs — numeric metrics are encoded as decimal strings. + public let metadata: [String: String] + /// Wall-clock time the event was created. + public let timestamp: Date + + public init( + level: LogLevel, + category: Category, + message: String, + metadata: [String: String] = [:], + timestamp: Date = Date() + ) { + self.level = level + self.category = category + self.message = message + self.metadata = metadata + self.timestamp = timestamp + } +} + +// MARK: - LogLevel + +/// Severity levels mirroring conventional logging frameworks. +public enum LogLevel: Sendable { + case debug + case info + case warn + case error +} + +// MARK: - Category + +/// Functional category of a log event within the Realtime SDK. +public enum Category: Sendable { + /// Events related to the WebSocket connection lifecycle. + case connection + /// Events related to channel join/leave lifecycle. + case channel + /// Events related to broadcast send/receive. + case broadcast + /// Events related to presence track/untrack. + case presence + /// Events related to postgres_changes subscriptions. + case postgres +} + +// MARK: - OSLogLogger + +#if canImport(OSLog) + import OSLog + + /// A `RealtimeLogger` that forwards events to `os.Logger` (Apple unified logging). + /// + /// Available on macOS 11+, iOS 14+, tvOS 14+, watchOS 7+, visionOS 1+. + @available(macOS 11, iOS 14, tvOS 14, watchOS 7, visionOS 1, *) + public struct OSLogLogger: RealtimeLogger { + private let logger: Logger + + /// Creates an `OSLogLogger` using the given subsystem and category. + /// + /// - Parameters: + /// - subsystem: Reverse-DNS identifier for the logger (e.g. `"io.supabase.realtime"`). + /// - category: OSLog category string. Defaults to `"RealtimeV3"`. + public init(subsystem: String = "io.supabase.realtime", category: String = "RealtimeV3") { + self.logger = Logger(subsystem: subsystem, category: category) + } + + public func log(_ event: LogEvent) { + let message = Self.format(event) + switch event.level { + case .debug: + logger.debug("\(message, privacy: .public)") + case .info: + logger.info("\(message, privacy: .public)") + case .warn: + logger.warning("\(message, privacy: .public)") + case .error: + logger.error("\(message, privacy: .public)") + } + } + + private static func format(_ event: LogEvent) -> String { + var parts = ["[\(event.category)] \(event.message)"] + if !event.metadata.isEmpty { + let metaString = event.metadata.map { "\($0.key)=\($0.value)" }.sorted().joined( + separator: " ") + parts.append(metaString) + } + return parts.joined(separator: " | ") + } + } +#endif + +// MARK: - StdoutLogger + +/// A `RealtimeLogger` that prints formatted log lines to standard output. +/// +/// Output format: `[ISO8601 timestamp] [LEVEL] [category] message | key=value ...` +public struct StdoutLogger: RealtimeLogger { + public init() {} + + public func log(_ event: LogEvent) { + let timestamp = Self.formatDate(event.timestamp) + var line = + "\(timestamp) [\(Self.levelLabel(event.level))] [\(Self.categoryLabel(event.category))] \(event.message)" + if !event.metadata.isEmpty { + let metaString = event.metadata.map { "\($0.key)=\($0.value)" }.sorted().joined( + separator: " ") + line += " | \(metaString)" + } + print(line) + } + + private static func formatDate(_ date: Date) -> String { + // Use a thread-local formatter to avoid Sendable issues with ISO8601DateFormatter. + // The format is ISO 8601 with fractional seconds. + var calendar = Calendar(identifier: .gregorian) + // The format string emits a "Z" suffix, so decompose in UTC — otherwise the + // printed components would be local time mislabeled as UTC. + calendar.timeZone = TimeZone(identifier: "UTC") ?? .gmt + let comps = calendar.dateComponents( + [.year, .month, .day, .hour, .minute, .second], + from: date + ) + let ms = Int((date.timeIntervalSince1970 - floor(date.timeIntervalSince1970)) * 1000) + let year = comps.year ?? 0 + let month = comps.month ?? 0 + let day = comps.day ?? 0 + let hour = comps.hour ?? 0 + let minute = comps.minute ?? 0 + let second = comps.second ?? 0 + return String( + format: "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", + year, month, day, hour, minute, second, ms + ) + } + + private static func levelLabel(_ level: LogLevel) -> String { + switch level { + case .debug: return "DEBUG" + case .info: return "INFO" + case .warn: return "WARN" + case .error: return "ERROR" + } + } + + private static func categoryLabel(_ category: Category) -> String { + switch category { + case .connection: return "connection" + case .channel: return "channel" + case .broadcast: return "broadcast" + case .presence: return "presence" + case .postgres: return "postgres" + } + } +} diff --git a/Sources/RealtimeV3/PhoenixMessage.swift b/Sources/RealtimeV3/PhoenixMessage.swift new file mode 100644 index 000000000..563a11b16 --- /dev/null +++ b/Sources/RealtimeV3/PhoenixMessage.swift @@ -0,0 +1,92 @@ +// +// PhoenixMessage.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 27/06/26. +// + +import Foundation + +// MARK: - PhoenixEvent + +/// A Phoenix event name. An open `RawRepresentable` wrapper so server-sent +/// events unknown to the client still round-trip. Known events are provided +/// as static constants. +public struct PhoenixEvent: RawRepresentable, Sendable, Hashable, ExpressibleByStringLiteral { + public let rawValue: String + public init(rawValue: String) { self.rawValue = rawValue } + public init(stringLiteral value: String) { self.rawValue = value } + + public static let broadcast = PhoenixEvent(rawValue: "broadcast") + public static let presence = PhoenixEvent(rawValue: "presence") + public static let postgresChanges = PhoenixEvent(rawValue: "postgres_changes") + public static let presenceState = PhoenixEvent(rawValue: "presence_state") + public static let presenceDiff = PhoenixEvent(rawValue: "presence_diff") + public static let system = PhoenixEvent(rawValue: "system") + public static let reply = PhoenixEvent(rawValue: "phx_reply") + public static let close = PhoenixEvent(rawValue: "phx_close") + public static let error = PhoenixEvent(rawValue: "phx_error") + public static let join = PhoenixEvent(rawValue: "phx_join") + public static let leave = PhoenixEvent(rawValue: "phx_leave") + public static let heartbeat = PhoenixEvent(rawValue: "heartbeat") + public static let accessToken = PhoenixEvent(rawValue: "access_token") +} + +// MARK: - PhoenixMessage + +/// A Phoenix protocol message received from the WebSocket connection. +public struct PhoenixMessage: Sendable { + /// Phoenix join reference correlating this frame to its `phx_join`. Always + /// `nil` when the channel is configured for protocol v1 (4-tuple frames + /// have no joinRef field). Under v2: `nil` for frames that predate the + /// current join (rare). + public let joinRef: String? + + /// Phoenix message reference for request/reply correlation. Set on + /// pushes the SDK sent and on the matching `phx_reply`. `nil` for + /// server-pushed events (`broadcast`, `postgres_changes`, etc.). + public let ref: String? + + /// Channel topic this frame belongs to. Always matches this channel's topic + /// for channel iterators; included on the struct so consumers that hand + /// `PhoenixMessage` values across boundaries (logging, debugging, + /// multi-topic aggregation) keep the routing key. + public let topic: String + + /// Server-side event name. Includes user-level events (`"broadcast"`, + /// `"postgres_changes"`, `"presence_diff"`, `"presence_state"`, `"system"`) + /// and Phoenix internals (`"phx_reply"`, `"phx_close"`, `"phx_error"`). + public let event: PhoenixEvent + + /// Raw payload as received. JSON for text frames, `Data` for binary + /// (Phoenix v2 broadcast). + public let payload: PhoenixPayload + + /// Local receipt timestamp. + public let receivedAt: Date + + /// Creates a new Phoenix message. + public init( + joinRef: String?, + ref: String?, + topic: String, + event: PhoenixEvent, + payload: PhoenixPayload, + receivedAt: Date + ) { + self.joinRef = joinRef + self.ref = ref + self.topic = topic + self.event = event + self.payload = payload + self.receivedAt = receivedAt + } +} + +/// The payload of a Phoenix message. +public enum PhoenixPayload: Sendable { + /// JSON payload from a text frame. + case json(JSONValue) + /// Binary payload from a binary frame. + case binary(Data) +} diff --git a/Sources/RealtimeV3/Postgres/ChangeRegistration.swift b/Sources/RealtimeV3/Postgres/ChangeRegistration.swift new file mode 100644 index 000000000..b412f49b8 --- /dev/null +++ b/Sources/RealtimeV3/Postgres/ChangeRegistration.swift @@ -0,0 +1,140 @@ +// +// ChangeRegistration.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation + +// MARK: - ChangeEventVariant + +/// Variant protocol — each variant is itself generic over the row type and +/// declares the element type of `postgresChanges(for:)` for that variant. +public protocol ChangeEventVariant: Sendable { + associatedtype Element: Sendable +} + +// MARK: - Variant types + +/// Variant for INSERT events. The stream element is the decoded row `T`. +public enum Insert: ChangeEventVariant { + public typealias Element = T +} + +/// Variant for UPDATE events. The stream element wraps the new record and +/// optional old record raw JSON. +public enum Update: ChangeEventVariant { + public typealias Element = PostgresUpdate +} + +/// Variant for DELETE events. The stream element wraps the old record raw JSON. +public enum Delete: ChangeEventVariant { + public typealias Element = PostgresDelete +} + +/// Variant that receives INSERT, UPDATE, and DELETE events combined. +public enum AnyEvent: ChangeEventVariant { + public typealias Element = PostgresChange +} + +// MARK: - Element wrapper types + +/// Wraps an UPDATE event with the new fully-decoded record and the raw old record. +/// +/// The backend does not guarantee `oldRecord` is a full row unless the table +/// has `REPLICA IDENTITY FULL` set and RLS permits reading the old values. +public struct PostgresUpdate: Sendable { + /// Fully decoded new row. + public let record: T + /// Raw old record. May contain only key columns under default `REPLICA IDENTITY`. + public let oldRecord: JSONValue? +} + +/// Wraps a DELETE event with the raw old record. +/// +/// The full old row is only available when the table has `REPLICA IDENTITY FULL` +/// and the caller has read access via RLS. +public struct PostgresDelete: Sendable { + /// Raw old record. + public let oldRecord: JSONValue +} + +/// Tagged union of all postgres change variants for use with `AnyEvent`. +public enum PostgresChange: Sendable { + case insert(T) + case update(PostgresUpdate) + case delete(PostgresDelete) +} + +// MARK: - Event mask + +/// The event mask sent to the server in the postgres_changes entry. +enum PostgresEventMask: String, Sendable { + case insert = "INSERT" + case update = "UPDATE" + case delete = "DELETE" + case all = "*" +} + +// MARK: - VariantKind + +/// Discriminator for the variant type at the `ChangeRegistrationConfig` level. +/// +/// Stored in `ChangeRegistrationConfig` so `Channel.postgresChanges(for:)` can switch on the +/// known variant kind and build the type-erased decode+yield closure without needing to +/// keep the generic type parameter `E` in the registry (which would require existential boxing). +/// +/// The mapping is: +/// - `.insert` → `Insert`, `E.Element == JSONValue` +/// - `.update` → `Update`, `E.Element == PostgresUpdate` +/// - `.delete` → `Delete`, `E.Element == PostgresDelete` +/// - `.anyEvent`→ `AnyEvent`, `E.Element == PostgresChange` +enum VariantKind: Sendable { + case insert + case update + case delete + case anyEvent +} + +// MARK: - ChangeRegistrationConfig + +/// Internal descriptor of a postgres-changes registration. Captured in +/// `ChangeRegistration` and serialised into the `phx_join` payload. +struct ChangeRegistrationConfig: Sendable { + var event: PostgresEventMask + var schema: String + var table: String + /// Serialized filter string (e.g. `"room_id=eq.1"`). `nil` means no filter. + var filter: String? + /// Stable per-registration identifier used for server-id routing (Task 28). + let id: UUID + /// Identity of the owning channel — used by Task 28 to detect `.unknownToken`. + let channelID: ObjectIdentifier + /// The variant kind — used by Task 28 to build the type-erased decode/yield closure. + let variantKind: VariantKind +} + +// MARK: - ChangeRegistration + +/// An opaque token that records a postgres-changes subscription intent. +/// +/// Obtain tokens via `channel.inserts(...)`, `channel.updates(...)`, +/// `channel.deletes(...)`, or `channel.changes(...)` **before** calling +/// `channel.subscribe()`. Each factory appends the token to the channel's +/// pending-registration set; when `subscribe()` triggers the `phx_join` +/// handshake those entries are baked into `config.postgres_changes`. +/// +/// **Reusable across subscribe cycles.** After `channel.leave()` the same +/// token replays on the next `channel.subscribe()`. Registering new tokens +/// while the channel is `.joined` or `.joining` throws +/// `.cannotRegisterAfterJoin`. +/// +/// The generic parameter `E` is a `ChangeEventVariant` that determines the +/// element type of the `postgresChanges(for:)` stream (Task 28). Its internal +/// state is not public — callers treat this type as opaque. +public struct ChangeRegistration: Sendable { + /// Internal config read by `Channel` to build the `phx_join` payload and + /// by Task 28 to route incoming server events. + let config: ChangeRegistrationConfig +} diff --git a/Sources/RealtimeV3/Postgres/RealtimePostgresFilterValue.swift b/Sources/RealtimeV3/Postgres/RealtimePostgresFilterValue.swift new file mode 100644 index 000000000..4f548db1b --- /dev/null +++ b/Sources/RealtimeV3/Postgres/RealtimePostgresFilterValue.swift @@ -0,0 +1,45 @@ +// +// RealtimePostgresFilterValue.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation + +/// A value that can be used to filter Realtime postgres-changes in a channel. +/// +/// Conforming types must provide a `rawValue` string that represents the value +/// in the wire format expected by the Realtime backend (i.e. the portion after +/// the operator dot in `column=op.value`). +public protocol RealtimePostgresFilterValue: Sendable { + var rawValue: String { get } +} + +extension String: RealtimePostgresFilterValue { + public var rawValue: String { self } +} + +extension Int: RealtimePostgresFilterValue { + public var rawValue: String { "\(self)" } +} + +extension Double: RealtimePostgresFilterValue { + public var rawValue: String { "\(self)" } +} + +extension Bool: RealtimePostgresFilterValue { + public var rawValue: String { "\(self)" } +} + +extension UUID: RealtimePostgresFilterValue { + public var rawValue: String { uuidString } +} + +extension Date: RealtimePostgresFilterValue { + public var rawValue: String { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return formatter.string(from: self) + } +} diff --git a/Sources/RealtimeV3/Postgres/UntypedFilter.swift b/Sources/RealtimeV3/Postgres/UntypedFilter.swift new file mode 100644 index 000000000..39b3b4334 --- /dev/null +++ b/Sources/RealtimeV3/Postgres/UntypedFilter.swift @@ -0,0 +1,227 @@ +// +// UntypedFilter.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation + +// MARK: - UntypedFilter + +/// An untyped filter for Realtime postgres-changes subscriptions. +/// +/// Use this when the row type cannot or does not conform to `RealtimeTable`. +/// Column names are raw strings; values are still constrained to +/// `RealtimePostgresFilterValue` for correct wire encoding. +/// +/// Each clause serializes to `column=op.value`. Compound filters join multiple +/// clauses with `,` (AND semantics on the backend). Use `.not` to negate a +/// filter: it prepends `not.` before the operator in every clause it wraps, so +/// `.not(.eq("col", 1))` produces `col=not.eq.1`. +/// +/// `isNull` produces `column=is.null`; `isNotNull` produces +/// `column=not.is.null` (matching the backend `not.` prefix convention). +/// +/// The `in` factory enforces a maximum of 100 values — the backend hard-limits +/// this, and exceeding it is a programmer error, so it traps via `precondition`. +/// +/// String values that contain commas (`,`), opening parentheses (`(`), +/// closing parentheses (`)`), or backslashes (`\`) are automatically +/// double-quoted inside an `in` value list, with internal double-quotes escaped +/// as `\"` and backslashes escaped as `\\`. +public struct UntypedFilter: Sendable { + + // MARK: - Stored properties + + /// The wire-format representation of this filter. + /// + /// A single clause has the form `column=op.value`. A compound filter joins + /// multiple clauses with `,`. + public let serialized: String + + // MARK: - Initializer + + private init(_ serialized: String) { + self.serialized = serialized + } + + // MARK: - Comparison factories + + /// Creates an equality filter: `column=eq.value`. + public static func eq( + _ column: String, + _ value: any RealtimePostgresFilterValue + ) -> UntypedFilter { + UntypedFilter("\(column)=eq.\(value.rawValue)") + } + + /// Creates an inequality filter: `column=neq.value`. + public static func neq( + _ column: String, + _ value: any RealtimePostgresFilterValue + ) -> UntypedFilter { + UntypedFilter("\(column)=neq.\(value.rawValue)") + } + + /// Creates a greater-than filter: `column=gt.value`. + public static func gt( + _ column: String, + _ value: any RealtimePostgresFilterValue + ) -> UntypedFilter { + UntypedFilter("\(column)=gt.\(value.rawValue)") + } + + /// Creates a greater-than-or-equal filter: `column=gte.value`. + public static func gte( + _ column: String, + _ value: any RealtimePostgresFilterValue + ) -> UntypedFilter { + UntypedFilter("\(column)=gte.\(value.rawValue)") + } + + /// Creates a less-than filter: `column=lt.value`. + public static func lt( + _ column: String, + _ value: any RealtimePostgresFilterValue + ) -> UntypedFilter { + UntypedFilter("\(column)=lt.\(value.rawValue)") + } + + /// Creates a less-than-or-equal filter: `column=lte.value`. + public static func lte( + _ column: String, + _ value: any RealtimePostgresFilterValue + ) -> UntypedFilter { + UntypedFilter("\(column)=lte.\(value.rawValue)") + } + + // MARK: - Set membership + + /// Creates an `in` filter: `column=in.(v1,v2,...)`. + /// + /// - Precondition: `values.count <= 100`. The backend hard-limits in-lists to + /// 100 entries; exceeding this is a programmer error. + /// + /// String values that contain commas, parentheses, or backslashes are + /// double-quoted with internal escaping so the backend can parse them correctly. + public static func `in`( + _ column: String, + _ values: [any RealtimePostgresFilterValue] + ) -> UntypedFilter { + precondition( + values.count <= 100, + "UntypedFilter.in: too many values (\(values.count)); the backend permits at most 100" + ) + let encoded = + values + .map { quoteInValue($0.rawValue) } + .joined(separator: ",") + return UntypedFilter("\(column)=in.(\(encoded))") + } + + // MARK: - Pattern matching + + /// Creates a LIKE filter: `column=like.pattern`. + public static func like(_ column: String, _ pattern: String) -> UntypedFilter { + UntypedFilter("\(column)=like.\(pattern)") + } + + /// Creates an ILIKE (case-insensitive LIKE) filter: `column=ilike.pattern`. + public static func ilike(_ column: String, _ pattern: String) -> UntypedFilter { + UntypedFilter("\(column)=ilike.\(pattern)") + } + + /// Creates a regular-expression match filter: `column=match.pattern`. + public static func match(_ column: String, _ pattern: String) -> UntypedFilter { + UntypedFilter("\(column)=match.\(pattern)") + } + + /// Creates a case-insensitive regular-expression match filter: + /// `column=imatch.pattern`. + public static func imatch(_ column: String, _ pattern: String) -> UntypedFilter { + UntypedFilter("\(column)=imatch.\(pattern)") + } + + // MARK: - Null checks + + /// Creates an IS NULL filter: `column=is.null`. + public static func isNull(_ column: String) -> UntypedFilter { + UntypedFilter("\(column)=is.null") + } + + /// Creates an IS NOT NULL filter: `column=not.is.null`. + /// + /// Uses the `not.` prefix convention (consistent with all other negations) rather + /// than the separate `isnotnull` operator. + public static func isNotNull(_ column: String) -> UntypedFilter { + UntypedFilter("\(column)=not.is.null") + } + + // MARK: - Distinct check + + /// Creates an IS DISTINCT FROM filter: `column=isdistinct.value`. + public static func isDistinct( + _ column: String, + _ value: any RealtimePostgresFilterValue + ) -> UntypedFilter { + UntypedFilter("\(column)=isdistinct.\(value.rawValue)") + } + + // MARK: - Composition + + /// Returns a new filter that ANDs this filter with `other` by joining their + /// serialized clauses with `,`. + public func and(_ other: UntypedFilter) -> UntypedFilter { + UntypedFilter("\(serialized),\(other.serialized)") + } + + /// Returns a new filter that ANDs all given filters by joining their serialized + /// clauses with `,`. + public static func all(_ filters: [UntypedFilter]) -> UntypedFilter { + UntypedFilter(filters.map(\.serialized).joined(separator: ",")) + } + + /// Returns a new filter that negates every clause in `filter` by inserting + /// `not.` before the operator token. + /// + /// The transformation is applied to each comma-separated clause independently, + /// so `not(.eq("a",1).and(.eq("b",2)))` produces `a=not.eq.1,b=not.eq.2`. + public static func not(_ filter: UntypedFilter) -> UntypedFilter { + let negated = filter.serialized + .split(separator: ",", omittingEmptySubsequences: false) + .map { clause -> String in + let s = String(clause) + // Each clause has the form `column=op.rest` (or `column=not.op.rest`). + // Find the `=` and insert `not.` immediately after it. + guard let eqRange = s.range(of: "=") else { + return s + } + let afterEq = s[eqRange.upperBound...] + return "\(s[.. String { + let needsQuoting = + raw.contains(",") + || raw.contains("(") + || raw.contains(")") + || raw.contains("\\") + || raw.contains("\"") + guard needsQuoting else { return raw } + let escaped = + raw + .replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\"\(escaped)\"" + } +} diff --git a/Sources/RealtimeV3/Realtime+FrameRouting.swift b/Sources/RealtimeV3/Realtime+FrameRouting.swift new file mode 100644 index 000000000..1c62da8f6 --- /dev/null +++ b/Sources/RealtimeV3/Realtime+FrameRouting.swift @@ -0,0 +1,88 @@ +// +// Realtime+FrameRouting.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Clocks +import Foundation +import IssueReporting + +extension Realtime { + // MARK: - Internal routing entry point + + /// Starts consuming frames from `connection` and routes them to the appropriate + /// handler. Spawns a detached `Task` so it does not block `connect()`. + /// + /// Call this once, immediately after the connection is established. + func startFrameRouting(connection: any RealtimeConnection) { + routingTask = Task { + await routeFrames(from: connection) + } + } + + /// Drains the `connection.frames` stream, decodes each frame, and dispatches + /// to `inflightPushRegistry` (for phx_reply) or the matching channel. + /// + /// A single malformed frame is logged and skipped; it does not kill the loop. + /// When the stream ends (normally or with an error), `handleConnectionLost` is called, + /// which drives the reconnection loop (Task 13). + private func routeFrames(from connection: any RealtimeConnection) async { + var streamError: (any Error)? + do { + for try await frame in connection.frames { + await handleFrame(frame) + } + } catch { + // Stream ended with an error (e.g. network loss). + streamError = error + } + + // Stream finished (normal or error) — trigger connection-loss handler. + // handleConnectionLost owns the reconnection loop and idempotency guard. + await handleConnectionLost( + .transportFailure(underlying: streamError ?? RealtimeError.disconnected) + ) + } + + /// Decodes and dispatches one `TransportFrame`. + private func handleFrame(_ frame: TransportFrame) async { + let message: PhoenixMessage + do { + let now = Date() + switch frame { + case .text(let text): + message = try serializer.decodeText(text, receivedAt: now) + case .binary(let data): + message = try serializer.decodeBinary(data, receivedAt: now) + } + } catch { + // Malformed frame: swallow and continue. A bad frame must never kill routing. + return + } + + if message.event == .reply { + // phx_reply: resolve the pending push registered for this ref. + guard let ref = message.ref else { return } + // Extract status and response from the payload JSON object. + // Shape: {"status": "...", "response": {...}} + guard + case .json(let json) = message.payload, + let obj = json.objectValue, + let status = obj["status"]?.stringValue, + let response = obj["response"] + else { + // Malformed reply payload: skip. + return + } + inflightPushRegistry.resolve(ref: ref, status: status, response: response) + return + } + + // Route to the matching channel (if registered). + if let channel = registry.channel(for: message.topic) { + await channel.receive(message) + } + } +} diff --git a/Sources/RealtimeV3/Realtime+Heartbeat.swift b/Sources/RealtimeV3/Realtime+Heartbeat.swift new file mode 100644 index 000000000..7fbeade8c --- /dev/null +++ b/Sources/RealtimeV3/Realtime+Heartbeat.swift @@ -0,0 +1,58 @@ +// +// Realtime+Heartbeat.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Clocks +import Foundation + +// MARK: - Heartbeat + +extension Realtime { + /// Starts the heartbeat loop and stores the task handle so it can be cancelled. + func startHeartbeat() { + heartbeatTask?.cancel() + let heartbeater = Heartbeater( + heartbeat: configuration.heartbeat, + clock: configuration.clock, + beat: { [weak self] in try await self?._sendHeartbeat() }, + onConnectionLost: { [weak self] error in + await self?.handleConnectionLost(error) + } + ) + heartbeatTask = heartbeater.start() + } + + /// Sends one `[null, ref, "phoenix", "heartbeat", {}]` frame on the current connection and + /// awaits its reply, updating `ConnectionStatus.latency` with the measured round-trip. + /// + /// Routes through ``_push`` with `lazyConnect: false`: a dead socket must surface as a + /// `.disconnected`/timeout (→ `onConnectionLost`) rather than trigger a reconnect. Throws + /// if the send fails or the reply times out, which the heartbeat loop maps to connection loss. + func _sendHeartbeat() async throws(RealtimeError) { + // Wall clock for round-trip measurement; scheduling/timeout uses `configuration.clock`. + let wallClock = ContinuousClock() + let sentAt = wallClock.now + _ = try await _push( + topic: "phoenix", .heartbeat, .text([:]), + joinRef: nil, lazyConnect: false, + ack: .require(timeout: configuration.heartbeat, error: .disconnected) + ) + updateLatency(sentAt.duration(to: wallClock.now)) + } + + /// Updates `ConnectionStatus.latency` in-place while preserving the current state. + private func updateLatency(_ latency: Duration) { + let current = statusBroadcaster.current + statusBroadcaster.emit( + ConnectionStatus(state: current.state, since: current.since, latency: latency) + ) + // Emit heartbeat RTT metric as a log event (spec §10 metrics-as-logs). + log( + .debug, .connection, "Heartbeat round-trip complete", + metadata: ["heartbeat.rtt_ms": "\(latency.inMilliseconds)"] + ) + } +} diff --git a/Sources/RealtimeV3/Realtime+Push.swift b/Sources/RealtimeV3/Realtime+Push.swift new file mode 100644 index 000000000..f4a56a7bf --- /dev/null +++ b/Sources/RealtimeV3/Realtime+Push.swift @@ -0,0 +1,168 @@ +// +// Realtime+Push.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Clocks +import Foundation +import Helpers + +// MARK: - Channel seam (internal API consumed by Channel) + +extension Realtime { + /// Returns the next monotonic ref string from the shared generator. + nonisolated func nextRef() -> String { + refGenerator.next() + } + + /// Sends a raw frame on the current connection without lazy-connecting. + /// + /// Throws `.disconnected` if no connection is available, `.transportFailure` if the send + /// fails. This is the single point where any frame reaches the socket. + func _rawSend(_ frame: TransportFrame) async throws(RealtimeError) { + guard let conn = connection else { + throw .disconnected + } + do { + try await conn.send(frame) + } catch { + throw .transportFailure(underlying: error) + } + } + + /// Encodes and sends a single Phoenix frame, optionally awaiting its `phx_reply`. + /// + /// The single funnel for every framed send — channel pushes (broadcast, presence, join, + /// leave, access_token) and the connection heartbeat all route through here. Handles ref + /// generation, text/binary encoding (mapping encode failures to `.encoding`), the send, + /// and — when `ack == .require` — reply correlation via the in-flight registry. Send + /// failures (`.transportFailure`/`.disconnected`) propagate unchanged. + /// + /// Because the registry buffers early replies, awaiting *after* sending is race-free, so + /// every ack site uses this one flow (no manual pre-register task needed). + /// + /// - Parameters: + /// - topic: The Phoenix topic (e.g. a channel's `realtime:` topic, or `"phoenix"` for heartbeat). + /// - event: The Phoenix event for the frame. + /// - body: The wire payload (text JSON, or a binary broadcast frame). + /// - ref: The push ref. Defaults to a fresh `nextRef()`; `join` passes its own ref so + /// `ref == joinRef`. + /// - joinRef: The join ref stamped on the frame. + /// - lazyConnect: When `true` (default) the socket is opened if needed before sending + /// (spec §6.1). The heartbeat passes `false` — it must report a dead connection via the + /// ack timeout rather than trigger a reconnect. + /// - ack: Whether to await a reply. + /// - Returns: The `phx_reply` when `ack == .require`, otherwise `nil`. + @discardableResult + func _push( + topic: String, + _ event: PhoenixEvent, + _ body: PushBody, + ref: String? = nil, + joinRef: String? = nil, + lazyConnect: Bool = true, + ack: AckPolicy = .none + ) async throws(RealtimeError) -> PushReply? { + let ref = ref ?? nextRef() + + let frame: TransportFrame + do { + switch body { + case .text(let payload): + frame = .text( + try serializer.encodeText( + joinRef: joinRef, ref: ref, topic: topic, event: event.rawValue, payload: payload)) + case .broadcastJSON(let payload): + frame = .binary( + try serializer.encodeBroadcastPush( + joinRef: joinRef, ref: ref, topic: topic, event: event.rawValue, jsonPayload: payload)) + case .broadcastData(let payload): + frame = .binary( + try serializer.encodeBroadcastPush( + joinRef: joinRef, ref: ref, topic: topic, event: event.rawValue, binaryPayload: payload) + ) + } + } catch { + throw .encoding(underlying: error) + } + + // Lazy connect (idempotent) then send. The heartbeat opts out so a dead socket surfaces + // as `.disconnected` rather than kicking off a reconnect that races the recovery loop. + if lazyConnect { + try await connect() + } + let wallClock = ContinuousClock() + let sentAt = wallClock.now + try await _rawSend(frame) + + switch ack { + case .none: + return nil + case .require(let timeout, let error): + let reply = try await awaitReply(ref: ref, timeout: timeout, timeoutError: error) + // Emit the acked-broadcast round-trip metric (spec §10 metrics-as-logs). The heartbeat + // reports its own `heartbeat.rtt_ms`; join/leave/presence acks have no defined metric. + if event == .broadcast { + log( + .debug, .broadcast, "Broadcast acknowledged", + metadata: [ + "broadcast.ack_latency_ms": "\(sentAt.duration(to: wallClock.now).inMilliseconds)" + ] + ) + } + return reply + } + } + + /// Encodes an `Encodable` value to `AnyJSON` using `configuration.encoder`, mapping any + /// failure to `.encoding`. Shared by channel broadcast/presence payloads and HTTP broadcast. + /// + /// `nonisolated`: it only reads the immutable, Sendable `configuration` and does pure + /// encoding, so callers (including the synchronous `Channel._encodeToJSON`) need no `await`. + nonisolated func _encodeToJSON(_ value: T) throws(RealtimeError) + -> AnyJSON + { + do { + let data = try configuration.encoder.encode(value) + return try JSONDecoder().decode(AnyJSON.self, from: data) + } catch { + throw .encoding(underlying: error) + } + } + + /// Registers `ref` with the in-flight registry and suspends until the matching + /// `phx_reply` arrives or `timeout` elapses on `configuration.clock`. + func awaitReply( + ref: String, + timeout: Duration, + timeoutError: RealtimeError + ) async throws(RealtimeError) -> PushReply { + try await inflightPushRegistry.awaitReply( + ref: ref, + timeout: timeout, + clock: configuration.clock, + timeoutError: timeoutError + ) + } + + /// Returns the current access token for channel join, consulting stored state in order: + /// + /// 1. `_overrideToken` — set by `updateToken(_:)`; takes highest precedence. + /// 2. `accessTokenProvider` — async closure supplied at init. + /// 3. `nil` — anonymous / public channels (no token configured). + /// + /// This ordering ensures a token pushed via `updateToken(_:)` is immediately + /// used for any subsequent joins, regardless of whether a provider is also set. + func accessTokenForJoin() async throws(RealtimeError) -> String? { + if let override = _overrideToken { return override } + guard let provider = accessTokenProvider else { return nil } + do { + return try await provider() + } catch { + throw .authenticationFailed( + reason: "Access token provider threw an error.", underlying: error) + } + } +} diff --git a/Sources/RealtimeV3/Realtime+Reconnection.swift b/Sources/RealtimeV3/Realtime+Reconnection.swift new file mode 100644 index 000000000..55fed7a41 --- /dev/null +++ b/Sources/RealtimeV3/Realtime+Reconnection.swift @@ -0,0 +1,173 @@ +// +// Realtime+Reconnection.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Clocks +import Foundation + +// MARK: - Connection loss + reconnection + +extension Realtime { + /// Connection-loss handler with automatic reconnection. + /// + /// When `intentionalDisconnect` is false (the default), this drives the reconnection + /// loop: it consults `configuration.reconnection.nextDelay`, sleeps on + /// `configuration.clock`, and retries `transport.connect` until success or the + /// policy returns `nil` (give up). + /// + /// Idempotency: re-entry is prevented by the `connection == nil` guard at the top of + /// this function. The guard atomically claims and clears the connection reference before + /// any suspension point, so a concurrent or re-entrant call (e.g. both the frame-routing + /// stream and the heartbeat firing almost simultaneously) will see `connection == nil` + /// and return immediately, preventing overlapping cleanup or reconnection loops. + /// + /// `isReconnecting` is reserved for Task 14 (disconnect()/state introspection) and is + /// NOT the idempotency mechanism. + func handleConnectionLost(_ error: RealtimeError) async { + // Atomically claim and clear the connection reference before any suspension point. + // This is the single-ownership guard: any concurrent or re-entrant call will see + // connection == nil and return immediately, preventing overlapping cleanup or + // reconnection loops. + guard let lostConnection = connection else { return } + connection = nil + + // Cancel the connection's background loops and fail all pending pushes. + await _teardownConnectionTasks(failPushesWith: error) + + // Close the connection we captured above. + await lostConnection.close(code: 1001, reason: "connection lost") + + // If the disconnect was intentional (set by disconnect()), stay closed. + // Do NOT overwrite .closed(.clientDisconnected) and do NOT trigger reconnection. + if intentionalDisconnect { + return + } + + // If the socket was closed by the idle-close timer, suppress reconnection. + // The idle close already transitioned status to .idle and cleared the connection. + // A future connect() call (from subscribe()) will re-open the socket. + if idleClosed { + return + } + + // Spawn reconnection as an unstructured Task so it runs independently of the routing + // task (which may be the caller and may be cancelled). The reconnection loop must NOT + // inherit cancellation from the routing task — it needs to outlive it. + reconnectTask = Task { + await runReconnectionLoop(initialError: error) + } + } + + /// Reconnection loop. Runs in its own unstructured Task so it is not affected by the + /// cancellation of the routing task that detected the connection loss. + private func runReconnectionLoop(initialError: RealtimeError) async { + isReconnecting = true + // Clear reconnectTask on ALL exit paths (success, give-up, cancellation) so Task 14 + // can reliably check `reconnectTask == nil` to determine whether reconnection is in progress. + defer { + isReconnecting = false + reconnectTask = nil + } + + var attempt = 1 + var lastError: any Error & Sendable = initialError + + while true { + // Consult the policy for the delay before this attempt. + guard let delay = configuration.reconnection.nextDelay(attempt, lastError) else { + // Policy says give up: fail all (in case new pushes arrived), go to closed. + await inflightPushRegistry.failAll(initialError) + transition(to: .closed(.transportFailure)) + // Terminate all eligible channels so their streams throw/finish (Task 29). + await terminateChannelsOnGiveUp() + return + } + + // Signal reconnecting. + log( + .info, .connection, "Reconnecting to Realtime server", + metadata: ["reconnect.attempt": "\(attempt)"] + ) + transition(to: .reconnecting(attempt: attempt, lastError: lastError)) + + // Wait the backoff delay on the configured clock. + do { + try await configuration.clock.sleep(for: delay) + } catch { + // CancellationError: actor is being torn down — exit silently. + return + } + + // Attempt to reconnect. + do { + let conn = try await _openConnection() + // Successfully reconnected: bind fresh tasks to the new connection. + // (reconnectTask is cleared by the defer at the top of this function.) + connection = conn + log(.info, .connection, "Reconnected to Realtime server") + transition(to: .connected) + startConnectionTasks(connection: conn) + // Re-join eligible channels (Task 29). + await rejoinEligibleChannels() + return + } catch { + // Reconnect attempt failed — record and loop. + log(.warn, .connection, "Reconnect attempt \(attempt) failed: \(error)") + lastError = error + attempt += 1 + } + } + } + + // MARK: - Channel rejoin on reconnect (Task 29) + + /// Re-joins all channels that were previously joined and not explicitly left by the user. + /// + /// Called after every successful reconnect. Channels with `shouldRejoin == true` are + /// eligible (they were transport-dropped, not user-left). Each channel's existing streams + /// (messages, broadcasts, postgres, presence) stay open across the gap — only the + /// `phx_join` handshake is re-sent. + /// + /// Channels are re-joined concurrently so a slow join on one topic does not block others. + private func rejoinEligibleChannels() async { + // Snapshot the full channel list. We check eligibility per-channel (actor hop). + let allChannels = registry.all + guard !allChannels.isEmpty else { return } + + // Re-join concurrently. Each channel's rejoin() checks its own shouldRejoin flag + // at the start and is a no-op if not eligible. + await withTaskGroup(of: Void.self) { group in + for channel in allChannels { + group.addTask { + // Only rejoin if this channel was previously joined (actor-isolated read). + guard await channel.shouldRejoin else { return } + await channel.rejoin() + } + } + } + } + + /// Terminates all eligible channels when the reconnection policy gives up. + /// + /// Called from the give-up path in `runReconnectionLoop`. For each channel with + /// `shouldRejoin == true`, transitions it to `.closed(.transportFailure)`, which + /// cascades stream terminations via the existing `transition(to:)` logic. + /// Those channels are then evicted from the registry. + private func terminateChannelsOnGiveUp() async { + // Snapshot topic+channel pairs. We check eligibility per-channel (actor hop). + let snapshot = registry.allByTopic + var toEvict: [String] = [] + for (topic, channel) in snapshot { + if await channel.shouldRejoin { + await channel.transition(to: .closed(.transportFailure)) + toEvict.append(topic) + } + } + for topic in toEvict { + registry.remove(topic: topic) + } + } +} diff --git a/Sources/RealtimeV3/Realtime.swift b/Sources/RealtimeV3/Realtime.swift new file mode 100644 index 000000000..88984b023 --- /dev/null +++ b/Sources/RealtimeV3/Realtime.swift @@ -0,0 +1,780 @@ +// +// Realtime.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Clocks +import ConcurrencyExtras +import Foundation +import Helpers +import IssueReporting + +/// The wire form of an outgoing push, consumed by ``Realtime/_push(topic:_:_:ref:joinRef:lazyConnect:ack:)``. +/// +/// `text` is a JSON text frame (join, leave, presence, access_token, heartbeat); the two +/// `broadcast*` cases are Phoenix binary broadcast frames (kind `0x03`) carrying either a +/// JSON envelope or raw bytes. +enum PushBody: Sendable { + case text(JSONObject) + case broadcastJSON(JSONObject) + case broadcastData(Data) +} + +/// Whether an outgoing push awaits a `phx_reply`. +/// +/// `require` suspends until the matching reply arrives or `timeout` elapses, throwing +/// `error` on timeout. Best-effort sends (e.g. access_token) use `none` and/or a +/// `try?` at the call site. +enum AckPolicy: Sendable { + case none + case require(timeout: Duration, error: RealtimeError) +} + +/// The top-level Realtime client. Manages the WebSocket connection, channel registry, +/// and distributes `ConnectionStatus` events to subscribers. +/// +/// Create exactly one `Realtime` per Supabase project. Obtain channels via +/// `channel(_:configure:)`, then call `connect()` to establish the WebSocket. +/// +/// ## Connection lifecycle +/// `connect()` is idempotent and coalescing — concurrent callers share the single +/// in-flight connect task. Once connected, repeated `connect()` calls are no-ops. +/// +/// ## Transport +/// Defaults to `URLSessionTransport()` (production WebSocket). Pass a custom +/// transport in tests via the `InMemoryTransport` test double. +public actor Realtime { + // MARK: - Public typealiases + + /// A closure that asynchronously vends a fresh access token. + public typealias AccessTokenProvider = @Sendable () async throws -> String + + // MARK: - Stored properties + + let url: URL + let apiKey: String + let accessTokenProvider: AccessTokenProvider? + let configuration: Configuration + + /// The logger extracted from `configuration` at init time. + /// Stored as `nonisolated let` so `log(...)` can be called from synchronous + /// actor-isolated contexts (including from Channel's `log` helper) without an `await`. + nonisolated let logger: (any RealtimeLogger)? + + private let transport: any RealtimeTransport + + /// HTTP client for broadcast API calls. Uses the HTTP-scheme version of `url`. + let httpClient: _HTTPClient + + /// Active connection returned by the transport after `connect()`. + /// Internal (not private) so `_rawSend` in `Realtime+Push.swift` can reach it. + var connection: (any RealtimeConnection)? + + /// In-flight connect task — used for coalescing concurrent `connect()` callers + /// and for idempotency once connected. + private var connectTask: Task? + + /// Background task that consumes `connection.frames` and routes messages. + // intra-module: read/written by Realtime+FrameRouting.swift (same module, separate file). + // Swift `private` does not cross file boundaries; `internal` is the tightest we can use here. + var routingTask: Task? + + /// Background task that sends periodic heartbeat frames and checks replies. + var heartbeatTask: Task? + + /// When `true`, a reconnection loop is currently running. + /// Set to `true` by `runReconnectionLoop`, reset when it exits. + var isReconnecting: Bool = false + + /// The running reconnection loop task. Stored so it can be cancelled on intentional disconnect. + var reconnectTask: Task? + + /// When `true`, connection loss should NOT trigger auto-reconnect. + /// Set by disconnect() in Task 14. + var intentionalDisconnect: Bool = false + + /// When `true`, the socket was closed by the idle-close timer (no live channels for + /// `disconnectOnEmptyChannelsAfter`). The idle close is intentional and must NOT trigger + /// the auto-reconnect loop, but it IS recoverable: the next `connect()` (from a new + /// `subscribe()`) clears this flag and re-opens the socket. + /// + /// Distinct from `intentionalDisconnect` (which is set by an explicit `disconnect()` call + /// and represents a permanent user intent). Using a separate flag preserves the semantics + /// of both: `intentionalDisconnect` keeps existing behaviour, and `idleClosed` adds the + /// new "idle, but reconnectable" state. + var idleClosed: Bool = false + + /// The pending idle-close timer task. Armed when the last live channel leaves (live count + /// transitions to 0). Cancelled when a channel joins (live count rises above 0) or when + /// `disconnect()` is called (to avoid double teardown). + /// + /// `nonisolated` call sites (`_markJoined` / `_markLeft`) arm / cancel via actor-isolated + /// async tasks spawned from those nonisolated methods — see their implementations. + var idleCloseTask: Task? + + /// Registry tracking in-flight pushes awaiting a `phx_reply`. + let inflightPushRegistry = InflightPushRegistry() + + /// The lifecycle event source injected at init time. `nonisolated let` so it can be + /// stored during the actor initializer before isolation is established. + /// `nil` when `lifecycle == .manual` or on unsupported platforms. + nonisolated let lifecycleSource: (any LifecycleEventSource)? + + /// The active lifecycle observer. Created lazily on the first `connect()` call and + /// cancelled on `disconnect()`. + private var lifecycleObserverHandle: LifecycleObserver? + + /// Monotonic ref generator shared across all protocol frames. + nonisolated let refGenerator = RefGenerator() + + /// Tracks topics of channels that have successfully joined but have not yet been explicitly left. + /// + /// ## Design: nonisolated + LockIsolated (Task 32) + /// Stored as `nonisolated let` backed by a `LockIsolated` value so the synchronous `deinit` + /// can read it without an actor hop (no `await`, no Swift 6.2 isolated deinit required). + /// + /// Topics are added by `_markJoined(_:)` when a channel transitions to `.joined`, and removed + /// by `_markLeft(_:)` when the channel is explicitly left or terminally evicted. + /// + /// ## disconnect() does NOT clear this set + /// `disconnect()` is a transport-level operation; it does NOT call `leave()` on any channel + /// (Decision 29). A channel that was joined but never left remains in `joinedTopics` through + /// a disconnect so the deinit warning fires when the developer forgets to call `leave()`. + nonisolated let joinedTopics = LockIsolated>([]) + + /// Serializer for encoding/decoding Phoenix protocol frames. + nonisolated let serializer = PhoenixSerializer() + + /// Topic → Channel collection. First-call-wins (Decision 33). + var registry = ChannelRegistry() + + /// Explicitly-set access token, stored by `updateToken(_:)`. + /// + /// ## Token precedence (spec §6.3) + /// `accessTokenForJoin()` checks this field first. When non-nil it is returned + /// directly, bypassing the `accessTokenProvider` closure. This allows callers to + /// push a concrete token (e.g. after a token refresh) without replacing the provider. + /// + /// Precedence order (highest → lowest): + /// 1. `_overrideToken` — set by `updateToken(_:)` + /// 2. `accessTokenProvider` — closure supplied at init + /// 3. `nil` (anonymous / public channels) + var _overrideToken: String? + + /// Holds the current `ConnectionStatus` and fans transitions out to `status` subscribers. + /// Internal (not private) so `updateLatency` in `Realtime+Heartbeat.swift` can update it. + var statusBroadcaster = ConnectionStatusBroadcaster( + initial: ConnectionStatus(state: .idle, since: Date(), latency: nil) + ) + + // MARK: - Initializer + + /// Creates a `Realtime` client. + /// + /// - Parameters: + /// - url: The Realtime endpoint base URL (e.g. `wss://project.supabase.co/realtime/v1`). + /// - apiKey: The project's anon (or service-role) key, sent as `apikey` query param. + /// - accessToken: Optional closure that vends a fresh JWT for authenticated operations. + /// Used for channel join, **not** for the WebSocket handshake (spec §6.3). + /// - configuration: Tuning knobs — heartbeat, reconnection, etc. Defaults to `.default`. + /// - transport: The transport to use for the WebSocket connection. + /// Defaults to `URLSessionTransport()` (production). Pass `InMemoryTransport` + /// in tests. + public init( + url: URL, + apiKey: String, + accessToken: AccessTokenProvider? = nil, + configuration: Configuration = .default, + transport: any RealtimeTransport = URLSessionTransport(), + urlSession: URLSession = .shared + ) { + self.init( + url: url, + apiKey: apiKey, + accessToken: accessToken, + configuration: configuration, + transport: transport, + urlSession: urlSession, + lifecycleSource: nil + ) + } + + /// Designated initializer. The `lifecycleSource` parameter is `internal` so tests can + /// inject a `TestLifecycleEventSource` without exposing it in the public API. + init( + url: URL, + apiKey: String, + accessToken: AccessTokenProvider? = nil, + configuration: Configuration = .default, + transport: any RealtimeTransport = URLSessionTransport(), + urlSession: URLSession = .shared, + lifecycleSource: (any LifecycleEventSource)? + ) { + self.url = url + self.apiKey = apiKey + self.accessTokenProvider = accessToken + self.configuration = configuration + self.logger = configuration.logger + self.transport = transport + + // Build the HTTP base URL from the WebSocket URL by converting the scheme. + // wss → https, ws → http. The path prefix (/realtime/v1) is preserved. + let httpBaseURL = Self._httpBaseURL(from: url) + + // Build the _HTTPClient. Token injection is handled at call time via actor isolation, + // not via the tokenProvider closure, because we need access to the actor-isolated + // _overrideToken at the time of each call. + self.httpClient = _HTTPClient(host: httpBaseURL, session: urlSession) + + // Resolve the effective lifecycle source: + // - Use the injected source if provided (test overrides). + // - Otherwise, when lifecycle == .automatic, create the platform NotificationCenter source. + // - .manual (or unsupported platform) → no source, no observation. + if let injected = lifecycleSource { + self.lifecycleSource = injected + } else { + switch configuration.lifecycle { + case .automatic: + #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) + self.lifecycleSource = NotificationCenterLifecycleEventSource() + #else + self.lifecycleSource = nil + #endif + case .manual: + self.lifecycleSource = nil + } + } + // The lifecycle observer is started lazily in connect() to avoid capturing + // `self` before the actor is fully initialized. + } + + /// Converts a WebSocket URL to its HTTP equivalent for use with ``_HTTPClient``. + /// + /// - `wss://` → `https://` + /// - `ws://` → `http://` + /// - Other schemes are left unchanged. + private static func _httpBaseURL(from wsURL: URL) -> URL { + var components = URLComponents(url: wsURL, resolvingAgainstBaseURL: false) ?? URLComponents() + switch components.scheme { + case "wss": components.scheme = "https" + case "ws": components.scheme = "http" + default: break + } + return components.url ?? wsURL + } + + // MARK: - Channel registry + + /// Returns an existing channel for `topic`, or creates a new one applying `configure`. + /// + /// **First-call-wins**: if a channel for `topic` already exists, the `configure` closure + /// is ignored and a debug warning is emitted (Decision 33). The returned channel's + /// `options` reflect the effective (first-call) options. + /// + /// - Parameters: + /// - topic: The Phoenix topic string (e.g. `"realtime:public:messages"`). + /// - configure: Closure applied to `ChannelOptions` before the channel is created. + /// Only called once — on first creation. + /// - Returns: The `Channel` for this topic (created or pre-existing). + public func channel( + _ topic: String, + configure: (inout ChannelOptions) -> Void = { _ in } + ) -> Channel { + // Prepend the "realtime:" namespace once. Guard against double-prefix if the caller + // already passes the fully-qualified form (e.g. "realtime:room:1"). + let fullTopic = topic.hasPrefix("realtime:") ? topic : "realtime:\(topic)" + + if let existing = registry.channel(for: fullTopic) { + // Decision 33: first-call-wins — warn only if the caller requested different options. + var requested = ChannelOptions() + configure(&requested) + if requested != existing.options { + reportIssue( + "Realtime.channel(\"\(topic)\") called more than once with different options. " + + "The options from the first call are in effect. " + + "To change options, deinit the previous channel first." + ) + } + return existing + } + + var options = ChannelOptions() + configure(&options) + let ch = Channel(topic: fullTopic, options: options, realtime: self) + registry.insert(ch, for: fullTopic) + return ch + } + + // MARK: - Connect + + // MARK: - Disconnect + + /// Closes the socket and awaits close completion. + /// + /// Does NOT evict the channel cache or call leave() on any channel (Decision 29). + /// The reconnection policy does NOT auto-reopen after a manual disconnect; the + /// next `connect()` starts a fresh session. + /// + /// - Note: Channels retain their `.joined` state across a manual disconnect — this + /// is by design, so a subsequent `connect()` transparently re-joins them. Call + /// ``Channel/leave()`` first if you want a channel to settle to `.closed`. + /// + /// Idempotent: if already disconnected, this is a no-op. + public func disconnect() async { + // Guard: if there is no active connection AND no reconnect in progress, nothing to do. + // We still set intentionalDisconnect=true to block any in-flight handleConnectionLost. + log(.info, .connection, "Disconnecting from Realtime server") + intentionalDisconnect = true + + // Cancel any pending idle-close timer so there is no double teardown. + idleCloseTask?.cancel() + idleCloseTask = nil + + // Cancel the lifecycle observer so foreground events no longer trigger reconnect. + lifecycleObserverHandle?.cancel() + lifecycleObserverHandle = nil + + // Cancel a running reconnection loop if any. + reconnectTask?.cancel() + reconnectTask = nil + + // Cancel the connection's background loops and fail any in-flight pushes (runs even with no + // live socket, e.g. mid-reconnect, so queued pushes don't hang). + await _teardownConnectionTasks(failPushesWith: .disconnected) + + // Close and clear the active connection (if any). + if let conn = connection { + connection = nil + await conn.close(code: 1000, reason: "client disconnected") + } + + // Transition to closed state. + transition(to: .closed(.clientDisconnected)) + } + + // MARK: - App lifecycle foreground handler + + /// Called by `LifecycleObserver` when the app returns to the foreground. + /// + /// Reconnects if all of the following are true: + /// - `intentionalDisconnect` is false (the user did not call `disconnect()`). + /// - The socket is not currently connected. + /// + /// If the connection is already live, this is a no-op. + func handleAppForeground() async { + // Do nothing if the caller explicitly disconnected. + guard !intentionalDisconnect else { return } + + // Already connected — no-op. + if case .connected = statusBroadcaster.current.state { return } + + // Already reconnecting — no-op (the running loop will recover). + if isReconnecting { return } + + // Trigger a fresh connect attempt (the same path as connect(), which clears + // intentionalDisconnect and coalesces concurrent callers). + try? await connect() + } + + // MARK: - Connect + + /// Establishes the WebSocket connection to the Realtime server. + /// + /// Idempotent: if already connected or connecting, this call joins the in-flight task + /// (coalescing) without triggering a second `transport.connect`. On success the + /// `status` stream emits `.connecting(attempt: 1)` then `.connected`. + /// + /// The WebSocket handshake uses the literal `apiKey`, never the `accessTokenProvider` + /// (spec §6.3 "On connect()"). + /// + /// - Throws: `RealtimeError.transportFailure` if the underlying transport fails. + public func connect() async throws(RealtimeError) { + // Clear the intentional-disconnect flag so reconnection works for the new session. + intentionalDisconnect = false + // Clear the idle-closed flag so a subscribe() after an idle close re-opens the socket. + idleClosed = false + // Cancel any pending idle-close timer (it would be stale after a new connect). + idleCloseTask?.cancel() + idleCloseTask = nil + + // Start the lifecycle observer lazily (idempotent — skipped if already running). + _startLifecycleObserverIfNeeded() + + // Already connected — no-op. + if case .connected = statusBroadcaster.current.state { + return + } + + // Coalesce concurrent callers onto the existing in-flight task. + if let existing = connectTask { + do { + try await existing.value + } catch let error as RealtimeError { + throw error + } catch { + throw .transportFailure(underlying: error) + } + return + } + + // Build and store the connect task. + let task = Task { + try await self._performConnect() + } + connectTask = task + + do { + try await task.value + connectTask = nil + } catch let error as RealtimeError { + // Preserve a specific RealtimeError (e.g. from _openConnection); only wrap raw + // transport errors as `.transportFailure`. + connectTask = nil + throw error + } catch { + connectTask = nil + throw .transportFailure(underlying: error) + } + } + + // MARK: - Status stream + + /// Returns a fresh `AsyncStream` seeded with the current status. + /// + /// Each call to `status` mints an independent stream. Callers should iterate it to + /// receive future transitions. The stream completes only when the continuation is + /// explicitly cancelled (e.g. task cancellation). + public var status: AsyncStream { + statusBroadcaster.makeStream { [weak self] id in + Task { [weak self] in await self?.removeStatusContinuation(id: id) } + } + } + + // MARK: - Private helpers + + private func removeStatusContinuation(id: UUID) { + statusBroadcaster.remove(id) + } + + func transition(to state: ConnectionStatus.State, latency: Duration? = nil) { + statusBroadcaster.emit(ConnectionStatus(state: state, since: Date(), latency: latency)) + } + + /// Stops the background work bound to the active connection: cancels the heartbeat and + /// frame-routing loops and fails all in-flight pushes with `error`. + /// + /// Shared teardown for `disconnect()`, the idle-close timer, and connection-loss handling. + /// Each caller owns clearing `connection`, closing the socket (with its own code/reason), any + /// caller-specific tasks/flags, and the subsequent status transition. + /// Internal (not private) so `handleConnectionLost` in `Realtime+Reconnection.swift` can call it. + func _teardownConnectionTasks(failPushesWith error: RealtimeError) async { + heartbeatTask?.cancel() + heartbeatTask = nil + routingTask?.cancel() + routingTask = nil + await inflightPushRegistry.failAll(error) + } + + /// Signals connecting, opens the transport, stores the connection, signals connected, + /// and starts the connection tasks. + private func _performConnect() async throws { + // Signal connecting. + log(.info, .connection, "Connecting to Realtime server") + transition(to: .connecting(attempt: 1)) + + // Open the transport (shared helper also used during reconnection). + let conn = try await _openConnection() + connection = conn + + // Signal connected. + log(.info, .connection, "Connected to Realtime server") + transition(to: .connected) + + // Start the background frame routing and heartbeat loops bound to this connection. + startConnectionTasks(connection: conn) + } + + // MARK: - Connection tasks + + /// Starts (or restarts) the frame routing task and heartbeat loop for `connection`. + /// + /// Call this after every successful connect — both initial and after a reconnect. + /// Cancels any pre-existing tasks first so they are never duplicated. + func startConnectionTasks(connection: any RealtimeConnection) { + startFrameRouting(connection: connection) + startHeartbeat() + } + + /// Builds the connect URL and headers, then calls transport.connect. + /// Used by both initial connect and reconnect. + /// Internal (not private) so the reconnection loop in `Realtime+Reconnection.swift` can reuse it. + func _openConnection() async throws -> any RealtimeConnection { + let connectURL = try Self._connectURL( + base: url, apiKey: apiKey, vsn: configuration.protocolVersion.rawValue) + + var headers = configuration.headers + headers["x-api-key"] = apiKey + + return try await transport.connect(to: connectURL, headers: headers) + } + + /// Builds the WebSocket connect URL from `base`: appends the Phoenix `/websocket` endpoint + /// (idempotently — guards against a caller-supplied suffix), and sets the `apikey` + `vsn` + /// query items, replacing any pre-existing ones. Pure and synchronous so it is unit-testable + /// without a transport. + nonisolated static func _connectURL( + base: URL, apiKey: String, vsn: String + ) throws(RealtimeError) -> URL { + var components = URLComponents(url: base, resolvingAgainstBaseURL: false) ?? URLComponents() + + // Supabase Realtime routes WebSocket upgrades at `.../websocket`. + let existingPath = components.path + if !existingPath.hasSuffix("/websocket") { + components.path = + existingPath.hasSuffix("/") ? existingPath + "websocket" : existingPath + "/websocket" + } + + var queryItems = components.queryItems ?? [] + queryItems.removeAll { $0.name == "apikey" || $0.name == "vsn" } + queryItems.append(URLQueryItem(name: "apikey", value: apiKey)) + // `vsn` lets the backend negotiate the serializer format (default v2 = "2.0.0"). + queryItems.append(URLQueryItem(name: "vsn", value: vsn)) + components.queryItems = queryItems + + guard let url = components.url else { + throw .transportFailure(underlying: URLError(.badURL)) + } + return url + } + + // MARK: - Leaked-channel deinit warning (Task 32) + + deinit { + // Synchronous deinit: read joinedTopics WITHOUT an actor hop. + // `joinedTopics` is a nonisolated LockIsolated — safe to read from a synchronous deinit + // on any thread without isolation (no `await`, no Swift 6.2 isolated deinit). + let leaked = joinedTopics.value + guard !leaked.isEmpty else { return } + + let sorted = leaked.sorted() + let list = sorted.joined(separator: ", ") + reportIssue( + "Realtime deinited with \(leaked.count) channel(s) still joined and never left: [\(list)]. " + + "Call channel.leave() before releasing the Realtime instance to avoid server-side " + + "resource leaks. In release builds the server will close the channels after its " + + "heartbeat timeout." + ) + } + + // MARK: - Joined-topic registry (nonisolated — readable from synchronous deinit) + + /// Records that `topic` has successfully joined. Called by `Channel` after a successful + /// `phx_join` handshake (transition to `.joined`). + /// + /// Declared `nonisolated` so `Channel` can call it synchronously from its actor-isolated + /// `transition(to:)` without an `await`, and so `deinit` can read the same `LockIsolated` + /// without an actor hop. + /// + /// ## Idle-close interaction + /// When the live-channel count rises above zero (i.e. the first channel joins), any pending + /// idle-close timer is cancelled. The actor-isolated cancellation is performed by spawning + /// an unstructured `Task` that hops back onto the actor. This is safe because: + /// - The task only writes actor-isolated state (`idleCloseTask`). + /// - Any ordering race (timer fires just before cancel) is benign: the timer performs its + /// own final liveness check before closing, so a concurrent join wins. + nonisolated func _markJoined(_ topic: String) { + joinedTopics.withValue { _ = $0.insert(topic) } + // Cancel any pending idle-close timer: live count is now > 0. + Task { await _cancelIdleCloseTimer() } + } + + /// Records that `topic` has been left or terminally evicted. Called by `Channel` when + /// it transitions to a terminal state (`.closed(.userRequested)` via `leave()`, or when + /// the reconnection policy gives up and the channel is evicted). + /// + /// Declared `nonisolated` for the same reasons as `_markJoined`. + /// + /// ## Idle-close interaction + /// When the live-channel count drops to zero (the last channel left), the idle-close timer + /// is armed. The actor-isolated arming is performed by spawning an unstructured `Task`. + nonisolated func _markLeft(_ topic: String) { + joinedTopics.withValue { _ = $0.remove(topic) } + // Arm the idle-close timer if live count just reached zero. + // The actual liveness check (is socket open? is count still 0?) happens inside the actor. + Task { await _armIdleCloseTimerIfNeeded() } + } + + // MARK: - Idle-close timer (Decision 39) + + /// Arms the idle-close timer if the socket is connected and no channels are live. + /// + /// Called from `_markLeft` (via an unstructured Task) whenever a channel leaves. + /// The timer fires after `configuration.disconnectOnEmptyChannelsAfter`; if the live + /// count is still zero and the socket is still open at that point, the socket is closed + /// with status `.idle` (NOT `.closed(.clientDisconnected)` — this is an optimization). + /// + /// ## Re-arm behaviour + /// Each call supersedes the previous timer. This handles rapid leave/join/leave cycles + /// correctly: only the last arming matters. Cancellation via `_cancelIdleCloseTimer` + /// prevents the close when a new channel joins before the timer fires. + private func _armIdleCloseTimerIfNeeded() { + // Only arm when the socket is connected and no live channels remain. + guard connection != nil else { return } + guard joinedTopics.value.isEmpty else { return } + + // Cancel any previously-armed timer (supersede it). + idleCloseTask?.cancel() + + let idleDuration = configuration.disconnectOnEmptyChannelsAfter + let clock = configuration.clock + + idleCloseTask = Task { + do { + try await clock.sleep(for: idleDuration) + } catch { + // Cancelled (a channel joined or disconnect() was called) — do nothing. + return + } + + // Re-check conditions after the sleep: connection still open, no live channels. + guard connection != nil else { return } + guard joinedTopics.value.isEmpty else { return } + + // Perform the idle close. + await _performIdleClose() + } + } + + /// Cancels the pending idle-close timer. Called from `_markJoined` when a channel joins. + private func _cancelIdleCloseTimer() { + idleCloseTask?.cancel() + idleCloseTask = nil + } + + /// Closes the socket as an idle-optimization (no live channels for the configured duration). + /// + /// - Status transitions to `.idle` (NOT `.closed(.clientDisconnected)`). + /// - Sets `idleClosed = true` to suppress the auto-reconnect loop in `handleConnectionLost`. + /// - Does NOT set `intentionalDisconnect` — the next `connect()` clears `idleClosed` and + /// fully re-opens the socket. + private func _performIdleClose() async { + guard let conn = connection else { return } + connection = nil + + log( + .info, .connection, + "Idle-closing socket (no live channels for disconnectOnEmptyChannelsAfter)") + + // Mark as idle-closed so handleConnectionLost (triggered by the frame stream ending) + // does NOT start the auto-reconnect loop. + idleClosed = true + idleCloseTask = nil + + // Cancel the connection's background loops and fail in-flight pushes. + await _teardownConnectionTasks(failPushesWith: .disconnected) + + // Close the underlying connection. + await conn.close(code: 1000, reason: "idle close") + + // Transition to .idle (recoverable — next connect() re-opens the socket). + transition(to: .idle) + } + + // MARK: - Token management + + /// Updates the access token used for future channel joins and pushes it to all + /// currently-joined channels via the Phoenix `access_token` event. + /// + /// ## Behavior + /// The new token is stored immediately (before any network I/O) so subsequent + /// `subscribe()` calls always use it, even if the socket is currently down. + /// + /// For each channel in the registry that is currently `.joined`, an `access_token` + /// frame is sent best-effort via `Channel.pushAccessToken(_:)`. The backend does + /// **not** send a `phx_reply` to this event (Finding I1), so `updateToken` returns + /// after queueing the pushes rather than awaiting an ACK. + /// + /// If `sendText` throws (e.g. socket is down), the per-channel push failure is + /// swallowed — the stored token still applies on the next reconnect/rejoin. + /// + /// - Parameter newToken: The new JWT to store and distribute. + /// - Throws: `RealtimeError` only if an unexpected non-send error occurs. Send + /// failures are swallowed (best-effort push semantics). + public func updateToken(_ newToken: String) async throws(RealtimeError) { + // Store the token first so future joins always pick it up, even if push fails. + _overrideToken = newToken + + // Push to each joined channel. Failures are swallowed (best-effort). + for channel in registry.all { + // Channel.pushAccessToken is a no-op unless the channel is .joined. + await channel.pushAccessToken(newToken) + } + } + + // MARK: - Lifecycle observer + + /// Starts the lifecycle observer if a source is available and no observer is running yet. + /// Idempotent: a second call while the observer is already active is a no-op. + private func _startLifecycleObserverIfNeeded() { + guard lifecycleObserverHandle == nil, let source = lifecycleSource else { return } + lifecycleObserverHandle = LifecycleObserver(source: source, client: self) + } + + // MARK: - Test shims + + #if DEBUG + /// Registers a pending push with the in-flight registry and suspends until the + /// matching `phx_reply` arrives or the timeout fires. + /// + /// - Note: **Test-only**, compiled out of release builds. + func _test_awaitReply(ref: String, timeoutError: RealtimeError) async throws -> PushReply { + try await inflightPushRegistry.awaitReply( + ref: ref, + timeout: configuration.joinTimeout, + clock: configuration.clock, + timeoutError: timeoutError + ) + } + + /// The number of pushes currently registered with the in-flight registry. + /// + /// - Note: **Test-only**, compiled out of release builds. Used to synchronize tests + /// before injecting reply frames. + var _test_pendingCount: Int { + inflightPushRegistry.pendingCount + } + #endif + + // MARK: - Logging helper + + /// Emits a structured `LogEvent` to the configured logger. + /// + /// No-ops when no logger is configured. Never throws and never affects control flow. + /// Declared `nonisolated` so it can be called from Channel's synchronous `log` helper + /// without requiring an `await`. + nonisolated func log( + _ level: LogLevel, + _ category: Category, + _ message: String, + metadata: [String: String] = [:] + ) { + logger?.log( + LogEvent( + level: level, + category: category, + message: message, + metadata: metadata + ) + ) + } +} + +extension Duration { + /// Whole milliseconds, for metric logging (`heartbeat.rtt_ms`, `broadcast.ack_latency_ms`). + /// Internal (not fileprivate) so `_push` in `Realtime+Push.swift` can use it too. + var inMilliseconds: Int64 { + components.seconds * 1000 + components.attoseconds / 1_000_000_000_000_000 + } +} diff --git a/Sources/RealtimeV3/RealtimeError.swift b/Sources/RealtimeV3/RealtimeError.swift new file mode 100644 index 000000000..be613cb1c --- /dev/null +++ b/Sources/RealtimeV3/RealtimeError.swift @@ -0,0 +1,78 @@ +// +// RealtimeError.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 27/06/26. +// + +import Foundation + +public enum RealtimeError: Error, Sendable { + case disconnected + case transportFailure(underlying: any Error & Sendable) + case reconnectionGaveUp(lastError: any Error & Sendable) + + case channelJoinTimeout + case channelJoinRejected(reason: String) + case notSubscribed + case channelClosed(CloseReason) + case cannotRegisterAfterJoin + case unknownToken + + case authenticationFailed(reason: String, underlying: (any Error & Sendable)?) + + case rateLimited(retryAfter: Duration?) + case serverError(code: Int, message: String) + case postgresSubscriptionFailed(reason: String) + + case broadcastFailed(reason: String) + case broadcastAckTimeout + + case decoding(type: String, underlying: any Error & Sendable) + case encoding(underlying: any Error & Sendable) + + case cancelled +} + +extension RealtimeError: LocalizedError { + public var errorDescription: String? { + switch self { + case .disconnected: + "The realtime connection is disconnected" + case .transportFailure: + "A transport failure occurred" + case .reconnectionGaveUp: + "Reconnection attempts have been exhausted" + case .channelJoinTimeout: + "The channel join request timed out" + case .channelJoinRejected(let reason): + "Channel join was rejected: \(reason)" + case .notSubscribed: + "The channel is not subscribed" + case .channelClosed: + "The channel has been closed" + case .cannotRegisterAfterJoin: + "Cannot register postgres changes after the channel has joined" + case .unknownToken: + "Unknown token" + case .authenticationFailed(let reason, _): + "Authentication failed: \(reason)" + case .rateLimited: + "Rate limited" + case .serverError(let code, let message): + "Server error (\(code)): \(message)" + case .postgresSubscriptionFailed(let reason): + "Postgres subscription failed: \(reason)" + case .broadcastFailed(let reason): + "Broadcast failed: \(reason)" + case .broadcastAckTimeout: + "Broadcast acknowledgment timed out" + case .decoding(let type, _): + "Failed to decode \(type)" + case .encoding: + "Failed to encode value" + case .cancelled: + "Operation was cancelled" + } + } +} diff --git a/Sources/RealtimeV3/RealtimeV3.swift b/Sources/RealtimeV3/RealtimeV3.swift new file mode 100644 index 000000000..7ff1fee72 --- /dev/null +++ b/Sources/RealtimeV3/RealtimeV3.swift @@ -0,0 +1,6 @@ +// +// RealtimeV3.swift +// RealtimeV3 +// + +// Umbrella file for the RealtimeV3 module. Types live in their own files. diff --git a/Sources/RealtimeV3/ReconnectionPolicy.swift b/Sources/RealtimeV3/ReconnectionPolicy.swift new file mode 100644 index 000000000..079a31d87 --- /dev/null +++ b/Sources/RealtimeV3/ReconnectionPolicy.swift @@ -0,0 +1,77 @@ +// +// ReconnectionPolicy.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation + +/// Determines when and whether to reconnect after a disconnection. +public struct ReconnectionPolicy: Sendable { + /// Returns the delay before the next reconnection attempt, or `nil` to give up. + /// + /// - Parameters: + /// - attempt: The zero-based reconnection attempt number. + /// - lastError: The error that caused the disconnection. + /// - Returns: The delay to wait before the next attempt, or `nil` to give up. + public var nextDelay: + @Sendable ( + _ attempt: Int, + _ lastError: any Error & Sendable + ) -> Duration? + + /// Never reconnects — always returns `nil`. + public static let never: Self = Self { _, _ in nil } + + /// Exponential backoff with optional jitter. + /// + /// - Parameters: + /// - initial: The delay for attempt 0. + /// - max: The maximum delay (clamped). + /// - jitter: Fractional jitter applied deterministically based on attempt number (0 = no jitter). + public static func exponentialBackoff( + initial: Duration, + max: Duration, + jitter: Double = 0.2 + ) -> Self { + Self { attempt, _ in + // Compute base: initial * 2^attempt, clamped to max + let multiplier = Double(1 << min(attempt, 62)) + let initialSeconds = + Double(initial.components.seconds) + + Double(initial.components.attoseconds) * 1e-18 + let baseSeconds = min( + initialSeconds * multiplier, + Double(max.components.seconds) + + Double(max.components.attoseconds) * 1e-18) + + // Apply deterministic jitter derived from attempt (no random, no Date). + // jitter:0 produces no adjustment; jitter>0 varies ±jitter based on attempt parity. + let jitterFraction: Double + if jitter == 0 { + jitterFraction = 0 + } else { + // Deterministic: odd attempts subtract jitter/2, even attempts add jitter/2 + jitterFraction = attempt % 2 == 0 ? jitter / 2 : -(jitter / 2) + } + let finalSeconds = Swift.max(0, baseSeconds * (1 + jitterFraction)) + let nanoseconds = Int64(finalSeconds * 1_000_000_000) + return .nanoseconds(nanoseconds) + } + } + + /// Fixed delay with an optional maximum number of attempts. + /// + /// - Parameters: + /// - delay: The fixed delay between attempts. + /// - maxAttempts: Maximum number of attempts. `nil` means unlimited. + public static func fixed(_ delay: Duration, maxAttempts: Int?) -> Self { + Self { attempt, _ in + if let max = maxAttempts, attempt >= max { + return nil + } + return delay + } + } +} diff --git a/Sources/RealtimeV3/Serialization/PhoenixSerializer.swift b/Sources/RealtimeV3/Serialization/PhoenixSerializer.swift new file mode 100644 index 000000000..05cc3befc --- /dev/null +++ b/Sources/RealtimeV3/Serialization/PhoenixSerializer.swift @@ -0,0 +1,325 @@ +// +// PhoenixSerializer.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 27/06/26. +// + +import Foundation +import Helpers + +/// Handles encoding/decoding between `PhoenixMessage` and the Phoenix protocol 2.0.0 wire format. +/// +/// Text frames use a JSON array format: `[joinRef, ref, topic, event, payload]`. +/// +/// ## Encoder note +/// This type uses bare `JSONEncoder()` / `JSONDecoder()` throughout. That is intentional: +/// these encoders operate on the **protocol frame structure** (arrays of strings, +/// `JSONObject`, `JSONValue`) rather than on user-supplied `Codable` types. Applying +/// `Configuration.encoder` (which may carry custom date or key strategies) to protocol +/// internals would corrupt the wire format. User payloads are encoded with +/// `Configuration.encoder` at the call sites in `Channel+Broadcast.swift`, +/// `Channel.sendPresenceTrack`, and `HttpBroadcast.swift` *before* they arrive here as +/// `JSONObject` / `Data`. +struct PhoenixSerializer: Sendable { + + // MARK: - Text encoding (JSON array format) + + /// Encodes a Phoenix message as a JSON array string: `[joinRef, ref, topic, event, payload]`. + func encodeText( + joinRef: String?, + ref: String?, + topic: String, + event: String, + payload: JSONObject + ) throws -> String { + let array: [AnyJSON] = [ + joinRef.map { .string($0) } ?? .null, + ref.map { .string($0) } ?? .null, + .string(topic), + .string(event), + .object(payload), + ] + + let data: Data + do { + data = try JSONEncoder().encode(array) + } catch { + throw RealtimeError.encoding(underlying: error) + } + + guard let text = String(data: data, encoding: .utf8) else { + struct UTF8EncodingError: Error & Sendable { + let message = "Failed to encode message as UTF-8 string." + } + throw RealtimeError.encoding(underlying: UTF8EncodingError()) + } + return text + } + + // MARK: - Binary encoding (type 0x03 - client -> server) + + /// Byte value for the binary frame kind field. + private enum BinaryKind: UInt8 { + /// Client -> server broadcast push. + case userBroadcastPush = 3 + /// Server -> client broadcast. + case userBroadcast = 4 + } + + /// Indicates whether the binary frame payload is raw bytes or JSON-encoded. + private enum PayloadEncoding: UInt8 { + case binary = 0 + case json = 1 + } + + /// Encodes a broadcast push as a binary frame (type 0x03) with a JSON payload. + /// + /// Binary frame format: + /// ``` + /// [kind:1][joinRef_len:1][ref_len:1][topic_len:1][event_len:1][meta_len:1][encoding:1] + /// [joinRef][ref][topic][event][metadata][payload] + /// ``` + func encodeBroadcastPush( + joinRef: String?, + ref: String?, + topic: String, + event: String, + jsonPayload: JSONObject + ) throws -> Data { + let payloadData: Data + do { + payloadData = try JSONEncoder().encode(jsonPayload) + } catch { + throw RealtimeError.encoding(underlying: error) + } + return try _encodeBroadcastPush( + joinRef: joinRef, + ref: ref, + topic: topic, + event: event, + encoding: .json, + payload: payloadData + ) + } + + /// Encodes a broadcast push as a binary frame (type 0x03) with a binary payload. + func encodeBroadcastPush( + joinRef: String?, + ref: String?, + topic: String, + event: String, + binaryPayload: Data + ) throws -> Data { + try _encodeBroadcastPush( + joinRef: joinRef, + ref: ref, + topic: topic, + event: event, + encoding: .binary, + payload: binaryPayload + ) + } + + private func _encodeBroadcastPush( + joinRef: String?, + ref: String?, + topic: String, + event: String, + encoding: PayloadEncoding, + payload: Data + ) throws -> Data { + struct OversizedHeaderField: Error & Sendable { + let message = "Binary frame header fields must not exceed 255 bytes each." + } + + let joinRefBytes = Data((joinRef ?? "").utf8) + let refBytes = Data((ref ?? "").utf8) + let topicBytes = Data(topic.utf8) + let eventBytes = Data(event.utf8) + // No metadata for now (empty). + let metaBytes = Data() + + guard joinRefBytes.count <= 255, + refBytes.count <= 255, + topicBytes.count <= 255, + eventBytes.count <= 255, + metaBytes.count <= 255 + else { + throw RealtimeError.encoding(underlying: OversizedHeaderField()) + } + + var data = Data() + data.append(BinaryKind.userBroadcastPush.rawValue) + data.append(UInt8(joinRefBytes.count)) + data.append(UInt8(refBytes.count)) + data.append(UInt8(topicBytes.count)) + data.append(UInt8(eventBytes.count)) + data.append(UInt8(metaBytes.count)) + data.append(encoding.rawValue) + data.append(joinRefBytes) + data.append(refBytes) + data.append(topicBytes) + data.append(eventBytes) + data.append(metaBytes) + data.append(payload) + + return data + } + + // MARK: - Binary decoding (type 0x04 - server -> client) + + /// Decodes a binary frame (type 0x04) into a `PhoenixMessage`. + /// + /// Binary frame format: + /// ``` + /// [kind:1][topic_len:1][event_len:1][meta_len:1][encoding:1] + /// [topic][event][metadata][payload] + /// ``` + /// + /// The returned message always has `event = "broadcast"`, `joinRef = nil`, and `ref = nil`. + func decodeBinary(_ data: Data, receivedAt: Date) throws -> PhoenixMessage { + struct StructuralError: Error & Sendable { + let message: String + } + + guard data.count >= 5 else { + throw RealtimeError.decoding( + type: "PhoenixMessage", + underlying: StructuralError(message: "Binary frame too short: \(data.count) bytes.") + ) + } + + let kind = data[data.startIndex] + guard kind == BinaryKind.userBroadcast.rawValue else { + throw RealtimeError.decoding( + type: "PhoenixMessage", + underlying: StructuralError( + message: + "Unexpected binary frame kind: \(kind), expected \(BinaryKind.userBroadcast.rawValue)." + ) + ) + } + + let topicLen = Int(data[data.startIndex + 1]) + let eventLen = Int(data[data.startIndex + 2]) + let metaLen = Int(data[data.startIndex + 3]) + let encodingByte = data[data.startIndex + 4] + + guard let encoding = PayloadEncoding(rawValue: encodingByte) else { + throw RealtimeError.decoding( + type: "PhoenixMessage", + underlying: StructuralError(message: "Unknown payload encoding: \(encodingByte).") + ) + } + + let headerSize = 5 + let expectedMinSize = headerSize + topicLen + eventLen + metaLen + guard data.count >= expectedMinSize else { + throw RealtimeError.decoding( + type: "PhoenixMessage", + underlying: StructuralError(message: "Binary frame too short for declared field lengths.") + ) + } + + var offset = data.startIndex + headerSize + + let topicData = data[offset..<(offset + topicLen)] + offset += topicLen + + // Ignore the user-facing event field from the frame; the Phoenix event for all binary + // broadcast frames is "broadcast". + offset += eventLen + + // Skip metadata for now. + offset += metaLen + + let payloadData = data[offset...] + + guard let topic = String(data: topicData, encoding: .utf8) else { + throw RealtimeError.decoding( + type: "PhoenixMessage", + underlying: StructuralError(message: "Failed to decode topic as UTF-8.") + ) + } + + let payload: PhoenixPayload + switch encoding { + case .json: + do { + let jsonObject = try JSONDecoder().decode(JSONObject.self, from: Data(payloadData)) + payload = .json(.object(jsonObject)) + } catch { + throw RealtimeError.decoding(type: "PhoenixMessage", underlying: error) + } + case .binary: + payload = .binary(Data(payloadData)) + } + + return PhoenixMessage( + joinRef: nil, + ref: nil, + topic: topic, + event: .broadcast, + payload: payload, + receivedAt: receivedAt + ) + } + + // MARK: - Text decoding (JSON array format) + + /// Decodes a JSON array string `[joinRef, ref, topic, event, payload]` into a `PhoenixMessage`. + func decodeText(_ text: String, receivedAt: Date) throws -> PhoenixMessage { + struct StructuralError: Error & Sendable { + let message: String + } + + let data = Data(text.utf8) + let array: [AnyJSON] + do { + array = try JSONDecoder().decode([AnyJSON].self, from: data) + } catch { + throw RealtimeError.decoding(type: "PhoenixMessage", underlying: error) + } + + guard array.count >= 5 else { + throw RealtimeError.decoding( + type: "PhoenixMessage", + underlying: StructuralError( + message: "Expected JSON array with 5 elements, got \(array.count)." + ) + ) + } + + let joinRef = array[0].stringValue + let ref = array[1].stringValue + + guard let topic = array[2].stringValue else { + throw RealtimeError.decoding( + type: "PhoenixMessage", + underlying: StructuralError(message: "Expected string for topic at index 2.") + ) + } + guard let eventString = array[3].stringValue else { + throw RealtimeError.decoding( + type: "PhoenixMessage", + underlying: StructuralError(message: "Expected string for event at index 3.") + ) + } + guard let payloadObject = array[4].objectValue else { + throw RealtimeError.decoding( + type: "PhoenixMessage", + underlying: StructuralError(message: "Expected object for payload at index 4.") + ) + } + + return PhoenixMessage( + joinRef: joinRef, + ref: ref, + topic: topic, + event: PhoenixEvent(rawValue: eventString), + payload: .json(.object(payloadObject)), + receivedAt: receivedAt + ) + } +} diff --git a/Sources/RealtimeV3/Transport/RealtimeTransport.swift b/Sources/RealtimeV3/Transport/RealtimeTransport.swift new file mode 100644 index 000000000..be498d1e5 --- /dev/null +++ b/Sources/RealtimeV3/Transport/RealtimeTransport.swift @@ -0,0 +1,89 @@ +// +// RealtimeTransport.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 27/06/26. +// + +import Foundation + +/// A frame sent or received over a Realtime transport connection. +/// +/// A `TransportFrame` represents the low-level unit of communication between the client and server. +/// It can be either a text frame (containing a UTF-8 encoded string) or a binary frame (containing raw bytes). +public enum TransportFrame: Sendable, Equatable { + /// A text frame containing a UTF-8 encoded string payload. + case text(String) + /// A binary frame containing raw bytes. + case binary(Data) +} + +/// A protocol for establishing Realtime connections over a transport layer. +/// +/// `RealtimeTransport` abstracts the underlying connection mechanism (e.g., WebSocket) +/// and provides a unified interface for creating connections to a Realtime endpoint. +/// Implementations should handle authentication via the provided headers and manage +/// the underlying socket lifecycle. +public protocol RealtimeTransport: Sendable { + /// Establishes a connection to the specified Realtime endpoint. + /// + /// This method returns once the underlying connection is established and ready to send/receive frames. + /// The provided headers are sent as part of the connection handshake (e.g., WebSocket upgrade headers) + /// and may include authentication credentials or custom metadata. + /// + /// - Parameters: + /// - url: The Realtime endpoint URL to connect to. + /// - headers: HTTP headers to send during the connection handshake. + /// + /// - Returns: A ``RealtimeConnection`` object that can be used to send frames and receive frames. + /// + /// - Throws: An error if the connection fails (e.g., network error, authentication failure, + /// or if the server closes the connection during handshake). + func connect(to url: URL, headers: [String: String]) async throws -> any RealtimeConnection +} + +/// A protocol representing an active connection to a Realtime endpoint. +/// +/// Once established via ``RealtimeTransport/connect(to:headers:)``, a `RealtimeConnection` +/// provides a bidirectional communication channel: the SDK consumes the ``frames`` stream +/// to receive frames from the server, and uses ``send(_:)`` to transmit frames to the server. +/// +/// The `frames` stream is **single-consumer**: only one iterator should be created and owned +/// by the SDK's connection manager. The iterator remains active until the connection is closed +/// or an error occurs on the underlying transport. +public protocol RealtimeConnection: Sendable { + /// An asynchronous stream of frames received from the server. + /// + /// This stream is single-consumer and should be owned and iterated by the SDK's connection owner. + /// Once an iterator is created, it remains active until: + /// - A frame is received indicating server-initiated close + /// - An error is thrown (e.g., connection lost) + /// - The connection is explicitly closed via ``close(code:reason:)`` + /// - The iterator is deallocated + /// + /// The stream yields ``TransportFrame`` values as they arrive from the server. + var frames: AsyncThrowingStream { get } + + /// Sends a frame to the server. + /// + /// This method asynchronously sends the provided frame and returns once the frame + /// has been queued for transmission (not necessarily fully delivered to the network). + /// + /// - Parameter frame: The ``TransportFrame`` to send. + /// + /// - Throws: An error if the connection is closed or if the underlying transport + /// encounters an error during transmission. + func send(_ frame: TransportFrame) async throws + + /// Closes the connection with an optional code and reason. + /// + /// This method initiates a graceful close of the connection and awaits its completion. + /// After this method returns, the underlying connection is fully closed and no further + /// frames can be sent. The ``frames`` stream may emit additional frames before closing, + /// depending on the underlying protocol behavior. + /// + /// - Parameters: + /// - code: An optional close code (e.g., 1000 for normal closure in WebSocket). + /// - reason: An optional human-readable reason for closing. + func close(code: Int, reason: String) async +} diff --git a/Sources/RealtimeV3/Transport/URLSessionTransport.swift b/Sources/RealtimeV3/Transport/URLSessionTransport.swift new file mode 100644 index 000000000..05e6032c1 --- /dev/null +++ b/Sources/RealtimeV3/Transport/URLSessionTransport.swift @@ -0,0 +1,141 @@ +// +// URLSessionTransport.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +// MARK: - URLSessionTransport + +/// A production `RealtimeTransport` backed by `URLSessionWebSocketTask`. +/// +/// Inject a custom `URLSession` for testing. The default session is configured +/// with `.default` and a delegate that bridges the WebSocket lifecycle events. +/// +/// Headers are set on the `URLRequest` rather than on +/// `URLSessionConfiguration.httpAdditionalHeaders`, which can interfere with +/// the WebSocket upgrade on iOS (see `URLSessionWebSocket` in the v2 transport). +public struct URLSessionTransport: RealtimeTransport { + private let session: URLSession? + + /// Creates a transport that uses the shared `URLSession.shared`. + public init() { + self.session = nil + } + + /// Creates a transport backed by `session`. Use this to inject a custom session + /// (e.g. one with a mock delegate) in tests. + public init(session: URLSession) { + self.session = session + } + + public func connect(to url: URL, headers: [String: String]) async throws + -> any RealtimeConnection + { + let effectiveSession: URLSession + if let session { + effectiveSession = session + } else { + effectiveSession = URLSession.shared + } + + var request = URLRequest(url: url) + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + + let task = effectiveSession.webSocketTask(with: request) + let connection = URLSessionConnection(task: task) + task.resume() + return connection + } +} + +// MARK: - URLSessionConnection + +/// A `RealtimeConnection` backed by a `URLSessionWebSocketTask`. +/// +/// The `frames` stream is driven by a background receive loop that calls +/// `task.receive()` in a tight `while` loop. The loop terminates when the task +/// is cancelled, when the WebSocket is closed, or when `receive()` throws. +private final class URLSessionConnection: RealtimeConnection, @unchecked Sendable { + let frames: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + private let task: URLSessionWebSocketTask + private let receiveLoop: Task + private let _isClosed = LockIsolated(false) + + init(task: URLSessionWebSocketTask) { + self.task = task + let (stream, cont) = AsyncThrowingStream.makeStream() + self.frames = stream + self.continuation = cont + + // Capture continuation in a local to avoid capturing self before init finishes. + let capturedTask = task + let capturedCont = cont + self.receiveLoop = Task { + await URLSessionConnection.runReceiveLoop(task: capturedTask, continuation: capturedCont) + } + } + + /// Drives the receive loop without capturing `self`, avoiding init-before-capture issues. + private static func runReceiveLoop( + task: URLSessionWebSocketTask, + continuation: AsyncThrowingStream.Continuation + ) async { + while !Task.isCancelled { + do { + let message = try await task.receive() + switch message { + case .string(let text): + continuation.yield(.text(text)) + case .data(let data): + continuation.yield(.binary(data)) + @unknown default: + break + } + } catch { + // receive() threw — connection closed or cancelled. Finish the stream. + continuation.finish(throwing: error) + return + } + } + continuation.finish() + } + + func send(_ frame: TransportFrame) async throws { + guard !_isClosed.value else { return } + switch frame { + case .text(let text): + try await task.send(.string(text)) + case .binary(let data): + try await task.send(.data(data)) + } + } + + func close(code: Int, reason: String) async { + _isClosed.setValue(true) + receiveLoop.cancel() + // URLSessionWebSocketTask accepts close codes 1000 or 3000–4999. + // Map any invalid code to 1000 to avoid a precondition failure. + let validCode: URLSessionWebSocketTask.CloseCode + if code == 1000 { + validCode = .normalClosure + } else if code >= 3000, code <= 4999, + let c = URLSessionWebSocketTask.CloseCode(rawValue: code) + { + validCode = c + } else { + validCode = .normalClosure + } + task.cancel(with: validCode, reason: Data(reason.utf8)) + } +} diff --git a/Tests/RealtimeV3IntegrationTests/BroadcastE2ETests.swift b/Tests/RealtimeV3IntegrationTests/BroadcastE2ETests.swift new file mode 100644 index 000000000..e35c69248 --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/BroadcastE2ETests.swift @@ -0,0 +1,163 @@ +// +// BroadcastE2ETests.swift +// RealtimeV3IntegrationTests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import RealtimeV3 +import Testing + +// MARK: - Shared fixture + +private struct ChatMsg: Codable, Sendable, Equatable { + let text: String +} + +/// IE-3: Broadcast e2e tests against a live local Supabase instance. +/// +/// These tests require a running local Supabase stack: +/// cd Tests/RealtimeV3IntegrationTests/supabase && supabase start +/// +/// They are automatically skipped when the instance is not reachable. +@Suite("IE-3 Broadcast", .requiresLocalSupabase) +struct BroadcastE2ETests { + + // MARK: - IE-3a: WS round-trip between two clients + + @Test("two WS clients on the same topic exchange a broadcast message") + func broadcastRoundTripBetweenTwoClients() async throws { + let rtA = IntegrationEnv.makeRealtime() + let rtB = IntegrationEnv.makeRealtime() + + let channelA = await rtA.channel("room:e2e-broadcast") + let channelB = await rtB.channel("room:e2e-broadcast") + + // Open the receive stream on B BEFORE subscribe so no message is missed. + let receivedStream = await channelB.broadcasts(of: ChatMsg.self, event: "chat") + + // Subscribe both clients. + try await channelA.subscribe() + try await channelB.subscribe() + + // Wait for both channels to be joined before broadcasting. + let stateA = await channelA.state + let stateB = await channelB.state + try await waitFor(stateA, timeout: .seconds(10), description: "channelA joined") { + $0 == .joined + } + try await waitFor(stateB, timeout: .seconds(10), description: "channelB joined") { + $0 == .joined + } + + // A sends; B should receive. + try await channelA.broadcast(ChatMsg(text: "hi"), as: "chat") + + // Collect the first element from B's stream within the timeout. + var receivedMsg: ChatMsg? + try await withThrowingTaskGroup(of: ChatMsg?.self) { group in + group.addTask { + for try await msg in receivedStream { + return msg + } + return nil + } + group.addTask { + try await Task.sleep(for: .seconds(10)) + throw TimeoutError( + description: "B did not receive broadcast within timeout", + timeout: .seconds(10) + ) + } + receivedMsg = try await group.next()! + group.cancelAll() + } + + #expect(receivedMsg == ChatMsg(text: "hi")) + + try await channelA.leave() + try await channelB.leave() + await rtA.disconnect() + await rtB.disconnect() + } + + // MARK: - IE-3b: HTTP broadcast received by WS subscriber + + // `Channel.httpBroadcast` strips the SDK-internal `realtime:` topic prefix for the + // HTTP `/api/broadcast` body (the endpoint matches WS subscribers on the short topic); + // the endpoint also requires a service-role Bearer token (anon → HTTP 500). This test + // exercises the real `Channel.httpBroadcast` path end-to-end against the live server. + + @Test( + "HTTP broadcast via Channel.httpBroadcast is delivered to a WS subscriber" + ) + func httpBroadcastReceivedByWSSubscriber() async throws { + let rtB = IntegrationEnv.makeRealtime() + let channelB = await rtB.channel("room:e2e-http-broadcast") + + let receivedStream = await channelB.broadcasts(of: ChatMsg.self, event: "chat") + + try await channelB.subscribe() + let stateB = await channelB.state + try await waitFor(stateB, timeout: .seconds(10), description: "channelB joined for HTTP test") { + $0 == .joined + } + + // Exercise the real `Channel.httpBroadcast` path. The SDK now strips the + // `realtime:` prefix from the channel topic for the HTTP body (the endpoint + // expects the short topic). The Realtime HTTP broadcast API still requires a + // service-role Bearer token (an anon key → HTTP 500), so the sender uses a + // service-role-authenticated client. + let rtSender = IntegrationEnv.makeRealtimeWithServiceRole() + let senderChannel = await rtSender.channel("room:e2e-http-broadcast") + try await senderChannel.httpBroadcast(event: "chat", payload: ChatMsg(text: "http-hello")) + + // B should receive the message over its WS subscription. + var receivedMsg: ChatMsg? + try await withThrowingTaskGroup(of: ChatMsg?.self) { group in + group.addTask { + for try await msg in receivedStream { + return msg + } + return nil + } + group.addTask { + try await Task.sleep(for: .seconds(10)) + throw TimeoutError( + description: "B did not receive HTTP broadcast within timeout", + timeout: .seconds(10) + ) + } + receivedMsg = try await group.next()! + group.cancelAll() + } + + #expect(receivedMsg == ChatMsg(text: "http-hello")) + + try await channelB.leave() + await rtB.disconnect() + } + + // MARK: - IE-3c: broadcast with ack returns without timeout + + @Test("broadcast with acknowledge=true returns without timing out") + func ackBroadcastReturns() async throws { + let rt = IntegrationEnv.makeRealtime() + let channel = await rt.channel("room:e2e-ack") { + $0.broadcast.acknowledge = true + } + + try await channel.subscribe() + let state = await channel.state + try await waitFor(state, timeout: .seconds(10), description: "ack channel joined") { + $0 == .joined + } + + // This should return cleanly (server acks broadcast pushes). + try await channel.broadcast(ChatMsg(text: "ack-me"), as: "chat") + + try await channel.leave() + await rt.disconnect() + } +} diff --git a/Tests/RealtimeV3IntegrationTests/ConnectionE2ETests.swift b/Tests/RealtimeV3IntegrationTests/ConnectionE2ETests.swift new file mode 100644 index 000000000..2cec9979e --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/ConnectionE2ETests.swift @@ -0,0 +1,82 @@ +// +// ConnectionE2ETests.swift +// RealtimeV3IntegrationTests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import RealtimeV3 +import Testing + +/// IE-1: Connection lifecycle e2e tests against a live local Supabase instance. +/// +/// These tests require a running local Supabase stack: +/// cd Tests/RealtimeV3IntegrationTests/supabase && supabase start +/// +/// They are automatically skipped when the instance is not reachable. +@Suite("IE-1 Connection Lifecycle", .requiresLocalSupabase) +struct ConnectionE2ETests { + + // MARK: - IE-1a: connect -> connected -> disconnect -> closed + + @Test("connects to live instance and transitions through connection states") + func connectsToLiveInstance() async throws { + let rt = IntegrationEnv.makeRealtime() + let statusStream = await rt.status + + // connect() should not throw + try await rt.connect() + + // Observe that status reaches .connected + try await waitFor( + statusStream, + timeout: .seconds(10), + description: "status == .connected" + ) { status in + if case .connected = status.state { return true } + return false + } + + // Disconnect + await rt.disconnect() + + // After disconnect the status should reach .closed or .idle + let postDisconnectStream = await rt.status + try await waitFor( + postDisconnectStream, + timeout: .seconds(5), + description: "status == .closed or .idle after disconnect" + ) { status in + switch status.state { + case .closed, .idle: return true + default: return false + } + } + } + + // MARK: - IE-1b: repeated connect is idempotent + + @Test("repeated connect() calls are idempotent once connected") + func repeatedConnectIsIdempotent() async throws { + let rt = IntegrationEnv.makeRealtime() + + try await rt.connect() + + // Second connect should be a no-op and not throw + try await rt.connect() + + // Verify still connected + let statusStream = await rt.status + try await waitFor( + statusStream, + timeout: .seconds(5), + description: "status == .connected after second connect()" + ) { status in + if case .connected = status.state { return true } + return false + } + + await rt.disconnect() + } +} diff --git a/Tests/RealtimeV3IntegrationTests/PostgresChangesE2ETests.swift b/Tests/RealtimeV3IntegrationTests/PostgresChangesE2ETests.swift new file mode 100644 index 000000000..ce38db479 --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/PostgresChangesE2ETests.swift @@ -0,0 +1,212 @@ +// +// PostgresChangesE2ETests.swift +// RealtimeV3IntegrationTests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import PostgREST +import RealtimeV3 +import Testing + +// MARK: - Helpers + +private func makePostgrest() -> PostgrestClient { + PostgrestClient( + url: IntegrationEnv.restURL, + headers: [ + "apikey": IntegrationEnv.anonKey, + "Authorization": "Bearer \(IntegrationEnv.anonKey)", + ] + ) +} + +/// IE-5: Postgres changes e2e tests against a live local Supabase instance. +/// +/// Requires: +/// - `public.messages` table (id uuid pk, room_id uuid, content text, user_id uuid, created_at) +/// - `REPLICA IDENTITY FULL` on `public.messages` +/// - Table added to the `supabase_realtime` publication +/// - RLS permissive for anon role +/// +/// Tests are automatically skipped when the instance is not reachable. +@Suite("IE-5 Postgres Changes", .requiresLocalSupabase) +struct PostgresChangesE2ETests { + + // MARK: - IE-5a: INSERT delivers a postgres change + + @Test("inserting a row delivers an INSERT postgres change via realtime") + func insertDeliversPostgresChange() async throws { + let roomID = UUID() + let expectedContent = "hello-\(roomID.uuidString.prefix(8))" + + let rt = IntegrationEnv.makeRealtime() + let channel = await rt.channel("room:e2e-postgres-insert") + + // Register BEFORE subscribe. + let token = try await channel.inserts( + schema: "public", + table: "messages", + filter: .eq("room_id", roomID) + ) + + // Open the stream before subscribe so we don't miss the first event. + let changesStream = await channel.postgresChanges(for: token) + + try await channel.subscribe() + let state = await channel.state + try await waitFor(state, timeout: .seconds(10), description: "postgres channel joined") { + $0 == .joined + } + + // INSERT via PostgREST. + let db = makePostgrest() + try await db.from("messages") + .insert([ + "room_id": roomID.uuidString, + "content": expectedContent, + "user_id": UUID().uuidString, + ]) + .execute() + + // Wait for the INSERT event to arrive. + var receivedRow: JSONValue? + try await withThrowingTaskGroup(of: JSONValue?.self) { group in + group.addTask { + for try await row in changesStream { + return row + } + return nil + } + group.addTask { + try await Task.sleep(for: .seconds(15)) + throw TimeoutError( + description: "did not receive postgres INSERT within timeout", + timeout: .seconds(15) + ) + } + receivedRow = try await group.next()! + group.cancelAll() + } + + // The record should contain the expected content field. + let contentValue = receivedRow?.objectValue?["content"]?.stringValue + #expect(contentValue == expectedContent) + + try await channel.leave() + await rt.disconnect() + } + + // MARK: - IE-5b: UPDATE and DELETE deliver old_record (REPLICA IDENTITY FULL) + + @Test("updating a row delivers UPDATE with old_record; deleting delivers DELETE with old_record") + func updateAndDeleteDeliverOldRecord() async throws { + let roomID = UUID() + let db = makePostgrest() + + // Pre-insert a row we will later UPDATE and DELETE. + let insertedContent = "original-\(roomID.uuidString.prefix(8))" + let updatedContent = "updated-\(roomID.uuidString.prefix(8))" + let rowID = UUID() + + try await db.from("messages") + .insert([ + "id": rowID.uuidString, + "room_id": roomID.uuidString, + "content": insertedContent, + "user_id": UUID().uuidString, + ]) + .execute() + + let rt = IntegrationEnv.makeRealtime() + let channel = await rt.channel("room:e2e-postgres-update-delete") + + let updateToken = try await channel.updates( + schema: "public", + table: "messages", + filter: .eq("room_id", roomID) + ) + let deleteToken = try await channel.deletes( + schema: "public", + table: "messages", + filter: .eq("room_id", roomID) + ) + + let updatesStream = await channel.postgresChanges(for: updateToken) + let deletesStream = await channel.postgresChanges(for: deleteToken) + + try await channel.subscribe() + let state = await channel.state + try await waitFor(state, timeout: .seconds(10), description: "update/delete channel joined") { + $0 == .joined + } + + // UPDATE the row. + try await db.from("messages") + .update(["content": updatedContent]) + .eq("id", value: rowID.uuidString) + .execute() + + // Wait for UPDATE event. + var receivedUpdate: PostgresUpdate? + try await withThrowingTaskGroup(of: PostgresUpdate?.self) { group in + group.addTask { + for try await update in updatesStream { + return update + } + return nil + } + group.addTask { + try await Task.sleep(for: .seconds(15)) + throw TimeoutError( + description: "did not receive postgres UPDATE within timeout", + timeout: .seconds(15) + ) + } + receivedUpdate = try await group.next()! + group.cancelAll() + } + + // New record should have updated content; old_record should have original content. + #expect(receivedUpdate?.record.objectValue?["content"]?.stringValue == updatedContent) + #expect(receivedUpdate?.oldRecord?.objectValue?["content"]?.stringValue == insertedContent) + + // DELETE the row. + try await db.from("messages") + .delete() + .eq("id", value: rowID.uuidString) + .execute() + + // Wait for DELETE event. + var receivedDelete: PostgresDelete? + try await withThrowingTaskGroup(of: PostgresDelete?.self) { group in + group.addTask { + for try await del in deletesStream { + return del + } + return nil + } + group.addTask { + try await Task.sleep(for: .seconds(15)) + throw TimeoutError( + description: "did not receive postgres DELETE within timeout", + timeout: .seconds(15) + ) + } + receivedDelete = try await group.next()! + group.cancelAll() + } + + // Realtime server v2.x returns only the primary key in old_record for DELETE events, + // even with REPLICA IDENTITY FULL. This is an intentional security constraint in the + // server: the deleted row no longer exists, so RLS cannot be evaluated for full-row + // access. The SDK correctly surfaces whatever the server sends (which is the PK only). + // Verify at minimum the row ID is present in old_record. + let deletedRowId = receivedDelete?.oldRecord.objectValue?["id"]?.stringValue + #expect(deletedRowId?.uppercased() == rowID.uuidString.uppercased()) + + try await channel.leave() + await rt.disconnect() + } +} diff --git a/Tests/RealtimeV3IntegrationTests/PresenceE2ETests.swift b/Tests/RealtimeV3IntegrationTests/PresenceE2ETests.swift new file mode 100644 index 000000000..196cb0644 --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/PresenceE2ETests.swift @@ -0,0 +1,110 @@ +// +// PresenceE2ETests.swift +// RealtimeV3IntegrationTests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import RealtimeV3 +import Testing + +// MARK: - Shared fixture + +private struct UserPresence: Codable, Sendable, Equatable { + let userId: String + let status: String +} + +/// IE-4: Presence e2e tests against a live local Supabase instance. +/// +/// These tests require a running local Supabase stack: +/// cd Tests/RealtimeV3IntegrationTests/supabase && supabase start +/// +/// They are automatically skipped when the instance is not reachable. +@Suite("IE-4 Presence", .requiresLocalSupabase) +struct PresenceE2ETests { + + // MARK: - IE-4a: presence sync between two clients + + @Test("presence state from A is visible to B, and A's leave removes it") + func presenceSyncBetweenTwoClients() async throws { + let rtA = IntegrationEnv.makeRealtime() + let rtB = IntegrationEnv.makeRealtime() + + // Both clients join the same topic with presence enabled. + let channelA = await rtA.channel("room:e2e-presence") { + $0.presence.enabled = true + } + let channelB = await rtB.channel("room:e2e-presence") { + $0.presence.enabled = true + } + + // Register B's observer stream before subscribe so no event is lost. + let presenceStream = await channelB.presence.observe(UserPresence.self) + + // Subscribe A first, then B. + try await channelA.subscribe() + let stateA = await channelA.state + try await waitFor(stateA, timeout: .seconds(10), description: "channelA joined (presence)") { + $0 == .joined + } + + try await channelB.subscribe() + let stateB = await channelB.state + try await waitFor(stateB, timeout: .seconds(10), description: "channelB joined (presence)") { + $0 == .joined + } + + // A tracks its presence. + let handle = try await channelA.presence.track( + UserPresence(userId: "user-a", status: "active") + ) + + // Wait until B sees A in the active map. + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await state in presenceStream { + let userIds = state.active.values.flatMap { $0 }.map(\.userId) + if userIds.contains("user-a") { return } + } + } + group.addTask { + try await Task.sleep(for: .seconds(10)) + throw TimeoutError( + description: "B did not see A in presence within timeout", + timeout: .seconds(10) + ) + } + try await group.next() + group.cancelAll() + } + + // A untrack: cancel the handle. + try await handle.cancel() + + // B should see A leave — active map should no longer contain "user-a". + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await state in presenceStream { + let userIds = state.active.values.flatMap { $0 }.map(\.userId) + if !userIds.contains("user-a") { return } + } + } + group.addTask { + try await Task.sleep(for: .seconds(10)) + throw TimeoutError( + description: "B did not see A leave presence within timeout", + timeout: .seconds(10) + ) + } + try await group.next() + group.cancelAll() + } + + try await channelA.leave() + try await channelB.leave() + await rtA.disconnect() + await rtB.disconnect() + } +} diff --git a/Tests/RealtimeV3IntegrationTests/ReconnectionE2ETests.swift b/Tests/RealtimeV3IntegrationTests/ReconnectionE2ETests.swift new file mode 100644 index 000000000..0048c71aa --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/ReconnectionE2ETests.swift @@ -0,0 +1,179 @@ +// +// ReconnectionE2ETests.swift +// RealtimeV3IntegrationTests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import RealtimeV3 +import Testing + +/// IE-6: Reconnection e2e tests against a live local Supabase instance. +/// +/// ## Scope and rationale +/// +/// Forcing a genuine transport-level socket drop from the client side (without +/// restarting the Supabase server or killing the OS-level socket) is not possible +/// via the public `Realtime` API — `disconnect()` is an intentional/clean close, +/// not a network fault. +/// +/// Deterministic reconnect + rejoin behaviour (after unclean transport drops) is +/// therefore covered by the unit test suite (`RealtimeV3Tests/RejoinTests`), which +/// uses an `InMemoryTransport` that can simulate unclean closes at will. +/// +/// ## SDK gap — intentional disconnect + reconnect channel state +/// +/// After `disconnect()`, channel states remain `.joined` in the SDK (the transport +/// is closed but the logical channel state is preserved for transparent reconnection +/// on unclean drops). Calling `connect()` re-establishes the socket but does NOT +/// trigger channel rejoins — `intentionalDisconnect = true` suppresses the reconnect +/// loop. Callers that want to reuse a channel after an intentional disconnect must +/// call `channel.leave()` first, then `channel.subscribe()` after `connect()`. +/// +/// The live tests below exercise the leave → reconnect → re-subscribe cycle as the +/// supported pattern, and document the gap above. +/// +/// Tests are automatically skipped when the instance is not reachable. +@Suite("IE-6 Reconnection", .requiresLocalSupabase) +struct ReconnectionE2ETests { + + // MARK: - IE-6a: leave + disconnect + connect + subscribe cycle + + @Test("channel rejoins after leave → disconnect → connect → subscribe") + func channelRejoinsAfterLeaveDisconnectReconnect() async throws { + let rt = IntegrationEnv.makeRealtime() + let channel = await rt.channel("room:e2e-reconnect") + + // First subscribe cycle. + try await channel.subscribe() + let state1 = await channel.state + try await waitFor(state1, timeout: .seconds(10), description: "channel joined (first)") { + $0 == .joined + } + + // Clean leave before disconnect so the channel reaches .closed. + try await channel.leave() + let state2 = await channel.state + try await waitFor(state2, timeout: .seconds(5), description: "channel closed after leave") { + if case .closed = $0 { return true } + return false + } + + // Intentional disconnect. + await rt.disconnect() + + // Reconnect and re-subscribe. + try await channel.subscribe() + let state3 = await channel.state + try await waitFor( + state3, timeout: .seconds(10), description: "channel re-joined after reconnect" + ) { + $0 == .joined + } + + // Clean up. + try await channel.leave() + await rt.disconnect() + } + + // MARK: - IE-6b: broadcast stream after leave + reconnect cycle + + // Note: forced-drop reconnection (simulating network failure mid-stream without + // intentional disconnect) is covered deterministically by `RealtimeV3Tests/RejoinTests` + // using InMemoryTransport. + // + // SDK gap: after `disconnect()` without a prior `leave()`, the channel state stays + // `.joined` and `subscribe()` is idempotent (returns immediately). Callers must + // call `leave()` before `disconnect()` to allow re-subscription after `connect()`. + + @Test("broadcast stream delivers messages after leave → disconnect → connect → subscribe") + func broadcastStreamResumesAfterReconnect() async throws { + struct Ping: Codable, Sendable, Equatable { let seq: Int } + + let rtSender = IntegrationEnv.makeRealtime() + let rtReceiver = IntegrationEnv.makeRealtime() + + let channelR = await rtReceiver.channel("room:e2e-reconnect-bcast") + let channelS = await rtSender.channel("room:e2e-reconnect-bcast") + + try await channelR.subscribe() + try await channelS.subscribe() + + let stateR1 = await channelR.state + let stateS = await channelS.state + try await waitFor(stateR1, timeout: .seconds(10), description: "receiver joined (first)") { + $0 == .joined + } + try await waitFor(stateS, timeout: .seconds(10), description: "sender joined") { + $0 == .joined + } + + // Verify the channel works pre-disconnect. + let preStream = await channelR.broadcasts(of: Ping.self, event: "ping") + try await channelS.broadcast(Ping(seq: 1), as: "ping") + + var firstPing: Ping? + try await withThrowingTaskGroup(of: Ping?.self) { group in + group.addTask { + for try await p in preStream { return p } + return nil + } + group.addTask { + try await Task.sleep(for: .seconds(10)) + throw TimeoutError( + description: "receiver did not get pre-disconnect ping", + timeout: .seconds(10) + ) + } + firstPing = try await group.next()! + group.cancelAll() + } + #expect(firstPing == Ping(seq: 1)) + + // Leave and disconnect receiver cleanly. + // Note: leave() is required before disconnect() to clear the channel's .joined state + // and allow re-subscription after connect() (see SDK gap note in the suite header). + try await channelR.leave() + let stateR2 = await channelR.state + try await waitFor(stateR2, timeout: .seconds(5), description: "receiver closed after leave") { + if case .closed = $0 { return true } + return false + } + await rtReceiver.disconnect() + + // Reconnect receiver and re-subscribe. + try await channelR.subscribe() + let stateR3 = await channelR.state + try await waitFor(stateR3, timeout: .seconds(10), description: "receiver re-joined") { + $0 == .joined + } + + // Open a fresh broadcast stream after reconnect and verify delivery. + let postStream = await channelR.broadcasts(of: Ping.self, event: "ping") + try await channelS.broadcast(Ping(seq: 2), as: "ping") + + var secondPing: Ping? + try await withThrowingTaskGroup(of: Ping?.self) { group in + group.addTask { + for try await p in postStream { return p } + return nil + } + group.addTask { + try await Task.sleep(for: .seconds(10)) + throw TimeoutError( + description: "receiver did not get post-reconnect ping", + timeout: .seconds(10) + ) + } + secondPing = try await group.next()! + group.cancelAll() + } + #expect(secondPing == Ping(seq: 2)) + + try await channelR.leave() + try await channelS.leave() + await rtReceiver.disconnect() + await rtSender.disconnect() + } +} diff --git a/Tests/RealtimeV3IntegrationTests/SubscribeLeaveE2ETests.swift b/Tests/RealtimeV3IntegrationTests/SubscribeLeaveE2ETests.swift new file mode 100644 index 000000000..c90ac2710 --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/SubscribeLeaveE2ETests.swift @@ -0,0 +1,122 @@ +// +// SubscribeLeaveE2ETests.swift +// RealtimeV3IntegrationTests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import RealtimeV3 +import Testing + +/// IE-2: Channel subscribe/leave e2e tests against a live local Supabase instance. +/// +/// These tests require a running local Supabase stack: +/// cd Tests/RealtimeV3IntegrationTests/supabase && supabase start +/// +/// They are automatically skipped when the instance is not reachable. +@Suite("IE-2 Subscribe and Leave", .requiresLocalSupabase) +struct SubscribeLeaveE2ETests { + + // MARK: - IE-2a: subscribe -> joined -> leave -> closed(.userRequested) + + @Test("subscribes to a public broadcast channel and leaves cleanly") + func subscribeAndLeavePublicChannel() async throws { + let rt = IntegrationEnv.makeRealtime() + + // channel() automatically prepends "realtime:" — pass the short topic here. + let channel = await rt.channel("room:1") + let stateStream = await channel.state + + // subscribe() auto-connects the socket and joins the channel + try await channel.subscribe() + + // Observe channel reaches .joined + try await waitFor( + stateStream, + timeout: .seconds(10), + description: "channel state == .joined" + ) { state in + state == .joined + } + + // Leave the channel + try await channel.leave() + + // Observe channel reaches .closed(.userRequested) + let postLeaveStream = await channel.state + try await waitFor( + postLeaveStream, + timeout: .seconds(5), + description: "channel state == .closed(.userRequested)" + ) { state in + if case .closed(.userRequested) = state { return true } + return false + } + + await rt.disconnect() + } + + // MARK: - IE-2b: subscribe to a realtime:public:messages topic + + @Test("subscribes to a public:messages topic (SDK adds realtime: prefix)") + func subscribeToRealtimeTopic() async throws { + let rt = IntegrationEnv.makeRealtime() + + // The canonical Supabase realtime topic for postgres-changes on a table. + // channel() prepends "realtime:" — pass the short form "public:messages". + let channel = await rt.channel("public:messages") + let stateStream = await channel.state + + try await channel.subscribe() + + try await waitFor( + stateStream, + timeout: .seconds(10), + description: "realtime:public:messages channel state == .joined" + ) { state in + state == .joined + } + + try await channel.leave() + + let postLeaveStream = await channel.state + try await waitFor( + postLeaveStream, + timeout: .seconds(5), + description: "realtime:public:messages channel state == .closed" + ) { state in + if case .closed = state { return true } + return false + } + + await rt.disconnect() + } + + // MARK: - IE-2c: first-call-wins channel identity + + @Test("subscribing to the same topic returns the pre-existing channel (first-call-wins)") + func subscribeToSameTopicIsIdempotent() async throws { + let rt = IntegrationEnv.makeRealtime() + + let ch1 = await rt.channel("room:idempotent") + let ch2 = await rt.channel("room:idempotent") + + // Both references must point to the same actor identity + #expect(ch1 === ch2) + + try await ch1.subscribe() + + let stateStream = await ch1.state + try await waitFor( + stateStream, + timeout: .seconds(10), + description: "idempotent channel state == .joined" + ) { state in + state == .joined + } + + try await ch1.leave() + await rt.disconnect() + } +} diff --git a/Tests/RealtimeV3IntegrationTests/Support/AsyncHelpers.swift b/Tests/RealtimeV3IntegrationTests/Support/AsyncHelpers.swift new file mode 100644 index 000000000..eca7571c9 --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/Support/AsyncHelpers.swift @@ -0,0 +1,47 @@ +// +// AsyncHelpers.swift +// RealtimeV3IntegrationTests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation + +/// Waits for the first element of `stream` that satisfies `predicate`, with a timeout. +/// +/// - Parameters: +/// - stream: An `AsyncStream` to consume. +/// - timeout: Maximum wall-clock duration to wait. +/// - description: Human-readable description used in the timeout error message. +/// - predicate: Returns `true` when the desired element has arrived. +/// - Throws: `TimeoutError` if `timeout` elapses before `predicate` returns `true`. +func waitFor( + _ stream: AsyncStream, + timeout: Duration = .seconds(10), + description: String, + where predicate: @Sendable @escaping (T) -> Bool +) async throws { + try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for await value in stream { + if predicate(value) { return } + } + } + group.addTask { + try await Task.sleep(for: timeout) + throw TimeoutError(description: description, timeout: timeout) + } + // Take the first result — either the predicate matched or we timed out. + try await group.next() + group.cancelAll() + } +} + +struct TimeoutError: Error, CustomStringConvertible { + let description: String + let timeout: Duration + + var localizedDescription: String { + "Timed out waiting for '\(description)' after \(timeout)" + } +} diff --git a/Tests/RealtimeV3IntegrationTests/Support/IntegrationEnv.swift b/Tests/RealtimeV3IntegrationTests/Support/IntegrationEnv.swift new file mode 100644 index 000000000..ce794d28d --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/Support/IntegrationEnv.swift @@ -0,0 +1,91 @@ +// +// IntegrationEnv.swift +// RealtimeV3IntegrationTests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import RealtimeV3 + +/// Environment configuration for the RealtimeV3 integration test suite. +/// +/// All values default to the standard Supabase local dev stack (127.0.0.1:54321) +/// and can be overridden via environment variables for CI or remote instances. +enum IntegrationEnv { + + /// Base WebSocket URL for RealtimeV3 (no `/websocket` suffix). + /// + /// The SDK appends `/websocket` automatically in `_openConnection()` before dialling + /// the transport, so callers should supply only the base path + /// (e.g. `ws://127.0.0.1:54321/realtime/v1`). Kong routes the WebSocket upgrade + /// to the Realtime server; the SDK appends the Phoenix endpoint path `/websocket`. + static var realtimeURL: URL { + ProcessInfo.processInfo.environment["SUPABASE_REALTIME_URL"] + .flatMap(URL.init(string:)) + ?? URL(string: "ws://127.0.0.1:54321/realtime/v1")! + } + + /// PostgREST REST endpoint for direct DB writes in postgres-change e2e tests. + static var restURL: URL { + ProcessInfo.processInfo.environment["SUPABASE_REST_URL"] + .flatMap(URL.init(string:)) + ?? URL(string: "http://127.0.0.1:54321/rest/v1")! + } + + /// Standard local anon JWT (demo key shipped with every local Supabase instance). + /// Override via SUPABASE_ANON_KEY for non-standard instances. + static var anonKey: String { + ProcessInfo.processInfo.environment["SUPABASE_ANON_KEY"] + ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRXP1A7WOeoJeXxjNni43kdQwgnWNReilDMblYTn_I0" + } + + /// Standard local service-role JWT. Required for the HTTP broadcast API endpoint + /// (`/realtime/v1/api/broadcast`), which rejects anon-key requests with HTTP 500. + /// + /// Override via SUPABASE_SERVICE_ROLE_KEY for non-standard instances. + static var serviceRoleKey: String { + ProcessInfo.processInfo.environment["SUPABASE_SERVICE_ROLE_KEY"] + ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6InNlcnZpY2Vfcm9sZSIsImV4cCI6MTk4MzgxMjk5Nn0.EGIM96RAZx35lJzdJsyH-qQwv8Hdp7fsn3W0YpN81IU" + } + + /// Builds a `Realtime` client configured with the service-role key as the access token + /// provider. Use this for HTTP broadcast tests — the `/api/broadcast` endpoint requires + /// a service-role or user JWT (Bearer auth), not just the anon `apikey` header. + static func makeRealtimeWithServiceRole() -> Realtime { + let key = serviceRoleKey + return Realtime( + url: realtimeURL, + apiKey: anonKey, + accessToken: { key } + ) + } + + /// Builds a fresh `Realtime` client pointed at the local instance. + static func makeRealtime(configuration: Configuration = .default) -> Realtime { + Realtime(url: realtimeURL, apiKey: anonKey, configuration: configuration) + } + + /// Checks whether the local Supabase instance is reachable. + /// + /// Uses a lightweight HTTP GET to the REST root endpoint, passing the anon key as a + /// header (not a query param — PostgREST would try to parse query params as filters). + /// Avoids the overhead and potential scheduling races of a full WebSocket handshake. + /// Returns `false` on any error so that tests are skipped when the instance is down. + static func isReachable() async -> Bool { + var request = URLRequest(url: restURL, timeoutInterval: 5) + request.setValue(anonKey, forHTTPHeaderField: "apikey") + + do { + let (_, response) = try await URLSession.shared.data(for: request) + if let http = response as? HTTPURLResponse { + // 200 means PostgREST answered normally. + // Any 2xx/3xx/4xx means the server is up (even if this specific call is rejected). + return (200..<500).contains(http.statusCode) + } + return false + } catch { + return false + } + } +} diff --git a/Tests/RealtimeV3IntegrationTests/Support/RequiresLocalSupabase.swift b/Tests/RealtimeV3IntegrationTests/Support/RequiresLocalSupabase.swift new file mode 100644 index 000000000..b5d34f9ff --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/Support/RequiresLocalSupabase.swift @@ -0,0 +1,31 @@ +// +// RequiresLocalSupabase.swift +// RealtimeV3IntegrationTests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import Testing + +/// A `ConditionTrait` that skips the annotated suite or test when the local +/// Supabase instance is not reachable. +/// +/// Apply at the suite level so every test in the suite is skipped together: +/// ```swift +/// @Suite("IE-1 Connection Lifecycle", .requiresLocalSupabase) +/// struct ConnectionE2ETests { ... } +/// ``` +/// +/// Or per-test: +/// ```swift +/// @Test(.requiresLocalSupabase) +/// func myE2ETest() async throws { ... } +/// ``` +extension Trait where Self == ConditionTrait { + static var requiresLocalSupabase: Self { + .enabled("Requires a running local Supabase instance") { + await IntegrationEnv.isReachable() + } + } +} diff --git a/Tests/RealtimeV3IntegrationTests/supabase/.branches/_current_branch b/Tests/RealtimeV3IntegrationTests/supabase/.branches/_current_branch new file mode 100644 index 000000000..88d050b19 --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/supabase/.branches/_current_branch @@ -0,0 +1 @@ +main \ No newline at end of file diff --git a/Tests/RealtimeV3IntegrationTests/supabase/.temp/cli-latest b/Tests/RealtimeV3IntegrationTests/supabase/.temp/cli-latest new file mode 100644 index 000000000..d8f3e1e57 --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/supabase/.temp/cli-latest @@ -0,0 +1 @@ +v2.108.0 \ No newline at end of file diff --git a/Tests/RealtimeV3IntegrationTests/supabase/config.toml b/Tests/RealtimeV3IntegrationTests/supabase/config.toml new file mode 100644 index 000000000..50e08625f --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/supabase/config.toml @@ -0,0 +1,115 @@ +# RealtimeV3 integration test project +# For detailed configuration reference documentation, visit: +# https://supabase.com/docs/guides/local-development/cli/config +project_id = "realtimev3" + +[api] +enabled = true +port = 54321 +schemas = ["public", "graphql_public"] +extra_search_path = ["public", "extensions"] +max_rows = 1000 + +[api.tls] +enabled = false + +[db] +port = 54322 +shadow_port = 54320 +major_version = 15 + +[db.pooler] +enabled = false +port = 54329 +pool_mode = "transaction" +default_pool_size = 20 +max_client_conn = 100 + +[db.migrations] +schema_paths = [] + +[db.seed] +enabled = true +sql_paths = ["./seed.sql"] + +[realtime] +enabled = true + +[studio] +enabled = true +port = 54323 +api_url = "http://127.0.0.1" + +[inbucket] +enabled = true +port = 54324 + +[storage] +enabled = true +file_size_limit = "50MiB" + +[auth] +enabled = true +site_url = "http://127.0.0.1:3000" +additional_redirect_urls = ["https://127.0.0.1:3000"] +jwt_expiry = 3600 +enable_refresh_token_rotation = true +refresh_token_reuse_interval = 10 +enable_signup = true +enable_anonymous_sign_ins = true +enable_manual_linking = true +minimum_password_length = 6 +password_requirements = "" + +[auth.email] +enable_signup = true +double_confirm_changes = true +enable_confirmations = false +secure_password_change = false +max_frequency = "1s" +otp_length = 6 +otp_expiry = 3600 + +[auth.sms] +enable_signup = false +enable_confirmations = false +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +[auth.sms.twilio] +enabled = false +account_sid = "" +message_service_sid = "" +auth_token = "env(SUPABASE_AUTH_SMS_TWILIO_AUTH_TOKEN)" + +[auth.mfa] +max_enrolled_factors = 10 + +[auth.mfa.totp] +enroll_enabled = false +verify_enabled = false + +[auth.mfa.phone] +enroll_enabled = false +verify_enabled = false +otp_length = 6 +template = "Your code is {{ .Code }}" +max_frequency = "5s" + +[edge_runtime] +enabled = true +policy = "oneshot" +inspector_port = 8083 +deno_version = 1 + +[analytics] +enabled = true +port = 54327 +backend = "postgres" + +[experimental] +orioledb_version = "" +s3_host = "env(S3_HOST)" +s3_region = "env(S3_REGION)" +s3_access_key = "env(S3_ACCESS_KEY)" +s3_secret_key = "env(S3_SECRET_KEY)" diff --git a/Tests/RealtimeV3IntegrationTests/supabase/migrations/0001_realtime_v3_schema.sql b/Tests/RealtimeV3IntegrationTests/supabase/migrations/0001_realtime_v3_schema.sql new file mode 100644 index 000000000..022597491 --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/supabase/migrations/0001_realtime_v3_schema.sql @@ -0,0 +1,82 @@ +-- RealtimeV3 integration test schema +-- Comprehensive realtime-enabled schema for IE test suite. + +-- messages table: primary target for postgres-changes e2e tests +create table if not exists public.messages ( + id uuid primary key default gen_random_uuid(), + room_id uuid not null, + content text not null, + user_id uuid, + created_at timestamptz not null default now() +); + +-- REPLICA IDENTITY FULL so UPDATE/DELETE carry old_record for postgres-changes tests +alter table public.messages replica identity full; + +-- Add to realtime publication so postgres-changes subscriptions receive events +alter publication supabase_realtime add table public.messages; + +-- Enable Row Level Security +alter table public.messages enable row level security; + +-- Permissive RLS policies for the test anon role (this is a test DB, not production) +create policy "anon can select messages" + on public.messages for select + to anon, authenticated + using (true); + +create policy "anon can insert messages" + on public.messages for insert + to anon, authenticated + with check (true); + +create policy "anon can update messages" + on public.messages for update + to anon, authenticated + using (true) + with check (true); + +create policy "anon can delete messages" + on public.messages for delete + to anon, authenticated + using (true); + +-- Explicit privilege grants (required since Supabase no longer grants public schema by default) +grant all on table public.messages to anon, authenticated; + +-- presence_demo table: secondary table for future presence e2e tests +create table if not exists public.presence_demo ( + id uuid primary key default gen_random_uuid(), + user_id uuid not null, + channel_name text not null, + metadata jsonb, + last_seen_at timestamptz not null default now() +); + +alter table public.presence_demo replica identity full; +alter publication supabase_realtime add table public.presence_demo; + +alter table public.presence_demo enable row level security; + +create policy "anon can select presence_demo" + on public.presence_demo for select + to anon, authenticated + using (true); + +create policy "anon can insert presence_demo" + on public.presence_demo for insert + to anon, authenticated + with check (true); + +create policy "anon can update presence_demo" + on public.presence_demo for update + to anon, authenticated + using (true) + with check (true); + +create policy "anon can delete presence_demo" + on public.presence_demo for delete + to anon, authenticated + using (true); + +grant all on table public.presence_demo to anon, authenticated; diff --git a/Tests/RealtimeV3IntegrationTests/supabase/seed.sql b/Tests/RealtimeV3IntegrationTests/supabase/seed.sql new file mode 100644 index 000000000..0b8cf3622 --- /dev/null +++ b/Tests/RealtimeV3IntegrationTests/supabase/seed.sql @@ -0,0 +1,7 @@ +-- Seed data for RealtimeV3 integration tests. +-- Kept minimal — most tests create their own rows. + +insert into public.messages (room_id, content) +values + ('00000000-0000-0000-0000-000000000001', 'seed message 1'), + ('00000000-0000-0000-0000-000000000001', 'seed message 2'); diff --git a/Tests/RealtimeV3Tests/BroadcastReceiveTests.swift b/Tests/RealtimeV3Tests/BroadcastReceiveTests.swift new file mode 100644 index 000000000..38e43b2b8 --- /dev/null +++ b/Tests/RealtimeV3Tests/BroadcastReceiveTests.swift @@ -0,0 +1,141 @@ +// +// BroadcastReceiveTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import Testing + +@testable import RealtimeV3 + +// MARK: - Test Helpers + +private struct ChatMsg: Codable, Sendable, Equatable { + let text: String +} + +// MARK: - BroadcastReceiveTests + +@Suite struct BroadcastReceiveTests { + + // MARK: - receivesTypedBroadcast + + /// Verifies that a broadcast frame with the correct inner event is decoded and yielded. + @Test func receivesTypedBroadcast() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + try await channel.subscribe() + + // Register the typed stream BEFORE injecting the frame. + let stream = await channel.broadcasts(of: ChatMsg.self, event: "chat") + var iter = stream.makeAsyncIterator() + + // Inject a broadcast frame with inner event "chat". + server.send( + .text( + #"["1",null,"realtime:room:1","broadcast",{"type":"broadcast","event":"chat","payload":{"text":"hi"}}]"# + )) + + // Read first value — bounded by task timeout. + let value = try await iter.next() + #expect(value == ChatMsg(text: "hi")) + } + + // MARK: - ignoresOtherEvents + + /// Injects an "other" event first, then a "chat" event. Only the "chat" event should + /// be yielded to a broadcasts(of:event:"chat") stream. + @Test func ignoresOtherEvents() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + try await channel.subscribe() + + // Register the typed stream BEFORE injecting any frames. + let stream = await channel.broadcasts(of: ChatMsg.self, event: "chat") + var iter = stream.makeAsyncIterator() + + // Inject "other" event — should be ignored by the "chat" stream. + server.send( + .text( + #"["1",null,"realtime:room:1","broadcast",{"type":"broadcast","event":"other","payload":{"text":"ignored"}}]"# + )) + + // Inject "chat" event — should be the FIRST thing yielded. + server.send( + .text( + #"["2",null,"realtime:room:1","broadcast",{"type":"broadcast","event":"chat","payload":{"text":"hello"}}]"# + )) + + // Only the "chat" message arrives; if "other" were yielded, it would arrive first. + let value = try await iter.next() + #expect(value == ChatMsg(text: "hello")) + } + + // MARK: - leaveTerminatesBroadcastStreamWithChannelClosed + + /// Calling leave() causes the broadcasts stream to throw .channelClosed(.userRequested). + @Test func leaveTerminatesBroadcastStreamWithChannelClosed() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + server.autoReplyToLeaves() + + try await channel.subscribe() + + // Register stream BEFORE calling leave(). + let stream = await channel.broadcasts(of: ChatMsg.self, event: "chat") + + // Collect results in a background task — looking for the thrown error. + let receivedError = LockIsolated(nil) + let done = LockIsolated(false) + let collectionTask = Task { + do { + for try await _ in stream { + // No messages expected before leave. + } + // Stream ended without throwing — unexpected. + done.withValue { $0 = true } + } catch let error as RealtimeError { + receivedError.withValue { $0 = error } + done.withValue { $0 = true } + } catch { + done.withValue { $0 = true } + } + } + + // Leave the channel — this should terminate the stream with channelClosed. + try await channel.leave() + + // Wait for collection task to finish (bounded). + var waitIterations = 0 + while !done.value { + try await Task.sleep(nanoseconds: 1_000_000) // 1ms + waitIterations += 1 + if waitIterations > 1000 { + Issue.record("Broadcast stream was not finished after leave() within 1s") + collectionTask.cancel() + return + } + } + collectionTask.cancel() + + // Verify the stream threw .channelClosed(.userRequested). + let error = receivedError.value + if case .channelClosed(let reason) = error { + #expect(reason == .userRequested) + } else { + Issue.record("Expected .channelClosed(.userRequested), got \(String(describing: error))") + } + } +} diff --git a/Tests/RealtimeV3Tests/BroadcastSendTests.swift b/Tests/RealtimeV3Tests/BroadcastSendTests.swift new file mode 100644 index 000000000..e1d8523a1 --- /dev/null +++ b/Tests/RealtimeV3Tests/BroadcastSendTests.swift @@ -0,0 +1,147 @@ +// +// BroadcastSendTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import Testing + +@testable import RealtimeV3 + +// MARK: - Test models + +private struct ChatMsg: Encodable, Sendable { + let text: String +} + +// MARK: - BroadcastSendTests + +@Suite struct BroadcastSendTests { + + // MARK: - sendBeforeSubscribeThrowsNotSubscribed + + /// A channel that has never been subscribed must throw `.notSubscribed` when broadcast is called. + @Test func sendBeforeSubscribeThrowsNotSubscribed() async throws { + let (transport, _) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + do { + try await channel.broadcast(ChatMsg(text: "hi"), as: "chat") + Issue.record("Expected .notSubscribed to be thrown") + } catch { + // broadcast(_:as:) uses typed throws(RealtimeError), so error IS a RealtimeError. + if case .notSubscribed = error { + // Expected — test passes. + } else { + Issue.record("Expected .notSubscribed, got \(error)") + } + } + } + + // MARK: - subscribedBroadcastEmitsBinaryFrame + + /// After subscribing, broadcast() must emit a binary frame with kind byte 0x03. + @Test func subscribedBroadcastEmitsBinaryFrame() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + try await channel.subscribe() + + // Collect frames the client sends in background. + let sentFrames = LockIsolated<[TransportFrame]>([]) + let frameObserver = server.subscribeToClientFrames() + let observerTask = Task { + for await frame in frameObserver { + sentFrames.withValue { $0.append(frame) } + } + } + defer { observerTask.cancel() } + + try await channel.broadcast(ChatMsg(text: "hi"), as: "chat") + + // Allow a tick for frame to be observed. + await Task.yield() + + // Verify at least one binary frame with kind byte 3 was sent. + let frames = sentFrames.value + let binaryFrames = frames.compactMap { frame -> Data? in + if case .binary(let data) = frame { return data } + return nil + } + + let broadcastFrame = binaryFrames.first { data in + !data.isEmpty && data[data.startIndex] == 3 + } + #expect(broadcastFrame != nil, "Expected a binary broadcast frame (kind byte 3) to be sent") + } + + // MARK: - ackModeAwaitsReply + + /// When broadcast.acknowledge == true, broadcast() waits for the server ack before returning. + @Test func ackModeAwaitsReply() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + transport: transport + ) + let channel = await rt.channel("room:1") { opts in + opts.broadcast.acknowledge = true + } + + server.autoReplyToJoins() + try await channel.subscribe() + + // Set up auto-reply for broadcast acks. + server.autoReplyToBroadcasts() + + // This must return without timing out (the server will ack it). + try await channel.broadcast(ChatMsg(text: "ack-test"), as: "chat") + + // If we reach here, the ack was received successfully. + #expect(Bool(true)) + } + + // MARK: - dataOverloadEmitsBinaryFrame + + /// The Data overload sends a binary frame with kind byte 0x03. + @Test func dataOverloadEmitsBinaryFrame() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:2") + + server.autoReplyToJoins() + try await channel.subscribe() + + let sentFrames = LockIsolated<[TransportFrame]>([]) + let frameObserver = server.subscribeToClientFrames() + let observerTask = Task { + for await frame in frameObserver { + sentFrames.withValue { $0.append(frame) } + } + } + defer { observerTask.cancel() } + + let rawData = Data([0xDE, 0xAD, 0xBE, 0xEF]) + try await channel.broadcast(rawData, as: "raw") + + await Task.yield() + + let frames = sentFrames.value + let binaryFrames = frames.compactMap { frame -> Data? in + if case .binary(let data) = frame { return data } + return nil + } + let broadcastFrame = binaryFrames.first { data in + !data.isEmpty && data[data.startIndex] == 3 + } + #expect( + broadcastFrame != nil, "Expected a binary broadcast frame (kind byte 3) for Data overload") + } +} diff --git a/Tests/RealtimeV3Tests/ChannelStateTests.swift b/Tests/RealtimeV3Tests/ChannelStateTests.swift new file mode 100644 index 000000000..8f149e0bf --- /dev/null +++ b/Tests/RealtimeV3Tests/ChannelStateTests.swift @@ -0,0 +1,29 @@ +// +// ChannelStateTests.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import Testing + +@testable import RealtimeV3 + +@Suite struct ChannelStateTests { + @Test func defaultOptions() { + let o = ChannelOptions() + #expect(o.isPrivate == false) + #expect(o.broadcast.acknowledge == false) + #expect(o.presence.enabled == false) + } + + @Test func channelStartsUnsubscribed() async { + let (transport, _) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + var it = await channel.state.makeAsyncIterator() + let first = await it.next() + #expect(first == .unsubscribed) + } +} diff --git a/Tests/RealtimeV3Tests/ConfigurationTests.swift b/Tests/RealtimeV3Tests/ConfigurationTests.swift new file mode 100644 index 000000000..1cce06fd6 --- /dev/null +++ b/Tests/RealtimeV3Tests/ConfigurationTests.swift @@ -0,0 +1,55 @@ +// +// ConfigurationTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import Testing + +@testable import RealtimeV3 + +@Suite struct ConfigurationTests { + @Test func defaultsMatchSpec() { + let c = Configuration.default + #expect(c.heartbeat == .seconds(25)) + #expect(c.joinTimeout == .seconds(10)) + #expect(c.broadcastAckTimeout == .seconds(5)) + #expect(c.protocolVersion == .v2) + } + + @Test func neverPolicyGivesUpImmediately() { + #expect(ReconnectionPolicy.never.nextDelay(0, RealtimeError.disconnected) == nil) + } + + @Test func exponentialBackoffGrowsAndClamps() { + let p = ReconnectionPolicy.exponentialBackoff( + initial: .seconds(1), max: .seconds(30), jitter: 0) + let d0 = p.nextDelay(0, RealtimeError.disconnected) + let d1 = p.nextDelay(1, RealtimeError.disconnected) + #expect(d0 == .seconds(1)) + #expect(d1 == .seconds(2)) + } + + @Test func exponentialBackoffClampsToMax() { + let p = ReconnectionPolicy.exponentialBackoff( + initial: .seconds(1), max: .seconds(30), jitter: 0) + // attempt 10 → uncapped 1 * 2^10 = 1024s, must be clamped to 30s + let d = p.nextDelay(10, RealtimeError.disconnected) + #expect(d == .seconds(30)) + } + + @Test func fixedReturnsDelayThenNilAfterMaxAttempts() { + let p = ReconnectionPolicy.fixed(.seconds(2), maxAttempts: 3) + #expect(p.nextDelay(0, RealtimeError.disconnected) == .seconds(2)) + #expect(p.nextDelay(1, RealtimeError.disconnected) == .seconds(2)) + #expect(p.nextDelay(2, RealtimeError.disconnected) == .seconds(2)) + #expect(p.nextDelay(3, RealtimeError.disconnected) == nil) + } + + @Test func fixedWithNilMaxAttemptsIsUnlimited() { + let p = ReconnectionPolicy.fixed(.seconds(2), maxAttempts: nil) + #expect(p.nextDelay(1000, RealtimeError.disconnected) == .seconds(2)) + } +} diff --git a/Tests/RealtimeV3Tests/ConnectURLTests.swift b/Tests/RealtimeV3Tests/ConnectURLTests.swift new file mode 100644 index 000000000..3b1258e6c --- /dev/null +++ b/Tests/RealtimeV3Tests/ConnectURLTests.swift @@ -0,0 +1,55 @@ +// +// ConnectURLTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 30/06/26. +// + +import Foundation +import Testing + +@testable import RealtimeV3 + +@Suite struct ConnectURLTests { + private func query(_ url: URL) -> [String: String] { + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + return Dictionary( + items.map { ($0.name, $0.value ?? "") }, uniquingKeysWith: { _, last in last }) + } + + @Test func appendsWebsocketAndQueryItems() throws { + let url = try Realtime._connectURL( + base: URL(string: "wss://x.supabase.co/realtime/v1")!, apiKey: "anon", vsn: "2.0.0") + #expect(url.path == "/realtime/v1/websocket") + let q = query(url) + #expect(q["apikey"] == "anon") + #expect(q["vsn"] == "2.0.0") + } + + @Test func handlesTrailingSlashWithoutDoublingIt() throws { + let url = try Realtime._connectURL( + base: URL(string: "wss://x.supabase.co/realtime/v1/")!, apiKey: "anon", vsn: "2.0.0") + #expect(url.path == "/realtime/v1/websocket") + } + + @Test func doesNotDoubleAppendWebsocket() throws { + let url = try Realtime._connectURL( + base: URL(string: "wss://x.supabase.co/realtime/v1/websocket")!, apiKey: "anon", vsn: "2.0.0") + #expect(url.path == "/realtime/v1/websocket") + } + + @Test func replacesPreexistingApikeyAndVsn() throws { + let url = try Realtime._connectURL( + base: URL(string: "wss://x.supabase.co/realtime/v1?apikey=stale&vsn=1.0.0&keep=1")!, + apiKey: "fresh", vsn: "2.0.0") + let q = query(url) + #expect(q["apikey"] == "fresh") + #expect(q["vsn"] == "2.0.0") + // Unrelated query items are preserved. + #expect(q["keep"] == "1") + // No duplicate apikey/vsn entries survive. + let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems ?? [] + #expect(items.filter { $0.name == "apikey" }.count == 1) + #expect(items.filter { $0.name == "vsn" }.count == 1) + } +} diff --git a/Tests/RealtimeV3Tests/CoreTypesTests.swift b/Tests/RealtimeV3Tests/CoreTypesTests.swift new file mode 100644 index 000000000..bfe37fcf7 --- /dev/null +++ b/Tests/RealtimeV3Tests/CoreTypesTests.swift @@ -0,0 +1,23 @@ +import Helpers +import Testing + +@testable import RealtimeV3 + +@Suite struct CoreTypesTests { + @Test func jsonValueIsAnyJSONAlias() { + let v: JSONValue = .string("hi") + #expect(v == AnyJSON.string("hi")) + } + + @Test func channelStateEquatableAcrossCloseReason() { + #expect(ChannelState.closed(.userRequested) == ChannelState.closed(.userRequested)) + #expect(ChannelState.closed(.userRequested) != ChannelState.closed(.timeout)) + #expect(ChannelState.joined == ChannelState.joined) + } + + @Test func closeReasonServerClosedCarriesCodeAndMessage() { + let a = CloseReason.serverClosed(code: 1011, message: "boom") + let b = CloseReason.serverClosed(code: 1011, message: "boom") + #expect(a == b) + } +} diff --git a/Tests/RealtimeV3Tests/DisconnectTests.swift b/Tests/RealtimeV3Tests/DisconnectTests.swift new file mode 100644 index 000000000..25978b506 --- /dev/null +++ b/Tests/RealtimeV3Tests/DisconnectTests.swift @@ -0,0 +1,162 @@ +// +// DisconnectTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Clocks +import ConcurrencyExtras +import Foundation +import Testing + +@testable import RealtimeV3 + +@Suite struct DisconnectTests { + + // MARK: - Helpers + + /// Advance the TestClock in small steps with yields, up to `maxAttempts`, without waiting + /// for a specific condition. Used to prove "nothing happened" after disconnect. + private func advanceClockBeyondBackoff( + clock: TestClock, + step: Duration = .seconds(1), + steps: Int = 40 + ) async { + for _ in 0..([]) + let observerTask = Task { + for await s in statusStream { + observedStates.withValue { $0.append(s.state) } + } + } + defer { observerTask.cancel() } + + // Disconnect. + await rt.disconnect() + + // Status must be .closed(.clientDisconnected). + let statusAfterDisconnect = await rt.status.first(where: { _ in true }) + if let s = statusAfterDisconnect { + if case .closed(let reason) = s.state { + #expect(reason == .clientDisconnected) + } else { + Issue.record("Expected .closed(.clientDisconnected), got \(s.state)") + } + } + + // Advance the clock well past the backoff window (40 * 1s = 40s > max backoff of 30s). + await advanceClockBeyondBackoff(clock: clock) + + // Transport must NOT have reconnected — connectCallCount stays at 1. + #expect( + await transport.connectCallCount == 1, "Transport should not reconnect after disconnect()") + + // Status should still be .closed(.clientDisconnected), not .reconnecting/.connected. + let finalStatusStream = await rt.status + if let finalStatus = await finalStatusStream.first(where: { _ in true }) { + if case .closed(let reason) = finalStatus.state { + #expect(reason == .clientDisconnected, "Status should stay .closed(.clientDisconnected)") + } else { + Issue.record("Expected .closed(.clientDisconnected), got \(finalStatus.state)") + } + } + } + + @Test func connectAfterDisconnectReopens() async throws { + let (transport, _) = InMemoryTransport.pair() + let clock = TestClock() + var config = Configuration.default + config.clock = clock + config.heartbeat = .seconds(25) + config.reconnection = .exponentialBackoff(initial: .seconds(1), max: .seconds(30), jitter: 0) + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + configuration: config, + transport: transport + ) + + // First connect. + try await rt.connect() + #expect(await transport.connectCallCount == 1) + + // Disconnect. + await rt.disconnect() + + // Verify disconnected state. + let postDisconnectStream = await rt.status + if let s = await postDisconnectStream.first(where: { _ in true }) { + if case .closed(let reason) = s.state { + #expect(reason == .clientDisconnected) + } else { + Issue.record("Expected .closed(.clientDisconnected) after disconnect, got \(s.state)") + } + } + + // Second connect after disconnect should re-open. + try await rt.connect() + #expect(await transport.connectCallCount == 2, "Expected second transport.connect() call") + + // Status should be .connected. + let reconnectedStream = await rt.status + if let s = await reconnectedStream.first(where: { _ in true }) { + if case .connected = s.state { + // Good. + } else { + Issue.record("Expected .connected after second connect(), got \(s.state)") + } + } + } + + @Test func channelCachePreservedAcrossDisconnect() async throws { + let (transport, _) = InMemoryTransport.pair() + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + transport: transport + ) + + try await rt.connect() + + // Obtain a channel reference. + let c1 = await rt.channel("room:1") + + // Disconnect (should NOT evict the channel cache). + await rt.disconnect() + + // Obtain the same topic — must return the cached channel. + let c2 = await rt.channel("room:1") + + #expect(c1 === c2, "Channel cache must be preserved across disconnect()") + } +} diff --git a/Tests/RealtimeV3Tests/FrameRoutingTests.swift b/Tests/RealtimeV3Tests/FrameRoutingTests.swift new file mode 100644 index 000000000..1c3868fe2 --- /dev/null +++ b/Tests/RealtimeV3Tests/FrameRoutingTests.swift @@ -0,0 +1,40 @@ +// +// FrameRoutingTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import Testing + +@testable import RealtimeV3 + +@Suite struct FrameRoutingTests { + @Test func phxReplyResolvesPendingPush() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + try await rt.connect() + + // Register a pending push for ref "1" in a concurrent task. + async let pendingReply = rt._test_awaitReply(ref: "1", timeoutError: .channelJoinTimeout) + + // Wait until the registry has the push registered before injecting the reply. + // This prevents a race where the frame arrives before the continuation is stored. + var attempts = 0 + while await rt._test_pendingCount == 0 { + try await Task.sleep(nanoseconds: 1_000_000) // 1ms + attempts += 1 + if attempts > 1000 { + Issue.record("push not registered within timeout") + return + } + } + + // Inject a phx_reply frame from the server. + server.send(.text(#"[null,"1","realtime:room:1","phx_reply",{"status":"ok","response":{}}]"#)) + + let result = try await pendingReply + #expect(result.status == "ok") + } +} diff --git a/Tests/RealtimeV3Tests/HeartbeatTests.swift b/Tests/RealtimeV3Tests/HeartbeatTests.swift new file mode 100644 index 000000000..55dc295ff --- /dev/null +++ b/Tests/RealtimeV3Tests/HeartbeatTests.swift @@ -0,0 +1,308 @@ +// +// HeartbeatTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Clocks +import ConcurrencyExtras +import Foundation +import Helpers +import IssueReporting +import Testing + +@testable import RealtimeV3 + +@Suite struct HeartbeatTests { + + // MARK: - Helpers + + /// Advances `clock` in small steps, yielding to the cooperative thread pool between each + /// advance, until `condition()` returns `true` or `maxAttempts` is reached. + /// + /// This avoids the "advance-before-sleep-registered" hang class: the heartbeat loop must + /// call `clock.sleep(for:)` before the advance takes effect. By yielding first and + /// advancing in a bounded loop we guarantee the sleep is installed before the advance. + private func advanceUntil( + clock: TestClock, + step: Duration, + maxAttempts: Int = 50, + condition: () async -> Bool + ) async { + for _ in 0..= 2, + let ref = arr[1].stringValue + else { return nil } + return ref + } + + guard let ref = heartbeatRef else { + Issue.record("Could not extract heartbeat ref from sent frame") + return + } + + // Wait until the registry has registered the pending push for this ref, + // so we know awaitReply is suspended and will receive our injected reply. + for _ in 0..<300 { + await Task.yield() + if await rt._test_pendingCount > 0 { break } + } + + // Subscribe to the status stream before injecting the reply. + let statusStream = await rt.status + + // Inject the server's phx_reply for this heartbeat ref. + let replyJSON = + "[null,\"\(ref)\",\"phoenix\",\"phx_reply\",{\"status\":\"ok\",\"response\":{}}]" + server.send(.text(replyJSON)) + + // Observe the status stream until a ConnectionStatus with non-nil latency appears. + let sawLatency = LockIsolated(false) + let statusTask = Task { + for await s in statusStream { + if s.latency != nil { + sawLatency.setValue(true) + return + } + } + } + + // Give the cooperative scheduler enough time to route the frame and update latency. + for _ in 0..<300 { + await Task.yield() + if sawLatency.value { break } + } + statusTask.cancel() + + if !sawLatency.value { + Issue.record("Expected ConnectionStatus.latency to be non-nil after heartbeat round-trip") + } + } + + @Test func heartbeatTimeoutTriggersConnectionLost() async throws { + let (transport, server) = InMemoryTransport.pair() + let clock = TestClock() + var config = Configuration.default + config.clock = clock + config.heartbeat = .seconds(25) + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + configuration: config, + transport: transport + ) + try await rt.connect() + + // Subscribe to status changes. + let statusStream = await rt.status + + // Discard connection frames so the server buffer doesn't fill. + let drainTask = Task { + var it = server.clientSentFrames.makeAsyncIterator() + while await it.next() != nil {} + } + defer { drainTask.cancel() } + + // Advance past the heartbeat interval (sends the heartbeat frame). + await advanceUntil(clock: clock, step: .seconds(1)) { + await rt._test_pendingCount > 0 + } + + // Now advance past the heartbeat timeout WITHOUT sending a reply — the registry + // timeout task also sleeps on `configuration.heartbeat`. Advance until + // connection transitions away from .connected. + // Note: with Task 13 reconnection, the state transitions to .reconnecting (not .idle/.closed) + // unless the policy gives up immediately. The default policy retries, so we accept + // .reconnecting, .idle, or .closed as evidence that the heartbeat timeout was detected. + let sawDisconnected = LockIsolated(false) + let statusCheckTask = Task { + for await s in statusStream { + switch s.state { + case .idle, .closed, .reconnecting: + sawDisconnected.setValue(true) + return + default: + break + } + } + } + + await advanceUntil(clock: clock, step: .seconds(1), maxAttempts: 300) { + sawDisconnected.value + } + statusCheckTask.cancel() + + #expect( + sawDisconnected.value, + "Expected connection to transition away from .connected after heartbeat timeout") + } +} diff --git a/Tests/RealtimeV3Tests/HttpBroadcastTests.swift b/Tests/RealtimeV3Tests/HttpBroadcastTests.swift new file mode 100644 index 000000000..b8b696cff --- /dev/null +++ b/Tests/RealtimeV3Tests/HttpBroadcastTests.swift @@ -0,0 +1,140 @@ +// +// HttpBroadcastTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import Mocker +import Testing + +@testable import RealtimeV3 + +#if canImport(FoundationNetworking) + import FoundationNetworking +#endif + +@Suite(.serialized) struct HttpBroadcastTests { + + func mockedSession() -> URLSession { + let configuration = URLSessionConfiguration.ephemeral + configuration.protocolClasses = [MockingURLProtocol.self] + return URLSession(configuration: configuration) + } + + @Test func single202Succeeds() async throws { + // Mocker intercepts wss:// → converted to https:// inside Realtime + let broadcastURL = URL(string: "https://proj.supabase.co/realtime/v1/api/broadcast")! + + let capturedRequest = LockIsolated(nil) + var mock = Mock( + url: broadcastURL, + contentType: .json, + statusCode: 202, + data: [.post: Data()] + ) + mock.onRequestHandler = OnRequestHandler(requestCallback: { capturedRequest.setValue($0) }) + mock.register() + + let rt = Realtime( + url: URL(string: "wss://proj.supabase.co/realtime/v1")!, + apiKey: "anon", + urlSession: mockedSession() + ) + let channel = await rt.channel("room:1") + try await channel.httpBroadcast(event: "chat", payload: ["text": "hi"]) + + let req = try #require(capturedRequest.value) + // Body should be non-nil and contain "messages" + let body = req.httpBodyStreamData() ?? req.httpBody + let bodyString = body.map { String(decoding: $0, as: UTF8.self) } ?? "" + #expect(body != nil) + #expect(bodyString.contains("messages")) + + // No token configured → apikey header must be present + #expect(req.value(forHTTPHeaderField: "apikey") == "anon") + } + + @Test func rateLimitedThrows() async throws { + let broadcastURL = URL(string: "https://proj.supabase.co/realtime/v1/api/broadcast")! + Mock( + url: broadcastURL, + contentType: .json, + statusCode: 429, + data: [.post: Data(#"{"message":"Too many requests"}"#.utf8)] + ).register() + + let rt = Realtime( + url: URL(string: "wss://proj.supabase.co/realtime/v1")!, + apiKey: "anon", + urlSession: mockedSession() + ) + let channel = await rt.channel("room:1") + + do { + try await channel.httpBroadcast(event: "chat", payload: ["text": "hi"]) + Issue.record("Expected rateLimited error but call succeeded") + } catch { + if case .rateLimited = error { + // expected + } else { + Issue.record("Expected .rateLimited, got: \(error)") + } + } + } + + @Test func batchSendsMultipleMessages() async throws { + let broadcastURL = URL(string: "https://proj.supabase.co/realtime/v1/api/broadcast")! + + let capturedRequest = LockIsolated(nil) + var mock = Mock( + url: broadcastURL, + contentType: .json, + statusCode: 202, + data: [.post: Data()] + ) + mock.onRequestHandler = OnRequestHandler(requestCallback: { capturedRequest.setValue($0) }) + mock.register() + + let rt = Realtime( + url: URL(string: "wss://proj.supabase.co/realtime/v1")!, + apiKey: "anon", + urlSession: mockedSession() + ) + + let messages: [HttpBroadcastMessage] = [ + HttpBroadcastMessage(topic: "room:1", event: "chat", payload: ["text": "hello"]), + HttpBroadcastMessage(topic: "room:2", event: "status", payload: ["online": true]), + ] + try await rt.httpBroadcastBatch(messages) + + let req = try #require(capturedRequest.value) + let body = req.httpBodyStreamData() ?? req.httpBody + let bodyString = body.map { String(decoding: $0, as: UTF8.self) } ?? "" + #expect(body != nil) + // Body should contain both topics + #expect(bodyString.contains("room:1")) + #expect(bodyString.contains("room:2")) + } +} + +// MARK: - URLRequest extension for body capture in tests + +extension URLRequest { + fileprivate func httpBodyStreamData() -> Data? { + guard let bodyStream = httpBodyStream else { return nil } + bodyStream.open() + let bufferSize = 16 + let buffer = UnsafeMutablePointer.allocate(capacity: bufferSize) + var data = Data() + while bodyStream.hasBytesAvailable { + let readData = bodyStream.read(buffer, maxLength: bufferSize) + data.append(buffer, count: readData) + } + buffer.deallocate() + bodyStream.close() + return data + } +} diff --git a/Tests/RealtimeV3Tests/IdleDisconnectTests.swift b/Tests/RealtimeV3Tests/IdleDisconnectTests.swift new file mode 100644 index 000000000..a23441703 --- /dev/null +++ b/Tests/RealtimeV3Tests/IdleDisconnectTests.swift @@ -0,0 +1,267 @@ +// +// IdleDisconnectTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Clocks +import ConcurrencyExtras +import Foundation +import Testing + +@testable import RealtimeV3 + +@Suite struct IdleDisconnectTests { + + // MARK: - Helpers + + /// Advance the TestClock in small steps with yields until `condition()` returns true + /// or `maxAttempts` is reached. Uses the same bounded advance-until pattern as + /// ReconnectionTests to avoid hangs. + private func advanceUntil( + clock: TestClock, + step: Duration, + maxAttempts: Int = 300, + condition: () async -> Bool + ) async { + for _ in 0.. 0, attempts < 1000 { + await clock.advance(by: .seconds(5)) + await Task.yield() + attempts += 1 + } + + do { + _ = try await replyTask.value + Issue.record("Expected broadcastAckTimeout but the push succeeded") + } catch let error as RealtimeError { + guard case .broadcastAckTimeout = error else { + Issue.record("Expected .broadcastAckTimeout, got \(error)") + return + } + } catch { + Issue.record("Unexpected non-RealtimeError: \(error)") + } + } +} diff --git a/Tests/RealtimeV3Tests/LeakWarningTests.swift b/Tests/RealtimeV3Tests/LeakWarningTests.swift new file mode 100644 index 000000000..3a5e2e9d2 --- /dev/null +++ b/Tests/RealtimeV3Tests/LeakWarningTests.swift @@ -0,0 +1,126 @@ +// +// LeakWarningTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import IssueReporting +import Testing + +@testable import RealtimeV3 + +/// Tests that `Realtime` emits a debug warning when it is deinited with channels that +/// were joined but never left, and that no warning fires when all channels are properly left. +/// +/// ## Deterministic deinit +/// Background routing and heartbeat tasks hold a strong reference to the `Realtime` actor +/// via closures. To ensure the actor deinits within the test, we call `disconnect()` first — +/// this cancels those tasks and releases their captures. Once the tasks are cancelled, +/// dropping the last strong reference to the `Realtime` variable triggers `deinit`. +/// +/// ## disconnect() does NOT clear joinedTopics +/// `disconnect()` is intentionally NOT a substitute for `leave()`. It does not clear +/// `joinedTopics` because disconnecting is a transport-level operation, not a channel-level +/// leave. A channel that was subscribed but never left remains in `joinedTopics` so the +/// deinit warning still fires — guiding the developer to call `leave()` explicitly. +@Suite struct LeakWarningTests { + + // MARK: - warnsOnLeakedJoinedChannel + + /// A joined-but-unleft channel should trigger a warning when Realtime is deinited. + @Test func warnsOnLeakedJoinedChannel() async { + await withExpectedIssue("Realtime deinited with joined channel that was never left") { + // Scope the Realtime actor in an inner async function so Swift ARC releases it + // when the function returns — before we reach the withExpectedIssue check. + await subscribeWithoutLeaving() + // After subscribeWithoutLeaving() returns, the Realtime is fully released + // and its deinit has fired, reporting the issue. + } + } + + /// Creates a Realtime client, subscribes a channel, disconnects (to cancel background tasks + /// so the actor can deinit), then returns WITHOUT leaving the channel. + /// + /// When this function returns, all local references to `rt` are gone. Swift's ARC will + /// release the actor, triggering `deinit` which fires the leak warning. + private func subscribeWithoutLeaving() async { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + configuration: { + var c = Configuration.default + // Use .never reconnection and long heartbeat to prevent background tasks from + // interfering with the test by triggering unexpected state transitions. + c.reconnection = .never + c.heartbeat = .seconds(3600) + return c + }(), + transport: transport + ) + + server.autoReplyToJoins() + let channel = await rt.channel("room:leak-test") + try? await channel.subscribe() + + // Wait until the channel is fully joined before proceeding. + for await s in await channel.state { + if s == .joined { break } + } + + // disconnect() cancels background tasks (routing + heartbeat) so the actor can deinit + // once this function returns and `rt` goes out of scope. + // Critically, disconnect() does NOT call leave() and does NOT clear joinedTopics. + await rt.disconnect() + + // Yield several times to let cancelled tasks complete and release their strong + // captures of `rt`, ensuring ARC can release the actor when `rt` drops. + for _ in 0..<20 { + await Task.yield() + } + + // `rt` (and `channel`) go out of scope at the end of this function. + // ARC releases `rt` → deinit fires → reportIssue is called. + } + + // MARK: - noWarnWhenAllLeft + + /// A channel that is properly left before Realtime deinits should NOT trigger a warning. + @Test func noWarnWhenAllLeft() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + configuration: { + var c = Configuration.default + c.reconnection = .never + c.heartbeat = .seconds(3600) + return c + }(), + transport: transport + ) + + server.autoReplyToJoins() + server.autoReplyToLeaves() + + let channel = await rt.channel("room:no-leak-test") + try await channel.subscribe() + + // Wait until joined. + for await s in await channel.state { + if s == .joined { break } + } + + // Properly leave the channel — removes the topic from joinedTopics. + try await channel.leave() + + // disconnect() cancels background tasks so the actor can deinit. + await rt.disconnect() + + // rt goes out of scope here → deinit → joinedTopics is empty → NO warning fires. + // If a warning fires unexpectedly, the test framework routes it to a test failure. + } +} diff --git a/Tests/RealtimeV3Tests/LeaveTests.swift b/Tests/RealtimeV3Tests/LeaveTests.swift new file mode 100644 index 000000000..0ba3e7380 --- /dev/null +++ b/Tests/RealtimeV3Tests/LeaveTests.swift @@ -0,0 +1,107 @@ +// +// LeaveTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import Helpers +import Testing + +@testable import RealtimeV3 + +@Suite struct LeaveTests { + + // MARK: - leaveClosesChannel + + /// Verifies that leave() transitions the channel to .closed(.userRequested). + @Test func leaveClosesChannel() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + server.autoReplyToLeaves() + + try await channel.subscribe() + + // Confirm joined state. + let joinedState = await channel.state.first(where: { _ in true }) + #expect(joinedState == .joined) + + try await channel.leave() + + // Observe .closed(.userRequested) from state stream. + let stateStream = await channel.state + var last: ChannelState? + var iterations = 0 + for await s in stateStream { + last = s + iterations += 1 + if case .closed(.userRequested) = s { break } + if iterations > 20 { break } + } + if case .closed(.userRequested) = last { + // expected + } else { + Issue.record("Expected .closed(.userRequested), got: \(String(describing: last))") + } + } + + // MARK: - resubscribeAfterLeaveWorks + + /// Verifies that a channel can be re-joined after a successful leave(). + @Test func resubscribeAfterLeaveWorks() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + server.autoReplyToLeaves() + + // First subscribe. + try await channel.subscribe() + let joinedState = await channel.state.first(where: { _ in true }) + #expect(joinedState == .joined) + + // Leave. + try await channel.leave() + + // Re-subscribe from .closed state. + try await channel.subscribe() + + let resubState = await channel.state.first(where: { _ in true }) + #expect(resubState == .joined) + } + + // MARK: - leaveIsIdempotent + + /// Verifies that calling leave() twice does not hang and leaves the channel .closed(.userRequested). + @Test func leaveIsIdempotent() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + server.autoReplyToLeaves() + + try await channel.subscribe() + + // First leave. + try await channel.leave() + + // Second leave — should be a no-op, not hang. + try await channel.leave() + + let finalState = await channel.state.first(where: { _ in true }) + if case .closed(.userRequested) = finalState { + // expected + } else { + Issue.record( + "Expected .closed(.userRequested) after second leave, got: \(String(describing: finalState))" + ) + } + } +} diff --git a/Tests/RealtimeV3Tests/LifecycleTests.swift b/Tests/RealtimeV3Tests/LifecycleTests.swift new file mode 100644 index 000000000..e2f6fb3c5 --- /dev/null +++ b/Tests/RealtimeV3Tests/LifecycleTests.swift @@ -0,0 +1,184 @@ +// +// LifecycleTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Clocks +import ConcurrencyExtras +import Foundation +import Testing + +@testable import RealtimeV3 + +@Suite struct LifecycleTests { + + // MARK: - Helpers + + /// Advance the TestClock in small steps, yielding to the cooperative thread pool between each + /// advance, until `condition()` returns true or `maxAttempts` is reached. + private func advanceUntil( + clock: TestClock, + step: Duration, + maxAttempts: Int = 200, + condition: () async -> Bool + ) async { + for _ in 0..= 2 + } + + #expect( + await transport.connectCallCount >= 2, + "Expected transport.connect to be called again after foreground event following a drop" + ) + } + + /// A foreground event while already connected should NOT trigger an extra connect. + @Test func foregroundWhenConnectedIsNoOp() async throws { + let (transport, _) = InMemoryTransport.pair() + var config = Configuration.default + config.heartbeat = .seconds(25) + config.lifecycle = .automatic + + let lifecycleSource = TestLifecycleEventSource() + + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + configuration: config, + transport: transport, + lifecycleSource: lifecycleSource + ) + + // Connect successfully. + try await rt.connect() + #expect(await transport.connectCallCount == 1) + + // Background, then foreground WITHOUT dropping the socket. This exercises the + // real no-op guard in handleAppForeground() (the `.connected` check): the + // observer's "did background" gate is satisfied by the background event, but + // because the connection is still alive, foreground must NOT reconnect. + lifecycleSource.sendBackground() + for _ in 0..<20 { + await Task.yield() + } + lifecycleSource.sendForeground() + + // Let any potential spurious connects happen. + for _ in 0..<20 { + await Task.yield() + } + + // Connect count must stay at 1. + #expect( + await transport.connectCallCount == 1, + "Foreground event when already connected should not trigger an extra connect" + ) + } + + /// After an explicit disconnect(), a foreground event must NOT reconnect. + @Test func manualDisconnectSuppressesLifecycleReconnect() async throws { + let (transport, _) = InMemoryTransport.pair() + let clock = TestClock() + var config = Configuration.default + config.clock = clock + config.heartbeat = .seconds(25) + config.reconnection = .exponentialBackoff(initial: .seconds(1), max: .seconds(30), jitter: 0) + config.lifecycle = .automatic + + let lifecycleSource = TestLifecycleEventSource() + + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + configuration: config, + transport: transport, + lifecycleSource: lifecycleSource + ) + + // Connect successfully. + try await rt.connect() + #expect(await transport.connectCallCount == 1) + + // Intentional disconnect. + await rt.disconnect() + + // Emit foreground — must NOT reconnect. + lifecycleSource.sendForeground() + + // Advance clock well past backoff window to prove no reconnect occurs. + for _ in 0..<40 { + await Task.yield() + await clock.advance(by: .seconds(1)) + for _ in 0..<5 { + await Task.yield() + } + } + + #expect( + await transport.connectCallCount == 1, + "Foreground event after manual disconnect() must NOT reconnect" + ) + } +} diff --git a/Tests/RealtimeV3Tests/LoggingTests.swift b/Tests/RealtimeV3Tests/LoggingTests.swift new file mode 100644 index 000000000..40de26ef8 --- /dev/null +++ b/Tests/RealtimeV3Tests/LoggingTests.swift @@ -0,0 +1,191 @@ +// +// LoggingTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Clocks +import ConcurrencyExtras +import Foundation +import Helpers +import Testing + +@testable import RealtimeV3 + +// MARK: - SpyLogger + +/// A test double that captures every LogEvent emitted by the SDK. +final class SpyLogger: RealtimeLogger { + let events = LockIsolated<[LogEvent]>([]) + + func log(_ event: LogEvent) { + events.withValue { $0.append(event) } + } +} + +// MARK: - LoggingTests + +@Suite struct LoggingTests { + + // MARK: - connectEmitsConnectionLog + + /// Verifies that calling connect() emits at least one LogEvent with category == .connection. + @Test func connectEmitsConnectionLog() async throws { + let spy = SpyLogger() + let (transport, _) = InMemoryTransport.pair() + var config = Configuration.default + config.logger = spy + + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + configuration: config, + transport: transport + ) + try await rt.connect() + + // Yield a few times to allow any deferred log emissions to propagate. + for _ in 0..<10 { await Task.yield() } + + let captured = spy.events.value + let hasConnection = captured.contains { $0.category == .connection } + #expect(hasConnection, "Expected at least one .connection-category LogEvent after connect()") + } + + // MARK: - heartbeatEmitsRttMetric + + /// Verifies that after a heartbeat round-trip, a LogEvent with metadata["heartbeat.rtt_ms"] is emitted. + @Test func heartbeatEmitsRttMetric() async throws { + let spy = SpyLogger() + let (transport, server) = InMemoryTransport.pair() + let clock = TestClock() + var config = Configuration.default + config.clock = clock + config.heartbeat = .seconds(25) + config.logger = spy + + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + configuration: config, + transport: transport + ) + try await rt.connect() + + // Collect client-sent frames so we can find the heartbeat ref. + let collectedFrames = LockIsolated([TransportFrame]()) + let collectorTask = Task { + var it = server.clientSentFrames.makeAsyncIterator() + while let frame = await it.next() { + collectedFrames.withValue { $0.append(frame) } + } + } + defer { collectorTask.cancel() } + + // Advance clock until a heartbeat frame is on the wire. + for _ in 0..<300 { + await Task.yield() + await clock.advance(by: .seconds(1)) + let found = collectedFrames.withValue { frames in + frames.contains { + if case .text(let t) = $0 { return t.contains("heartbeat") } + return false + } + } + if found { break } + } + + // Extract the heartbeat ref. + let heartbeatRef: String? = collectedFrames.withValue { frames in + guard + let frame = frames.first(where: { + if case .text(let t) = $0 { return t.contains("heartbeat") } + return false + }), + case .text(let t) = frame, + let data = t.data(using: .utf8), + let arr = try? JSONDecoder().decode([AnyJSON].self, from: data), + arr.count >= 2, + let ref = arr[1].stringValue + else { return nil } + return ref + } + + guard let ref = heartbeatRef else { + Issue.record("Could not extract heartbeat ref") + return + } + + // Wait until the registry has the pending push registered. + for _ in 0..<300 { + await Task.yield() + if await rt._test_pendingCount > 0 { break } + } + + // Inject the server reply so the heartbeat round-trip completes. + let replyJSON = + "[null,\"\(ref)\",\"phoenix\",\"phx_reply\",{\"status\":\"ok\",\"response\":{}}]" + server.send(.text(replyJSON)) + + // Yield until the RTT log event appears (bounded). + var sawRttMetric = false + for _ in 0..<300 { + await Task.yield() + let captured = spy.events.value + if captured.contains(where: { $0.metadata["heartbeat.rtt_ms"] != nil }) { + sawRttMetric = true + break + } + } + + #expect( + sawRttMetric, + "Expected a LogEvent with metadata[\"heartbeat.rtt_ms\"] after heartbeat round-trip") + } + + // MARK: - reconnectAttemptEmitsMetric + + /// Verifies that a forced reconnect emits a LogEvent with metadata["reconnect.attempt"]. + @Test func reconnectAttemptEmitsMetric() async throws { + let spy = SpyLogger() + let (transport, server) = InMemoryTransport.pair() + let clock = TestClock() + var config = Configuration.default + config.clock = clock + config.heartbeat = .seconds(25) + config.reconnection = .exponentialBackoff(initial: .seconds(1), max: .seconds(30), jitter: 0) + config.logger = spy + + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + configuration: config, + transport: transport + ) + try await rt.connect() + + // Trigger a server-initiated close to start the reconnection loop. + server.closeFromServer(code: 1001, reason: "server went away") + + // Wait for the reconnection loop to start. + for _ in 0..<300 { + await Task.yield() + if await rt.isReconnecting { break } + } + + // Advance the clock to trigger the first reconnect attempt's delay. + for _ in 0..<300 { + await Task.yield() + await clock.advance(by: .seconds(1)) + for _ in 0..<10 { await Task.yield() } + let found = spy.events.value.contains { $0.metadata["reconnect.attempt"] != nil } + if found { break } + } + + let sawReconnectMetric = spy.events.value.contains { $0.metadata["reconnect.attempt"] != nil } + #expect( + sawReconnectMetric, + "Expected a LogEvent with metadata[\"reconnect.attempt\"] during reconnection") + } +} diff --git a/Tests/RealtimeV3Tests/MessagesFeedTests.swift b/Tests/RealtimeV3Tests/MessagesFeedTests.swift new file mode 100644 index 000000000..ab9f84e2a --- /dev/null +++ b/Tests/RealtimeV3Tests/MessagesFeedTests.swift @@ -0,0 +1,143 @@ +// +// MessagesFeedTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import Helpers +import Testing + +@testable import RealtimeV3 + +@Suite struct MessagesFeedTests { + + // MARK: - fanOutToMultipleConsumers + + /// Two independent messages() streams each receive the same broadcast frame. + @Test func fanOutToMultipleConsumers() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + try await channel.subscribe() + + // Register both streams BEFORE injecting the frame. + let stream1 = await channel.messages() + let stream2 = await channel.messages() + + // Inject a broadcast frame for the channel topic. + server.send( + .text(#"["1",null,"realtime:room:1","broadcast",{"event":"chat","payload":{"x":1}}]"#)) + + // Each stream should yield exactly one message with event == .broadcast. + var iter1 = stream1.makeAsyncIterator() + var iter2 = stream2.makeAsyncIterator() + + let msg1 = await iter1.next() + let msg2 = await iter2.next() + + #expect(msg1?.event == .broadcast) + #expect(msg2?.event == .broadcast) + } + + // MARK: - lateConsumerStillReceivesSubsequentFrames + + /// A stream created AFTER a frame was already consumed still receives subsequent frames. + @Test func lateConsumerStillReceivesSubsequentFrames() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + try await channel.subscribe() + + // First consumer registered before frame A. + let stream1 = await channel.messages() + var iter1 = stream1.makeAsyncIterator() + + // Inject frame A — first consumer receives it. + server.send( + .text(#"["1",null,"realtime:room:1","broadcast",{"event":"frameA","payload":{}}]"#)) + let msgA = await iter1.next() + #expect(msgA?.event == .broadcast) + + // Register second consumer AFTER frame A was already delivered. + let stream2 = await channel.messages() + var iter2 = stream2.makeAsyncIterator() + + // Inject frame B — second consumer should receive it. + server.send( + .text(#"["2",null,"realtime:room:1","broadcast",{"event":"frameB","payload":{}}]"#)) + let msgB = await iter2.next() + #expect(msgB?.event == .broadcast) + } + + // MARK: - leaveFinishesMessageStreams + + /// Calling leave() finishes all open messages() streams so for-await loops end. + @Test func leaveFinishesMessageStreams() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + server.autoReplyToLeaves() + + try await channel.subscribe() + + let stream = await channel.messages() + + // Collect messages in a background task. The task should complete once leave() finishes + // all streams. We use a bounded array to avoid hanging if finish never comes. + let collected = LockIsolated<[PhoenixMessage]>([]) + let done = LockIsolated(false) + let collectionTask = Task { + for await msg in stream { + collected.withValue { $0.append(msg) } + } + done.withValue { $0 = true } + } + + // Inject one frame to confirm the stream is live. + server.send( + .text(#"["1",null,"realtime:room:1","broadcast",{"event":"alive","payload":{}}]"#)) + + // Give the collection task a moment to consume the frame. + var waitIterations = 0 + while collected.value.isEmpty { + try await Task.sleep(nanoseconds: 1_000_000) // 1ms + waitIterations += 1 + if waitIterations > 1000 { + Issue.record("First frame not received within 1s") + collectionTask.cancel() + return + } + } + + // Now leave — this should finish the stream. + try await channel.leave() + + // Await the collection task to complete (stream finished). + var finishIterations = 0 + while !done.value { + try await Task.sleep(nanoseconds: 1_000_000) // 1ms + finishIterations += 1 + if finishIterations > 1000 { + Issue.record("messages() stream was not finished after leave() within 1s") + collectionTask.cancel() + return + } + } + + // We received one frame and then the stream finished. + #expect(collected.value.count == 1) + #expect(collected.value.first?.event == .broadcast) + #expect(done.value == true) + + collectionTask.cancel() + } +} diff --git a/Tests/RealtimeV3Tests/PhoenixMessageTests.swift b/Tests/RealtimeV3Tests/PhoenixMessageTests.swift new file mode 100644 index 000000000..ed4f7641f --- /dev/null +++ b/Tests/RealtimeV3Tests/PhoenixMessageTests.swift @@ -0,0 +1,19 @@ +import Foundation +import Testing + +@testable import RealtimeV3 + +@Suite struct PhoenixMessageTests { + @Test func constructsBroadcastFrame() { + let msg = PhoenixMessage( + joinRef: "1", ref: nil, topic: "room:1", event: .broadcast, + payload: .json(["x": 1]), receivedAt: Date(timeIntervalSince1970: 0) + ) + #expect(msg.event == .broadcast) + if case .json(let v) = msg.payload { + #expect(v.objectValue?["x"] == 1) + } else { + Issue.record("expected json") + } + } +} diff --git a/Tests/RealtimeV3Tests/PhoenixSerializerBinaryTests.swift b/Tests/RealtimeV3Tests/PhoenixSerializerBinaryTests.swift new file mode 100644 index 000000000..c10740f32 --- /dev/null +++ b/Tests/RealtimeV3Tests/PhoenixSerializerBinaryTests.swift @@ -0,0 +1,61 @@ +// +// PhoenixSerializerBinaryTests.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 27/06/26. +// + +import Foundation +import Helpers +import Testing + +@testable import RealtimeV3 + +@Suite struct PhoenixSerializerBinaryTests { + let s = PhoenixSerializer() + + @Test func jsonPushHasCorrectHeader() throws { + let data = try s.encodeBroadcastPush( + joinRef: "1", ref: "2", topic: "t", event: "e", jsonPayload: ["a": 1] + ) + #expect(data[data.startIndex] == 3) // kind = userBroadcastPush + #expect(data[data.startIndex + 1] == 1) // joinRef "1" length + #expect(data[data.startIndex + 2] == 1) // ref "2" length + #expect(data[data.startIndex + 3] == 1) // topic "t" length + #expect(data[data.startIndex + 4] == 1) // event "e" length + #expect(data[data.startIndex + 5] == 0) // metadata empty length + #expect(data[data.startIndex + 6] == 1) // encoding = json + } + + @Test func decodesServerBinaryFrameAsBinaryPayload() throws { + // kind=4, topicLen=1, eventLen=1, metaLen=0, encoding=0(binary), "t","e", payload bytes + var frame = Data([4, 1, 1, 0, 0]) + frame.append(contentsOf: Array("t".utf8)) + frame.append(contentsOf: Array("e".utf8)) + frame.append(contentsOf: [0xDE, 0xAD]) + let msg = try s.decodeBinary(frame, receivedAt: Date(timeIntervalSince1970: 0)) + #expect(msg.joinRef == nil) + #expect(msg.ref == nil) + #expect(msg.topic == "t") + #expect(msg.event == .broadcast) + if case .binary(let d) = msg.payload { + #expect(Array(d) == [0xDE, 0xAD]) + } else { + Issue.record("Expected .binary payload, got \(msg.payload)") + } + } + + @Test func rejectsUnexpectedKind() { + #expect(throws: (any Error).self) { + try s.decodeBinary(Data([9, 0, 0, 0, 0]), receivedAt: Date()) + } + } + + @Test func rejectsOversizedHeaderField() { + let big = String(repeating: "x", count: 256) + #expect(throws: (any Error).self) { + try s.encodeBroadcastPush( + joinRef: nil, ref: nil, topic: big, event: "e", binaryPayload: Data()) + } + } +} diff --git a/Tests/RealtimeV3Tests/PhoenixSerializerTextTests.swift b/Tests/RealtimeV3Tests/PhoenixSerializerTextTests.swift new file mode 100644 index 000000000..98c2bb946 --- /dev/null +++ b/Tests/RealtimeV3Tests/PhoenixSerializerTextTests.swift @@ -0,0 +1,47 @@ +// +// PhoenixSerializerTextTests.swift +// RealtimeV3 +// +// Created by Guilherme Souza on 27/06/26. +// + +import Foundation +import Helpers +import Testing + +@testable import RealtimeV3 + +@Suite struct PhoenixSerializerTextTests { + let serializer = PhoenixSerializer() + + @Test func encodesJoinAsJSONArray() throws { + let text = try serializer.encodeText( + joinRef: "1", ref: "1", topic: "room:1", event: "phx_join", payload: [:] + ) + let decoded = try JSONDecoder().decode([AnyJSON].self, from: Data(text.utf8)) + #expect(decoded.count == 5) + #expect(decoded[0] == "1") + #expect(decoded[2] == "room:1") + #expect(decoded[3] == "phx_join") + #expect(decoded[4] == AnyJSON.object([:])) + } + + @Test func decodesReplyWithNullJoinRef() throws { + let frame = #"[null,"7","room:1","phx_reply",{"status":"ok","response":{}}]"# + let msg = try serializer.decodeText(frame, receivedAt: Date(timeIntervalSince1970: 0)) + #expect(msg.joinRef == nil) + #expect(msg.ref == "7") + #expect(msg.event == .reply) + if case .json(let v) = msg.payload { + #expect(v.objectValue?["status"] == "ok") + } else { + Issue.record("json") + } + } + + @Test func rejectsShortArray() { + #expect(throws: (any Error).self) { + try serializer.decodeText("[1,2,3]", receivedAt: Date()) + } + } +} diff --git a/Tests/RealtimeV3Tests/PostgresConsumeTests.swift b/Tests/RealtimeV3Tests/PostgresConsumeTests.swift new file mode 100644 index 000000000..b71ee8d8c --- /dev/null +++ b/Tests/RealtimeV3Tests/PostgresConsumeTests.swift @@ -0,0 +1,480 @@ +// +// PostgresConsumeTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import Helpers +import Testing + +@testable import RealtimeV3 + +// MARK: - PostgresConsumeTests + +@Suite struct PostgresConsumeTests { + + // MARK: - insertYieldsRecord + + /// After subscribe (join reply assigns id 0 to the insert token), an injected + /// postgres_changes INSERT frame with ids:[0] yields a decoded JSONValue record. + @Test func insertYieldsRecord() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + // Register insert token BEFORE subscribe. + let token = try await channel.inserts(schema: "public", table: "messages") + + // Auto-reply to join with a postgres_changes response that assigns id 0 to our registration. + server.autoReplyToJoinsWithPostgres( + postgresChanges: [ + [ + "id": .integer(0), "event": .string("INSERT"), "schema": .string("public"), + "table": .string("messages"), + ] + ] + ) + try await channel.subscribe() + + // Register the stream AFTER subscribe (but the implementation must tolerate it). + let stream = await channel.postgresChanges(for: token) + var iter = stream.makeAsyncIterator() + + // Inject a postgres_changes frame with ids:[0]. + server.send( + .text( + #"["1",null,"realtime:room:1","postgres_changes",{"ids":[0],"data":{"type":"INSERT","record":{"id":1,"text":"hi"},"columns":[],"commit_timestamp":"2024-01-01T00:00:00Z"}}]"# + )) + + // Read first value — decoded record must be the JSONValue object. + let value = try await iter.next() + let obj = value?.objectValue + #expect(obj?["text"]?.stringValue == "hi") + #expect(obj?["id"]?.intValue == 1) + } + + // MARK: - overlappingIdsFanOutToBothTokens + + /// Two insert tokens both mapped to server id 0; one frame with ids:[0] must + /// fan out to both streams. + @Test func overlappingIdsFanOutToBothTokens() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:2") + + // Two registrations — both will map to id 0 (server assigns by index, so both get the same id + // when the reply lists two entries both with id 0, OR we use two registrations sharing id 0). + // Here we use two registrations and the server assigns id 0 to both (overlapping). + let tokenA = try await channel.inserts(schema: "public", table: "messages") + let tokenB = try await channel.inserts(schema: "public", table: "messages") + + // The server can assign the same id to both — or different ids. + // We assign both to id 0 so a single frame fans out to both. + server.autoReplyToJoinsWithPostgres( + postgresChanges: [ + [ + "id": .integer(0), "event": .string("INSERT"), "schema": .string("public"), + "table": .string("messages"), + ], + [ + "id": .integer(0), "event": .string("INSERT"), "schema": .string("public"), + "table": .string("messages"), + ], + ] + ) + try await channel.subscribe() + + let streamA = await channel.postgresChanges(for: tokenA) + let streamB = await channel.postgresChanges(for: tokenB) + var iterA = streamA.makeAsyncIterator() + var iterB = streamB.makeAsyncIterator() + + // Inject one frame — both streams should receive it. + server.send( + .text( + #"["1",null,"realtime:room:2","postgres_changes",{"ids":[0],"data":{"type":"INSERT","record":{"id":42},"columns":[],"commit_timestamp":"2024-01-01T00:00:00Z"}}]"# + )) + + let valueA = try await iterA.next() + let valueB = try await iterB.next() + + #expect(valueA?.objectValue?["id"]?.intValue == 42) + #expect(valueB?.objectValue?["id"]?.intValue == 42) + } + + // MARK: - unknownTokenThrows + + /// A token created on a DIFFERENT channel passed to channelB.postgresChanges(for:) + /// produces a stream that throws .unknownToken immediately. + @Test func unknownTokenThrows() async throws { + let (transport, _) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + + let channelA = await rt.channel("room:A") + let channelB = await rt.channel("room:B") + + // Token is for channelA's identity. + let tokenA = try await channelA.inserts(schema: "public", table: "messages") + + // Pass the token from channelA to channelB — must throw .unknownToken. + let stream = await channelB.postgresChanges(for: tokenA) + + do { + for try await _ in stream { + Issue.record("Expected .unknownToken, but stream yielded a value") + return + } + Issue.record("Expected .unknownToken, but stream finished without throwing") + } catch { + if case .unknownToken = error as? RealtimeError { + // Expected — test passes. + } else { + Issue.record("Expected .unknownToken, got: \(error)") + } + } + } + + // MARK: - systemPostgresErrorFails + + /// A system event with extension "postgres_changes" and status "error" causes + /// all postgres streams on the channel to throw .postgresSubscriptionFailed. + @Test func systemPostgresErrorFails() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:3") + + let token = try await channel.inserts(schema: "public", table: "messages") + + server.autoReplyToJoinsWithPostgres( + postgresChanges: [ + [ + "id": .integer(0), "event": .string("INSERT"), "schema": .string("public"), + "table": .string("messages"), + ] + ] + ) + try await channel.subscribe() + + let stream = await channel.postgresChanges(for: token) + + let receivedError = LockIsolated(nil) + let done = LockIsolated(false) + let collectionTask = Task { + do { + for try await _ in stream { + // No messages expected before the error. + } + done.withValue { $0 = true } + } catch { + receivedError.withValue { $0 = error } + done.withValue { $0 = true } + } + } + + // Inject a system event indicating a postgres subscription failure. + server.send( + .text( + #"[null,null,"realtime:room:3","system",{"extension":"postgres_changes","status":"error","message":"subscription failed"}]"# + )) + + // Wait for the error (bounded). + var waitIterations = 0 + while !done.value { + try await Task.sleep(nanoseconds: 1_000_000) // 1ms + waitIterations += 1 + if waitIterations > 1000 { + collectionTask.cancel() + Issue.record("Postgres stream was not failed after system error event within 1s") + return + } + } + collectionTask.cancel() + + let error = receivedError.value + if let realtimeError = error as? RealtimeError, + case .postgresSubscriptionFailed = realtimeError + { + // Expected — test passes. + } else { + Issue.record("Expected .postgresSubscriptionFailed, got: \(String(describing: error))") + } + } + + // MARK: - updateYieldsPostgresUpdate + + /// Verifies that an UPDATE frame with record+old_record yields a PostgresUpdate. + @Test func updateYieldsPostgresUpdate() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:4") + + let token = try await channel.updates(schema: "public", table: "messages") + + server.autoReplyToJoinsWithPostgres( + postgresChanges: [ + [ + "id": .integer(0), "event": .string("UPDATE"), "schema": .string("public"), + "table": .string("messages"), + ] + ] + ) + try await channel.subscribe() + + let stream = await channel.postgresChanges(for: token) + var iter = stream.makeAsyncIterator() + + server.send( + .text( + #"["1",null,"realtime:room:4","postgres_changes",{"ids":[0],"data":{"type":"UPDATE","record":{"id":1,"text":"new"},"old_record":{"id":1,"text":"old"},"columns":[],"commit_timestamp":"2024-01-01T00:00:00Z"}}]"# + )) + + let value = try await iter.next() + #expect(value?.record.objectValue?["text"]?.stringValue == "new") + #expect(value?.oldRecord?.objectValue?["text"]?.stringValue == "old") + } + + // MARK: - deleteYieldsPostgresDelete + + /// Verifies that a DELETE frame with old_record yields a PostgresDelete. + @Test func deleteYieldsPostgresDelete() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:5") + + let token = try await channel.deletes(schema: "public", table: "messages") + + server.autoReplyToJoinsWithPostgres( + postgresChanges: [ + [ + "id": .integer(0), "event": .string("DELETE"), "schema": .string("public"), + "table": .string("messages"), + ] + ] + ) + try await channel.subscribe() + + let stream = await channel.postgresChanges(for: token) + var iter = stream.makeAsyncIterator() + + server.send( + .text( + #"["1",null,"realtime:room:5","postgres_changes",{"ids":[0],"data":{"type":"DELETE","old_record":{"id":1,"text":"deleted"},"columns":[],"commit_timestamp":"2024-01-01T00:00:00Z"}}]"# + )) + + let value = try await iter.next() + #expect(value?.oldRecord.objectValue?["text"]?.stringValue == "deleted") + } + + // MARK: - malformedFrameFinishesStreamWithDecoding + + /// An INSERT frame that is targeted at this token (ids:[0]) but has no `record` field + /// must finish the stream throwing `.decoding`, not silently return. + @Test func malformedFrameFinishesStreamWithDecoding() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:mal1") + + let token = try await channel.inserts(schema: "public", table: "messages") + server.autoReplyToJoinsWithPostgres( + postgresChanges: [ + [ + "id": .integer(0), "event": .string("INSERT"), "schema": .string("public"), + "table": .string("messages"), + ] + ] + ) + try await channel.subscribe() + + let stream = await channel.postgresChanges(for: token) + var iter = stream.makeAsyncIterator() + + // Inject a frame with ids:[0] but missing the `record` key — malformed INSERT. + server.send( + .text( + #"["1",null,"realtime:room:mal1","postgres_changes",{"ids":[0],"data":{"type":"INSERT","columns":[],"commit_timestamp":"2024-01-01T00:00:00Z"}}]"# + )) + + do { + _ = try await iter.next() + Issue.record("Expected .decoding error but stream yielded a value or finished cleanly") + } catch { + if case .decoding = error as? RealtimeError { + // Expected — test passes. + } else { + Issue.record("Expected .decoding, got: \(error)") + } + } + } + + // MARK: - bogusTypeFinishesStreamWithDecoding + + /// An anyEvent frame with an unknown `type` string must finish the stream throwing `.decoding`. + @Test func bogusTypeFinishesStreamWithDecoding() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:mal2") + + let token = try await channel.changes(schema: "public", table: "messages") + server.autoReplyToJoinsWithPostgres( + postgresChanges: [ + [ + "id": .integer(0), "event": .string("*"), "schema": .string("public"), + "table": .string("messages"), + ] + ] + ) + try await channel.subscribe() + + let stream = await channel.postgresChanges(for: token) + var iter = stream.makeAsyncIterator() + + // Inject a frame with ids:[0] but an unknown type string. + server.send( + .text( + #"["1",null,"realtime:room:mal2","postgres_changes",{"ids":[0],"data":{"type":"BOGUS","record":{},"columns":[],"commit_timestamp":"2024-01-01T00:00:00Z"}}]"# + )) + + do { + _ = try await iter.next() + Issue.record("Expected .decoding error but stream yielded a value or finished cleanly") + } catch { + if case .decoding = error as? RealtimeError { + // Expected — test passes. + } else { + Issue.record("Expected .decoding, got: \(error)") + } + } + } + + // MARK: - deleteWithoutOldRecordThrowsDecoding + + /// A DELETE frame targeted at this token but with no `old_record` must finish the stream + /// throwing `.decoding` (PostgresDelete.oldRecord is non-optional). + @Test func deleteWithoutOldRecordThrowsDecoding() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:mal3") + + let token = try await channel.deletes(schema: "public", table: "messages") + server.autoReplyToJoinsWithPostgres( + postgresChanges: [ + [ + "id": .integer(0), "event": .string("DELETE"), "schema": .string("public"), + "table": .string("messages"), + ] + ] + ) + try await channel.subscribe() + + let stream = await channel.postgresChanges(for: token) + var iter = stream.makeAsyncIterator() + + // DELETE frame with no old_record. + server.send( + .text( + #"["1",null,"realtime:room:mal3","postgres_changes",{"ids":[0],"data":{"type":"DELETE","columns":[],"commit_timestamp":"2024-01-01T00:00:00Z"}}]"# + )) + + do { + _ = try await iter.next() + Issue.record("Expected .decoding error but stream yielded a value or finished cleanly") + } catch { + if case .decoding = error as? RealtimeError { + // Expected — test passes. + } else { + Issue.record("Expected .decoding, got: \(error)") + } + } + } + + // MARK: - nonMatchingFrameDoesNotTerminateStream + + /// A frame with ids that do NOT include this token's server id must be silently skipped + /// (SKIP behavior). The stream must remain open and deliver subsequent matching frames. + @Test func nonMatchingFrameDoesNotTerminateStream() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:skip1") + + let token = try await channel.inserts(schema: "public", table: "messages") + server.autoReplyToJoinsWithPostgres( + postgresChanges: [ + [ + "id": .integer(0), "event": .string("INSERT"), "schema": .string("public"), + "table": .string("messages"), + ] + ] + ) + try await channel.subscribe() + + let stream = await channel.postgresChanges(for: token) + var iter = stream.makeAsyncIterator() + + // First: inject a frame whose ids do NOT include 0 — must be silently skipped. + server.send( + .text( + #"["1",null,"realtime:room:skip1","postgres_changes",{"ids":[99],"data":{"type":"INSERT","record":{"id":99},"columns":[],"commit_timestamp":"2024-01-01T00:00:00Z"}}]"# + )) + + // Then: inject a valid frame with ids:[0] — stream must still deliver this. + server.send( + .text( + #"["1",null,"realtime:room:skip1","postgres_changes",{"ids":[0],"data":{"type":"INSERT","record":{"id":1},"columns":[],"commit_timestamp":"2024-01-01T00:00:00Z"}}]"# + )) + + // The first next() must be from the valid frame (id:1), not terminated by the skipped one. + let value = try await iter.next() + #expect(value?.objectValue?["id"]?.intValue == 1) + } + + // MARK: - anyEventYieldsPostgresChange + + /// Verifies that an AnyEvent token yields a PostgresChange with the right tag. + @Test func anyEventYieldsPostgresChange() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:6") + + let token = try await channel.changes(schema: "public", table: "messages") + + server.autoReplyToJoinsWithPostgres( + postgresChanges: [ + [ + "id": .integer(0), "event": .string("*"), "schema": .string("public"), + "table": .string("messages"), + ] + ] + ) + try await channel.subscribe() + + let stream = await channel.postgresChanges(for: token) + var iter = stream.makeAsyncIterator() + + server.send( + .text( + #"["1",null,"realtime:room:6","postgres_changes",{"ids":[0],"data":{"type":"INSERT","record":{"id":99},"columns":[],"commit_timestamp":"2024-01-01T00:00:00Z"}}]"# + )) + + let value = try await iter.next() + if case .insert(let record) = value { + #expect(record.objectValue?["id"]?.intValue == 99) + } else { + Issue.record("Expected .insert case, got: \(String(describing: value))") + } + } +} + +// MARK: - TransportServer helpers for postgres + +extension TransportServer { + /// Auto-replies to joins with a postgres_changes response that includes server-assigned IDs. + func autoReplyToJoinsWithPostgres(postgresChanges: [[String: AnyJSON]]) { + let response: [String: AnyJSON] = [ + "postgres_changes": .array(postgresChanges.map { .object($0) }) + ] + autoReplyToJoins(status: "ok", response: response) + } +} diff --git a/Tests/RealtimeV3Tests/PostgresRegisterTests.swift b/Tests/RealtimeV3Tests/PostgresRegisterTests.swift new file mode 100644 index 000000000..78e25d325 --- /dev/null +++ b/Tests/RealtimeV3Tests/PostgresRegisterTests.swift @@ -0,0 +1,165 @@ +// +// PostgresRegisterTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import Helpers +import Testing + +@testable import RealtimeV3 + +@Suite struct PostgresRegisterTests { + + // MARK: - registrationBakedIntoJoin + + /// Registers an insert token before subscribe and asserts the phx_join + /// postgres_changes array carries the expected entry. + @Test func registrationBakedIntoJoin() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + // Register before subscribe. + let _ = try await channel.inserts( + schema: "public", table: "messages", + filter: .eq("room_id", 1) + ) + + // Capture client-sent frames so we can inspect the phx_join before replying. + let sentFrames = server.subscribeToClientFrames() + + server.autoReplyToJoins() + try await channel.subscribe() + + // Pull the first text frame that contains phx_join. + var joinText: String? + for await frame in sentFrames { + guard case .text(let text) = frame, text.contains("phx_join") else { continue } + joinText = text + break + } + + guard let joinText else { + Issue.record("No phx_join frame observed") + return + } + + // Decode as JSON array: [joinRef, ref, topic, event, payload] + guard let data = joinText.data(using: .utf8), + let array = try? JSONDecoder().decode([AnyJSON].self, from: data), + array.count >= 5 + else { + Issue.record("Could not decode phx_join frame as JSON array") + return + } + + // payload is array[4]; navigate config.postgres_changes + guard let payload = array[4].objectValue, + let config = payload["config"]?.objectValue, + let changes = config["postgres_changes"]?.arrayValue + else { + Issue.record("Could not navigate to config.postgres_changes in payload: \(array[4])") + return + } + + #expect(changes.count == 1, "Expected 1 postgres_changes entry, got \(changes.count)") + + guard let entry = changes.first?.objectValue else { + Issue.record("postgres_changes[0] is not an object") + return + } + + #expect(entry["event"]?.stringValue == "INSERT") + #expect(entry["schema"]?.stringValue == "public") + #expect(entry["table"]?.stringValue == "messages") + #expect(entry["filter"]?.stringValue == "room_id=eq.1") + } + + // MARK: - registerAfterJoinThrows + + /// Registering after subscribe() reaches .joined throws .cannotRegisterAfterJoin. + @Test func registerAfterJoinThrows() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:2") + + server.autoReplyToJoins() + try await channel.subscribe() + + // Channel is now .joined — registration must throw. + do { + _ = try await channel.inserts(schema: "public", table: "messages") + Issue.record("Expected cannotRegisterAfterJoin, but inserts() returned normally") + } catch { + if case .cannotRegisterAfterJoin = error { + // Expected — test passes. + } else { + Issue.record("Expected cannotRegisterAfterJoin, got: \(error)") + } + } + } + + // MARK: - registrationsReplayAfterLeave + + /// Registrations persist across leave/resubscribe cycles: the second phx_join + /// still carries the postgres_changes entry registered before the first subscribe. + @Test func registrationsReplayAfterLeave() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:3") + + // Register once, before first subscribe. + let _ = try await channel.inserts( + schema: "public", table: "events" + ) + + server.autoReplyToJoins() + server.autoReplyToLeaves() + + try await channel.subscribe() + try await channel.leave() + + // Resubscribe — registrations must replay. + let sentFrames = server.subscribeToClientFrames() + try await channel.subscribe() + + // Capture the next phx_join (from the resubscribe). + var joinText: String? + for await frame in sentFrames { + guard case .text(let text) = frame, text.contains("phx_join") else { continue } + joinText = text + break + } + + guard let joinText else { + Issue.record("No phx_join frame observed on resubscribe") + return + } + + guard let data = joinText.data(using: .utf8), + let array = try? JSONDecoder().decode([AnyJSON].self, from: data), + array.count >= 5, + let payload = array[4].objectValue, + let config = payload["config"]?.objectValue, + let changes = config["postgres_changes"]?.arrayValue + else { + Issue.record("Could not decode postgres_changes from resubscribe phx_join") + return + } + + #expect(changes.count == 1, "Expected 1 postgres_changes entry on resubscribe") + + guard let entry = changes.first?.objectValue else { + Issue.record("postgres_changes[0] is not an object on resubscribe") + return + } + + #expect(entry["event"]?.stringValue == "INSERT") + #expect(entry["schema"]?.stringValue == "public") + #expect(entry["table"]?.stringValue == "events") + } +} diff --git a/Tests/RealtimeV3Tests/PresenceDecodeTests.swift b/Tests/RealtimeV3Tests/PresenceDecodeTests.swift new file mode 100644 index 000000000..b482277ac --- /dev/null +++ b/Tests/RealtimeV3Tests/PresenceDecodeTests.swift @@ -0,0 +1,136 @@ +// +// PresenceDecodeTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import Helpers +import Testing + +@testable import RealtimeV3 + +// MARK: - Test Helpers + +private struct UserPresence: Codable, Sendable, Equatable { + let userId: String + let status: String +} + +// MARK: - PresenceDecodeTests + +@Suite struct PresenceDecodeTests { + + // MARK: - decodePresenceState + + /// Verifies that a `presence_state` payload is decoded into `[PresenceKey: [T]]`. + @Test func decodesPresenceState() throws { + // Build a JSONValue representing: + // { "u1": {"metas":[{"phx_ref":"r1","userId":"u1","status":"active"}]}, + // "u2": {"metas":[{"phx_ref":"r2","userId":"u2","status":"idle"}]} } + let json: JSONValue = .object([ + "u1": .object([ + "metas": .array([ + .object([ + "phx_ref": .string("r1"), + "userId": .string("u1"), + "status": .string("active"), + ]) + ]) + ]), + "u2": .object([ + "metas": .array([ + .object([ + "phx_ref": .string("r2"), + "userId": .string("u2"), + "status": .string("idle"), + ]) + ]) + ]), + ]) + + let active = try decodePresenceState(json, as: UserPresence.self) + + #expect(active["u1"] == [UserPresence(userId: "u1", status: "active")]) + #expect(active["u2"] == [UserPresence(userId: "u2", status: "idle")]) + } + + /// Verifies that an empty `presence_state` payload `{}` decodes to `[:]`. + @Test func decodesEmptyPresenceState() throws { + let json: JSONValue = .object([:]) + let active = try decodePresenceState(json, as: UserPresence.self) + #expect(active.isEmpty) + } + + // MARK: - decodePresenceDiff + + /// Verifies that a `presence_diff` payload is decoded into `PresenceDiff`. + @Test func decodesPresenceDiff() throws { + // Build a JSONValue representing: + // { "joins": {"u3":{"metas":[{"phx_ref":"r3","userId":"u3","status":"active"}]}}, + // "leaves": {"u1":{"metas":[{"phx_ref":"r1","userId":"u1","status":"active"}]}} } + let json: JSONValue = .object([ + "joins": .object([ + "u3": .object([ + "metas": .array([ + .object([ + "phx_ref": .string("r3"), + "userId": .string("u3"), + "status": .string("active"), + ]) + ]) + ]) + ]), + "leaves": .object([ + "u1": .object([ + "metas": .array([ + .object([ + "phx_ref": .string("r1"), + "userId": .string("u1"), + "status": .string("active"), + ]) + ]) + ]) + ]), + ]) + + let diff = try decodePresenceDiff(json, as: UserPresence.self) + + // Check joined contains ("u3", UserPresence(userId: "u3", status: "active")) + let joinedKeys = diff.joined.map { $0.0 } + let joinedValues = diff.joined.map { $0.1 } + #expect(joinedKeys.contains("u3")) + let u3Index = joinedKeys.firstIndex(of: "u3")! + #expect(joinedValues[u3Index] == UserPresence(userId: "u3", status: "active")) + + // Check left contains ("u1", UserPresence(userId: "u1", status: "active")) + let leftKeys = diff.left.map { $0.0 } + let leftValues = diff.left.map { $0.1 } + #expect(leftKeys.contains("u1")) + let u1Index = leftKeys.firstIndex(of: "u1")! + #expect(leftValues[u1Index] == UserPresence(userId: "u1", status: "active")) + } + + @Test func malformedMetaThrowsDecoding() { + // A meta missing the required `userId` field cannot decode as UserPresence. + let json: JSONValue = .object([ + "u1": .object([ + "metas": .array([ + .object(["phx_ref": .string("r1"), "status": .string("active")]) + ]) + ]) + ]) + do { + _ = try decodePresenceState(json, as: UserPresence.self) + Issue.record("Expected a decoding error for the malformed meta") + } catch let error as RealtimeError { + guard case .decoding = error else { + Issue.record("Expected .decoding, got \(error)") + return + } + } catch { + Issue.record("Expected RealtimeError.decoding, got \(error)") + } + } +} diff --git a/Tests/RealtimeV3Tests/PresenceObserveTests.swift b/Tests/RealtimeV3Tests/PresenceObserveTests.swift new file mode 100644 index 000000000..5fad363f0 --- /dev/null +++ b/Tests/RealtimeV3Tests/PresenceObserveTests.swift @@ -0,0 +1,159 @@ +// +// PresenceObserveTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import Testing + +@testable import RealtimeV3 + +// MARK: - Test Payload + +private struct UserPresence: Codable, Sendable, Equatable { + let userId: String + let status: String +} + +// MARK: - PresenceObserveTests + +@Suite struct PresenceObserveTests { + + // MARK: - observeYieldsSnapshotThenDiff + + /// observe() yields an initial snapshot from presence_state, then an updated snapshot + /// after a presence_diff that accumulates the new state. + @Test func observeYieldsSnapshotThenDiff() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + try await channel.subscribe() + + // Register the observe stream BEFORE injecting any frames. + let stream = await channel.presence.observe(UserPresence.self) + var iter = stream.makeAsyncIterator() + + // Inject a presence_state frame. + server.send( + .text( + #"["1",null,"realtime:room:1","presence_state",{"u1":{"metas":[{"phx_ref":"r1","userId":"u1","status":"active"}]}}]"# + )) + + // Read first value: initial snapshot. + let snapshot = await iter.next() + #expect(snapshot != nil) + #expect(snapshot?.lastDiff == nil, "Initial snapshot should have nil lastDiff") + let u1Values = snapshot?.active["u1"] + #expect(u1Values?.count == 1) + #expect(u1Values?.first == UserPresence(userId: "u1", status: "active")) + + // Inject a presence_diff frame adding u2. + server.send( + .text( + #"["2",null,"realtime:room:1","presence_diff",{"joins":{"u2":{"metas":[{"phx_ref":"r2","userId":"u2","status":"idle"}]}},"leaves":{}}]"# + )) + + // Read second value: accumulated snapshot with diff. + let updated = await iter.next() + #expect(updated != nil) + #expect(updated?.lastDiff != nil, "Updated snapshot should have non-nil lastDiff") + + // Both u1 and u2 should be present in the accumulated active map. + let updatedU1 = updated?.active["u1"] + #expect(updatedU1?.count == 1) + #expect(updatedU1?.first == UserPresence(userId: "u1", status: "active")) + + let updatedU2 = updated?.active["u2"] + #expect(updatedU2?.count == 1) + #expect(updatedU2?.first == UserPresence(userId: "u2", status: "idle")) + } + + // MARK: - diffsYieldsOnlyDiffs + + /// diffs() stream does NOT emit on presence_state, emits a PresenceDiff on presence_diff. + @Test func diffsYieldsOnlyDiffs() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + try await channel.subscribe() + + // Register the diffs stream BEFORE injecting any frames. + let stream = await channel.presence.diffs(UserPresence.self) + var iter = stream.makeAsyncIterator() + + // Inject presence_state — should NOT emit on diffs stream. + server.send( + .text( + #"["1",null,"realtime:room:1","presence_state",{"u1":{"metas":[{"phx_ref":"r1","userId":"u1","status":"active"}]}}]"# + )) + + // Inject presence_diff adding u2 — SHOULD emit on diffs stream. + server.send( + .text( + #"["2",null,"realtime:room:1","presence_diff",{"joins":{"u2":{"metas":[{"phx_ref":"r2","userId":"u2","status":"idle"}]}},"leaves":{}}]"# + )) + + // The FIRST value from diffs() must be the presence_diff (not presence_state). + let diff = await iter.next() + #expect(diff != nil) + + // Check that u2 was joined. + let joinedKeys = diff?.joined.map { $0.0 } ?? [] + #expect(joinedKeys.contains("u2")) + let joinedValues = diff?.joined.map { $0.1 } ?? [] + #expect(joinedValues.contains(UserPresence(userId: "u2", status: "idle"))) + + // No leaves. + #expect(diff?.left.isEmpty == true) + } + + // MARK: - leaveFinishesPresenceStreams + + /// An open observe stream finishes when channel.leave() is called. + @Test func leaveFinishesPresenceStreams() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + server.autoReplyToLeaves() + try await channel.subscribe() + + // Register stream BEFORE calling leave(). + let stream = await channel.presence.observe(UserPresence.self) + + let done = LockIsolated(false) + let collectionTask = Task { + for await _ in stream { + // No messages expected before leave. + } + // Stream ended cleanly (no throw — observe is non-throwing). + done.withValue { $0 = true } + } + + // Leave the channel — this should finish the observe stream. + try await channel.leave() + + // Wait for collection task to finish (bounded to 1s). + var waitIterations = 0 + while !done.value { + try await Task.sleep(nanoseconds: 1_000_000) // 1ms + waitIterations += 1 + if waitIterations > 1000 { + Issue.record("Presence observe stream was not finished after leave() within 1s") + collectionTask.cancel() + return + } + } + collectionTask.cancel() + + #expect(done.value, "Observe stream should have finished after leave()") + } +} diff --git a/Tests/RealtimeV3Tests/PresenceTrackTests.swift b/Tests/RealtimeV3Tests/PresenceTrackTests.swift new file mode 100644 index 000000000..15a11e593 --- /dev/null +++ b/Tests/RealtimeV3Tests/PresenceTrackTests.swift @@ -0,0 +1,174 @@ +// +// PresenceTrackTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import Helpers +import Testing + +@testable import RealtimeV3 + +// MARK: - Test Payload + +private struct UserPresence: Codable, Sendable { + let userId: String + let status: String +} + +// MARK: - PresenceTrackTests + +@Suite struct PresenceTrackTests { + + // MARK: - trackOnJoinedChannelEmitsPresenceFrame + + /// track() on a joined channel sends a presence/track frame; cancel() sends an untrack frame. + @Test func trackOnJoinedChannelEmitsPresenceFrame() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + // Enable auto-replies BEFORE calls so acks arrive without clock tricks. + server.autoReplyToJoins() + server.autoReplyToPresence() + + try await channel.subscribe() + + // Observe frames in background BEFORE calling track. + let capturedFrames = LockIsolated<[TransportFrame]>([]) + let clientFrames = server.subscribeToClientFrames() + let observeTask = Task { + for await frame in clientFrames { + capturedFrames.withValue { $0.append(frame) } + } + } + defer { observeTask.cancel() } + + let handle = try await channel.presence.track( + UserPresence(userId: "u1", status: "active") + ) + + // Verify a text frame with channel event "presence" and inner event "track" was sent. + let frames = capturedFrames.value + let trackFrame = frames.first { frame in + guard case .text(let text) = frame else { return false } + return text.contains("\"presence\"") && text.contains("\"track\"") + } + #expect(trackFrame != nil, "Expected a presence/track frame to be sent") + + // cancel() should send an untrack frame. + try await handle.cancel() + + // Give the frame a moment to appear in the captured array. + var waitCount = 0 + while waitCount < 100 { + let hasUntrack = capturedFrames.value.contains { frame in + guard case .text(let text) = frame else { return false } + return text.contains("\"presence\"") && text.contains("\"untrack\"") + } + if hasUntrack { break } + try await Task.sleep(nanoseconds: 1_000_000) // 1ms + waitCount += 1 + } + + let hasUntrackFrame = capturedFrames.value.contains { frame in + guard case .text(let text) = frame else { return false } + return text.contains("\"presence\"") && text.contains("\"untrack\"") + } + #expect(hasUntrackFrame, "Expected a presence/untrack frame to be sent after cancel()") + } + + // MARK: - trackBeforeSubscribeThrowsNotSubscribed + + /// track() on a channel that hasn't been subscribed throws .notSubscribed. + @Test func trackBeforeSubscribeThrowsNotSubscribed() async throws { + let (transport, _) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + do { + _ = try await channel.presence.track(UserPresence(userId: "u1", status: "active")) + Issue.record("Expected .notSubscribed to be thrown") + } catch { + if case .notSubscribed = error { + // Expected — success. + } else { + Issue.record("Expected .notSubscribed, got \(error)") + } + } + } + + // MARK: - cancelIsIdempotent + + /// Calling cancel() twice must not hang or crash. + @Test func cancelIsIdempotent() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + server.autoReplyToPresence() + + try await channel.subscribe() + + let handle = try await channel.presence.track(UserPresence(userId: "u1", status: "active")) + + // First cancel — must succeed. + try await handle.cancel() + + // Second cancel — must be a no-op (idempotent), not hang, not throw. + try await handle.cancel() + } + + // MARK: - updateSendsNewTrackFrame + + /// update() re-sends a presence/track frame with the new state. + @Test func updateSendsNewTrackFrame() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + server.autoReplyToPresence() + + try await channel.subscribe() + + let handle = try await channel.presence.track(UserPresence(userId: "u1", status: "active")) + + // Observe frames. + let capturedFrames = LockIsolated<[TransportFrame]>([]) + let clientFrames = server.subscribeToClientFrames() + let observeTask = Task { + for await frame in clientFrames { + capturedFrames.withValue { $0.append(frame) } + } + } + defer { observeTask.cancel() } + + // Update with a new state. + try await handle.update(UserPresence(userId: "u1", status: "away")) + + // Give the frame a moment to appear. + var waitCount = 0 + while waitCount < 100 { + let hasTrack = capturedFrames.value.contains { frame in + guard case .text(let text) = frame else { return false } + return text.contains("\"presence\"") && text.contains("\"track\"") && text.contains("away") + } + if hasTrack { break } + try await Task.sleep(nanoseconds: 1_000_000) // 1ms + waitCount += 1 + } + + let hasUpdateFrame = capturedFrames.value.contains { frame in + guard case .text(let text) = frame else { return false } + return text.contains("\"presence\"") && text.contains("\"track\"") && text.contains("away") + } + #expect(hasUpdateFrame, "Expected a presence/track frame with updated state to be sent") + + try await handle.cancel() + } +} diff --git a/Tests/RealtimeV3Tests/RealtimeConnectTests.swift b/Tests/RealtimeV3Tests/RealtimeConnectTests.swift new file mode 100644 index 000000000..f442d36ec --- /dev/null +++ b/Tests/RealtimeV3Tests/RealtimeConnectTests.swift @@ -0,0 +1,66 @@ +// +// RealtimeConnectTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import Testing + +@testable import RealtimeV3 + +@Suite struct RealtimeConnectTests { + @Test func connectOpensTransportWithApiKey() async throws { + let (transport, _) = InMemoryTransport.pair() + let rt = Realtime( + url: URL(string: "wss://proj.supabase.co/realtime/v1")!, + apiKey: "anon", + transport: transport + ) + try await rt.connect() + let url = await transport.lastConnectURL + #expect(url?.query?.contains("apikey=anon") == true) + } + + @Test func connectIsIdempotent() async throws { + let (transport, _) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + try await rt.connect() + try await rt.connect() + #expect(await transport.connectCallCount == 1) + } + + @Test func connectDrivesStatusToConnectingThenConnected() async throws { + let (transport, _) = InMemoryTransport.pair() + let rt = Realtime( + url: URL(string: "wss://proj.supabase.co/realtime/v1")!, + apiKey: "anon", + transport: transport + ) + + // Subscribe to status before connecting so we catch all transitions. + let stream = await rt.status + + // Connect in the background so we can read from the stream concurrently. + async let connectResult: Void = rt.connect() + + var sawConnecting = false + var sawConnected = false + for await s in stream { + switch s.state { + case .connecting: sawConnecting = true + case .connected: + sawConnected = true + break + default: break + } + if sawConnected { break } + } + + try await connectResult + + #expect(sawConnecting) + #expect(sawConnected) + } +} diff --git a/Tests/RealtimeV3Tests/ReconnectionTests.swift b/Tests/RealtimeV3Tests/ReconnectionTests.swift new file mode 100644 index 000000000..9dbd4b422 --- /dev/null +++ b/Tests/RealtimeV3Tests/ReconnectionTests.swift @@ -0,0 +1,147 @@ +// +// ReconnectionTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Clocks +import ConcurrencyExtras +import Foundation +import IssueReporting +import Testing + +@testable import RealtimeV3 + +@Suite struct ReconnectionTests { + + // MARK: - Helpers + + /// Advance the TestClock in small steps, yielding to the cooperative thread pool between each + /// advance, until `condition()` returns true or `maxAttempts` is reached. + /// + /// This avoids the "advance-before-sleep-registered" hang class: the reconnection loop + /// must call `clock.sleep(for:)` before the advance takes effect. By yielding first and + /// advancing in a bounded loop we guarantee the sleep is installed before the advance. + /// + /// After each advance, we yield several times to give async work triggered by the clock + /// (actor hops, transport.connect, status transitions, observer task) time to complete + /// before checking the condition. + private func advanceUntil( + clock: TestClock, + step: Duration, + maxAttempts: Int = 200, + condition: () async -> Bool + ) async { + for _ in 0.., + step: Duration, + maxAttempts: Int = 200, + condition: () async -> Bool + ) async { + for _ in 0..= 1 means a new phx_join was sent + // after the counter was registered, i.e., the re-join after reconnect). + await advanceUntil(clock: clock, step: .seconds(1)) { + joinCount.value >= 1 + } + + #expect(joinCount.value >= 1, "Expected at least 1 re-join phx_join frame after reconnect") + + // Wait for the channel to complete its rejoin handshake (autoReplyToJoins sends a reply, + // the channel processes it and transitions to .joined). Yield until joined or max tries. + await advanceUntil(clock: clock, step: .milliseconds(10), maxAttempts: 100) { + await channel.channelState == .joined + } + + // The messages() stream must NOT have terminated during the reconnect gap. + for _ in 0..<20 { + await Task.yield() + } + #expect(!messagesStreamEnded.value, "messages() stream must survive reconnect gap") + + // Channel state should be .joined again after rejoin. + let stateAfterRejoin = await channel.channelState + #expect(stateAfterRejoin == .joined, "Channel must be .joined after rejoin") + } + + // MARK: - rejoinCarriesPostgresRegistration + + @Test func rejoinCarriesPostgresRegistration() async throws { + let (transport, server) = InMemoryTransport.pair() + let clock = TestClock() + var config = Configuration.default + config.clock = clock + config.heartbeat = .seconds(25) + config.reconnection = .exponentialBackoff(initial: .seconds(1), max: .seconds(30), jitter: 0) + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + configuration: config, + transport: transport + ) + + let channel = await rt.channel("realtime:public:items") + _ = try await channel.inserts(schema: "public", table: "items") + + server.autoReplyToJoins( + response: [ + "postgres_changes": .array([.object(["id": .integer(2)])]) + ] + ) + + try await channel.subscribe() + + // Collect the text of each phx_join frame. + let joinFrames = LockIsolated<[String]>([]) + let clientFrames = server.subscribeToClientFrames() + let frameTask = Task.detached { + for await frame in clientFrames { + guard case .text(let text) = frame else { continue } + guard text.contains("phx_join") else { continue } + joinFrames.withValue { $0.append(text) } + } + } + defer { frameTask.cancel() } + + await Task.yield() + + server.closeFromServer(code: 1006, reason: "abnormal") + + await advanceUntil(clock: clock, step: .seconds(1)) { + joinFrames.value.count >= 1 + } + + #expect(joinFrames.value.count >= 1, "Expected a re-join frame after reconnect") + // The re-join frame must include the postgres_changes subscription. + let rejoinText = joinFrames.value.last ?? "" + #expect( + rejoinText.contains("postgres_changes"), + "Re-join frame must carry postgres_changes registration" + ) + #expect( + rejoinText.contains("items"), + "Re-join frame must carry the table name" + ) + } + + // MARK: - userLeftChannelNotRejoined + + @Test func userLeftChannelNotRejoined() async throws { + let (transport, server) = InMemoryTransport.pair() + let clock = TestClock() + var config = Configuration.default + config.clock = clock + config.heartbeat = .seconds(25) + config.reconnection = .exponentialBackoff(initial: .seconds(1), max: .seconds(30), jitter: 0) + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + configuration: config, + transport: transport + ) + + // Create TWO channels: one we will leave, one we keep alive to trigger reconnect. + let leftChannel = await rt.channel("realtime:public:left_table") + let liveChannel = await rt.channel("realtime:public:live_table") + + server.autoReplyToJoins() + server.autoReplyToLeaves() + + // Subscribe both channels. + try await leftChannel.subscribe() + try await liveChannel.subscribe() + + // Leave the first channel explicitly. + try await leftChannel.leave() + let stateAfterLeave = await leftChannel.channelState + #expect(stateAfterLeave == .closed(.userRequested)) + + // Track phx_join frames per topic after the disconnect. + let leftTopicJoins = LockIsolated(0) + let liveTopicJoins = LockIsolated(0) + let clientFrames = server.subscribeToClientFrames() + let frameTask = Task.detached { + for await frame in clientFrames { + guard case .text(let text) = frame else { continue } + guard text.contains("phx_join") else { continue } + if text.contains("left_table") { + leftTopicJoins.withValue { $0 += 1 } + } + if text.contains("live_table") { + liveTopicJoins.withValue { $0 += 1 } + } + } + } + defer { frameTask.cancel() } + + await Task.yield() + + // Trigger server-initiated close. + server.closeFromServer(code: 1006, reason: "abnormal") + + // Advance until the live channel re-joins (proves reconnect happened). + await advanceUntil(clock: clock, step: .seconds(1)) { + liveTopicJoins.value >= 1 + } + + #expect(liveTopicJoins.value >= 1, "live_table channel must re-join after reconnect") + #expect(leftTopicJoins.value == 0, "left_table channel must NOT re-join (user left)") + } + + // MARK: - giveUpTerminatesChannelStreams + + @Test func giveUpTerminatesChannelStreams() async throws { + let (transport, server) = InMemoryTransport.pair() + var config = Configuration.default + config.reconnection = .never + let rt = Realtime( + url: URL(string: "wss://x")!, + apiKey: "k", + configuration: config, + transport: transport + ) + + let channel = await rt.channel("realtime:public:posts") + server.autoReplyToJoins() + try await channel.subscribe() + + // Open a broadcasts stream — it should throw .channelClosed(.transportFailure) on give-up. + let broadcastsStream = await channel.broadcasts(of: String.self, event: "new") + let broadcastError = LockIsolated(nil) + let broadcastTask = Task { + do { + for try await _ in broadcastsStream {} + } catch let err as RealtimeError { + broadcastError.setValue(err) + } catch {} + } + defer { broadcastTask.cancel() } + + // Open a messages() stream — it should finish (no error). + let messagesStream = await channel.messages() + let messagesEnded = LockIsolated(false) + let messagesTask = Task { + for await _ in messagesStream {} + messagesEnded.setValue(true) + } + defer { messagesTask.cancel() } + + // Simulate server close — with .never policy, give-up happens immediately. + server.closeFromServer(code: 1006, reason: "abnormal") + + // Yield until streams terminate. + for _ in 0..<200 { + await Task.yield() + if broadcastError.value != nil && messagesEnded.value { break } + } + + // Broadcasts stream must throw .channelClosed(.transportFailure). + if let err = broadcastError.value { + if case .channelClosed(let reason) = err { + #expect(reason == .transportFailure, "Expected .transportFailure close reason") + } else { + Issue.record("Expected .channelClosed(.transportFailure), got: \(err)") + } + } else { + Issue.record("broadcasts stream did not throw on give-up") + } + + // messages() stream must finish cleanly. + #expect(messagesEnded.value, "messages() stream must finish cleanly on give-up") + + // Channel state must be .closed(.transportFailure). + let finalState = await channel.channelState + if case .closed(let reason) = finalState { + #expect(reason == .transportFailure, "Channel state must be .closed(.transportFailure)") + } else { + Issue.record("Channel state must be .closed(.transportFailure), got: \(finalState)") + } + } +} diff --git a/Tests/RealtimeV3Tests/ServerCloseTests.swift b/Tests/RealtimeV3Tests/ServerCloseTests.swift new file mode 100644 index 000000000..f4f67b951 --- /dev/null +++ b/Tests/RealtimeV3Tests/ServerCloseTests.swift @@ -0,0 +1,360 @@ +// +// ServerCloseTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import Testing + +@testable import RealtimeV3 + +// MARK: - Server close / error event handling + +@Suite struct ServerCloseTests { + + // MARK: - serverPhxCloseTerminatesChannel + + /// An unsolicited `phx_close` from the server must: + /// 1. Transition the channel to `.closed(.serverClosed(...))`. + /// 2. Terminate any open `broadcasts(of:)` stream with `.channelClosed(.serverClosed(...))`. + @Test func serverPhxCloseTerminatesChannel() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + try await channel.subscribe() + + // Register a broadcast stream BEFORE injecting the close frame. + let broadcastStream = await channel.broadcasts(of: String.self, event: "evt") + + // Collect the broadcast stream error in a background task. + let streamError = LockIsolated<(any Error)?>(nil) + let streamDone = LockIsolated(false) + let collectTask = Task { + do { + for try await _ in broadcastStream { /* no messages expected */ } + streamDone.withValue { $0 = true } + } catch { + streamError.withValue { $0 = error } + streamDone.withValue { $0 = true } + } + } + defer { collectTask.cancel() } + + // Inject an unsolicited phx_close for the channel topic. + server.send(.text(#"["1",null,"realtime:room:1","phx_close",{}]"#)) + + // Wait for the state to reach .closed — bounded loop. + var stateIter = await channel.state.makeAsyncIterator() + var reachedClosed = false + var iterations = 0 + while let s = await stateIter.next() { + if case .closed = s { + reachedClosed = true + break + } + iterations += 1 + if iterations > 30 { break } + } + + #expect(reachedClosed, "Channel did not reach .closed after server phx_close") + + // Confirm it is specifically .closed(.serverClosed(...)). + let finalState = await channel.channelState + if case .closed(let reason) = finalState { + if case .serverClosed = reason { + // expected + } else { + Issue.record("Expected .serverClosed, got \(reason)") + } + } else { + Issue.record("Expected .closed, got \(finalState)") + } + + // Wait for the broadcast stream to finish (bounded). + var waitIterations = 0 + while !streamDone.value { + try await Task.sleep(nanoseconds: 1_000_000) // 1ms + waitIterations += 1 + if waitIterations > 500 { break } + } + + // Verify the broadcast stream threw .channelClosed with serverClosed reason. + let err = streamError.value + if let realtimeErr = err as? RealtimeError { + if case .channelClosed(let reason) = realtimeErr { + if case .serverClosed = reason { + // expected + } else { + Issue.record("Expected .channelClosed(.serverClosed), got .channelClosed(\(reason))") + } + } else { + Issue.record("Expected .channelClosed, got \(realtimeErr)") + } + } else { + Issue.record("Expected RealtimeError, got \(String(describing: err))") + } + } + + // MARK: - serverSystemAuthErrorClosesUnauthorized + + /// A `system` event with `status == "error"` and an auth-related message must close + /// the channel with `.unauthorized`. + @Test func serverSystemAuthErrorClosesUnauthorized() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + try await channel.subscribe() + + // Inject a non-postgres system error with "token" in the message. + server.send( + .text( + #"[null,null,"realtime:room:1","system",{"status":"error","message":"Invalid JWT token"}]"# + )) + + var stateIter = await channel.state.makeAsyncIterator() + var reachedClosed = false + var iterations = 0 + while let s = await stateIter.next() { + if case .closed = s { + reachedClosed = true + break + } + iterations += 1 + if iterations > 30 { break } + } + + #expect(reachedClosed, "Channel did not reach .closed after system auth error") + + let finalState = await channel.channelState + if case .closed(let reason) = finalState { + #expect(reason == .unauthorized, "Expected .unauthorized, got \(reason)") + } else { + Issue.record("Expected .closed, got \(finalState)") + } + } + + // MARK: - ownLeaveNotOverwrittenByTrailingPhxClose + + /// After `leave()` sets `.closed(.userRequested)`, a trailing `phx_close` frame from + /// the server must NOT overwrite the reason to `.serverClosed`. + @Test func ownLeaveNotOverwrittenByTrailingPhxClose() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + server.autoReplyToLeaves() + + try await channel.subscribe() + + // Leave the channel — transitions to .closed(.userRequested). + try await channel.leave() + + // Confirm the channel is already .closed(.userRequested). + let stateAfterLeave = await channel.channelState + if case .closed(let reason) = stateAfterLeave { + #expect(reason == .userRequested, "After leave(), expected .userRequested, got \(reason)") + } else { + Issue.record("After leave(), expected .closed, got \(stateAfterLeave)") + return + } + + // Inject a trailing phx_close from the server (simulating race between leave ACK and server close). + server.send(.text(#"["1",null,"realtime:room:1","phx_close",{}]"#)) + + // Yield briefly so the frame can be processed. + await Task.yield() + await Task.yield() + + // The reason must still be .userRequested, not overwritten to .serverClosed. + let finalState = await channel.channelState + if case .closed(let reason) = finalState { + #expect(reason == .userRequested, "Trailing phx_close overwrote reason to \(reason)") + } else { + Issue.record("Expected .closed after trailing phx_close, got \(finalState)") + } + } + + // MARK: - serverPhxErrorTerminatesChannel + + /// A `phx_error` frame from the server closes the channel with `.serverClosed`. + @Test func serverPhxErrorTerminatesChannel() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + try await channel.subscribe() + + server.send(.text(#"[null,null,"realtime:room:1","phx_error",{}]"#)) + + var stateIter = await channel.state.makeAsyncIterator() + var reachedClosed = false + var iterations = 0 + while let s = await stateIter.next() { + if case .closed = s { + reachedClosed = true + break + } + iterations += 1 + if iterations > 30 { break } + } + + #expect(reachedClosed, "Channel did not reach .closed after phx_error") + + let finalState = await channel.channelState + if case .closed(let reason) = finalState { + if case .serverClosed = reason { + // expected + } else { + Issue.record("Expected .serverClosed, got \(reason)") + } + } else { + Issue.record("Expected .closed, got \(finalState)") + } + } +} + +// MARK: - Encoder tests + +@Suite struct ConfiguredEncoderTests { + + // MARK: - configuredEncoderUsedForBroadcast + + /// A custom `keyEncodingStrategy` on `Configuration.encoder` must be reflected in + /// the broadcast payload bytes. We use `.convertToSnakeCase` which turns `myField` + /// into `my_field` — unambiguous in the JSON. + @Test func configuredEncoderUsedForBroadcast() async throws { + struct Payload: Encodable, Sendable { + let myField: String + } + + let (transport, server) = InMemoryTransport.pair() + + var config = Configuration() + config.encoder = { + let enc = JSONEncoder() + enc.keyEncodingStrategy = .convertToSnakeCase + return enc + }() + + let rt = Realtime( + url: URL(string: "wss://x")!, apiKey: "k", configuration: config, transport: transport + ) + let channel = await rt.channel("room:1") + + server.autoReplyToJoins() + server.autoReplyToBroadcasts() + try await channel.subscribe() + + // Observe frames the client sends. + let sentFrames = LockIsolated<[Data]>([]) + let frameObserver = server.subscribeToClientFrames() + let observerTask = Task { + for await frame in frameObserver { + if case .binary(let data) = frame { + sentFrames.withValue { $0.append(data) } + } + } + } + defer { observerTask.cancel() } + + try await channel.broadcast(Payload(myField: "hello"), as: "test") + + // Allow a tick for the frame to be observed. + await Task.yield() + await Task.yield() + + // Find the broadcast frame and decode its JSON payload. + let frames = sentFrames.value + let broadcastData = frames.first { !$0.isEmpty && $0[$0.startIndex] == 3 } + guard let data = broadcastData else { + Issue.record("No binary broadcast frame found") + return + } + + // Parse the binary frame: [kind:1][joinRefLen:1][refLen:1][topicLen:1][eventLen:1][metaLen:1][encoding:1][fields...][json] + let headerSize = 7 + guard data.count > headerSize else { + Issue.record("Binary frame too short") + return + } + let joinRefLen = Int(data[data.startIndex + 1]) + let refLen = Int(data[data.startIndex + 2]) + let topicLen = Int(data[data.startIndex + 3]) + let eventLen = Int(data[data.startIndex + 4]) + let metaLen = Int(data[data.startIndex + 5]) + + let payloadOffset = + data.startIndex + headerSize + joinRefLen + refLen + topicLen + eventLen + metaLen + guard data.count > payloadOffset - data.startIndex else { + Issue.record("Binary frame too short for payload") + return + } + + let payloadData = Data(data[payloadOffset...]) + // The outer payload is the broadcast envelope: {"type":"broadcast","event":"test","payload":{"my_field":"hello"}} + if let payloadStr = String(data: payloadData, encoding: .utf8) { + #expect( + payloadStr.contains("my_field"), + "Expected snake_case key 'my_field' in payload, got: \(payloadStr)" + ) + #expect( + !payloadStr.contains("myField"), + "Expected no camelCase key 'myField' in payload, got: \(payloadStr)" + ) + } else { + Issue.record("Could not decode payload as UTF-8") + } + } +} + +// MARK: - VSN connect tests + +@Suite struct VsnConnectTests { + + // MARK: - vsnSentOnConnect + + /// After `connect()`, the transport must receive a URL with `vsn=2.0.0` query param. + @Test func vsnSentOnConnect() async throws { + let (transport, _) = InMemoryTransport.pair() + let rt = Realtime( + url: URL(string: "wss://proj.supabase.co/realtime/v1")!, apiKey: "k", transport: transport) + try await rt.connect() + let url = await transport.lastConnectURL + #expect( + url?.query?.contains("vsn=2.0.0") == true, + "Expected vsn=2.0.0 in connect URL, got: \(url?.query ?? "(nil)")" + ) + } + + // MARK: - vsnRespectsProtocolVersionConfig + + /// If `Configuration.protocolVersion` is set to `.v1`, the URL should contain `vsn=1.0.0`. + @Test func vsnRespectsProtocolVersionConfig() async throws { + let (transport, _) = InMemoryTransport.pair() + + var config = Configuration() + config.protocolVersion = .v1 + + let rt = Realtime( + url: URL(string: "wss://proj.supabase.co/realtime/v1")!, + apiKey: "k", + configuration: config, + transport: transport + ) + try await rt.connect() + let url = await transport.lastConnectURL + #expect( + url?.query?.contains("vsn=1.0.0") == true, + "Expected vsn=1.0.0 in connect URL, got: \(url?.query ?? "(nil)")" + ) + } +} diff --git a/Tests/RealtimeV3Tests/SmokeTests.swift b/Tests/RealtimeV3Tests/SmokeTests.swift new file mode 100644 index 000000000..f2f273f31 --- /dev/null +++ b/Tests/RealtimeV3Tests/SmokeTests.swift @@ -0,0 +1,9 @@ +import Testing + +@testable import RealtimeV3 + +@Suite struct SmokeTests { + @Test func moduleImports() { + #expect(Bool(true)) + } +} diff --git a/Tests/RealtimeV3Tests/SubscribeTests.swift b/Tests/RealtimeV3Tests/SubscribeTests.swift new file mode 100644 index 000000000..2fa9a68cc --- /dev/null +++ b/Tests/RealtimeV3Tests/SubscribeTests.swift @@ -0,0 +1,90 @@ +// +// SubscribeTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import Helpers +import Testing + +@testable import RealtimeV3 + +@Suite struct SubscribeTests { + + // MARK: - subscribeJoinsOnServerReply + + @Test func subscribeJoinsOnServerReply() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + // Enable auto-reply BEFORE calling subscribe so the reply arrives without clock tricks. + server.autoReplyToJoins() + + try await channel.subscribe() + + // Read the state stream until we reach .joined (or exhaust a bounded window). + let stateStream = await channel.state + var last: ChannelState? + var iterations = 0 + for await s in stateStream { + last = s + iterations += 1 + if s == .joined { break } + if iterations > 20 { break } + } + #expect(last == .joined) + } + + // MARK: - secondSubscribeIsNoOp + + @Test func secondSubscribeIsNoOp() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + let joinsSeen = LockIsolated(0) + server.autoReplyToJoins(onJoin: { joinsSeen.withValue { $0 += 1 } }) + + // First subscribe — should join. + try await channel.subscribe() + // The state stream is seeded with the current state; the first element should be .joined. + let currentState = await channel.state.first(where: { _ in true }) + #expect(currentState == .joined) + + // Second subscribe — should be a no-op (no second join frame sent, returns immediately). + // Because the channel is already .joined, subscribe() returns synchronously on the + // actor without sending any frame. No sleep needed: actor-sequential execution + // guarantees that when subscribe() returns, no extra join was sent. + try await channel.subscribe() + + #expect(joinsSeen.value == 1) + } + + // MARK: - subscribeRejectedThrows + + @Test func subscribeRejectedThrows() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:2") + + server.autoReplyToJoins( + status: "error", + response: ["reason": "unauthorized"] + ) + + do { + try await channel.subscribe() + Issue.record("Expected subscribe() to throw channelJoinRejected, but it returned normally.") + } catch { + if case .channelJoinRejected = error { + // Expected — test passes. + } else { + Issue.record("Expected channelJoinRejected, got: \(error)") + } + } + } +} diff --git a/Tests/RealtimeV3Tests/Support/InMemoryTransport.swift b/Tests/RealtimeV3Tests/Support/InMemoryTransport.swift new file mode 100644 index 000000000..67181e778 --- /dev/null +++ b/Tests/RealtimeV3Tests/Support/InMemoryTransport.swift @@ -0,0 +1,410 @@ +// +// InMemoryTransport.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 27/06/26. +// + +import ConcurrencyExtras +import Foundation +import Helpers + +@testable import RealtimeV3 + +// MARK: - InMemoryTransport + +/// An in-memory transport suitable for unit tests. Call `pair()` to obtain a +/// `(transport, server)` tuple: hand `transport` to the Realtime client and +/// use `server` to inject and observe frames. +/// +/// **Reconnect behaviour:** `connect(to:headers:)` may be called more than once +/// (e.g., after a server-initiated close in reconnection tests). Each call +/// creates a fresh `InMemoryConnection` that shares the *same* `TransportServer` +/// streams, so the server can keep injecting/observing frames across reconnects. +actor InMemoryTransport: RealtimeTransport { + // Expose connection metadata for assertion in later tasks. + private(set) var lastConnectURL: URL? + private(set) var lastConnectHeaders: [String: String]? + private(set) var connectCallCount: Int = 0 + + private let server: TransportServer + + private init(server: TransportServer) { + self.server = server + } + + /// Creates a linked (transport, server) pair. + nonisolated static func pair() -> (transport: InMemoryTransport, server: TransportServer) { + let server = TransportServer() + let transport = InMemoryTransport(server: server) + return (transport, server) + } + + nonisolated func connect(to url: URL, headers: [String: String]) async throws + -> any RealtimeConnection + { + await _connect(to: url, headers: headers) + } + + private func _connect(to url: URL, headers: [String: String]) -> any RealtimeConnection { + lastConnectURL = url + lastConnectHeaders = headers + connectCallCount += 1 + // NOTE: All connections from repeated connect() calls share the same server streams + // (reconnect support), so the server can keep injecting/observing frames across reconnects. + return server.makeConnection() + } +} + +// MARK: - TransportServer + +/// The server-side handle of an `InMemoryTransport` pair. +/// +/// - `clientSentFrames`: yields every frame the Realtime client sends (across all connections). +/// - `send(_:)`: injects a frame that the currently-connected client will receive. +/// - `closeFromServer(code:reason:)`: finishes the current connection's streams, simulating a +/// server-initiated close. The next `makeConnection()` call (from a reconnect) establishes +/// a fresh server→client stream, so the server can keep injecting frames across reconnects. +/// +/// ## Reconnect behaviour +/// `closeFromServer` only terminates the *current* connection's server→client stream. +/// `makeConnection()` always creates a fresh server→client stream pair, so each reconnect +/// gets a live stream. The client→server `clientSentFrames` stream is shared across all +/// connections so the test observer sees all frames from every connection attempt. +final class TransportServer: Sendable { + // Frames the Realtime client sent → server observes (shared across reconnects). + // NOTE: `closeFromServer` intentionally does NOT finish this stream so it survives + // reconnects (the client→server channel is shared across all connection instances). + // Consumers must iterate with `break` or task cancellation rather than awaiting + // stream completion. + private let clientSentContinuation: LockIsolated.Continuation?> + let clientSentFrames: AsyncStream + + // Active server→client stream continuation. Replaced on each makeConnection(). + // `closeFromServer` finishes this, and the next makeConnection() installs a new one. + private let activeServerToClientContinuation: + LockIsolated< + AsyncStream.Continuation? + > + + // Multicast subscribers: each registered continuation receives a copy of every + // client-sent frame. This allows multiple autoReply* helpers to coexist without + // competing with each other or with direct `clientSentFrames` consumers. + let broadcastSubscribers: LockIsolated<[UUID: AsyncStream.Continuation]> + + init() { + let (clientSentStream, clientSentCont) = AsyncStream.makeStream(of: TransportFrame.self) + self.clientSentFrames = clientSentStream + self.clientSentContinuation = LockIsolated(clientSentCont) + self.activeServerToClientContinuation = LockIsolated(nil) + self.broadcastSubscribers = LockIsolated([:]) + } + + /// Returns a new `AsyncStream` that receives a copy of every + /// client-sent frame. Multiple streams may coexist — each gets every frame. + /// + /// Frames are published to subscribers in `notifyBroadcastSubscribers(_:)`, + /// called by `InMemoryConnection.send()` alongside the main `clientSentFrames` yield. + /// + /// The caller is responsible for breaking out of the loop when done (task + /// cancellation is the canonical mechanism). + func subscribeToClientFrames() -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream() + let subscribers = broadcastSubscribers + continuation.onTermination = { _ in + _ = subscribers.withValue { $0.removeValue(forKey: id) } + } + broadcastSubscribers.withValue { $0[id] = continuation } + return stream + } + + /// Called by `InMemoryConnection.send()` to fan each frame out to all broadcast subscribers. + func notifyBroadcastSubscribers(_ frame: TransportFrame) { + broadcastSubscribers.withValue { dict in + for cont in dict.values { + cont.yield(frame) + } + } + } + + /// Inject a frame that the connected client will receive on its `frames` stream. + func send(_ frame: TransportFrame) { + _ = activeServerToClientContinuation.withValue { $0?.yield(frame) } + } + + /// Simulate a server-initiated close. Finishes the current server→client stream. + /// The next `connect()` from the client will call `makeConnection()` which installs + /// a fresh stream, allowing reconnect tests to work naturally. + func closeFromServer(code: Int, reason: String) { + activeServerToClientContinuation.withValue { $0?.finish() } + } + + // MARK: - autoReplyToJoins + + /// Spawns a background task that watches client-sent frames for `phx_join` text frames and + /// automatically replies with a `phx_reply` carrying the same `ref` and the supplied `status` + /// / `response`. The task is detached and runs until the test ends or the stream is cancelled. + /// + /// Uses `subscribeToClientFrames()` so it can coexist with `autoReplyToLeaves()` without + /// competing for frames — each helper gets its own broadcast copy of every frame. + /// + /// - Parameters: + /// - status: The reply status — `"ok"` by default, use `"error"` to test rejection. + /// - response: Payload nested inside `{"status": ..., "response": ...}`. + /// - onJoin: Optional callback invoked each time a join frame is detected (before replying). + func autoReplyToJoins( + status: String = "ok", + response: [String: AnyJSON] = [:], + onJoin: (@Sendable () -> Void)? = nil + ) { + // Encode the response object once using JSONEncoder so nested/array values + // produce valid JSON rather than relying on manual string interpolation. + let responseJSON: String + if let data = try? JSONEncoder().encode(response), + let str = String(data: data, encoding: .utf8) + { + responseJSON = str + } else { + responseJSON = "{}" + } + + let server = self + let frames = subscribeToClientFrames() + Task.detached { + for await frame in frames { + guard case .text(let text) = frame else { continue } + // Only process phx_join frames. + guard text.contains("phx_join") else { continue } + + // Parse the ref from the JSON array: [joinRef, ref, topic, event, payload] + // The ref is the second element (index 1). + guard let ref = parseRef(from: text) else { continue } + guard let topic = parseTopic(from: text) else { continue } + + onJoin?() + + // Inject the reply with the same ref so the in-flight registry resolves it. + let reply = + "[null,\"\(ref)\",\"\(topic)\",\"phx_reply\",{\"status\":\"\(status)\",\"response\":\(responseJSON)}]" + server.send(.text(reply)) + + // When the join carries a non-empty postgres_changes set, the real server confirms the + // subscription is live with a follow-up `system` event — and the SDK now waits for it + // before declaring the channel joined. Mirror that here so postgres subscribes complete. + // (The join payload always includes the key; `[]` means no registrations.) + if status == "ok", text.contains("postgres_changes"), + !text.contains("\"postgres_changes\":[]") + { + let system = + "[null,null,\"\(topic)\",\"system\",{\"status\":\"ok\",\"extension\":\"postgres_changes\",\"message\":\"Subscribed to PostgreSQL\"}]" + server.send(.text(system)) + } + } + } + } + + // MARK: - autoReplyToLeaves + + /// Spawns a background task that watches client-sent frames for `phx_leave` text frames and + /// automatically replies with a `phx_reply` carrying the same `ref` and the supplied `status`. + /// The task is detached and runs until the test ends or the stream is cancelled. + /// + /// Uses `subscribeToClientFrames()` so it can coexist with `autoReplyToJoins()` without + /// competing for frames — each helper gets its own broadcast copy of every frame. + /// + /// - Parameters: + /// - status: The reply status — `"ok"` by default. + func autoReplyToLeaves(status: String = "ok") { + let server = self + let frames = subscribeToClientFrames() + Task.detached { + for await frame in frames { + guard case .text(let text) = frame else { continue } + // Only process phx_leave frames. + guard text.contains("phx_leave") else { continue } + + guard let ref = parseRef(from: text) else { continue } + guard let topic = parseTopic(from: text) else { continue } + + // Inject the reply with the same ref so the in-flight registry resolves it. + let reply = + "[null,\"\(ref)\",\"\(topic)\",\"phx_reply\",{\"status\":\"\(status)\",\"response\":{}}]" + server.send(.text(reply)) + } + } + } + + // MARK: - autoReplyToBroadcasts + + /// Spawns a background task that watches client→server frames for binary broadcast push + /// frames (kind byte `0x03`) and automatically replies with a `phx_reply` carrying the + /// same `ref` and the supplied `status`. + /// + /// Uses `subscribeToClientFrames()` so it can coexist with `autoReplyToJoins()` and + /// other helpers without competing for frames — each helper gets its own broadcast copy. + /// + /// - Parameter status: The reply status — `"ok"` by default. + func autoReplyToBroadcasts(status: String = "ok") { + let server = self + let frames = subscribeToClientFrames() + Task.detached { + for await frame in frames { + // Only handle binary frames. + guard case .binary(let data) = frame else { continue } + // Kind byte must be 0x03 (client → server broadcast push). + guard data.count >= 7, data[data.startIndex] == 3 else { continue } + + // Parse header lengths. + // Layout: [kind:1][joinRefLen:1][refLen:1][topicLen:1][eventLen:1][metaLen:1][encoding:1] + // [joinRef...][ref...][topic...][event...][meta...][payload...] + let joinRefLen = Int(data[data.startIndex + 1]) + let refLen = Int(data[data.startIndex + 2]) + let topicLen = Int(data[data.startIndex + 3]) + + let headerSize = 7 + let minRequired = headerSize + joinRefLen + refLen + topicLen + guard data.count >= minRequired else { continue } + + var offset = data.startIndex + headerSize + // Skip joinRef bytes. + offset += joinRefLen + // Extract ref bytes. + let refEnd = offset + refLen + guard data.count >= refEnd - data.startIndex else { continue } + let refData = data[offset..<(offset + refLen)] + offset += refLen + // Extract topic bytes. + let topicData = data[offset..<(offset + topicLen)] + + guard let ref = String(data: Data(refData), encoding: .utf8), + let topic = String(data: Data(topicData), encoding: .utf8), + !ref.isEmpty + else { continue } + + // Inject a text phx_reply so the in-flight registry resolves the push. + let reply = + "[null,\"\(ref)\",\"\(topic)\",\"phx_reply\",{\"status\":\"\(status)\",\"response\":{}}]" + server.send(.text(reply)) + } + } + } + + // MARK: - autoReplyToPresence + + /// Spawns a background task that watches client→server text frames for presence event frames + /// (`"presence"`) and automatically replies with a `phx_reply` carrying the same `ref` and + /// the supplied `status`. + /// + /// Uses `subscribeToClientFrames()` so it can coexist with `autoReplyToJoins()` and other + /// helpers without competing for frames — each helper gets its own broadcast copy of every frame. + /// + /// - Parameter status: The reply status — `"ok"` by default. + func autoReplyToPresence(status: String = "ok") { + let server = self + let frames = subscribeToClientFrames() + Task.detached { + for await frame in frames { + guard case .text(let text) = frame else { continue } + // Only process presence frames (channel event is "presence"). + guard text.contains("\"presence\"") else { continue } + // Filter out phx_reply frames to avoid feedback loops. + guard !text.contains("phx_reply") else { continue } + + guard let ref = parseRef(from: text) else { continue } + guard let topic = parseTopic(from: text) else { continue } + + // Inject the reply with the same ref so the in-flight registry resolves it. + let reply = + "[null,\"\(ref)\",\"\(topic)\",\"phx_reply\",{\"status\":\"\(status)\",\"response\":{}}]" + server.send(.text(reply)) + } + } + } + + /// Called by InMemoryTransport on each connect() to produce a fresh connection object. + /// Installs a new server→client continuation, replacing the previous (possibly finished) one. + fileprivate func makeConnection() -> InMemoryConnection { + // Create a fresh server→client stream for this connection. + let (serverToClientStream, serverToClientCont) = AsyncStream.makeStream( + of: TransportFrame.self + ) + // Replace the active continuation so `send()` targets the new connection. + activeServerToClientContinuation.withValue { $0 = serverToClientCont } + + return InMemoryConnection( + serverToClientStream: serverToClientStream, + clientSentContinuation: clientSentContinuation, + server: self + ) + } +} + +// MARK: - JSON parsing helpers (file-private) + +/// Extracts the `ref` (second element) from a Phoenix JSON array frame string. +/// Expected format: `[joinRef, ref, topic, event, payload]` +private func parseRef(from text: String) -> String? { + // Use Foundation JSON decoding for correctness. + guard let data = text.data(using: .utf8), + let array = try? JSONDecoder().decode([AnyJSON].self, from: data), + array.count >= 2, + let ref = array[1].stringValue + else { return nil } + return ref +} + +/// Extracts the `topic` (third element) from a Phoenix JSON array frame string. +private func parseTopic(from text: String) -> String? { + guard let data = text.data(using: .utf8), + let array = try? JSONDecoder().decode([AnyJSON].self, from: data), + array.count >= 3, + let topic = array[2].stringValue + else { return nil } + return topic +} + +// MARK: - InMemoryConnection (private) + +/// A `RealtimeConnection` backed by the in-process streams managed by `TransportServer`. +private final class InMemoryConnection: RealtimeConnection, Sendable { + let frames: AsyncThrowingStream + private let clientSentContinuation: LockIsolated.Continuation?> + private let bridgeTask: Task + private let server: TransportServer + + init( + serverToClientStream: AsyncStream, + clientSentContinuation: LockIsolated.Continuation?>, + server: TransportServer + ) { + // Wrap the plain AsyncStream in AsyncThrowingStream as required by the protocol. + // Use makeStream() so the bridging Task can be stored and cancelled on close(). + let (throwingStream, continuation) = AsyncThrowingStream< + TransportFrame, any Error & Sendable + >.makeStream() + self.frames = throwingStream + self.clientSentContinuation = clientSentContinuation + self.server = server + self.bridgeTask = Task { + // defer ensures the throwing-stream continuation is always finished, whether the + // forwarding loop exits normally (end-of-stream) or because the task was cancelled + // (by close()). Without this, a consumer awaiting `frames` could hang indefinitely. + defer { continuation.finish() } + for await frame in serverToClientStream { + continuation.yield(frame) + } + } + } + + func send(_ frame: TransportFrame) async throws { + // Publish to the single-consumer clientSentFrames stream. + _ = clientSentContinuation.withValue { $0?.yield(frame) } + // Also fan out to all broadcast subscribers (autoReply* helpers). + server.notifyBroadcastSubscribers(frame) + } + + func close(code: Int, reason: String) async { + bridgeTask.cancel() + } +} diff --git a/Tests/RealtimeV3Tests/Support/TestLifecycleEventSource.swift b/Tests/RealtimeV3Tests/Support/TestLifecycleEventSource.swift new file mode 100644 index 000000000..5ff3bf0b9 --- /dev/null +++ b/Tests/RealtimeV3Tests/Support/TestLifecycleEventSource.swift @@ -0,0 +1,44 @@ +// +// TestLifecycleEventSource.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation + +@testable import RealtimeV3 + +/// A test double for `LifecycleEventSource` that allows tests to programmatically +/// emit background and foreground events. +final class TestLifecycleEventSource: LifecycleEventSource, @unchecked Sendable { + let didEnterBackground: AsyncStream + let willEnterForeground: AsyncStream + + private let backgroundContinuation: AsyncStream.Continuation + private let foregroundContinuation: AsyncStream.Continuation + + init() { + let (bgStream, bgCont) = AsyncStream.makeStream() + let (fgStream, fgCont) = AsyncStream.makeStream() + self.didEnterBackground = bgStream + self.willEnterForeground = fgStream + self.backgroundContinuation = bgCont + self.foregroundContinuation = fgCont + } + + deinit { + backgroundContinuation.finish() + foregroundContinuation.finish() + } + + /// Emit a background event (simulating the app entering the background). + func sendBackground() { + backgroundContinuation.yield(()) + } + + /// Emit a foreground event (simulating the app returning to the foreground). + func sendForeground() { + foregroundContinuation.yield(()) + } +} diff --git a/Tests/RealtimeV3Tests/UntypedFilterTests.swift b/Tests/RealtimeV3Tests/UntypedFilterTests.swift new file mode 100644 index 000000000..f61956f88 --- /dev/null +++ b/Tests/RealtimeV3Tests/UntypedFilterTests.swift @@ -0,0 +1,189 @@ +// +// UntypedFilterTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import Foundation +import Testing + +@testable import RealtimeV3 + +// MARK: - UntypedFilterTests + +@Suite struct UntypedFilterTests { + + // MARK: - eqSerializes + + /// eq factory must produce `column=eq.value`. + @Test func eqSerializes() { + #expect(UntypedFilter.eq("room_id", 42).serialized == "room_id=eq.42") + } + + // MARK: - neqSerializes + + /// neq factory must produce `column=neq.value`. + @Test func neqSerializes() { + #expect(UntypedFilter.neq("status", "closed").serialized == "status=neq.closed") + } + + // MARK: - gtSerializes + + @Test func gtSerializes() { + #expect(UntypedFilter.gt("score", 100).serialized == "score=gt.100") + } + + // MARK: - gteSerializes + + @Test func gteSerializes() { + #expect(UntypedFilter.gte("score", 100).serialized == "score=gte.100") + } + + // MARK: - ltSerializes + + @Test func ltSerializes() { + #expect(UntypedFilter.lt("age", 18).serialized == "age=lt.18") + } + + // MARK: - lteSerializes + + @Test func lteSerializes() { + #expect(UntypedFilter.lte("age", 18).serialized == "age=lte.18") + } + + // MARK: - inSerializes + + /// in factory must produce `column=in.(v1,v2,v3)`. + @Test func inSerializes() { + #expect(UntypedFilter.in("id", [1, 2, 3]).serialized == "id=in.(1,2,3)") + } + + // MARK: - inSingleValue + + @Test func inSingleValue() { + #expect(UntypedFilter.in("id", [42]).serialized == "id=in.(42)") + } + + // MARK: - inWith100Values + + /// Exactly 100 values must succeed. + @Test func inWith100Values() { + let values = (1...100).map { $0 } + let expected = "id=in.(\(values.map { "\($0)" }.joined(separator: ",")))" + #expect(UntypedFilter.in("id", values).serialized == expected) + } + + // MARK: - likeSerializes + + @Test func likeSerializes() { + #expect(UntypedFilter.like("name", "%alice%").serialized == "name=like.%alice%") + } + + // MARK: - ilikeSerializes + + @Test func ilikeSerializes() { + #expect(UntypedFilter.ilike("name", "%alice%").serialized == "name=ilike.%alice%") + } + + // MARK: - matchSerializes + + @Test func matchSerializes() { + #expect(UntypedFilter.match("name", "^alice").serialized == "name=match.^alice") + } + + // MARK: - imatchSerializes + + @Test func imatchSerializes() { + #expect(UntypedFilter.imatch("name", "^alice").serialized == "name=imatch.^alice") + } + + // MARK: - isNullSerializes + + /// isNull must produce `column=is.null`. + @Test func isNullSerializes() { + #expect(UntypedFilter.isNull("deleted_at").serialized == "deleted_at=is.null") + } + + // MARK: - isNotNullSerializes + + /// isNotNull must produce `column=not.is.null`. + @Test func isNotNullSerializes() { + #expect(UntypedFilter.isNotNull("deleted_at").serialized == "deleted_at=not.is.null") + } + + // MARK: - isDistinctSerializes + + @Test func isDistinctSerializes() { + #expect(UntypedFilter.isDistinct("status", "active").serialized == "status=isdistinct.active") + } + + // MARK: - andJoinsWithComma + + /// and must join two clauses with a comma. + @Test func andJoinsWithComma() { + let filter = UntypedFilter.eq("a", 1).and(.eq("b", 2)) + #expect(filter.serialized == "a=eq.1,b=eq.2") + } + + // MARK: - allJoinsMultiple + + /// all must join all clauses with commas. + @Test func allJoinsMultiple() { + let filter = UntypedFilter.all([.eq("a", 1), .eq("b", 2), .eq("c", 3)]) + #expect(filter.serialized == "a=eq.1,b=eq.2,c=eq.3") + } + + // MARK: - notPrefixesOperator + + /// not must insert `not.` before the operator in each clause. + @Test func notPrefixesOperator() { + #expect(UntypedFilter.not(.eq("room_id", 42)).serialized == "room_id=not.eq.42") + } + + // MARK: - notOnCompoundFilter + + /// not applied to a compound filter must prefix each clause. + @Test func notOnCompoundFilter() { + let compound = UntypedFilter.eq("a", 1).and(.eq("b", 2)) + let negated = UntypedFilter.not(compound) + #expect(negated.serialized == "a=not.eq.1,b=not.eq.2") + } + + // MARK: - notOnIsNull + + /// not on isNull must produce `column=not.is.null`. + @Test func notOnIsNull() { + #expect(UntypedFilter.not(.isNull("col")).serialized == "col=not.is.null") + } + + // MARK: - stringValueQuoting + + /// String values that contain commas must be quoted for the `in` list. + @Test func stringValueQuoting() { + // A plain string value without special chars should not be quoted. + #expect(UntypedFilter.eq("name", "alice").serialized == "name=eq.alice") + } + + // MARK: - inStringValuesQuoted + + /// String values with commas inside an `in` list must be quoted. + @Test func inStringValuesQuoted() { + // "a,b" contains a comma so it must be double-quoted in the in() list. + let result = UntypedFilter.in("tag", ["a,b", "c"]).serialized + #expect(result == #"tag=in.("a,b",c)"#) + } + + // MARK: - doubleValue + + @Test func doubleValue() { + #expect(UntypedFilter.eq("price", 3.14).serialized == "price=eq.3.14") + } + + // MARK: - boolValue + + @Test func boolValue() { + #expect(UntypedFilter.eq("active", true).serialized == "active=eq.true") + } + +} diff --git a/Tests/RealtimeV3Tests/UpdateTokenTests.swift b/Tests/RealtimeV3Tests/UpdateTokenTests.swift new file mode 100644 index 000000000..c2724d4b7 --- /dev/null +++ b/Tests/RealtimeV3Tests/UpdateTokenTests.swift @@ -0,0 +1,157 @@ +// +// UpdateTokenTests.swift +// RealtimeV3Tests +// +// Created by Guilherme Souza on 29/06/26. +// + +import ConcurrencyExtras +import Foundation +import Helpers +import Testing + +@testable import RealtimeV3 + +@Suite struct UpdateTokenTests { + + // MARK: - updateTokenPushesAccessTokenToJoinedChannel + + /// Verifies that updateToken pushes an access_token event to each joined channel. + @Test func updateTokenPushesAccessTokenToJoinedChannel() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + let channel = await rt.channel("room:1") + + // Auto-reply to phx_join so channel reaches .joined. + server.autoReplyToJoins() + try await channel.subscribe() + + // Confirm joined. + let joinedState = await channel.state.first(where: { _ in true }) + #expect(joinedState == .joined) + + // Subscribe to client frames BEFORE calling updateToken to ensure we observe the frame. + let clientFrames = server.subscribeToClientFrames() + + // Call updateToken — must return immediately (no ACK expected per Finding I1). + try await rt.updateToken("new-token") + + // Read the next frame from the client observer with a bounded loop. + var foundAccessTokenFrame = false + var iterations = 0 + for await frame in clientFrames { + iterations += 1 + guard case .text(let text) = frame else { + if iterations > 20 { break } + continue + } + // Decode the Phoenix array: [joinRef, ref, topic, event, payload] + guard let data = text.data(using: .utf8), + let array = try? JSONDecoder().decode([AnyJSON].self, from: data), + array.count >= 5 + else { + if iterations > 20 { break } + continue + } + guard let event = array[3].stringValue, event == "access_token" else { + if iterations > 20 { break } + continue + } + guard let topic = array[2].stringValue, topic == "realtime:room:1" else { + if iterations > 20 { break } + continue + } + // Check payload: {"access_token": "new-token"} + if let payload = array[4].objectValue, + let tokenValue = payload["access_token"]?.stringValue, + tokenValue == "new-token" + { + foundAccessTokenFrame = true + break + } + if iterations > 20 { break } + } + #expect(foundAccessTokenFrame) + } + + // MARK: - updateTokenSkipsUnjoinedChannels + + /// Verifies that updateToken does NOT push an access_token event to unjoined channels. + /// Uses a Task-with-timeout pattern so the test never hangs: if no access_token frame + /// arrives within a bounded window, the assertion passes (the channel was correctly skipped). + @Test func updateTokenSkipsUnjoinedChannels() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + + // Create a channel but do NOT subscribe — it stays .unsubscribed. + _ = await rt.channel("room:unjoined") + + // Subscribe to client frames BEFORE calling updateToken. + let clientFrames = server.subscribeToClientFrames() + + // Track whether an access_token frame was (incorrectly) sent. + let accessTokenSeen = LockIsolated(false) + + // Spawn a bounded observer: reads up to 5 frames then finishes. + // Because no joined channel exists, no frames are expected at all. + // We cancel this task after updateToken returns to avoid hanging. + let observerTask = Task { + var count = 0 + for await frame in clientFrames { + count += 1 + if case .text(let text) = frame, text.contains("\"access_token\"") { + accessTokenSeen.withValue { $0 = true } + break + } + if count >= 5 { break } + } + } + + try await rt.updateToken("new-token") + + // Give the observer a brief moment to catch any spurious frame, then cancel it. + try? await Task.sleep(nanoseconds: 10_000_000) // 10 ms + observerTask.cancel() + + #expect(accessTokenSeen.value == false) + } + + // MARK: - updateTokenStoresTokenForFutureJoins + + /// Verifies that after updateToken, a subsequent subscribe() carries the new token + /// in its join payload. + @Test func updateTokenStoresTokenForFutureJoins() async throws { + let (transport, server) = InMemoryTransport.pair() + let rt = Realtime(url: URL(string: "wss://x")!, apiKey: "k", transport: transport) + + // Store the new token before any channel is joined. + try await rt.updateToken("stored-token") + + // Now create a channel and subscribe; the join payload should carry "stored-token". + let channel = await rt.channel("room:2") + + // Subscribe to client frames BEFORE subscribing the channel. + let clientFrames = server.subscribeToClientFrames() + let capturedJoinFrame = LockIsolated(nil) + + // Capture the join frame in a background task. + let captureTask = Task.detached { + for await frame in clientFrames { + guard case .text(let text) = frame, text.contains("phx_join") else { continue } + capturedJoinFrame.withValue { $0 = text } + break + } + } + + server.autoReplyToJoins() + try await channel.subscribe() + // subscribe() returns only after the join is confirmed, so the join frame was sent. + // Give the capture task a brief moment to process the frame from the stream. + try? await Task.sleep(nanoseconds: 50_000_000) // 50 ms + captureTask.cancel() + + let joinFrame = capturedJoinFrame.value + #expect(joinFrame != nil) + #expect(joinFrame?.contains("stored-token") == true) + } +} diff --git a/docs/design/realtime-v3-questions-for-backend.md b/docs/design/realtime-v3-questions-for-backend.md new file mode 100644 index 000000000..c7f9f560a --- /dev/null +++ b/docs/design/realtime-v3-questions-for-backend.md @@ -0,0 +1,1049 @@ +# Realtime v3 - Questions for the Realtime Backend Team + +Each section pairs **an assumption baked into the v3 Swift design** with +**the question(s) that need to be validated**. If an assumption is wrong, the +linked sections in `realtime-v3.md` need revisiting. + +## Backend source audit + +Findings below were checked against the local Realtime backend checkout at +`/Users/guilherme/src/github.com/supabase/realtime` on 2026-06-27. Source paths +are relative to that backend repository. + +--- + +## 1. Connection / Socket + +**Assumption A1.** WebSocket auth is a single `apikey` query param / header. +No additional handshake. (§1.1, §6.1) + +- Is `apikey` the only required auth on connect, or should we also send + `Authorization: Bearer ` and/or `vsn` as a query param? +- Are there any required subprotocols (`Sec-WebSocket-Protocol`) we should + be setting? + +**Finding.** Mostly confirmed, with one important header-name distinction. The +WebSocket connect path accepts an API key from the `apikey` query parameter or +the `x-api-key` header, validates it as a token/API key, authorizes the +connection, and does not read `Authorization` for the WebSocket handshake. The +endpoint also receives `x_headers` and `uri` connect info. `vsn` is used by the +Phoenix serializer negotiation, not as auth. No required WebSocket subprotocol +was found in endpoint configuration. Join payloads can additionally carry +`access_token` / `user_token`, but those are channel-level payload fields. + +Sources: `lib/realtime_web/channels/user_socket.ex:51`, +`lib/realtime_web/channels/user_socket.ex:67`, +`lib/realtime_web/channels/user_socket.ex:132`, +`lib/realtime_web/channels/user_socket.ex:146`, +`lib/realtime_web/endpoint.ex:16`, +`lib/realtime_web/endpoint.ex:20`, +`lib/realtime_web/channels/payloads/join.ex:11`. + +**Assumption A2.** `vsn=2.0.0` is the preferred wire version and is stable. +(§1.2, §11, Config.protocolVersion) + +- Is v2 (binary broadcast frames + array-encoded messages) the recommended + default for new clients? +- Any plans for v3? If so, what's the rough shape, and should we design an + escape hatch for it? +- Are there server deployments still pinned to v1 where v2 would break? + +**Finding.** Confirmed that Realtime registers the custom v2 serializer for +`~> 2.0.0` while still supporting Phoenix v1 JSON serializer for `~> 1.0.0`. +No v3 serializer or protocol branch was found in the backend code. Longpoll is +configured separately and does not use the custom Realtime v2 serializer. + +Sources: `lib/realtime_web/endpoint.ex:16`, `lib/realtime_web/endpoint.ex:35`, +`lib/realtime_web/socket/v2_serializer.ex:1`, +`test/support/generators.ex:338`. + +**Assumption A3.** Default heartbeat interval 25s is safe. (§1.2, §6.4) + +- What's the server-side heartbeat timeout (after how many missed + heartbeats does the server close the socket)? +- Are there Cloudflare/LB-level idle timeouts that could close an + otherwise-healthy socket? If so, what's the max safe heartbeat interval? + +**Finding.** Not fully confirmed from Realtime application code. The endpoint +does not set a Realtime-specific WebSocket heartbeat timeout, so heartbeat +timing appears to rely on Phoenix/Cowboy defaults and deployment/LB behavior. +The only Realtime-specific socket timeout found is `NO_CHANNEL_TIMEOUT_IN_MS`, +which kills a transport that has no open channels after the tracker interval; +that is not a heartbeat timeout. The backend repo does not answer Cloudflare or +load-balancer idle timeout values. + +Sources: `lib/realtime_web/endpoint.ex:16`, `config/runtime.exs:80`, +`lib/realtime/application.ex:148`, +`lib/realtime_web/channels/realtime_channel/tracker.ex:60`. + +**Assumption A4.** Heartbeat RTT is exposed as `phx_reply` latency and is +the canonical "is the connection healthy" signal. (§6.4, `ConnectionStatus.latency`) + +- Is `phx_reply` the right signal, or does the server also push periodic + presence/state messages we could use? +- Is there any server-initiated "ping" the client is expected to respond to? + +**Finding.** Confirmed as Phoenix-standard heartbeat behavior. Tests and helpers +send `"heartbeat"` on the `"phoenix"` topic. No Realtime-specific +server-initiated ping or periodic presence/state health message was found. + +Sources: `test/support/websocket_client.ex:60`, +`lib/realtime_web/channels/realtime_channel.ex:43`, +`lib/realtime_web/endpoint.ex:16`. + +--- + +## 2. Channel Join / Leave + +**Assumption B1.** A client may have at most one live subscription per +topic per socket. A second `phx_join` on the same topic while one is live +is rejected or ignored. (§2.1, §2.3) + +- Confirmed? If a second `phx_join` is sent for an already-joined topic, + what does the server do - error, overwrite, or dedupe silently? +- Does the server enforce a max number of topics per socket? What's the limit? + +**Finding.** Partially unresolved from Realtime code. Realtime enforces a max +number of channels per transport, but it does not implement an explicit +"one channel per topic" check in `RealtimeChannel`; duplicate topic behavior is +delegated to Phoenix channel machinery. The per-client channel limit is +tenant-configured as `max_channels_per_client` and defaults to 100. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:634`, +`lib/realtime_web/channels/realtime_channel.ex:653`, +`lib/realtime_web/channels/realtime_channel.ex:666`, +`config/runtime.exs:98`. + +**Assumption B2.** `phx_leave` is always ACKed by the server before the +server-side state is torn down. (§2.3 "await-to-ack") + +- Is `phx_leave` always ACKed? Under what conditions can it not be + (e.g., server shutdown mid-leave)? +- After ACK, is it safe to assume no further events for that topic will + arrive on this socket? +- If the socket drops mid-leave, what's the server's cleanup behavior? + (We need to know whether a reconnecting client should re-send leave + or just skip it.) + +**Finding.** Realtime has no custom `phx_leave` handler; leave behavior is +Phoenix channel behavior. Integration tests assert that `leave` produces +`phx_close`. On termination, Realtime untracks the transport/channel count, and +Postgres CDC subscriptions are cleaned up by the subscription manager. If the +transport drops mid-leave, cleanup is process-termination driven rather than +requiring the client to resend leave. + +Sources: `test/integration/tracker_test.exs:31`, +`lib/realtime_web/channels/realtime_channel.ex:617`, +`lib/realtime_web/channels/realtime_channel/tracker.ex:18`, +`lib/realtime_web/channels/realtime_channel/tracker.ex:73`, +`lib/extensions/postgres_cdc_rls/subscription_manager.ex:189`, +`lib/extensions/postgres_cdc_rls/subscription_manager.ex:216`, +`lib/extensions/postgres_cdc_rls/subscriptions.ex:170`. + +**Assumption B3.** A `phx_join` immediately after `phx_leave` on the same +topic is valid and produces a fresh subscription. (§2.3 "pipelined re-acquire") + +- If the client sends `phx_leave` then `phx_join` back-to-back (before + leave is ACKed), does the server queue them in order, reject the join, + or race them? +- Is there a minimum cooldown between leave and rejoin on the same topic? + +**Finding.** No Realtime-specific cooldown or queueing rule was found. Back to +back leave/join ordering is therefore Phoenix-level behavior. For deterministic +client behavior, the Swift design should continue to wait for the leave +completion/close before treating the next join as a fresh subscription. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:43`, +`lib/realtime_web/channels/realtime_channel.ex:617`, +`test/integration/tracker_test.exs:31`. + +**Assumption B4.** Dropping a client socket without leaving joined +channels is safe - the server GCs subscriptions within some finite window. +(§2.1 "leaked-channel warning") + +- What's the server-side cleanup delay for abandoned subscriptions? +- Are there billing/quota implications for abandoning vs leaving? + (We want to know how loud our leak warning should be.) + +**Finding.** Confirmed that cleanup is finite and process-driven. Channel +termination decrements the transport tracker; Phoenix Presence entries are tied +to the channel process; Postgres subscriptions include the channel pid and are +removed/updated by subscription management. A separate tracker kills transports +that have no channels after `NO_CHANNEL_TIMEOUT_IN_MS` (default 10 minutes), +but a dropped transport should tear down channel processes sooner. No billing +distinction between abandoned vs explicitly left channels was found in code. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:617`, +`lib/realtime_web/channels/realtime_channel/tracker.ex:73`, +`config/runtime.exs:80`, +`lib/realtime_web/channels/realtime_channel.ex:819`, +`lib/extensions/postgres_cdc_rls/subscription_manager.ex:189`, +`lib/extensions/postgres_cdc_rls/subscription_manager.ex:216`, +`lib/realtime_web/channels/realtime_channel/presence_handler.ex:130`. + +--- + +## 3. Channel Join Config + +**Assumption C1.** The entire `config` object is frozen at `phx_join` time. +No way to mutate `broadcast.ack`, `self`, `replay`, `presence.key`, or +`postgres_changes` mid-subscription without leaving and rejoining. +(§2.2 "options are locked at creation") + +- Confirmed? Are any of these fields mutable mid-flight? +- If a caller needs to change `postgres_changes` filters, is the correct + pattern always leave + rejoin, or is there a `phx_update`-style event? + +**Finding.** Confirmed. Join config is parsed into socket assigns at join time. +The only channel events handled after join are broadcast, presence, token +rotation (`access_token`), and fallback/error cases. No `phx_update` or +Realtime-specific config mutation event was found. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:43`, +`lib/realtime_web/channels/realtime_channel.ex:140`, +`lib/realtime_web/channels/realtime_channel.ex:439`, +`lib/realtime_web/channels/realtime_channel.ex:469`, +`lib/realtime_web/channels/realtime_channel.ex:520`, +`lib/realtime_web/channels/payloads/config.ex:20`. + +**Assumption C2.** `private: true` channels go through RLS at join time +and reject if the JWT is invalid or lacks permission. (§2.2) + +- What's the exact error the server returns on unauthorized private-channel + join? (`reason` string format, so we can map to `.authenticationFailed` + vs `.channelJoinRejected`.) +- Does `private: true` have implications for broadcast and postgres_changes + behavior beyond the join check? + +**Finding.** Confirmed, and `private: true` has ongoing authorization effects. +Private join computes authorization policies and rejects missing read +permission with a message shaped like +`You do not have permissions to read from this Channel topic: `. Private +broadcast writes and private presence read/write are also checked against RLS. +Postgres changes are separately authorized through CDC subscription claims and +RLS. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:906`, +`lib/realtime_web/channels/realtime_channel.ex:929`, +`lib/realtime_web/channels/realtime_channel/broadcast_handler.ex:25`, +`lib/realtime_web/channels/realtime_channel/presence_handler.ex:85`, +`lib/realtime/tenants/single_broadcast.ex:153`, +`lib/extensions/postgres_cdc_rls/subscriptions.ex:60`. + +--- + +## 4. Broadcast - WebSocket + +**Assumption D1.** `broadcast.ack: true` means every broadcast send gets a +`phx_reply` from the server. `ack: false` means none. (§3.2, BroadcastOptions.acknowledge) + +- Confirmed? What's the exact correlation mechanism - by `ref`? +- What's a reasonable default `broadcastAckTimeout`? (We picked 5s.) + +**Finding.** Confirmed for accepted public broadcasts and authorized private +broadcasts. The handler returns `{:reply, :ok, socket}` when `ack_broadcast` is +true and `{:noreply, socket}` when false, so correlation is the normal Phoenix +push `ref`. Payload-size errors also reply only when ack is enabled. One edge: +an unauthorized private broadcast path returns `noreply`, so an acking client +could time out instead of receiving a structured authorization error. + +Sources: `lib/realtime_web/channels/realtime_channel/broadcast_handler.ex:21`, +`lib/realtime_web/channels/realtime_channel/broadcast_handler.ex:25`, +`lib/realtime_web/channels/realtime_channel/broadcast_handler.ex:51`, +`lib/realtime_web/channels/realtime_channel/broadcast_handler.ex:62`, +`lib/realtime_web/channels/realtime_channel/broadcast_handler.ex:90`. + +**Assumption D2.** `self: true` echoes broadcasts back to the sender. +`self: false` does not. This is channel-wide, not per-message. (§3.2, Decision 23) + +- Confirmed channel-wide only, no per-message override? +- Ordering guarantee: if I broadcast 3 messages with `self: true`, are the + echoes guaranteed to arrive in send order? + +**Finding.** Confirmed channel-wide. `self_broadcast` is assigned from join +config and the handler chooses `pubsub_broadcast` when true or +`pubsub_broadcast_from(self())` when false. No per-message override was found. +The code does not document or enforce a formal ordering guarantee beyond normal +single-process/PubSub processing. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:145`, +`lib/realtime_web/channels/realtime_channel/broadcast_handler.ex:120`, +`lib/realtime_web/channels/realtime_channel/message_dispatcher.ex:70`. + +**Assumption D3.** v2 protocol sends broadcast payloads as binary frames +(opcode `0x02`), type byte `0x03` (client-to-server) / `0x04` (server-to-client). +Non-broadcast messages are text frames with JSON arrays. (§3.1, memory: +protocol 2.0.0) + +- Confirmed? What's the exact binary framing - is the payload length + length-prefixed, or end-of-frame delimited? +- Is there a max binary frame size the server enforces? + +**Finding.** Confirmed for user broadcast frames, with extra frame types to +document. Client-to-server user broadcast uses type byte `3`; server-to-client +user broadcast uses type byte `4`. The frame stores 1-byte lengths for +topic/event/metadata fields and the payload is end-of-frame delimited. v2 also +uses type byte `2` for generic binary `%Phoenix.Socket.Broadcast{}` payloads and +type byte `0` for generic binary pushes. Endpoint `max_frame_size` is +5,000,000 bytes. Topic, event, and metadata JSON fields are each limited to 255 +bytes by the serializer's 1-byte size fields. + +Sources: `lib/realtime_web/socket/v2_serializer.ex:9`, +`lib/realtime_web/socket/v2_serializer.ex:19`, +`lib/realtime_web/socket/v2_serializer.ex:27`, +`lib/realtime_web/socket/v2_serializer.ex:47`, +`lib/realtime_web/socket/v2_serializer.ex:158`, +`lib/realtime_web/socket/v2_serializer.ex:179`, +`lib/realtime_web/endpoint.ex:20`. + +**Assumption D4.** Arbitrary `Data` can be broadcast as a binary payload +without JSON encoding. (§3.2, Decision 25) + +- Does the server inspect broadcast payloads, or is any byte string valid? +- Any size limits specific to binary vs JSON broadcasts? + +**Finding.** Confirmed with size limits. v2 user broadcast payloads can carry +raw binary and the server preserves the encoding. Payload contents are not +inspected beyond decoding the frame and checking tenant payload size. JSON and +binary broadcasts use the same tenant payload-size check, and WebSocket frames +also hit the endpoint `max_frame_size`. + +Sources: `lib/realtime_web/socket/v2_serializer.ex:179`, +`lib/realtime_web/channels/realtime_channel/broadcast_handler.ex:146`, +`lib/realtime_web/channels/realtime_channel/broadcast_handler.ex:157`, +`lib/realtime/tenants.ex:532`, +`lib/realtime_web/endpoint.ex:20`. + +**Assumption D5.** Broadcast delivery is best-effort - no retry, no queue, +no ordering guarantees across topics. Within a single topic + sender, +order is preserved. (§3.1 "streams pause silently during reconnection") + +- Within-topic, within-sender order: guaranteed? (We document it as such.) +- Any cross-topic ordering guarantees we should not assume away? +- Are there rate limits? If so, what does the server return when exceeded? + +**Finding.** Best-effort/no cross-topic ordering is consistent with the code. +Realtime uses Phoenix PubSub and fastlane dispatch; no retry or durable queue +for live WebSocket broadcast delivery was found. The code does not state a +contractual within-topic ordering guarantee, although messages from the same +channel process are processed sequentially. Tenant message-rate limits exist. +When a WebSocket client exceeds messages/sec, Realtime pushes a `"system"` error +and stops the channel. + +Sources: `lib/realtime_web/channels/realtime_channel/broadcast_handler.ex:120`, +`lib/realtime_web/channels/realtime_channel/message_dispatcher.ex:70`, +`lib/realtime_web/channels/realtime_channel.ex:298`, +`lib/realtime_web/channels/realtime_channel.ex:776`, +`config/runtime.exs:100`. + +--- + +## 5. Broadcast - HTTP Endpoint + +**Assumption E1.** `POST /realtime/v1/api/broadcast` is the correct endpoint +for one-shot broadcasts without opening a WS. (§3.3 httpBroadcast) + +- Is that the canonical path? Is there a versioned alternative? +- Request body shape - batch-only (`{ messages: [...] }`) or single also + accepted? +- Response shape on success (200? 204? body?) +- Error shape - structured JSON with `code`/`message`? + +**Finding.** Internally the Phoenix router exposes `POST /api/broadcast` for +batch and `POST /api/broadcast/:topic/events/:event` for single-message +broadcasts; deployed Supabase paths are expected to add the `/realtime/v1` +prefix outside this router. Batch accepts `{ "messages": [...] }`; the single +endpoint accepts JSON or octet-stream payloads. Success is `202 Accepted` with +an empty body. HTTP error bodies are not uniformly structured with stable +`code` fields; common cases use JSON `message`, validation errors, or empty +responses depending on controller/fallback path. + +Sources: `lib/realtime_web/router.ex:111`, `lib/realtime_web/router.ex:117`, +`lib/realtime_web/controllers/broadcast_controller.ex:14`, +`lib/realtime_web/controllers/broadcast_controller.ex:35`, +`lib/realtime_web/controllers/broadcast_single_controller.ex:21`, +`lib/realtime_web/controllers/broadcast_single_controller.ex:78`. + +**Assumption E2.** HTTP broadcast uses the same `apikey` and JWT auth as +the WebSocket. (§3.3 "Auth uses the same `APIKeySource`") + +- Confirmed? Header names: `apikey`, `Authorization: Bearer `? +- Does HTTP broadcast honor RLS for private topics? If the JWT lacks + permission, what's the error? + +**Finding.** Partially different from WebSocket. HTTP tenant auth reads +`Authorization: Bearer ` first, then `apikey` header; it does not use the +WebSocket `x-api-key` header in the plug that authenticates broadcast requests. +Private single-message broadcast checks write authorization and returns +forbidden on missing permission. Private batch broadcast groups messages by +topic and checks write authorization, but unauthorized private messages are +skipped while the batch can still return success for the remaining work. + +Sources: `lib/realtime_web/plugs/auth_tenant.ex:34`, +`lib/realtime_web/plugs/auth_tenant.ex:65`, +`lib/realtime/tenants/single_broadcast.ex:153`, +`lib/realtime/tenants/single_broadcast.ex:183`, +`lib/realtime/tenants/batch_broadcast.ex:55`, +`lib/realtime/tenants/batch_broadcast.ex:80`. + +**Assumption E3.** HTTP broadcast emits the message to all WS subscribers +on that topic exactly as if a WS client had sent it. (§3.3) + +- Confirmed? Does `self: true` (if the sender happens to also have a WS + subscription to the topic) apply to HTTP-originated broadcasts? + +**Finding.** Confirmed that HTTP broadcast publishes through the same +tenant/topic PubSub path used by WebSocket subscribers. HTTP has no originating +channel process, so `self` suppression does not apply; all matching subscribers +receive the broadcast subject to topic and authorization behavior. + +Sources: `lib/realtime/tenants/batch_broadcast.ex:129`, +`lib/realtime/tenants/single_broadcast.ex:222`, +`lib/realtime_web/channels/realtime_channel/message_dispatcher.ex:70`. + +**Assumption E4.** HTTP broadcast has its own rate limits distinct from WS. + +- What are they? How are they communicated - `429` with `Retry-After` + header? Any per-topic limits vs per-project? + +**Finding.** Mostly false. HTTP broadcast uses the tenant events/sec limit also +used by WebSocket message accounting. HTTP additionally has a plug that sets +`x-rate-rolling`, `x-rate-limit`, and `x-rate-limit-remaining` headers and +returns `429` JSON `{ "message": "Too many requests" }`. No `Retry-After` +header or per-topic limit was found. + +Sources: `lib/realtime_web/plugs/rate_limiter.ex:13`, +`lib/realtime_web/plugs/rate_limiter.ex:29`, +`lib/realtime/tenants/batch_broadcast.ex:170`, +`lib/realtime/tenants/single_broadcast.ex:211`, +`config/runtime.exs:100`. + +--- + +## 6. Broadcast Replay + +**Assumption F1.** `replay.since: unix_ms` + optional `limit` is set in the +join config, and the server replays matching messages at join time before +live events start flowing. (§2.2 BroadcastOptions.replay) + +- Confirmed join-time-only? Can replay be re-triggered mid-subscription? +- What's the server-side retention window? If `since` is older than + retention, does the server return the partial window + newest first, + or return an error? +- Default `limit` if omitted? Max `limit` the server enforces? +- Does replay interact with `self: false`? (E.g., will it replay my own + messages even if self-echo is off?) +- Does replay cover private channels the same way as public? +- Ordering: are replayed messages guaranteed to arrive before any live + events after join? + +**Finding.** Confirmed join-time-only, and private-only. Replay is read from +join config; no mid-subscription replay event was found. Public-channel replay +is rejected as `:invalid_replay_channel`. Default limit is 25; hard max is 25 +and min is 1. Messages are queried in descending `inserted_at` order then +reversed before being pushed, so replay delivery is oldest-to-newest within the +returned window. Retention cleanup deletes message partitions older than about +72 hours; an older `since` returns the remaining retained window rather than a +special "too old" error. Replay is scheduled during join and live messages with +replayed IDs are skipped to avoid duplicates, but the code should be treated as +"join-time replay before normal live consumption" rather than a durable cursor. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:87`, +`lib/realtime_web/channels/realtime_channel.ex:962`, +`lib/realtime_web/channels/realtime_channel.ex:966`, +`lib/realtime_web/channels/realtime_channel.ex:287`, +`lib/realtime/messages.ex:10`, +`lib/realtime/messages.ex:22`, +`lib/realtime/messages.ex:51`, +`lib/realtime/messages.ex:69`, +`lib/realtime_web/channels/realtime_channel/message_dispatcher.ex:162`. + +--- + +## 7. Presence + +**Assumption G1.** Phoenix presence allows multiple `track` calls from the +same socket under the same presence key, each registering a distinct meta +entry. (§4 multi-track support, Decision 16) + +- Confirmed? Or does `track` overwrite any prior meta for the same key? +- If multi-meta: is there a server-enforced max metas per key? + +**Finding.** False for same socket + same key. The presence handler tracks a +single payload for the channel process and presence key. A later `track` from +the same pid/key updates the existing meta via `Presence.update`; same payload +is a no-op. Multiple different sockets can share a key and produce multiple +metas through Phoenix Presence, but the Swift "multiple handles from the same +socket/key" assumption is not supported by this backend path. + +Sources: `lib/realtime_web/channels/realtime_channel/presence_handler.ex:141`, +`lib/realtime_web/channels/realtime_channel/presence_handler.ex:162`, +`lib/realtime_web/channels/realtime_channel/presence_handler.ex:189`, +`test/realtime_web/channels/realtime_channel/presence_handler_test.exs:136`, +`test/realtime_web/channels/realtime_channel/presence_handler_test.exs:163`. + +**Assumption G2.** `presence.key` in join config sets this client's +presence key. If nil, the server generates one (random/per-connection). +(§4 "Presence key source", Decision 17, 45) + +- Confirmed the server generates if nil? What's the format + (UUID, random string)? +- Is the generated key stable across reconnects of the same socket, or + fresh every connect? + +**Finding.** Confirmed. If `presence.key` is nil or empty, the join payload +helper generates `UUID.uuid1()`. That happens per join, so it is not stable +across reconnect/rejoin unless the client supplies its own key. + +Sources: `lib/realtime_web/channels/payloads/join.ex:35`, +`lib/realtime_web/channels/payloads/presence.ex:10`. + +**Assumption G3.** There's an explicit "untrack" mechanism (the +`presence.untrack` event, or similar). Dropping all metas requires an +explicit untrack - merely going silent does not remove presence. +(§4 PresenceHandle.cancel) + +- Confirmed? What's the wire-level untrack event? +- Is untrack ACKed? (We document await-to-ack.) +- If I have 3 tracks and want to untrack one, how does the server know + which meta to remove - meta content match, or a per-track ref? + +**Finding.** Confirmed with a different event shape than the assumption text. +The wire event is channel event `"presence"` with payload field +`"event": "untrack"`. The channel handler replies `:ok` on valid presence +events, so untrack is acked through the normal push `ref`. It removes the +current channel process' single meta for the configured key; there is no +per-track ref because same-socket multi-track is not represented. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:469`, +`lib/realtime_web/channels/realtime_channel.ex:496`, +`lib/realtime_web/channels/realtime_channel/presence_handler.ex:69`, +`lib/realtime_web/channels/realtime_channel/presence_handler.ex:130`, +`test/realtime_web/channels/realtime_channel/presence_handler_test.exs:236`. + +**Assumption G4.** On `phx_leave`, the server removes all presence metas +for that socket+topic without requiring explicit untracks. (§4 +"when `channel.leave()` is called, all outstanding tracks are implicitly +torn down server-side") + +- Confirmed? Or must we send explicit untracks before leave? + +**Finding.** Confirmed by process ownership. Presence is tracked against the +channel process (`self()`), so channel termination/leave removes that process' +presence entries through Phoenix Presence. No explicit untrack-before-leave +requirement was found. + +Sources: `lib/realtime_web/channels/realtime_channel/presence_handler.ex:158`, +`lib/realtime_web/channels/presence.ex:8`, +`lib/realtime_web/channels/realtime_channel.ex:617`, +`test/realtime_web/channels/realtime_channel/presence_handler_test.exs:184`. + +**Assumption G5.** Presence is **not** auto-restored by the server on +rejoin. The client must re-send `track` for each live state after the +rejoin `phx_reply`. (§4 "auto re-track on reconnect", §9.2, Decision 18) + +- Confirmed the server does NOT remember presence across reconnects? +- If the server does remember: we need to either skip re-tracking + (optimal) or detect and reconcile (harder). + +**Finding.** Confirmed. Presence state is tied to the channel process and the +generated key is per join unless supplied by the client. No server session +state was found that restores presence after reconnect/rejoin. + +Sources: `lib/realtime_web/channels/realtime_channel/presence_handler.ex:158`, +`lib/realtime_web/channels/payloads/join.ex:35`, +`lib/realtime_web/channels/realtime_channel.ex:617`. + +**Assumption G6.** `presence_state` (snapshot) arrives once per join; +`presence_diff` arrives for every subsequent change. (§4 `observe` vs `diffs`) + +- Confirmed? Does the snapshot always arrive even when joining an empty + presence set? +- What's the payload shape - `{ [key]: { metas: [...] } }`? + +**Finding.** Snapshot is sent on join only when presence is enabled in join +config or enabled by tenant/private authorization. If presence config is +disabled, no initial `presence_state` is pushed; later `track` can still enable +presence and produce diffs. Snapshot/diff payloads are Phoenix Presence grouped +maps shaped like keys to `%{metas: [...]}`; empty state is `%{}`. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:169`, +`lib/realtime_web/channels/realtime_channel/presence_handler.ex:28`, +`lib/realtime_web/channels/realtime_channel/presence_handler.ex:193`, +`test/integration/rt_channel/presence_test.exs:23`, +`test/integration/rt_channel/presence_test.exs:61`, +`test/integration/rt_channel/presence_test.exs:74`. + +--- + +## 8. Postgres Changes + +**Assumption H1.** One `postgres_changes` entry in join config = one +server-side filter = one subscription. Multiple entries can be combined +OR-style in a single join. (§5.2, §5.3 "independent subscription") + +- Confirmed multiple entries per join are allowed? +- If two entries overlap (e.g., both match an INSERT on `messages`), does + the server emit duplicate events, deduplicate, or something else? + +**Finding.** Multiple entries per join are allowed. Each entry gets an id and is +inserted as a subscription. For the new API, overlapping entries do not produce +duplicate WebSocket messages; one `"postgres_changes"` message includes an +`ids` array listing the matching subscription ids for that WAL change. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:819`, +`lib/realtime_web/channels/realtime_channel.ex:856`, +`lib/realtime_web/channels/realtime_channel.ex:885`, +`lib/extensions/postgres_cdc_rls/message_dispatcher.ex:11`, +`test/e2e/realtime-check.ts:1183`. + +**Assumption H2.** Filter wire format is `column=op.value`. Exactly one +clause per entry. No `AND`/`OR`/parenthesization. (§5.2 "single optional +clause", Decision 12) + +- Confirmed single-clause-only? Even if multiple `filter:` fields were + supplied, would only one be honored? +- Are there plans to support `AND` composition? (So we know whether to + leave room in the API.) + +**Finding.** False. One `filter` string may contain multiple comma-separated +clauses, parsed as AND. The parser splits top-level commas while respecting +parentheses and quoted strings. A `not.` prefix is also supported. OR +composition was not found. + +Sources: `lib/extensions/postgres_cdc_rls/subscriptions.ex:241`, +`lib/extensions/postgres_cdc_rls/subscriptions.ex:390`, +`lib/extensions/postgres_cdc_rls/subscriptions.ex:397`, +`lib/extensions/postgres_cdc_rls/subscriptions.ex:439`, +`test/e2e/realtime-check.ts:1523`, +`test/e2e/realtime-check.ts:1551`. + +**Assumption H3.** Supported operators are `eq`, `neq`, `gt`, `gte`, `lt`, +`lte`, `in`. (§5.2 Filter factories) + +- Confirmed the full list? Is `is.null` / `is.not.null` supported? +- Is `like` / `ilike` / `match` supported? +- For `in`: what's the max list length? +- Value encoding: how should UUIDs, ISO dates, numbers, booleans, NULLs + be serialized in `column=op.value`? Any escaping for commas in `in`? + +**Finding.** Incomplete list. Backend supports `eq`, `neq`, `lt`, `lte`, `gt`, +`gte`, `in`, `like`, `ilike`, `is`, `match`, `imatch`, and `isdistinct`, with +`not.` negation. `is` supports `null`, `true`, `false`, and `unknown`. `in` +requires parenthesized values and enforces a maximum of 100 values. Quoted +values are parsed with quote/backslash escaping; commas inside quoted strings or +parentheses are not treated as clause separators. + +Sources: `lib/extensions/postgres_cdc_rls/subscriptions.ex:19`, +`lib/extensions/postgres_cdc_rls/subscriptions.ex:439`, +`lib/extensions/postgres_cdc_rls/subscriptions.ex:464`, +`lib/extensions/postgres_cdc_rls/subscriptions.ex:476`, +`lib/realtime/tenants/repo/migrations/20260626120000_readd_postgrest_filter_ops.ex:61`, +`lib/realtime/tenants/repo/migrations/20260527120000_add_select_columns_to_subscriptions.ex:75`, +`test/e2e/realtime-check.ts:1249`. + +**Assumption H4.** Event filtering on `INSERT`/`UPDATE`/`DELETE`/`*` is +exact - `*` subscribes to all three; anything else subscribes to only +that one. (§5.3 PostgresChangeEvent) + +- Confirmed? Are there other event types (TRUNCATE, etc.) we should + handle? + +**Finding.** `INSERT`, `UPDATE`, `DELETE`, and `*` are the only event filters in +the current subscription parser. Unknown event strings are normalized to `*`, +not rejected. The SQL apply function maps WAL record types `I`, `U`, and `D`; +no `TRUNCATE` subscription path was found. + +Sources: `lib/extensions/postgres_cdc_rls/subscriptions.ex:377`, +`lib/realtime/tenants/repo/migrations/20260626120000_readd_postgrest_filter_ops.ex:290`. + +**Assumption H5.** For `UPDATE`, the server sends both `old_record` and +`record`. For `DELETE`, only `old_record`. For `INSERT`, only `record`. +(§5.3 `InsertAction`/`UpdateAction`/`DeleteAction`) + +- Confirmed? Is `old_record` always populated on UPDATE, or only when + `REPLICA IDENTITY FULL` is set on the table? +- If `REPLICA IDENTITY` is not `FULL`, what's returned for DELETE? (Just + PKs, or entire row?) +- Schema column order and types match what PostgREST returns for selects? + +**Finding.** Confirmed at the field-shape level: INSERT has `record`, UPDATE +has `record` and `old_record`, DELETE has `old_record`. Contents of +`old_record` depend on what WAL supplies and on RLS. The latest SQL limits +DELETE `old_record` under RLS to primary-key columns; without full replica +identity, old values are not guaranteed to be the full row. Column/type payloads +come from the Realtime CDC SQL output, not directly from PostgREST. + +Sources: `lib/extensions/postgres_cdc_rls/replication_poller.ex:474`, +`lib/extensions/postgres_cdc_rls/replication_poller.ex:499`, +`lib/extensions/postgres_cdc_rls/replication_poller.ex:525`, +`lib/extensions/postgres_cdc_rls/replications.ex:87`, +`lib/realtime/tenants/repo/migrations/20260626120000_readd_postgrest_filter_ops.ex:563`, +`lib/realtime/tenants/repo/migrations/20260626120000_readd_postgrest_filter_ops.ex:590`. + +**Assumption H6.** If the underlying publication doesn't include a table +or column, events silently don't fire - no error at join time. (§5.3) + +- Confirmed? Or does the server reject the join with an error if the + table/column doesn't exist in `supabase_realtime` publication? + +**Finding.** False. The channel join may initially succeed, but Postgres +subscription setup reports errors through `"system"` messages with extension +`"postgres_changes"` when subscription params are malformed, the table is not +in the publication, the table does not exist, columns are invalid, or values +cannot be cast. This is not a silent no-events case. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:352`, +`lib/realtime_web/channels/realtime_channel.ex:379`, +`lib/extensions/postgres_cdc_rls/cdc_rls.ex:95`, +`lib/extensions/postgres_cdc_rls/subscriptions.ex:60`, +`test/realtime/extensions/cdc_rls/subscriptions_test.exs:458`, +`test/realtime/extensions/cdc_rls/subscriptions_test.exs:797`, +`test/realtime/extensions/cdc_rls/subscriptions_test.exs:1229`. + +**Assumption H7.** Postgres change subscriptions are automatically +re-registered on rejoin - the client just re-sends the same join config. +(§9.2 "postgres change subscriptions are restored") + +- Confirmed? Any gaps during rejoin that could lose events? If so, is + there a replay/cursor mechanism like broadcast replay? + +**Finding.** Confirmed for rejoin behavior: the client re-sends join config and +the server creates fresh CDC subscriptions. No Postgres change replay/cursor +mechanism was found, so disconnect/rejoin gaps are possible. Broadcast replay +does not cover Postgres changes. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:819`, +`lib/realtime_web/channels/realtime_channel.ex:856`, +`lib/extensions/postgres_cdc_rls/cdc_rls.ex:31`, +`lib/realtime/messages.ex:13`. + +--- + +## 9. Auth / Token Rotation + +**Assumption I1.** The Phoenix event name for pushing a new token is +`access_token` with `{ access_token: "..." }`. Server ACKs with `phx_reply`. +(§6.3 updateToken) + +- Confirmed event name and payload shape? +- Is the response always a `phx_reply` on the top-level socket (not + per-channel)? Or per-channel? +- What does the server do if the new token has different claims + (different `sub`, expired `exp`)? + +**Finding.** Event name and payload shape are confirmed, but the ACK assumption +is false. `access_token` is a per-channel event and successful/ignored updates +return `noreply`, not `phx_reply`. New valid tokens rebuild authorization +context, policies, and Postgres-change claims; revoked read permission or +invalid/expired/malformed tokens produce a `"system"` error and channel stop. +Tokens starting with `sb_`, nil tokens, and identical tokens are ignored. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:520`, +`lib/realtime_web/channels/realtime_channel.ex:524`, +`lib/realtime_web/channels/realtime_channel.ex:534`, +`lib/realtime_web/channels/realtime_channel.ex:572`, +`test/integration/rt_channel/token_handling_test.exs:182`, +`test/integration/rt_channel/token_handling_test.exs:295`. + +**Assumption I2.** On `token_expired`, the server sends a message the +client can distinguish from other errors, and the operation that triggered +it fails with a retryable error. (§6.3 "Reactive path") + +- What's the exact wire signal - a `phx_error` with `reason: "token_expired"`? + On which channel / on the socket itself? +- Does `token_expired` close the socket, close the individual channel, or + just reject the in-flight push? +- After pushing a refreshed token, is the retry on the same original + request, or do we need to resubscribe? + +**Finding.** No dedicated `token_expired` wire event was found. Expiry is +validated periodically and on token update/join. When detected on an existing +channel, the server pushes a `"system"` error message and stops the channel, +which leads to `phx_close`; join-time expiry returns a `phx_reply` error reason +such as `InvalidJWTToken: Token has expired`. The client should treat this as a +channel resubscribe path after refresh, not as a retry of the original push on +the same channel. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:412`, +`lib/realtime_web/channels/realtime_channel.ex:746`, +`lib/realtime_web/channels/realtime_channel.ex:776`, +`lib/realtime_web/channels/realtime_channel.ex:787`, +`test/integration/rt_channel/token_handling_test.exs:230`, +`test/integration/rt_channel/token_handling_test.exs:338`, +`test/integration/rt_channel/token_handling_test.exs:366`. + +**Assumption I3.** JWT `exp` is not parsed or enforced client-side - the +SDK reacts only to server-sent `token_expired`. (Decision 9 "No JWT +parsing in the SDK") + +- Is this safe, or is there meaningful latency between local expiry and + server detection that would justify proactive rotation? + +**Finding.** Server-side enforcement is real and periodic. The server parses +`exp`, schedules the next check at the lesser of five minutes or time until +expiry, and closes the channel on expiry. Client-side proactive parsing is not +required for correctness, but it could avoid server-initiated channel close +latency. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:746`, +`lib/realtime_web/channels/realtime_channel.ex:759`, +`test/integration/rt_channel/token_handling_test.exs:338`. + +--- + +## 10. Error Taxonomy + +**Assumption J1.** All server-sent errors arrive as `phx_error` / +`phx_reply {status: "error"}` with a `reason: String` field. No structured +error codes. (§7 RealtimeError) + +- Is there a stable set of `reason` strings we can pattern-match to map + into our error cases? Example: `"unauthorized"`, `"rate_limited"`, + `"token_expired"`, `"server_error"`, etc. +- If the set is unstable: can we get a structured `code` field added? + +**Finding.** False as a universal statement. Wire errors are mixed: join +failures use `phx_reply` with an error reason; runtime channel failures often +use a `"system"` event with fields `extension`, `status`, `message`, and +`channel`; HTTP errors use controller/fallback JSON or empty responses. The +backend has an `ERROR_CODES.md` operational-code list, but those codes are not +consistently present as structured WebSocket payload fields. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:787`, +`lib/realtime_web/channels/realtime_channel.ex:352`, +`lib/realtime_web/channels/realtime_channel.ex:776`, +`lib/realtime_web/controllers/broadcast_controller.ex:14`, +`ERROR_CODES.md:1`. + +**Assumption J2.** Server close codes on unexpected socket close are +meaningful and distinct for auth vs transient vs policy violations. + +- What close codes does the server use, and for which scenarios? + (E.g., 4001 = auth, 4003 = rate limit, 4008 = policy, etc.) +- Any close code that means "do not reconnect" vs "reconnect with backoff"? + +**Finding.** Not confirmed. No custom Realtime WebSocket close-code taxonomy was +found. Channel shutdown is usually represented by `phx_close` after a system +message or by transport process termination for certain rate-limit/no-channel +cases. Connect failures surface through HTTP/WebSocket handshake rejection +rather than documented custom close codes. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:776`, +`lib/realtime_web/channels/realtime_channel.ex:195`, +`lib/realtime_web/channels/realtime_channel/tracker.ex:73`, +`lib/realtime_web/channels/user_socket.ex:149`. + +--- + +## 11. Rate Limits and Quotas + +**Assumption K1.** Rate limits exist but are not surfaced in the v3 API +except via `.rateLimited(retryAfter:)`. (§7) + +- What are the default server-side limits - messages/sec per channel, + connections per project, topics per socket, presence entries per + channel, presence state size? +- When exceeded via WS: what's the wire signal? A `phx_error` with + `reason: "rate_limited"` + a `retry_after` field? Connection close? +- When exceeded via HTTP: `429` with `Retry-After` header? + +**Finding.** Rate limits are tenant/project-level counters, not uniformly +per-channel. Defaults include `max_events_per_second = 100`, +`max_joins_per_second = 100`, `max_channels_per_client = 100`, +`max_concurrent_users = 200`, `max_presence_events_per_second = 1000`, and +per-client presence update limit `5` calls per `30_000` ms. WS rate-limit +signals are not a structured retry-after field: message/presence limits push +`"system"` errors and stop or reject; join-rate limit sends a transport +disconnect path; channel-limit join returns an error reason. HTTP returns `429` +with `x-rate-*` headers and no `Retry-After`. + +Sources: `config/runtime.exs:97`, `config/runtime.exs:98`, +`config/runtime.exs:99`, `config/runtime.exs:100`, +`config/runtime.exs:101`, `config/runtime.exs:13`, +`lib/realtime/api/tenant.ex:22`, +`lib/realtime_web/channels/realtime_channel.ex:188`, +`lib/realtime_web/channels/realtime_channel.ex:298`, +`lib/realtime_web/channels/realtime_channel.ex:476`, +`lib/realtime_web/plugs/rate_limiter.ex:29`. + +**Assumption K2.** There's no per-client connection cooldown - clients +can reconnect immediately after any close. (§9.1 ReconnectionPolicy) + +- Is there a server-side "too many reconnects" throttle? If so, what + delays does it enforce and how are they communicated? + +**Finding.** No explicit per-client reconnect cooldown was found. There are +tenant connection/user, join-rate, and message-rate limits, plus a +`connect_error_backoff_ms` sleep before returning some connect errors. Clients +can still be rejected by those limits when reconnecting aggressively. + +Sources: `lib/realtime_web/channels/user_socket.ex:149`, +`lib/realtime_web/channels/realtime_channel.ex:634`, +`lib/realtime_web/channels/realtime_channel.ex:672`, +`config/runtime.exs:99`, +`config/runtime.exs:101`. + +--- + +## 12. Ordering and Delivery + +**Assumption L1.** Within a single topic, for a single client, events +arrive in the order the server processed them. Across topics, no ordering +guarantee. (Implicit throughout) + +- Confirmed per-topic-per-client ordering? +- For postgres_changes specifically: does the server guarantee WAL order + within a table, or can concurrent transactions reorder? + +**Finding.** Cross-topic ordering should not be assumed. Per-topic ordering is +not documented as an explicit backend contract, but same-process dispatch is +sequential through Phoenix PubSub/fastlane. Postgres changes come from logical +replication polling and are dispatched from the poller output; the code does +not expose a client cursor or documented ordering guarantee beyond WAL-derived +processing. + +Sources: `lib/realtime_web/channels/realtime_channel/message_dispatcher.ex:70`, +`lib/extensions/postgres_cdc_rls/replication_poller.ex:379`, +`lib/extensions/postgres_cdc_rls/replications.ex:87`. + +**Assumption L2.** Broadcasts and postgres_changes on the same topic +interleave arbitrarily. (§3, §5) + +- Confirmed? No implicit ordering between them? + +**Finding.** Confirmed. Broadcasts and Postgres changes use different producer +paths and no ordering coordination between those paths was found. + +Sources: `lib/realtime_web/channels/realtime_channel/broadcast_handler.ex:120`, +`lib/extensions/postgres_cdc_rls/replication_poller.ex:379`, +`lib/realtime_web/channels/realtime_channel/message_dispatcher.ex:70`. + +**Assumption L3.** Presence `diff` events and broadcast events on the +same topic interleave arbitrarily. + +- Confirmed? + +**Finding.** Confirmed. Presence and broadcast are separate event paths over the +same channel topic; no ordering coordination was found. + +Sources: `lib/realtime_web/channels/realtime_channel/presence_handler.ex:193`, +`lib/realtime_web/channels/realtime_channel/broadcast_handler.ex:120`, +`lib/realtime_web/channels/realtime_channel/message_dispatcher.ex:70`. + +--- + +## 13. Reconnection / Resilience + +**Assumption M1.** After a client reconnect, the server has no memory of +prior subscriptions - the client must re-send all `phx_join`s. (§9.2) + +- Confirmed, no session resumption? +- If session resumption is coming in a future version, is there a + protocol hint we should leave room for? + +**Finding.** Confirmed. Channels, presence, and Postgres subscriptions are tied +to socket/channel processes. No session-resumption protocol or reconnect token +was found. + +Sources: `lib/realtime_web/channels/realtime_channel.ex:43`, +`lib/realtime_web/channels/realtime_channel.ex:617`, +`lib/realtime_web/channels/realtime_channel/presence_handler.ex:158`, +`lib/extensions/postgres_cdc_rls/subscription_manager.ex:189`. + +**Assumption M2.** The server does not emit a "you missed events while +disconnected" signal. Gaps are silent and the client cannot detect them +without broadcast replay. (§3.1 "Gaps are inherent") + +- Confirmed no gap-detection mechanism? + +**Finding.** Confirmed for a general gap signal. No missed-events signal was +found. Broadcast replay can recover retained private broadcast messages if the +client requests it at join. Postgres changes have no replay/cursor, and the +replication poller can skip real rows when rate limits are triggered. + +Sources: `lib/realtime/messages.ex:13`, +`lib/realtime_web/channels/realtime_channel.ex:966`, +`lib/extensions/postgres_cdc_rls/replication_poller.ex:404`, +`lib/extensions/postgres_cdc_rls/replication_poller.ex:437`. + +--- + +## 14. App Lifecycle + +**Assumption N1.** The WebSocket can survive short iOS/macOS +background-foreground transitions without the server terminating the +connection. (§9.3 handleAppLifecycle) + +- What's the server-side idle/heartbeat timeout that determines how long + a backgrounded app can stay connected before the server closes? +- Is there a way to "pause" a connection server-side without closing it? + (Probably not, but worth asking.) + +**Finding.** Not confirmed from Realtime code. No server-side pause mechanism +was found. The endpoint does not set a Realtime-specific WebSocket heartbeat +timeout; lifecycle survival depends on Phoenix/Cowboy defaults, client +heartbeat behavior, and deployment/LB idle timeouts. Empty sockets can be +killed by the no-channel tracker after the configured interval. + +Sources: `lib/realtime_web/endpoint.ex:16`, `config/runtime.exs:80`, +`lib/realtime_web/channels/realtime_channel/tracker.ex:73`. + +--- + +## 15. Protocol Limits (Hard Numbers We Want to Document) + +Backend-derived values from the local checkout: + +| Limit | Backend finding | Source | +| ----- | --------------- | ------ | +| Max topics per WebSocket | Tenant `max_channels_per_client`, default 100. | `config/runtime.exs:98`; `lib/realtime_web/channels/realtime_channel.ex:653` | +| Max concurrent WebSockets per project | Tenant `max_concurrent_users`, default 200. Endpoint HTTP max connections default is 1000. | `config/runtime.exs:99`; `config/runtime.exs:426`; `lib/realtime_web/channels/realtime_channel.ex:672` | +| Max broadcast payload size (JSON) | Tenant `max_payload_size_in_kb`, default 3000 KB, checked with `:erlang.external_size(payload) <= max * 1000 + 500`; WebSocket frame cap is 5,000,000 bytes. | `lib/realtime/api/tenant.ex:23`; `lib/realtime/tenants.ex:532`; `lib/realtime_web/endpoint.ex:20` | +| Max broadcast payload size (binary) | Same tenant payload-size rule for WS and HTTP single binary; WebSocket frame cap still applies. | `lib/realtime/tenants/single_broadcast.ex:120`; `lib/realtime/tenants.ex:532`; `lib/realtime_web/endpoint.ex:20` | +| Max presence metas per key | No hard per-key count found. Same socket/key updates one meta; multiple sockets may share a key. | `lib/realtime_web/channels/realtime_channel/presence_handler.ex:141`; `lib/realtime_web/channels/realtime_channel/presence_handler.ex:162` | +| Max presence state bytes per channel | No channel-wide state byte cap found. Individual track payloads use the tenant payload-size check; presence event rate limits apply. | `lib/realtime/tenants.ex:532`; `lib/realtime_web/channels/realtime_channel/presence_handler.ex:202` | +| Max `postgres_changes` entries per join | No explicit per-join count found; bounded indirectly by join payload/frame size and resource limits. | `lib/realtime_web/channels/payloads/config.ex:13`; `lib/realtime_web/channels/realtime_channel.ex:819` | +| Max `in` list length in filter | 100 values. | `lib/realtime/tenants/repo/migrations/20260527120000_add_select_columns_to_subscriptions.ex:75` | +| Broadcast replay retention window | Message partition cleanup deletes partitions older than about 72 hours. | `lib/realtime/messages.ex:69` | +| Broadcast replay max limit | Default 25, hard max 25, min 1. | `lib/realtime/messages.ex:10`; `lib/realtime/messages.ex:22`; `lib/realtime_web/channels/realtime_channel.ex:974` | +| Default heartbeat timeout (server side) | No Realtime-specific value found in endpoint config; relies on Phoenix/Cowboy/deployment behavior. | `lib/realtime_web/endpoint.ex:16` | +| Rate limit: broadcasts/sec per channel | Tenant events/sec limit, default 100; this is tenant/project-level accounting rather than per-channel only. | `config/runtime.exs:100`; `lib/realtime_web/channels/realtime_channel.ex:298`; `lib/realtime/tenants/single_broadcast.ex:211` | +| Rate limit: joins/sec per socket | Tenant joins/sec limit, default 100. | `config/runtime.exs:101`; `lib/realtime_web/channels/realtime_channel.ex:634` | + +--- + +## 16. Open Design Questions that Depend on Backend + +These are v3 API decisions we deliberately deferred - the answer from +backend may change our preference. + +1. **Unbounded broadcast buffers.** We picked unbounded per-consumer + buffers (§3.1, Decision 7). Backend code does not show a durable + per-subscriber queue or client backpressure contract. It does enforce + WebSocket max frame size, heap/process limits, and tenant rate counters, so + the Swift SDK should still define its own consumer buffering/drop policy. +2. **Automatic retry on `token_expired`.** We retry once (§6.3, Decision 10). + Backend token rotation is per-channel and successful `access_token` updates + are not ACKed. Expired or invalid tokens close the channel after a system + error, so retry should mean refresh + resubscribe rather than replaying the + original push on the same channel. +3. **HTTP broadcast batching.** We expose a batch form (§3.3). Backend batch + and single endpoints have materially different private-topic failure + semantics: single private unauthorized returns forbidden, while batch private + unauthorized messages can be skipped while the request still returns 202. + Batch also checks the requested batch size against the tenant events/sec + limit before sending. +4. **Presence key ownership.** We pushed presence key to channel-level + config (§4, Decision 17). Backend confirms channel-level key ownership: + same socket/key has a single updatable meta, and generated keys are + per-join UUIDs. Per-track presence keys would require a different backend + wire contract. + +--- + +## How to respond + +Ideal format: for each question, either "yes, confirmed", "no, here's the +actual behavior", or "undefined - please don't rely on it". For the +numeric limits table, fill in concrete numbers or "no hard limit". diff --git a/docs/design/realtime-v3.md b/docs/design/realtime-v3.md new file mode 100644 index 000000000..c3b1384de --- /dev/null +++ b/docs/design/realtime-v3.md @@ -0,0 +1,1487 @@ +# Realtime v3 — Idiomatic Swift API Proposal + +> Status: Design revisited after backend source audit +> (`realtime-v3-questions-for-backend.md`). Greenfield design — no +> consideration given to V2 compatibility or other Supabase SDKs. Targets Swift +> 6.1+ / iOS 16+. Breaking changes accepted. + +## Design Principles + +1. **Explicit lifecycle.** Resources are acquired and released explicitly. No + auto-cleanup on `deinit`, no magic based on reference counting. If you + joined a channel, you call `leave()` when you're done. +2. **Type‑safety through the language.** Channels, events, presences, and + Postgres tables are generic. The compiler rejects the wrong payload type. +3. **`AsyncSequence` is the canonical surface.** Closures appear only where + they unlock a behavior a sequence cannot express. +4. **Observation‑friendly.** Streams drop cleanly into SwiftUI view models — + `@Observable` on iOS 17+, `ObservableObject`/Perception below that. The SDK + surface itself is `AsyncSequence`-based and does not depend on Observation. +5. **Typed throws throughout.** `throws(RealtimeError)` at every boundary. +6. **Resilient by default.** Automatic reconnection with pluggable policies; + transparent re‑joining of channels and presences; token refresh. +7. **Explicit, injectable transport and clock.** Deterministic unit tests + without real sockets or real wall‑clock time. +8. **No singletons.** Multiple `Realtime` instances coexist with zero shared + state. + +--- + +## 30‑Second Tour + +```swift +import Realtime + +let realtime = Realtime( + url: URL(string: "wss://project.supabase.co/realtime/v1")!, + apiKey: "anon-key", + accessToken: { try await auth.session.accessToken } +) + +let channel = await realtime.channel("room:42") + +// Optional: register postgres tokens BEFORE subscribe. +let inserts = await channel.inserts(into: Message.self, where: .eq(\.roomId, 42)) + +// Single explicit join. +try await channel.subscribe() + +// Typed broadcast receive. +Task { + let chats = await channel.broadcasts(of: ChatBroadcast.self, event: "chat") + for try await msg in chats { render(msg) } +} + +// Postgres consumption. +Task { + let rows = await channel.postgresChanges(for: inserts) + for try await row in rows { append(row) } +} + +// Untyped raw feed. +Task { + for await frame in await channel.messages() { + // frame: PhoenixMessage — broadcast / postgres_changes / presence_diff / ... + } +} + +// WebSocket send (requires a subscribed channel). +let payload = ChatBroadcast(...) // any Encodable & Sendable +try await channel.broadcast(payload, as: "chat") + +// Explicit release when done. +try await channel.leave() +``` + +One-shot HTTP send without joining: + +```swift +try await channel.httpBroadcast( + event: "chat", + payload: ChatBroadcast(...) +) +``` + +That's the mental model: + +- **Channels are topic handles.** `realtime.channel(topic)` returns a handle for + registering postgres tokens, triggering `subscribe()`, and issuing + topic-scoped HTTP broadcasts without joining. +- **`subscribe()` joins the channel.** After it returns, the same `Channel` + handle is used for all consumption (typed and untyped), WebSocket sending, + presence, and `leave()`. Runtime state determines whether an operation is + currently allowed. +- **Postgres changes are register-then-subscribe.** The Phoenix wire forces it; + the API reflects it. Tokens are reusable across `leave()` cycles. +- **One `phx_join` per topic.** All pending tokens land in that single join. + +Everything below is elaboration. + +--- + +## 1. Client Construction + +```swift +public actor Realtime { + public init( + url: URL, + apiKey: String, + accessToken: AccessTokenProvider? = nil, + configuration: Configuration = .default, + transport: any RealtimeTransport = URLSessionTransport() + ) +} +``` + +### 1.1 Credentials separate literal API key from dynamic access token + +```swift +/// Called before joining private/RLS-backed channels, before HTTP private +/// broadcasts, and during reconnect/resubscribe. This is a JWT access token, +/// not the project API key. +public typealias AccessTokenProvider = @Sendable () async throws -> String +``` + +The backend uses different credentials in different places: + +- WebSocket connect uses the `apikey` query parameter or `x-api-key` header. + It does not read `Authorization` during the socket handshake. +- Channel joins and token rotation can carry an access token in the join + payload or `access_token` channel event. +- HTTP broadcast accepts `Authorization: Bearer ` first, then an + `apikey` header fallback. It does not use the WebSocket `x-api-key` header. + +The SDK keeps those concepts separate. `apiKey` is required for connecting. +It is a literal string because project API keys do not rotate per operation in +the client. `accessToken` is optional for public channels but required for +private channels and RLS-backed operations. It is always dynamic because JWTs +expire and can change with auth session state. + +### 1.2 Configuration + +```swift +public struct Configuration: Sendable { + public var heartbeat: Duration = .seconds(25) + public var joinTimeout: Duration = .seconds(10) + public var leaveTimeout: Duration = .seconds(10) + public var broadcastAckTimeout: Duration = .seconds(5) + public var reconnection: ReconnectionPolicy = .exponentialBackoff( + initial: .seconds(1), max: .seconds(30), jitter: 0.2 + ) + public var disconnectOnEmptyChannelsAfter: Duration = .seconds(50) + public var lifecycle: LifecyclePolicy = .automaticDefault + public var protocolVersion: RealtimeProtocolVersion = .v2 + public var clock: any Clock & Sendable = ContinuousClock() + public var headers: HTTPFields = [:] + public var logger: (any RealtimeLogger)? = nil + public var decoder: JSONDecoder = .realtimeDefault // ISO 8601 dates + public var encoder: JSONEncoder = .realtimeDefault // ISO 8601 dates + + public static let `default` = Configuration() +} + +extension LifecyclePolicy { + /// `.automatic` on iOS/macOS/tvOS/visionOS; `.manual` elsewhere + /// (including watchOS and Linux, where lifecycle observation is + /// not supported). + public static let automaticDefault: LifecyclePolicy +} + +extension JSONDecoder { + /// SDK-provided decoder configured with `.iso8601` date strategy. + /// Replace via `Configuration.decoder` for custom needs. + public static let realtimeDefault: JSONDecoder +} + +extension JSONEncoder { + /// SDK-provided encoder configured with `.iso8601` date strategy. + public static let realtimeDefault: JSONEncoder +} +``` + +`disconnectOnEmptyChannelsAfter` is an idle‑socket timeout: when the last +live channel has left, the socket stays open for this duration in case a new +channel joins, avoiding reconnect churn. `.zero` for immediate close. + +--- + +## 2. Channels + +### 2.1 Identity and lifecycle + +```swift +public extension Realtime { + /// Returns the `Channel` for `topic`. Shared by topic — two callers asking + /// for the same topic receive the same underlying actor. + /// + /// The channel does not join the server until `subscribe()` is called. The + /// caller must call `leave()` to unsubscribe; `deinit` does NOT unsubscribe. + /// + /// Isolated: the topic→channel registry is actor state (Decision 1), so + /// lookup/creation reads-modifies-writes it and callers `await`. + func channel( + _ topic: String, + configure: (inout ChannelOptions) -> Void = { _ in } + ) -> Channel +} + +public actor Channel { + // Immutable metadata — `nonisolated let` (no `await` to read). + public nonisolated let topic: String + public nonisolated let options: ChannelOptions + + /// Lifecycle stream. Isolated — backed by state stored in the actor. + public var state: AsyncStream { get } + + /// Explicit join. Idempotent: calling while joined returns immediately; + /// concurrent calls before join await the same in-flight join. + /// + /// Postgres-change registrations made before this call are baked into the + /// `phx_join` payload (see §5). After the call returns, registration of + /// new tokens throws `.cannotRegisterAfterJoin` until the next `leave()`. + public func subscribe() async throws(RealtimeError) + + /// Explicit unsubscribe. Global (§2.3); awaits server channel close + /// confirmation. After leave, live methods throw `.channelClosed`. + public func leave() async throws(RealtimeError) + + /// One-shot HTTP broadcast to this channel's topic. Does not join the + /// channel and does not open the WebSocket. + public func httpBroadcast( + event: String, payload: T, + isPrivate: Bool = false + ) async throws(RealtimeError) + + /// Raw feed — every Phoenix frame on this channel, including `broadcast`, + /// `postgres_changes`, `presence_diff`, `presence_state`, `system`, + /// `phx_reply`, `phx_close`, and `phx_error`. The SDK still consumes these + /// internally (ack correlation, lifecycle); raw consumers observe a copy. + /// + /// A method, not a property: each call mints a fresh, independent stream + /// (per-call fan-out). Isolated — it registers a consumer in the actor's + /// routing state, so callers `await channel.messages()`. + public func messages() -> AsyncStream + + // Typed stream factories (§3, §5). Isolated — each registers a consumer in + // the actor; callers `await` to obtain the stream, then iterate it. + public func broadcasts(of type: T.Type, event: String) + -> AsyncThrowingStream + public func postgresChanges(for token: ChangeRegistration) + -> AsyncThrowingStream + + /// Presence namespace. `nonisolated` because it only wraps `self`; its + /// stream methods register lazily on first iteration (§4). + public nonisolated var presence: Presence { get } + + // Postgres-change registration (§5.3) is isolated — see that section. + + // Sending (§3.2) — requires a subscribed channel at runtime; isolated. + public func broadcast(_ payload: T, as event: String) + async throws(RealtimeError) + public func broadcast(_ data: Data, as event: String) async throws(RealtimeError) +} +``` + +**Isolation contract.** `Channel` is a plain actor that owns all of its state +(join status, the WebSocket, in-flight pushes, stream-routing tables, pending +registrations). There is no separate Sendable side-store — the actor is the +single source of truth. + +- `nonisolated` (no `await`): only the immutable constants `topic` and + `options`, plus the `presence` accessor (which merely wraps `self`; its + stream methods register lazily on first iteration, §4). +- **isolated** (`await` at the call site): everything that reads or mutates + actor state — `state`, `messages()`, `broadcasts(of:event:)`, + `postgresChanges(for:)`, the registration factories + `changes`/`inserts`/`updates`/`deletes` (§5.3), `subscribe()`, `leave()`, + `broadcast(_:as:)`, `httpBroadcast(...)`, `presence.track(...)`, and + `realtime.channel(_:)`. Stream factories register a consumer in the actor + and return the stream, so obtaining one is `await`; iterating it then + suspends as values arrive. + +Registering tokens before `subscribe()` is therefore `let t = await +channel.inserts(...)` — async, but still before join, and stored in the +actor's pending-registration set. + +```swift +public struct PhoenixMessage: Sendable { + /// Phoenix join reference correlating this frame to its `phx_join`. Always + /// `nil` when the channel is configured for protocol v1 (4-tuple frames + /// have no joinRef field). Under v2: `nil` for frames that predate the + /// current join (rare). + public let joinRef: String? + + /// Phoenix message reference for request/reply correlation. Set on + /// pushes the SDK sent and on the matching `phx_reply`. `nil` for + /// server-pushed events (`broadcast`, `postgres_changes`, etc.). + public let ref: String? + + /// Channel topic this frame belongs to. Always matches this channel's topic + /// for channel iterators; included on the struct so consumers that hand + /// `PhoenixMessage` values across boundaries (logging, debugging, + /// multi-topic aggregation) keep the routing key. + public let topic: String + + /// Server-side event name. Includes user-level events (`"broadcast"`, + /// `"postgres_changes"`, `"presence_diff"`, `"presence_state"`, `"system"`) + /// and Phoenix internals (`"phx_reply"`, `"phx_close"`, `"phx_error"`). + public let event: String + + /// Raw payload as received. JSON for text frames, `Data` for binary + /// (Phoenix v2 broadcast). + public let payload: PhoenixPayload + + /// Local receipt timestamp. + public let receivedAt: Date +} + +public enum PhoenixPayload: Sendable { + case json(JSONValue) + case binary(Data) +} +``` + +Key invariants: + +- **Topic identity.** `realtime.channel("x")` always returns the same actor. + One server-side subscription per topic per `Realtime` instance. +- **No auto-unsubscribe.** Dropping a `Channel` does nothing. Explicit + `leave()` is the only way. +- **`subscribe()` is the only join path.** No lazy-join via iteration. The + WebSocket opens lazily on the first `subscribe()` (§6.1). +- **Postgres tokens register before join.** `channel.changes(...)`, + `channel.inserts(...)`, etc. mutate channel state and return tokens. Calling + these *after* the channel has joined throws `.cannotRegisterAfterJoin`. After + `leave()`, registration is allowed again — tokens are reusable across + subscribe cycles. (§5) +- **Live mutations and sends are runtime-gated.** `broadcast` and + `presence.track` are methods on `Channel`, but they require the channel to be + subscribed. Before subscribe they throw `.notSubscribed`; after manual leave + or terminal close they throw `.channelClosed(...)`. During reconnect, streams + stay open and sends throw `.disconnected`. +- **`subscribe()` is idempotent.** Multiple callers share the same backing + channel state. A single `leave()` ends the channel for every holder of the + topic. +- **Streams belong to the channel.** They can be created before subscribe and + will start producing only after the channel joins. Manual `leave()` terminates + streams with `.channelClosed(.userRequested)`. Reconnects do not terminate + streams unless the reconnection policy gives up. +- **Leaked-channel warning.** When `Realtime` deinits with channels that + have been joined but never left, an `IssueReporting` warning fires in + debug builds. Release builds silently rely on server-side timeouts. (The + joined-but-unleft set is tracked in a nonisolated location so the + synchronous `deinit` can read it — no Swift 6.2 isolated deinit required.) + +### 2.2 Channel options are locked at creation + +```swift +public struct ChannelOptions: Sendable { + public var isPrivate: Bool = false + public var broadcast: BroadcastOptions = .init() + public var presence: PresenceOptions = .init() +} + +public struct BroadcastOptions: Sendable { + public var acknowledge: Bool = false + public var receiveOwnBroadcasts: Bool = false + /// Backend replay is join-time-only and private-channel-only. + public var replay: ReplayOption? = nil +} + +public struct ReplayOption: Sendable { + public var since: Date + public var limit: Int? +} + +public struct PresenceOptions: Sendable { + /// Sends `presence.enabled = true` in the join config. Required for an + /// initial `presence_state` snapshot on join. If false, `track` can still + /// create/update presence later, but observers cannot retroactively get the + /// initial snapshot. + public var enabled: Bool = false + + /// Presence key for this channel process. If nil/empty, the server generates + /// a fresh UUID per join. + public var key: String? = nil +} +``` + +Options are applied on the first `channel(topic)` call. **A later call with a +different `configure` closure is ignored** — the first call wins. An +`IssueReporting` warning fires in debug. The returned `Channel.options` +reflects the effective options. + +`BroadcastOptions.replay` is valid only with `isPrivate == true`; public-channel +replay is rejected by the backend. Replay `limit` is clamped server-side to +1...25, defaulting to 25 when omitted. + +`PresenceOptions.enabled` should be set when the caller intends to observe +presence state. Setting `PresenceOptions.key` does not by itself create +presence; it only controls the key used when this channel tracks. + +### 2.3 `leave()` semantics (shared-handle model) + +- `leave()` is **global**: it tears down the subscription for every holder of + the same topic. Other holders' active streams terminate by throwing + `RealtimeError.channelClosed(.userRequested)`. +- `leave()` is **await-to-close**: it returns only after the server confirms + channel close (`phx_close` / Phoenix leave completion). On transport failure + or timeout, it throws. +- A **pipelined re-acquire** is safe: if `realtime.channel("x")` is called + while a leave for `"x"` is in flight, the caller gets the same `Channel` + actor (topic identity, Decision 1) — now in `unsubscribed` state — and the + next `subscribe()` is queued behind the pending leave. Same-topic churn is + transparent. + +> **Topic ownership convention.** Because `leave()` is global, coincidental +> sharing of the same topic by unrelated features can tear down each +> other's streams. Topics should be namespaced by feature +> (`"chat:room:42"`, not `"room:42"`), or routed through a single owner. +> Document loudly in the user guide. + +### 2.4 Channel state + +```swift +public enum ChannelState: Sendable, Equatable { + case unsubscribed + case joining + case joined + case leaving + case closed(CloseReason) +} + +public enum CloseReason: Sendable, Equatable { + case userRequested // someone called leave() + case clientDisconnected // someone called realtime.disconnect() + case serverClosed(code: Int?, message: String?) + case timeout + case unauthorized + case policyViolation(String) + case transportFailure // reconnection policy gave up +} +``` + +The backend does not expose a stable custom WebSocket close-code taxonomy for +auth, rate limit, and policy failures. `code` is optional because many terminal +channel states arrive as `system` + `phx_close` rather than a meaningful +transport close code. + +--- + +## 3. Broadcast + +All broadcast surfaces — typed receiving, typed WebSocket sending, HTTP +one-shot sending, and the untyped iteration over the raw Phoenix feed — live on +`Channel`. WebSocket sending requires the channel to be subscribed at runtime. + +### 3.1 Receiving + +```swift +public extension Channel { + /// Typed event stream — decodes each broadcast message's payload to `T`, + /// filtered to a single event name. Fan-out is **per call**: each call + /// returns an independent stream, and N calls observe every matching + /// message N times. A single returned stream still follows + /// `AsyncThrowingStream` semantics — one consumer per value; iterating the + /// same returned stream from two tasks splits values between them. For two + /// consumers, call `broadcasts(of:event:)` twice. + /// + /// Isolated: `await channel.broadcasts(...)` to obtain the stream. + func broadcasts( + of type: T.Type, + event: String + ) -> AsyncThrowingStream +} + +// Untyped iteration is the `channel.messages()` stream (§2.1). Element is +// `PhoenixMessage`, which spans broadcasts, postgres_changes, presence_diff, +// and other channel-level events. To filter to broadcasts only, match on +// `event == "broadcast"` and decode `payload` manually — but the typed +// `broadcasts(of:event:)` method is the recommended path. +``` + +Streams pause silently during reconnection and resume on rejoin. Gaps are +inherent in fire-and-forget pub/sub and not surfaced — callers who care +correlate against `channel.state`. + +Backpressure: each stream has an **unbounded** buffer. A slow consumer +will accumulate pending messages and eventually OOM under sustained lag. A +`SlowConsumerPolicy` knob may be added later without breaking source. The +backend does not provide a durable per-subscriber queue or a client-visible +backpressure contract. + +### 3.2 Sending + +```swift +public extension Channel { + /// Sends a broadcast. Behavior depends on `ChannelOptions.broadcast.acknowledge`: + /// - `false` (default): fire-and-forget; returns after the frame is queued. + /// - `true`: awaits server ack; throws on timeout (`broadcastAckTimeout`). + /// The backend can silently drop unauthorized private-channel broadcasts, + /// so timeout is also the observable failure mode for that edge. + /// + /// Throws `.notSubscribed` before the channel has joined. Throws + /// `.channelClosed` if `leave()` has been called or the channel terminally + /// closed. Throws `.disconnected` if the socket is down — no queuing. + func broadcast( + _ payload: T, + as event: String + ) async throws(RealtimeError) + + /// `Data` bypasses encoding and ships as a binary frame (Phoenix v2). + /// The backend accepts arbitrary bytes subject to tenant payload limits and + /// the WebSocket max frame size. + func broadcast(_ data: Data, as event: String) async throws(RealtimeError) +} +``` + +WebSocket sends are not queued across disconnected periods. A send before +`subscribe()` fails with `.notSubscribed`; a send during reconnect fails with +`.disconnected`; a send after leave fails with `.channelClosed(...)`. + +### 3.3 HTTP one-shot broadcast + +For senders that don't need to join, `channel.httpBroadcast` POSTs to +the Realtime HTTP broadcast endpoint for that channel's topic. It does not open +the WebSocket and does not join the channel. + +```swift +public extension Channel { + /// Single-message HTTP broadcast. Uses + /// `POST /realtime/v1/api/broadcast/:topic/events/:event`. + func httpBroadcast( + event: String, payload: T, + isPrivate: Bool = false + ) async throws(RealtimeError) +} + +public extension Realtime { + /// Multi-topic batch form. Uses `POST /realtime/v1/api/broadcast` with + /// `{ "messages": [...] }`. This remains on `Realtime` because a batch can + /// contain messages for multiple topics. + func httpBroadcastBatch(_ messages: [HttpBroadcastMessage]) async throws(RealtimeError) +} + +public struct HttpBroadcastMessage: Sendable { + public let topic: String + public let event: String + public let payload: any Encodable & Sendable + public let isPrivate: Bool +} +``` + +HTTP broadcast auth uses `Authorization: Bearer ` when an +`accessToken` provider is configured and falls back to the `apikey` header. This is +deliberately different from the WebSocket connect path, which uses `apikey` or +`x-api-key`. + +Success is `202 Accepted` with no response body. Errors map into the shared +taxonomy where the backend provides enough information +(`.authenticationFailed`, `.rateLimited`, `.serverError`). HTTP rate-limit +responses expose `x-rate-*` headers but no `Retry-After`, so +`.rateLimited(retryAfter:)` is normally `nil`. + +Batch and single-message private broadcasts have different backend failure +semantics: `channel.httpBroadcast` private unauthorized returns forbidden; batch +private unauthorized messages can be skipped while the request still returns +`202` for the accepted work. The SDK documents this instead of pretending batch +is a transaction. + +--- + +## 4. Presence + +Presence lives on `Channel`. Join-time presence behavior is configured through +`ChannelOptions.presence` (§2.2); `track` requires a subscribed channel at +runtime, while observation streams may be created before subscribe and start +emitting after join. + +```swift +public extension Channel { + nonisolated var presence: Presence { get } +} + +public struct Presence: Sendable { + /// Begin tracking, or update the existing tracked state, for this + /// channel process. The backend stores one meta per channel process and presence + /// key; repeated calls update that meta rather than registering additional + /// metas. + /// + /// The returned handle represents that single presence slot. Calling + /// `track` again while the handle is live is equivalent to + /// `handle.update(newState)` and returns a handle for the same logical slot. + /// The handle must be explicitly `cancel()`ed to untrack. Dropping the + /// handle without cancelling does NOT untrack — but when `leave()` is called + /// on any holder of the topic, the slot is implicitly torn down server-side. + /// + /// Debug warning fires if a handle is deinited without `cancel()` while + /// the channel is still joined. + public func track( + _ state: T + ) async throws(RealtimeError) -> PresenceHandle + + /// Snapshot + diff stream of all presences, keyed by presence key. + public func observe( + _ type: T.Type + ) -> AsyncStream> + + /// Incremental diffs only. + public func diffs( + _ type: T.Type + ) -> AsyncStream> +} + +/// The presence key string the server attaches to each meta. Comes from +/// `ChannelOptions.presence.key` if set, otherwise server-generated. +public typealias PresenceKey = String + +public struct PresenceState: Sendable { + public let active: [PresenceKey: [T]] + public let lastDiff: PresenceDiff? +} + +public struct PresenceDiff: Sendable { + public let joined: [(PresenceKey, T)] + public let left: [(PresenceKey, T)] +} + +public final class PresenceHandle: Sendable { + /// Update the current presence meta. This does not create a second meta. + public func update(_ state: T) async throws(RealtimeError) + + /// Idempotent; awaits server ACK of the untrack. + public func cancel() async throws(RealtimeError) +} +``` + +- **Presence key source.** Set via `ChannelOptions.presence.key` at channel + creation. If `nil` or empty, the server generates a fresh UUID per join. +- **Presence snapshots.** `presence_state` is sent on join only when + `ChannelOptions.presence.enabled` is true. `track` can still create/update + presence later, but it cannot retroactively request the initial snapshot. +- **Auto re-track on reconnect.** The SDK remembers the last state passed + to the live presence slot and re-sends it on rejoin. Presence state is + restored transparently across transport outages, but only the latest state is + restored because the backend has one meta per channel process/key. + +--- + +## 5. Postgres Changes + +### 5.1 Declare your table + +```swift +@RealtimeTable(schema: "public", table: "messages") +struct Message: Codable, Sendable, Identifiable { + var id: UUID + var roomId: UUID + var text: String + var createdAt: Date +} +``` + +`@RealtimeTable` synthesizes: + +- Conformance to `RealtimeTable` +- `static let schema: String`, `static let tableName: String` +- A `columnName(for: KeyPath) -> String` lookup, honoring + `CodingKeys` if the type customizes them + +Types the caller doesn't own can conform manually: + +```swift +extension ExternalType: RealtimeTable { + public static let schema = "public" + public static let tableName = "widgets" + public static func columnName(for kp: KeyPath) -> String { ... } +} +``` + +### 5.2 Filters — typed and untyped + +Phoenix Realtime supports one filter string per `postgres_changes` entry, but +that string can contain multiple comma-separated clauses. Clauses are ANDed by +the backend. The SDK reflects this with composable filter values. OR is modeled +by registering multiple tokens and routing the backend `ids` array to each +matching registration. + +Two filter types use the same wire encoding but different input shapes: + +```swift +/// Type-checked filter for `RealtimeTable` types. Column is a `KeyPath`; the +/// value's type must match the keypath's `Value`. `.eq(\.roomId, 42)` against +/// `var roomId: UUID` fails at compile time. +public struct Filter: Sendable { + public static func eq( + _ column: KeyPath, _ value: V + ) -> Filter + public static func neq(…) -> Filter + public static func gt(…) -> Filter + public static func gte(…) -> Filter + public static func lt(…) -> Filter + public static func lte(…) -> Filter + public static func `in`( + _ column: KeyPath, _ values: [V] + ) -> Filter + public static func like(_ column: KeyPath, _ pattern: String) -> Filter + public static func ilike(_ column: KeyPath, _ pattern: String) -> Filter + public static func match(_ column: KeyPath, _ pattern: String) -> Filter + public static func imatch(_ column: KeyPath, _ pattern: String) -> Filter + public static func isNull(_ column: KeyPath) -> Filter + public static func isNotNull(_ column: KeyPath) -> Filter + public static func isDistinct( + _ column: KeyPath, _ value: V + ) -> Filter + + public func and(_ other: Filter) -> Filter + public static func all(_ filters: [Filter]) -> Filter + public static func not(_ filter: Filter) -> Filter +} + +/// Untyped filter for cases where the row type cannot or does not conform +/// to `RealtimeTable`. Column is a raw string; values are still constrained +/// to `RealtimePostgresFilterValue` for correct wire encoding. +public struct UntypedFilter: Sendable { + public static func eq(_ column: String, + _ value: any RealtimePostgresFilterValue) -> UntypedFilter + public static func neq(…) -> UntypedFilter + public static func gt(…) -> UntypedFilter + public static func gte(…) -> UntypedFilter + public static func lt(…) -> UntypedFilter + public static func lte(…) -> UntypedFilter + public static func `in`(_ column: String, + _ values: [any RealtimePostgresFilterValue]) -> UntypedFilter + public static func like(_ column: String, _ pattern: String) -> UntypedFilter + public static func ilike(_ column: String, _ pattern: String) -> UntypedFilter + public static func match(_ column: String, _ pattern: String) -> UntypedFilter + public static func imatch(_ column: String, _ pattern: String) -> UntypedFilter + public static func isNull(_ column: String) -> UntypedFilter + public static func isNotNull(_ column: String) -> UntypedFilter + public static func isDistinct( + _ column: String, _ value: any RealtimePostgresFilterValue + ) -> UntypedFilter + + public func and(_ other: UntypedFilter) -> UntypedFilter + public static func all(_ filters: [UntypedFilter]) -> UntypedFilter + public static func not(_ filter: UntypedFilter) -> UntypedFilter +} +``` + +Each clause serializes to `column=op.value`; a compound filter serializes as +`clause,clause`. The split is purely about call-site ergonomics — typed gets +compile-time checking via `KeyPath`, untyped pays runtime cost for not requiring +conformance. + +`in` values are encoded with backend-compatible quoting/escaping and must not +exceed 100 values. `not` maps to the backend `not.` prefix and applies to each +clause in the filter it wraps. `is` supports null checks through +`isNull`/`isNotNull`; boolean/unknown helpers can be added if use cases need +them. + +### 5.3 Register-then-subscribe + +Phoenix requires postgres_changes filters in the `phx_join` payload — they +cannot be added after join. The API reflects this: registration records the +token in the actor's pending-registration set; `subscribe()` triggers the join +with all pending tokens; consumption happens through the same `Channel`. + +```swift +public extension Channel { + // All registration factories are isolated (they mutate the actor's + // pending-registration set), so callers `await` them — still before + // `subscribe()`. See §2.1 "Isolation contract". + + // Typed factories — require RealtimeTable, return registrations whose + // variant carries the row type. Filter is a typed `Filter`. + func changes( + to type: T.Type, where filter: Filter? = nil + ) -> ChangeRegistration> + + func inserts( + into type: T.Type, where filter: Filter? = nil + ) -> ChangeRegistration> + + func updates( + of type: T.Type, where filter: Filter? = nil + ) -> ChangeRegistration> + + func deletes( + from type: T.Type, where filter: Filter? = nil + ) -> ChangeRegistration> + + // Untyped factories — for types without RealtimeTable. Return registrations + // whose variant carries `JSONValue`. Filter is `UntypedFilter`. + func changes( + schema: String, table: String, filter: UntypedFilter? = nil + ) -> ChangeRegistration> + + func inserts( + schema: String, table: String, filter: UntypedFilter? = nil + ) -> ChangeRegistration> + + func updates( + schema: String, table: String, filter: UntypedFilter? = nil + ) -> ChangeRegistration> + + func deletes( + schema: String, table: String, filter: UntypedFilter? = nil + ) -> ChangeRegistration> +} + +/// Variant protocol — each variant is itself generic over the row type and +/// declares the element type of `postgresChanges(for:)` for that variant. +public protocol ChangeEventVariant: Sendable { + associatedtype Element: Sendable +} + +public enum Insert: ChangeEventVariant { public typealias Element = T } +public enum Update: ChangeEventVariant { public typealias Element = PostgresUpdate } +public enum Delete: ChangeEventVariant { public typealias Element = PostgresDelete } +public enum AnyEvent: ChangeEventVariant { public typealias Element = PostgresChange } + +public struct PostgresUpdate: Sendable { + /// Fully decoded new row (`record`). + public let record: T + + /// Raw `old_record`. The backend does not guarantee this is a full row; + /// without `REPLICA IDENTITY FULL`, or under RLS, it may contain only key + /// columns. + public let oldRecord: JSONValue? +} + +public struct PostgresDelete: Sendable { + /// Raw `old_record`. This is not guaranteed to decode as `T` unless the + /// table and RLS configuration make the full old row available. + public let oldRecord: JSONValue +} + +/// Single generic over the variant — variant carries `T`, no extra type +/// parameter on the registration. Same registration type for typed and +/// untyped paths; only the variant's `T` differs. +public struct ChangeRegistration: Sendable { + // Opaque. Holds the table descriptor (typed via RealtimeTable, or raw + // schema+table strings), optional filter, event mask, and routing state. +} + +public extension Channel { + /// Single overload, dispatched on the variant. Element type follows from + /// `E.Element` — `T` for inserts, `PostgresUpdate` for updates, + /// `PostgresDelete` for deletes, `PostgresChange` for `AnyEvent`. + /// Works identically for typed and untyped registrations (`T` is `JSONValue` + /// in the untyped case). + /// + /// Passing a token that was created on a different channel is a + /// programmer error: the iterator throws `.unknownToken` on first + /// iteration. (`Channel` actor identity is captured in the token.) + /// + /// Isolated: `await channel.postgresChanges(for:)` to obtain the stream. + func postgresChanges(for token: ChangeRegistration) + -> AsyncThrowingStream +} + +public enum PostgresChange: Sendable { + case insert(T) + case update(PostgresUpdate) + case delete(PostgresDelete) +} +``` + +Usage: + +```swift +// 1. Register tokens (no join yet) — async, but still before subscribe. +let inserts = await channel.inserts(into: Message.self, where: .eq(\.roomId, id)) +let allMsgs = await channel.changes(to: Message.self, where: .eq(\.roomId, id)) +let roomGone = await channel.deletes(from: Room.self, where: .eq(\.id, id)) + +// 2. Trigger join. All three tokens land in the same phx_join payload. +try await channel.subscribe() + +// 3. Consume — element type follows the token's variant. +try await withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + for try await row in await channel.postgresChanges(for: inserts) { + // row: Message + } + } + group.addTask { + for try await event in await channel.postgresChanges(for: allMsgs) { + // event: PostgresChange + switch event { + case .insert(let row): handle(row) + case .update(let change): render(change.record, previous: change.oldRecord) + case .delete(let change): remove(usingOldRecord: change.oldRecord) + } + } + } + group.addTask { + for try await _ in await channel.postgresChanges(for: roomGone) { close() } + } + try await group.waitForAll() +} +``` + +**Tokens are reusable across subscribe cycles.** After `channel.leave()`, the +same tokens replay on the next `channel.subscribe()`. New tokens may also be +registered between leave and resubscribe. Registering while joined throws +`.cannotRegisterAfterJoin`. + +**Fan-out per token.** Fan-out is **per call**: each +`channel.postgresChanges(for: token)` call returns a fresh stream, and N calls +each receive every event. A single returned stream is single-consumer +(standard `AsyncThrowingStream` semantics) — for two consumers of the same +token, call `postgresChanges(for:)` twice rather than iterating one returned +stream from two tasks. + +**Reconnect is transparent.** Channel streams survive silent reconnects (§9.2); +all tokens are re-registered automatically on rejoin. Streams terminate only on +explicit `leave()` or terminal `.transportFailure`. + +**AND composition is available.** Use `filter.and(...)` or `Filter.all(...)` +for same-entry conjunction. OR remains a multi-registration pattern: register +multiple tokens and consume each stream. If two registrations overlap, the +backend sends one wire event with multiple matching IDs; the SDK fans that event +out to each matching token's stream. + +**Postgres setup errors are asynchronous to join.** The backend can accept the +Phoenix join and then push a `system` error for `postgres_changes` setup +(missing publication/table, invalid column, malformed filter, cast failure). +The SDK should consume those setup messages before resolving `subscribe()` when +possible; if an error arrives later, the affected `postgresChanges(for:)` streams throw +`.postgresSubscriptionFailed(reason:)`. + +**Gaps are possible on reconnect.** Tokens are re-registered on rejoin, but +there is no Postgres replay/cursor mechanism. Broadcast replay does not cover +Postgres changes. + +### 5.4 Untyped escape hatch + +For types without `@RealtimeTable`, the same register-then-subscribe flow +applies — only the filter and element types change. + +```swift +// Use the dedicated untyped factory (per-event variant) — the schema+table +// arguments are strings; the filter is an `UntypedFilter`. +let deletes = await channel.deletes( + schema: "public", table: "messages", + filter: .eq("room_id", id) +) +// deletes: ChangeRegistration> + +try await channel.subscribe() + +for try await record in await channel.postgresChanges(for: deletes) { + // record: JSONValue — caller decodes manually +} +``` + +The untyped path produces the same `ChangeRegistration` type the typed +factories return — only the variant's `T` differs (`JSONValue` instead of +your row type). Consumption via `channel.postgresChanges(for:)` is identical. Tokens +from typed and untyped factories can be mixed freely on the same channel. + +--- + +## 6. Connection + +### 6.1 Lazy open + +```swift +public extension Realtime { + /// Opens the WebSocket without joining any channel. Useful for pre-warming + /// or surfacing auth errors before the first `subscribe()`. Idempotent: + /// calling on an already-connected client returns immediately. Concurrent + /// calls coalesce around a single in-flight connect. + func connect() async throws(RealtimeError) +} +``` + +The WebSocket opens lazily on the first `channel.subscribe()` call. There is +no iteration-driven lazy-join in v3 — the only path from "no socket" to +"joined channel" is an explicit `subscribe()`. `channel.httpBroadcast` and +`realtime.httpBroadcastBatch` do not open the socket. + +`realtime.connect()` is the explicit pre-warm path; it does not join any +channel. + +### 6.2 Disconnect + +```swift +public extension Realtime { + /// Closes the socket and awaits close completion. Does NOT evict the channel + /// cache or call leave() on any channel. Streams throw + /// `.channelClosed(.clientDisconnected)`; subsequent operations trigger a + /// fresh connect + rejoin. + func disconnect() async +} +``` + +After a manual `disconnect()`, the `ReconnectionPolicy` does NOT auto-reopen +— the policy applies only to *unexpected* closes (transport failure, server +hangup). The next channel operation (`subscribe()`, send via a re-acquired +channel, or explicit `connect()`) triggers a fresh connect. + +### 6.3 Mid-session token rotation + +```swift +public extension Realtime { + /// Update the access token used for future joins/HTTP private broadcasts and + /// push it to currently joined channels via the Phoenix `access_token` event. + func updateToken(_ newToken: String) async throws(RealtimeError) +} +``` + +`access_token` is a per-channel event. The backend does not ACK successful token +updates with `phx_reply`; `updateToken(_:)` returns after updating local state +and queueing the event to joined channels. If the new token is invalid, expired, +or loses required read policies, the backend pushes a `system` error and closes +the affected channel. + +**Reactive path.** The backend does not emit a stable `token_expired` event. +Expiry is observed as a join rejection or `system` error followed by +`phx_close`. When that happens and an `accessToken` provider is configured, the +SDK fetches a fresh token and resubscribes affected channels. In-flight +operations are not retried on the same channel; they fail with +`.channelClosed(.unauthorized)` or `.authenticationFailed(...)` and callers use +the re-established channel for new work. + +**If the access-token provider throws:** propagates as +`.authenticationFailed(underlying:)`. Connection enters +`.closed(.unauthorized)`. The `ReconnectionPolicy` does NOT apply — auth +recovery is caller-owned. + +**On `connect()`:** the socket uses the literal `apiKey` and does not call the +access-token provider. Channel join calls the provider only for operations that +need an access token (private channels, RLS-backed features, or token refresh). + +### 6.4 Status + +```swift +public extension Realtime { + /// Isolated — backed by connection state stored in the actor. + var status: AsyncStream { get } +} + +public struct ConnectionStatus: Sendable { + public enum State: Sendable { + case idle + case connecting(attempt: Int) + case connected + case reconnecting(attempt: Int, lastError: (any Error & Sendable)?) + case closed(CloseReason) + } + public let state: State + /// When the *current* `state` was entered. Reset on every state transition. + public let since: Date + /// Last successful heartbeat round-trip time, if any. `nil` before the + /// first heartbeat reply or after the connection drops. + public let latency: Duration? +} +``` + +--- + +## 7. Error Model + +```swift +public enum RealtimeError: Error, Sendable { + case disconnected + case transportFailure(underlying: any Error & Sendable) + case reconnectionGaveUp(lastError: any Error & Sendable) + + case channelJoinTimeout + case channelJoinRejected(reason: String) + case notSubscribed + case channelClosed(CloseReason) + case cannotRegisterAfterJoin // postgres_changes registration after join (§5.3) + case unknownToken // postgresChanges(for:) called with a token from another channel (§5.3) + + case authenticationFailed(reason: String, underlying: (any Error & Sendable)?) + + case rateLimited(retryAfter: Duration?) + case serverError(code: Int, message: String) + case postgresSubscriptionFailed(reason: String) + + case broadcastFailed(reason: String) + case broadcastAckTimeout + + case decoding(type: String, underlying: any Error & Sendable) + case encoding(underlying: any Error & Sendable) + + case cancelled // includes task cancellation; Swift's CancellationError is folded here +} +``` + +Single flat enum. Swift's `CancellationError` is caught internally and +re-thrown as `.cancelled` so call sites exhaustively handle one type. +Underlying errors are preserved as `any Error & Sendable` for debugging. + +**`.disconnected` vs `.channelClosed`.** `.disconnected` is thrown by +*operations attempted while the socket is down* — sends, broadcast acks, +explicit `connect()` failures during reconnect. The channel itself may +still be subscribed in the SDK; just not reachable on the wire right now. +`.channelClosed(reason)` is thrown by *streams whose channel has actually +terminated* — manual leave, server-initiated close, transport giveup. +Once a stream throws `.channelClosed`, it ends; `.disconnected` is +recoverable on reconnect. + +**`.cannotRegisterAfterJoin`.** Thrown by `channel.changes(...)`, +`channel.inserts(...)`, etc. when the channel has already joined. Tokens +must be registered before the first `subscribe()` returns. + +**`.notSubscribed`.** Thrown by live channel mutations and sends such as +WebSocket `broadcast` and `presence.track` when called before a successful +`subscribe()`. Stream factories may be created before subscribe and start +producing after the join succeeds. + +**Backend error shapes are mixed.** Join failures usually arrive as +`phx_reply` errors. Runtime channel failures often arrive as a `system` event +with `extension`, `status`, `message`, and `channel`, then `phx_close`. HTTP +failures may be JSON errors, validation payloads, or empty responses. The SDK +maps these best-effort into `RealtimeError`; it does not rely on a stable +backend close-code taxonomy. + +--- + +## 8. Transport and Testing + +### 8.1 Public transport protocol + +```swift +public protocol RealtimeTransport: Sendable { + func connect(to url: URL, headers: HTTPFields) + async throws -> any RealtimeConnection +} + +public protocol RealtimeConnection: Sendable { + var frames: AsyncThrowingStream { get } + func send(_ frame: TransportFrame) async throws + func close(code: Int, reason: String) async +} + +public enum TransportFrame: Sendable { + case text(String) + case binary(Data) +} +``` + +### 8.2 Built-in implementations + +- `URLSessionTransport` (default). Production. Accepts a custom + `URLSession` via init for proxy / header / session-config customization. +- `InMemoryTransport.pair()` — test helper in `RealtimeTestHelpers` module. + Returns `(client, server)`; the server has `send(_:)` and + `AsyncStream` of frames the client sent. Zero real I/O. + +### 8.3 Deterministic clock + +`Configuration.clock: any Clock` lets tests use `TestClock` to +advance heartbeats/timeouts synchronously. Matches existing `swift-clocks` +patterns in the codebase. + +--- + +## 9. Resilience + +### 9.1 Reconnection policies + +```swift +public struct ReconnectionPolicy: Sendable { + public var nextDelay: @Sendable ( + _ attempt: Int, + _ lastError: any Error & Sendable + ) -> Duration? // nil = give up + + public static let never: Self + public static func exponentialBackoff( + initial: Duration, max: Duration, jitter: Double = 0.2 + ) -> Self + public static func fixed(_ delay: Duration, maxAttempts: Int?) -> Self +} +``` + +### 9.2 Behavior during reconnection + +- **Streams stay open silently.** No sentinel values — events just pause + and resume. `channel.state` is the source of truth for lifecycle. +- **Presence is auto-restored.** The SDK re-sends the latest live presence + state on rejoin. Observers see the re-synced state naturally. +- **Postgres change subscriptions are restored.** Filters re-register on + join. +- **In-flight sends throw immediately.** `try await channel.broadcast(...)` + during an outage throws `.disconnected` — no queuing. +- **On give-up.** Channel streams throw `.channelClosed(.transportFailure)`, + the channel cache evicts affected entries, `channel.state` transitions + to `.closed(.transportFailure)`. This is distinct from user `leave()` — + `.transportFailure` means "server-initiated close the SDK surfaces," not + "you were supposed to call leave." + +### 9.3 App lifecycle + +```swift +public enum LifecyclePolicy: Sendable { + case manual + case automatic +} +``` + +On `automatic` (default on iOS/macOS/tvOS/visionOS), short +background/foreground cycles keep the socket alive; longer cycles or +OS-killed sockets trigger a reconnect on foreground. No caller code. + +--- + +## 10. Observability + +```swift +public protocol RealtimeLogger: Sendable { + func log(_ event: LogEvent) +} + +public struct LogEvent: Sendable { + public let level: LogLevel // .debug, .info, .warn, .error + public let category: Category // .connection, .channel, .broadcast, .presence, .postgres + public let message: String + public let metadata: [String: String] + public let timestamp: Date +} + +public enum LogLevel: Sendable { case debug, info, warn, error } +public enum Category: Sendable { case connection, channel, broadcast, presence, postgres } +``` + +Ship `OSLogLogger` and `StdoutLogger`. Metrics are logs with numeric +metadata (`heartbeat.rtt_ms`, `reconnect.attempt`, `broadcast.ack_latency_ms`) — +consumers extract as they need via their logger of choice. No swift-metrics +dependency in the core module. + +--- + +## 11. Migration Sketch (V2 → V3) + +| V2 | V3 | +| ----------------------------------------------- | --------------------------------------------------------- | +| `RealtimeClientV2(url:options:)` | `Realtime(url:apiKey:accessToken:configuration:transport:)` | +| `client.channel("x")` | `realtime.channel("x")` (shared; explicit `leave()`) | +| `await channel.subscribe()` | `try await channel.subscribe()` | +| `await channel.unsubscribe()` | `try await channel.leave()` (typed throws, global) | +| `channel.broadcastStream(event:)` | `channel.broadcasts(of: T.self, event:)` (typed stream) | +| `await channel.broadcast(event:message:)` | `try await channel.broadcast(payload, as: event)` | +| — (no equivalent) | `channel.httpBroadcast(event:payload:)` | +| `channel.postgresChange(.all, …)` | `let token = channel.changes(to: Message.self, …); try await channel.subscribe(); channel.postgresChanges(for: token)` | +| `channel.presenceChange()` | `channel.presence.diffs(T.self)` / `.observe(T.self)` | +| `channel.track(...)` | `try await channel.presence.track(state)` → handle | +| `ObservationToken` / `subscription.cancel()` | `AsyncSequence` iteration ends on task cancel | +| `accessToken: () async -> String?` closure | `accessToken: { ... }` + `realtime.updateToken(…)` | +| `any Error` | `RealtimeError` (typed throws everywhere) | +| `RealtimeClientOptions.maxRetryAttempts` etc. | `Configuration.reconnection: ReconnectionPolicy` | +| `options.vsn` | `Configuration.protocolVersion` (default `.v2`) | +| `options.handleAppLifecycle` | unchanged | + +--- + +## 12. Locked Decisions + +Everything below was resolved during design review and the backend source audit. +Kept here for reference so implementors don't re-litigate. + +| # | Decision | Rationale | +| - | -------- | --------- | +| 1 | Channels are shared by topic within a `Realtime` instance | One server-side subscription per topic; predictable identity | +| 2 | No auto-unsubscribe on `deinit`; explicit `leave()` only | Explicit lifecycle; no ref-count magic | +| 3 | Global `leave()` — other holders' streams throw `.channelClosed(.userRequested)` | Mirrors the wire; surfaces the shared nature | +| 4 | `leave()` is `async throws`, awaits server channel close confirmation | Deterministic; consistent with the rest of the API | +| 5 | Pipelined re-acquire after `leave()` | Same-topic churn is transparent | +| 6 | Reconnect is silent in typed streams; `channel.state` is the lifecycle source of truth | Avoids leaky delivery-guarantee abstractions | +| 7 | Unbounded per-stream buffer (for now) | Simplest; `SlowConsumerPolicy` knob can be added additively | +| 8 | Fan-out is **per call**: each `broadcasts(of:event:)` / `postgresChanges(for:)` call is independent. A single returned stream is single-consumer (`AsyncStream` semantics); two consumers = two calls | Matches pub/sub intuition and Swift's stream model; iterating one returned stream twice would split values | +| 8a | `Realtime` and `Channel` are plain `actor`s (no `final`, no explicit `: Sendable` — both implicit) that own all their state. No separate Sendable side-store. Only the immutable `topic`/`options` constants and the `presence` accessor are `nonisolated`; everything else (`state`, `messages()`, `broadcasts`, `postgresChanges`, registration, `subscribe`, `leave`, sends, `httpBroadcast`, `presence.track`, `realtime.channel(_:)`, `realtime.status`) is isolated and `await`ed | Honest single-source-of-truth actor; avoids a parallel lock-protected store. Matches the repo's `public actor X` house style | +| 9 | Literal `apiKey: String` for connect; dynamic `accessToken` provider for JWT authorization; `updateToken(_:)` pushes access tokens to joined channels | Backend uses stable API keys for connect and rotating JWTs for channel/HTTP authorization | +| 10 | On token expiry/system auth close: refresh access token and resubscribe; do not retry the original push on the same channel | Backend closes the channel instead of ACKing a retryable `token_expired` operation | +| 11 | Access-token provider throwing does NOT trigger `ReconnectionPolicy` | Auth recovery is caller-owned | +| 12 | Composable AND filters per postgres_changes registration (typed `Filter` or `UntypedFilter`); OR is modeled with multiple registrations | Reflects backend support for comma-separated AND clauses and backend `ids` routing for overlapping registrations | +| 13 | Both `Filter` and `UntypedFilter` are structs with static factories; read like enums at call site | Typed path preserves `KeyPath` + `V` binding; untyped path is a sibling type for raw column strings | +| 14 | `@RealtimeTable` macro for column-name resolution; manual conformance as escape hatch | Type-safe without forcing macros on every type | +| 14a | Postgres changes are **register-then-subscribe**: `channel.changes(...)` returns a `ChangeRegistration` token; `channel.subscribe()` triggers the join with all pending tokens; consumption via `channel.postgresChanges(for: token)` | Phoenix requires postgres_changes filters in the join payload — the API can't pretend lazy join works for them | +| 14b | Variants are themselves generic over the row type (`Insert`/`Update`/`Delete`/`AnyEvent` conforming to `ChangeEventVariant`); registration is `ChangeRegistration` (single generic, variant carries `T`); single `postgresChanges(for:)` overload dispatched on the variant | Cleaner type signatures than two-param `` and a single overload covers typed and untyped paths | +| 14c | Registering after join throws `.cannotRegisterAfterJoin`; tokens are reusable across `leave()` + resubscribe cycles | Honest about the wire; ergonomic across reconnects and cycles | +| 14d | `subscribe()` is the **only** join path; no iteration-driven lazy-join | One mental model; no surprises from broadcast iteration silently joining | +| 14e | `subscribe()` returns `Void`; `Channel` remains the single surface for consumption, sending, presence, and leave | A separate subscription value cannot honestly guarantee live connectivity across reconnects or global leave | +| 14f | Raw feed is an isolated `func messages() -> AsyncStream` method (not a property, not an `AsyncSequence` conformance). `state` stays an isolated property | A method signals that each call mints a fresh stream; a property implying a stored value would mislead. Avoids a public iterator type and the awkward synchronous `makeAsyncIterator()` requirement on an actor | +| 14g | (merged into Decision 26) | — | +| 14h | Multiple `subscribe()` calls coalesce/idempotently join the same backing channel state | Topic identity (Decision 1) extends to joining | +| 14i | `Channel` drop without `leave()` does nothing (debug warning); leave is global as in Decision 3 | Consistency with channel rules; no auto-leave footguns under topic sharing | +| 14j | `Presence` accessor lives on `Channel`; `track` is runtime-gated by joined state | Same single-handle model as broadcast and Postgres changes | +| 14k | `PhoenixMessage` is fully raw — exposes `joinRef`, `ref`, `event`, `payload` (JSON or binary). Includes internal `phx_reply`/`phx_close`/`phx_error` frames | Direct iteration is the escape hatch for advanced consumers; SDK consumes the same frames internally for correlation | +| 14l | Separate liveness accessor **deferred** | `channel.state` is the lifecycle source of truth | +| 14m | After manual `leave()`, live `Channel` methods throw `.channelClosed(.userRequested)` and iteration terminates. Reconnects keep streams open; `.transportFailure` terminates them | Lifecycle is explicit without creating a stale subscription value | +| 14n | Filters split into two types: `Filter` (KeyPath-based, compile-time-checked) and `UntypedFilter` (raw column strings + `any RealtimePostgresFilterValue`). Both serialize to backend filter clauses and can compose with AND | Untyped path needs raw column strings; typed path needs `RealtimeTable` for `columnName(for:)`; one type can't be both | +| 14o | Untyped factories (`channel.changes(schema:table:filter:)`, `inserts/updates/deletes(schema:table:filter:)`) return `ChangeRegistration>`. Tokens from typed and untyped factories interoperate — same registration type, different variant `T` | Single consumption surface; mix freely on one channel | +| 15 | `PresenceHandle` is a regular class; explicit `cancel()`; debug warning on leak | Consistent with `Channel` lifecycle rule | +| 16 | One presence meta per channel process/key; repeated `track` updates that meta | Matches Realtime backend behavior for the same channel process and presence key | +| 17 | Presence key is channel-level only; server generates a fresh UUID per join when nil/empty | Simpler; per-track keys confuse more than they help | +| 18 | Auto re-track latest presence state on reconnect | Presence is a best-effort synced-state abstraction, but backend stores one meta per channel process/key | +| 19 | `withChannel` dropped entirely | Dangerous under global-leave semantics; 3-line explicit pattern is clearer | +| 20 | Flat `RealtimeError` enum; cancellation folded as `.cancelled` | Simpler call sites than grouped or union-throws | +| 21 | Underlying errors preserved as `any Error & Sendable` | Debug value outweighs Equatable/Codable loss | +| 22 | Single `broadcast` call site (with a `Data` overload for binary payloads, Decision 25); ack at channel-level config | Uniform call site | +| 23 | Self-broadcast is channel-level only (wire constraint) | Don't lie about the wire | +| 24 | Replay via `ChannelOptions.broadcast.replay`, private-channel-only | Backend replay is join-time-only and rejected on public channels | +| 25 | `Data` payloads bypass encoding; ship as binary frames | Natural Swift affordance | +| 26 | WebSocket broadcast send lives on `Channel` and is runtime-gated by subscribed state; one-shot HTTP sends without joining go via `channel.httpBroadcast` | The single-handle model is more honest than a subscription value that cannot guarantee a live connection | +| 27 | `channel.httpBroadcast(...)` for topic-scoped one-shot sends; `realtime.httpBroadcastBatch(...)` for multi-topic batches; both use HTTP auth semantics (`Authorization`/`apikey`) | The single-message operation belongs to the topic handle; batch remains client-level because it can span topics | +| 28 | Socket opens lazily on first channel join | Zero ceremony for common paths; explicit `connect()` still exists | +| 29 | `disconnect()` closes socket, keeps channel cache | Pause/resume, not total teardown | +| 30 | `disconnect()` is `async`, awaits close completion | Consistent with other terminal operations | +| 31 | `connect()` is idempotent | No ceremony for retry paths | +| 32 | No auto-reconnect after manual `disconnect()` | `ReconnectionPolicy` is for unexpected closes | +| 33 | Duplicate `channel(topic)` with different options: first-call wins + debug warning | Silent drift is worse than a warning | +| 34 | `@RealtimeSchema` (typed event channels) deferred | Per-call generics cover 90% of the typing benefit; macro complexity can wait | +| 35 | Public `RealtimeTransport` protocol | Custom transports for testing and advanced networking | +| 36 | Ship `InMemoryTransport.pair()` in test helpers | Table stakes for deterministic testing | +| 37 | Inject `Clock` via `Configuration` | Deterministic timeout/heartbeat tests | +| 38 | Drop obsoleted V2 knobs (`connectOnSubscribe`, `maxRetryAttempts`, `logLevel`, `fetch`, `accessToken`, `disconnectOnSessionLoss`) | Subsumed by better abstractions | +| 39 | Keep `disconnectOnEmptyChannelsAfter` (socket idle timeout) and `protocolVersion` | Still useful | +| 40 | Per-operation timeouts: `joinTimeout`, `leaveTimeout`, `broadcastAckTimeout` | One global knob can't tune distinct round-trips | +| 41 | Logger only; no separate metrics stream; no swift-metrics dep | Metrics = logs with numeric metadata | +| 42 | No custom join payload | Unused in practice; removes surface | +| 43 | Multiple `Realtime` instances are fully independent | No singleton; no hidden coupling | +| 44 | Topic strings are not validated | Server is the source of truth for validity | +| 45 | Presence key default: server-generated UUID per join when nil/empty | Matches Realtime backend behavior | + +--- + +## Appendix A — End-to-end example + +```swift +import Combine // for ObservableObject on the iOS 16 floor; not needed with @Observable (iOS 17+) +import Realtime + +@RealtimeTable(schema: "public", table: "messages") +struct Message: Codable, Sendable, Identifiable { + var id: UUID + var roomId: UUID + var text: String + var authorId: UUID + var createdAt: Date +} + +/// Wire payload for the "chat" broadcast event — distinct from `Message`, +/// which is the persisted postgres row consumed via `postgresChanges(for:)`. +struct ChatBroadcast: Codable, Sendable { + let authorId: UUID + let text: String +} + +struct UserPresence: Codable, Sendable { + let userId: UUID + let status: Status + enum Status: String, Codable, Sendable { case active, idle } +} + +// iOS 16 floor → `ObservableObject`. On iOS 17+ this would be `@Observable` +// (and the `@Published` annotations would drop away). +@MainActor +final class ChatRoomModel: ObservableObject { + private let realtime: Realtime + private let roomId: UUID + private let me: UUID + private var channel: Channel? + private var runTask: Task? + private var trackHandle: PresenceHandle? + + @Published var messages: [Message] = [] + @Published var onlineUsers: [UUID: UserPresence] = [:] + @Published var connection: ConnectionStatus.State = .idle + + init(realtime: Realtime, roomId: UUID, me: UUID) { + self.realtime = realtime + self.roomId = roomId + self.me = me + } + + func start() { + runTask = Task { [realtime, roomId, me, weak self] in + do { + // Acquire the channel (isolated: touches the topic registry). + let channel = await realtime.channel("chat:room:\(roomId)") { + $0.presence.enabled = true + $0.presence.key = "user-\(me)" + } + await MainActor.run { self?.channel = channel } + + // Register postgres tokens BEFORE subscribe — they bake into phx_join. + let messageInserts = await channel.inserts( + into: Message.self, where: .eq(\.roomId, roomId) + ) + + // Single explicit join captures the registration above. + try await channel.subscribe() + + // `withThrowingTaskGroup` (not the iOS 17 `…DiscardingTaskGroup`) for + // the iOS 16 floor; `waitForAll()` rethrows the first child error. + try await withThrowingTaskGroup(of: Void.self) { group in + // Postgres inserts → append + group.addTask { + let rows = await channel.postgresChanges(for: messageInserts) + for try await row in rows { + await MainActor.run { self?.messages.append(row) } + } + } + // Presence observers + group.addTask { + for await state in channel.presence.observe(UserPresence.self) { + let mapped = Dictionary( + uniqueKeysWithValues: state.active.values + .flatMap { $0 } + .map { ($0.userId, $0) } + ) + await MainActor.run { self?.onlineUsers = mapped } + } + } + // Track myself + group.addTask { + let handle = try await channel.presence.track( + UserPresence(userId: me, status: .active) + ) + await MainActor.run { self?.trackHandle = handle } + } + // Connection status mirror + group.addTask { + for await status in await realtime.status { + await MainActor.run { self?.connection = status.state } + } + } + + try await group.waitForAll() + } + } catch is CancellationError { + // expected on view teardown + } catch let error as RealtimeError { + print("chat failed:", error) // exhaustive — compiler enforces + } + } + } + + /// Broadcast through the active channel. No-op before the channel exists; + /// throws `.notSubscribed` if created but not yet joined. + func send(_ text: String) async throws(RealtimeError) { + guard let channel else { return } + try await channel.broadcast( + ChatBroadcast(authorId: me, text: text), + as: "chat" + ) + } + + func stop() async { + runTask?.cancel() + try? await trackHandle?.cancel() + try? await channel?.leave() + } +} +``` + +--- + +## Appendix B — Why not Combine? + +- `AsyncSequence` is the lingua franca of new Apple frameworks. +- Combine cannot express typed throws or structured cancellation cleanly. +- Callers who want Combine can wrap any stream in `Publisher` in ~5 lines — + the reverse is lossy. + +## Appendix C — Platform requirements + +- **Swift 6.1+.** Uses typed throws (Swift 6.0), parameterized existentials, + and macros. No Swift 6.2-only features are required — in particular, the + leaked-channel warning does *not* depend on isolated deinit (it reads a + nonisolated flag from the synchronous `deinit`). +- **iOS 16 / macOS 13 / tvOS 16 / watchOS 9 / visionOS 1.** The floor is set by + `Duration` / `Clock` / `ContinuousClock` (used for timeouts, backoff, and + heartbeat RTT), which are iOS 16 / macOS 13. Lowering further would mean + replacing `Duration` with `TimeInterval` throughout. +- **`@Observable` requires iOS 17 / macOS 14.** The Appendix A example targets + the iOS 16 floor with `ObservableObject`; on iOS 17+ prefer `@Observable`, and + the Perception package back-ports it below 17. The SDK's own public surface is + `AsyncSequence`-based and does not depend on Observation, so it builds on the + full iOS 16 range.