diff --git a/.gitignore b/.gitignore index ed99f4c0f..564d2a3ee 100644 --- a/.gitignore +++ b/.gitignore @@ -91,7 +91,9 @@ iOSInjectionProject/ .DS_Store /.build -/Packages +/Packages/* +!/Packages/_Realtime +!/Packages/_RealtimeTableMacros /*.xcodeproj /.swiftpm diff --git a/Examples/Examples/Realtime/TodoRealtimeView.swift b/Examples/Examples/Realtime/TodoRealtimeView.swift index 015a45e03..4cc2d90bd 100644 --- a/Examples/Examples/Realtime/TodoRealtimeView.swift +++ b/Examples/Examples/Realtime/TodoRealtimeView.swift @@ -105,14 +105,16 @@ struct TodoRealtimeView: View { // Handle insertions async let insertionObservation: () = { @MainActor in for await insertion in insertions { - try todos.insert(insertion.decodeRecord(decoder: PostgrestClient.Configuration.jsonDecoder), at: 0) + try todos.insert( + insertion.decodeRecord(decoder: PostgrestClient.Configuration.jsonDecoder), at: 0) } }() // Handle updates async let updatesObservation: () = { @MainActor in for await update in updates { - let record = try update.decodeRecord(decoder: PostgrestClient.Configuration.jsonDecoder) as Todo + let record = + try update.decodeRecord(decoder: PostgrestClient.Configuration.jsonDecoder) as Todo todos[id: record.id] = record } }() diff --git a/Package.resolved b/Package.resolved index 05802e9c5..d076d0f68 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "8ef4a0acd4a4228eca04b86f70fb3f991c48a833b8b5e071fe66e010188bd531", + "originHash" : "090f7337829787c9103137789fae5f2051e00fa7f7974d0f83d59920aff25a49", "pins" : [ { "identity" : "mocker", @@ -78,8 +78,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/swiftlang/swift-syntax", "state" : { - "revision" : "0687f71944021d616d34d922343dcef086855920", - "version" : "600.0.1" + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" } }, { diff --git a/Package.swift b/Package.swift index 43522cedd..d363330db 100644 --- a/Package.swift +++ b/Package.swift @@ -22,6 +22,7 @@ let package = Package( .library(name: "Supabase", targets: ["Supabase"]), ], dependencies: [ + .package(path: "Packages/_Realtime"), .package(url: "https://github.com/apple/swift-crypto.git", "3.0.0"..<"5.0.0"), .package(url: "https://github.com/apple/swift-http-types.git", from: "1.3.0"), .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), diff --git a/Packages/_Realtime/Package.resolved b/Packages/_Realtime/Package.resolved new file mode 100644 index 000000000..1d44d3847 --- /dev/null +++ b/Packages/_Realtime/Package.resolved @@ -0,0 +1,60 @@ +{ + "originHash" : "0ccef3c255895d0f63930521784cbaf680de93ee6df5696f616e57a74d240eb5", + "pins" : [ + { + "identity" : "swift-clocks", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-clocks", + "state" : { + "revision" : "cc46202b53476d64e824e0b6612da09d84ffde8e", + "version" : "1.0.6" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "06c57924455064182d6b217f06ebc05d00cb2990", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "ad5e3190cc63dc288f28546f9c6827efc1e9d495", + "version" : "1.19.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "dfd70507def84cb5fb821278448a262c6ff2bbad", + "version" : "1.9.0" + } + } + ], + "version" : 3 +} diff --git a/Packages/_Realtime/Package.swift b/Packages/_Realtime/Package.swift new file mode 100644 index 000000000..b2e9febb4 --- /dev/null +++ b/Packages/_Realtime/Package.swift @@ -0,0 +1,49 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "_Realtime", + platforms: [ + .iOS(.v17), + .macOS(.v14), + .tvOS(.v17), + .watchOS(.v10), + .visionOS(.v1), + ], + products: [ + .library(name: "_Realtime", targets: ["_Realtime"]) + ], + dependencies: [ + .package(path: "../_RealtimeTableMacros"), + .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.0"), + ], + targets: [ + .target( + name: "_Realtime", + dependencies: [ + .product(name: "_RealtimeTableMacros", package: "_RealtimeTableMacros"), + .product(name: "Clocks", package: "swift-clocks"), + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + ] + ), + .testTarget( + name: "_RealtimeTests", + dependencies: [ + .product(name: "Clocks", package: "swift-clocks"), + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + "_Realtime", + ] + ), + ] +) diff --git a/Packages/_Realtime/Sources/_Realtime/Broadcast/BroadcastMessage.swift b/Packages/_Realtime/Sources/_Realtime/Broadcast/BroadcastMessage.swift new file mode 100644 index 000000000..afcb3b707 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Broadcast/BroadcastMessage.swift @@ -0,0 +1,24 @@ +// +// BroadcastMessage.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation + +/// A broadcast message received from a Realtime channel. +public struct BroadcastMessage: Sendable { + /// The event name carried by this broadcast. + public let event: String + /// The message payload as a `JSONValue`. + public let payload: JSONValue + /// The local time at which this message was received. + public let receivedAt: Date + + public init(event: String, payload: JSONValue, receivedAt: Date = Date()) { + self.event = event + self.payload = payload + self.receivedAt = receivedAt + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Broadcast/Channel+Broadcast.swift b/Packages/_Realtime/Sources/_Realtime/Broadcast/Channel+Broadcast.swift new file mode 100644 index 000000000..94ab58f00 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Broadcast/Channel+Broadcast.swift @@ -0,0 +1,148 @@ +// +// Channel+Broadcast.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation + +extension Channel { + // MARK: - Receive API + + /// Returns an `AsyncThrowingStream` that yields every broadcast message arriving on this channel. + /// + /// The channel is automatically joined on the first call if it has not been joined yet. + /// The stream finishes with a `RealtimeError` when the channel closes or an error occurs. + /// + /// Multiple calls create independent fan-out streams — all subscribers receive every message. + /// + /// - Returns: An async stream of `BroadcastMessage` values. + public func broadcasts() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let id = UUID() + Task { + self.registerBroadcastContinuation(id: id, continuation: continuation) + continuation.onTermination = { [id] _ in + Task { await self.removeBroadcastContinuation(id: id) } + } + do { + try await self.joinIfNeeded() + } catch { + continuation.finish(throwing: error) + } + } + } + } + + /// Returns an `AsyncThrowingStream` that yields broadcast messages matching `event`, + /// decoded as `T` using the channel's configured `JSONDecoder`. + /// + /// - Parameters: + /// - event: Only messages whose `event` field equals this string are emitted. + /// - decoder: A custom `JSONDecoder`. Defaults to `JSONDecoder()`. + /// - type: The `Decodable` type to decode the payload into. + /// - Returns: An async stream of decoded values. + public func broadcasts( + of _: T.Type = T.self, + event: String, + decoder: JSONDecoder = JSONDecoder() + ) -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let base = self.broadcasts() + let task = Task { + do { + for try await msg in base { + guard msg.event == event else { continue } + do { + let data = try JSONEncoder().encode(msg.payload) + let value = try decoder.decode(T.self, from: data) + continuation.yield(value) + } catch { + continuation.finish( + throwing: RealtimeError.decoding( + type: String(describing: T.self), underlying: error) + ) + return + } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Send API + + /// Broadcasts an `Encodable` value as a JSON payload on this channel. + /// + /// The channel must be in the `.joined` state. Use ``join()`` or rely on ``broadcasts()`` + /// auto-join before calling this method. + /// + /// - Parameters: + /// - value: The value to encode and broadcast. + /// - event: The event name carried by the broadcast. + /// - Throws: `RealtimeError.channelNotJoined` if the channel has not joined, + /// `RealtimeError.encoding` if the value cannot be encoded. + public func broadcast( + _ value: T, + as event: String + ) async throws(RealtimeError) { + guard currentState == .joined else { throw .channelNotJoined } + guard let realtime else { throw .disconnected } + + let payloadData: Data + do { + payloadData = try realtime.configuration.encoder.encode(value) + } catch { + throw .encoding(underlying: error) + } + + let payloadDict: [String: JSONValue] + do { + payloadDict = try JSONDecoder().decode([String: JSONValue].self, from: payloadData) + } catch { + throw .encoding(underlying: error) + } + + let msg = PhoenixMessage( + joinRef: nil, ref: nil, + topic: topic, event: "broadcast", + payload: [ + "type": "broadcast", + "event": .string(event), + "payload": .object(payloadDict), + ] + ) + try await realtime.send(msg) + } + + /// Broadcasts raw binary data on this channel using the Realtime binary frame format. + /// + /// - Parameters: + /// - data: The raw binary payload. + /// - event: The event name carried by the broadcast. + /// - Throws: `RealtimeError.channelNotJoined` if not joined, or a transport error. + public func broadcast(_ data: Data, as event: String) async throws(RealtimeError) { + guard currentState == .joined else { throw .channelNotJoined } + guard let realtime else { throw .disconnected } + + let frame: Data + do { + frame = try PhoenixSerializer.encodeBroadcastPush( + joinRef: nil, ref: nil, + topic: topic, event: event, + binaryPayload: data + ) + } catch let e as RealtimeError { + throw e + } catch { + throw .encoding(underlying: error) + } + + try await realtime.sendBinary(frame) + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Broadcast/Realtime+Broadcast.swift b/Packages/_Realtime/Sources/_Realtime/Broadcast/Realtime+Broadcast.swift new file mode 100644 index 000000000..4a2013019 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Broadcast/Realtime+Broadcast.swift @@ -0,0 +1,149 @@ +// +// Realtime+Broadcast.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation + +/// A single message to broadcast via the HTTP broadcast endpoint. +public struct HttpBroadcastMessage: Sendable { + public let topic: String + public let event: String + /// The payload to broadcast as a JSON value. + public let payload: JSONValue + public let isPrivate: Bool + + public init( + topic: String, + event: String, + payload: JSONValue, + isPrivate: Bool = false + ) { + self.topic = topic + self.event = event + self.payload = payload + self.isPrivate = isPrivate + } +} + +extension Realtime { + // MARK: - HTTP Broadcast (one-shot, no WebSocket required) + + /// Broadcasts a single typed message via HTTP POST. + /// + /// This does not open a WebSocket connection. Use it for fire-and-forget scenarios where + /// persistent subscriptions are not needed. + /// + /// - Parameters: + /// - topic: The channel topic to broadcast on. + /// - event: The broadcast event name. + /// - payload: An `Encodable` value to send as the JSON payload. + /// - isPrivate: When `true`, only authenticated subscribers receive the message. + public func httpBroadcast( + topic: String, + event: String, + payload: T, + isPrivate: Bool = false + ) async throws(RealtimeError) { + let jsonPayload: JSONValue + do { + let data = try configuration.encoder.encode(payload) + guard + let obj = try? JSONDecoder().decode([String: JSONValue].self, from: data) + else { + throw RealtimeError.encoding( + underlying: EncodingError.invalidValue( + payload, + EncodingError.Context( + codingPath: [], + debugDescription: "Payload must be a JSON object (dictionary)" + ) + ) + ) + } + jsonPayload = .object(obj) + } catch let e as RealtimeError { + throw e + } catch { + throw .encoding(underlying: error) + } + let msg = HttpBroadcastMessage( + topic: topic, event: event, payload: jsonPayload, isPrivate: isPrivate) + try await httpBroadcast([msg]) + } + + /// Broadcasts multiple messages via HTTP POST in a single request. + /// + /// - Parameter messages: The messages to broadcast. + public func httpBroadcast(_ messages: [HttpBroadcastMessage]) async throws(RealtimeError) { + let token = try await resolveTokenForHTTP() + + let httpURL = buildHTTPBroadcastURL() + + var bodyMessages: [[String: JSONValue]] = [] + for m in messages { + var entry: [String: JSONValue] = [ + "topic": .string(m.topic), + "event": .string(m.event), + "payload": m.payload, + ] + if m.isPrivate { entry["private"] = true } + bodyMessages.append(entry) + } + + let bodyData: Data + do { + bodyData = try JSONEncoder().encode(["messages": bodyMessages]) + } catch { + throw .encoding(underlying: error) + } + + var request = URLRequest(url: httpURL) + request.httpMethod = "POST" + request.httpBody = bodyData + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(token, forHTTPHeaderField: "apikey") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + do { + let (_, response) = try await configuration.urlSession.data(for: request) + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + if http.statusCode == 429 { + throw RealtimeError.rateLimited(retryAfter: nil) + } + throw RealtimeError.serverError( + code: http.statusCode, + message: HTTPURLResponse.localizedString(forStatusCode: http.statusCode) + ) + } + } catch let e as RealtimeError { + throw e + } catch { + throw .transportFailure(underlying: error) + } + } +} + +// MARK: - Private helpers + +extension Realtime { + /// Resolves the API key for use in HTTP requests. + func resolveTokenForHTTP() async throws(RealtimeError) -> String { + do { + return try await resolveToken() + } catch { + throw .authenticationFailed(reason: error.localizedDescription, underlying: nil) + } + } + + /// Builds the HTTP broadcast endpoint URL from the WebSocket URL. + private func buildHTTPBroadcastURL() -> URL { + guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } + comps.scheme = (comps.scheme == "wss") ? "https" : "http" + comps.path = "/realtime/v1/api/broadcast" + comps.queryItems = nil + return comps.url ?? url + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Channel/Channel.swift b/Packages/_Realtime/Sources/_Realtime/Channel/Channel.swift new file mode 100644 index 000000000..b58bb5bbc --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Channel/Channel.swift @@ -0,0 +1,278 @@ +// +// Channel.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation + +struct PostgresSubscription: Sendable { + let id: UUID + let schema: String + let table: String + let filter: String? +} + +public final actor Channel: Sendable { + public let topic: String + public private(set) var options: ChannelOptions + weak var realtime: Realtime? + + private var _state: ChannelState = .unsubscribed + var currentState: ChannelState { _state } + + // Fan-out continuation dictionaries — populated by Phase 5/6/7 extensions + var broadcastContinuations: + [UUID: AsyncThrowingStream.Continuation] = [:] + var stateContinuations: [UUID: AsyncStream.Continuation] = [:] + + // Presence handler registrations + var presenceSnapshotHandlers: [UUID: @Sendable ([String: JSONValue]) -> Void] = [:] + var presenceDiffHandlers: [UUID: @Sendable ([String: JSONValue]) -> Void] = [:] + var presenceFinishHandlers: [UUID: @Sendable () -> Void] = [:] + // Track registry: presenceTrackId → state, for auto re-track on rejoin + var trackedStates: [UUID: [String: JSONValue]] = [:] + + // Postgres Changes handler registrations + var postgresHandlers: [UUID: @Sendable ([String: JSONValue]) -> Void] = [:] + var postgresFinishHandlers: [UUID: @Sendable () -> Void] = [:] + var _postgresSubscriptions: [UUID: PostgresSubscription] = [:] + + var joinRef: String? + + init(topic: String, options: ChannelOptions, realtime: Realtime) { + self.topic = topic + self.options = options + self.realtime = realtime + } + + // MARK: - Public API + + public var state: AsyncStream { + AsyncStream { continuation in + let id = UUID() + stateContinuations[id] = continuation + continuation.yield(_state) + continuation.onTermination = { [id] _ in + Task { await self.removeStateContinuation(id: id) } + } + } + } + + public func join() async throws(RealtimeError) { + switch _state { + case .unsubscribed, .closed: + break // allowed to join + default: + return // already joining, joined, or leaving + } + try await _join() + } + + public func leave() async throws(RealtimeError) { + guard _state == .joined || _state == .joining else { return } + setState(.leaving) + guard let realtime else { throw .disconnected } + let config = realtime.configuration + let msg = PhoenixMessage( + joinRef: joinRef, ref: nil, + topic: topic, event: "phx_leave", payload: [:] + ) + _ = try await realtime.sendAndAwait(msg, timeout: config.leaveTimeout) + setState(.closed(.userRequested)) + trackedStates.removeAll() + _postgresSubscriptions.removeAll() + finishAllContinuations(throwing: .channelClosed(.userRequested)) + await realtime.removeChannel(topic) + } + + // MARK: - Internal routing (called by Realtime actor) + + func handle(_ msg: PhoenixMessage) async { + switch msg.event { + case "phx_close": + let reason = CloseReason.serverClosed(code: 0, message: nil) + setState(.closed(reason)) + finishAllContinuations(throwing: .channelClosed(reason)) + case "phx_error": + let reasonStr = msg.payload["reason"].flatMap { + if case .string(let s) = $0 { return s } else { return nil } + } + let reason = CloseReason.serverClosed(code: 0, message: reasonStr) + setState(.closed(reason)) + finishAllContinuations(throwing: .channelClosed(reason)) + case "broadcast": + await deliverBroadcast(from: msg.payload) + case "presence_state": + for handler in presenceSnapshotHandlers.values { handler(msg.payload) } + case "presence_diff": + for handler in presenceDiffHandlers.values { handler(msg.payload) } + case "postgres_changes": + for handler in postgresHandlers.values { handler(msg.payload) } + default: + break + } + } + + func handleBinaryBroadcast(_ broadcast: BinaryBroadcast) async { + guard case .json(let obj) = broadcast.payload else { return } + await deliverBroadcast(event: broadcast.event, payload: .object(obj)) + } + + /// Converts a raw Phoenix broadcast payload dict to `BroadcastMessage` and fans out to subscribers. + func deliverBroadcast(from payload: [String: JSONValue]) async { + guard let eventValue = payload["event"], + case .string(let event) = eventValue + else { return } + let innerPayload: JSONValue = payload["payload"] ?? .object([:]) + await deliverBroadcast(event: event, payload: innerPayload) + } + + private func deliverBroadcast(event: String, payload: JSONValue) async { + let msg = BroadcastMessage(event: event, payload: payload) + for cont in broadcastContinuations.values { cont.yield(msg) } + } + + func handleConnectionLoss() async { + if _state == .joined || _state == .joining { + setState(.unsubscribed) + } + } + + func rejoin() async throws(RealtimeError) { + guard _state == .unsubscribed else { return } + try await _join() + // Re-track all live presence handles after rejoining + guard let realtime else { return } + for (_, state) in trackedStates { + let msg = PhoenixMessage( + joinRef: joinRef, ref: nil, + topic: topic, event: "presence", + payload: ["event": "track", "payload": .object(state)] + ) + try? await realtime.sendAndAwait(msg, timeout: realtime.configuration.joinTimeout) + } + } + + // MARK: - Private + + private func _join() async throws(RealtimeError) { + guard let realtime else { throw .disconnected } + setState(.joining) + let ref = await realtime.nextRef() + joinRef = ref + + let joinPayload = buildJoinPayload() + let msg = PhoenixMessage( + joinRef: ref, ref: nil, + topic: topic, event: "phx_join", + payload: joinPayload + ) + let config = realtime.configuration + let reply = try await realtime.sendAndAwait(msg, timeout: config.joinTimeout) + let status = reply.payload["status"].flatMap { + if case .string(let s) = $0 { return s } else { return nil } + } + if status == "ok" { + setState(.joined) + } else { + let responseObj = reply.payload["response"].flatMap { + if case .object(let o) = $0 { return o } else { return nil } + } + let reason = + responseObj?["reason"].flatMap { + if case .string(let s) = $0 { return s } else { return nil } + } ?? "rejected" + setState(.closed(.policyViolation(reason))) + trackedStates.removeAll() + _postgresSubscriptions.removeAll() + throw .channelJoinRejected(reason: reason) + } + } + + private func buildJoinPayload() -> [String: JSONValue] { + var config: [String: JSONValue] = [:] + + var bc: [String: JSONValue] = [:] + if options.broadcast.acknowledge { bc["ack"] = true } + if options.broadcast.receiveOwnBroadcasts { bc["self"] = true } + if let replay = options.broadcast.replay { + bc["replay"] = .object([ + "since": .int(Int(replay.since.timeIntervalSince1970 * 1000)), + "limit": replay.limit.map { .int($0) } ?? .null, + ]) + } + if !bc.isEmpty { config["broadcast"] = .object(bc) } + + if let key = options.presenceKey { + config["presence"] = .object(["key": .string(key)]) + } + if options.isPrivate { + config["private"] = true + } + + // Add postgres_changes subscriptions + let changes: [JSONValue] = _postgresSubscriptions.values.map { sub in + var entry: [String: JSONValue] = [ + "event": "*", + "schema": .string(sub.schema), + "table": .string(sub.table), + ] + if let f = sub.filter { entry["filter"] = .string(f) } + return .object(entry) + } + if !changes.isEmpty { config["postgres_changes"] = .array(changes) } + + return ["config": .object(config)] + } + + private func setState(_ new: ChannelState) { + _state = new + for cont in stateContinuations.values { cont.yield(new) } + } + + func finishAllContinuations(throwing error: RealtimeError) { + let err: any Error = error + for cont in broadcastContinuations.values { cont.finish(throwing: err) } + for cont in stateContinuations.values { cont.finish() } + broadcastContinuations.removeAll() + stateContinuations.removeAll() + for finish in presenceFinishHandlers.values { finish() } + presenceSnapshotHandlers.removeAll() + presenceDiffHandlers.removeAll() + presenceFinishHandlers.removeAll() + for finish in postgresFinishHandlers.values { finish() } + postgresHandlers.removeAll() + postgresFinishHandlers.removeAll() + // Note: trackedStates is intentionally NOT cleared here — transient errors + // (phx_close / phx_error) should preserve tracked states so rejoin() can re-track them. + // trackedStates is cleared only on permanent closes: leave() and _join() rejection. + } + + // MARK: - Continuation cleanup helpers (called from onTermination Tasks) + + func joinIfNeeded() async throws(RealtimeError) { + switch _state { + case .unsubscribed, .closed: + try await _join() + default: + break + } + } + + func registerBroadcastContinuation( + id: UUID, + continuation: AsyncThrowingStream.Continuation + ) { + broadcastContinuations[id] = continuation + } + + private func removeStateContinuation(id: UUID) { + stateContinuations.removeValue(forKey: id) + } + + func removeBroadcastContinuation(id: UUID) { + broadcastContinuations.removeValue(forKey: id) + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Channel/ChannelOptions.swift b/Packages/_Realtime/Sources/_Realtime/Channel/ChannelOptions.swift new file mode 100644 index 000000000..8bdb51129 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Channel/ChannelOptions.swift @@ -0,0 +1,31 @@ +// +// ChannelOptions.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation + +public struct ChannelOptions: Sendable { + public var isPrivate: Bool = false + public var broadcast: BroadcastOptions = .init() + public var presenceKey: String? = nil + public init() {} +} + +public struct BroadcastOptions: Sendable { + public var acknowledge: Bool = false + public var receiveOwnBroadcasts: Bool = false + public var replay: ReplayOption? = nil + public init() {} +} + +public struct ReplayOption: Sendable { + public var since: Date + public var limit: Int? + public init(since: Date, limit: Int? = nil) { + self.since = since + self.limit = limit + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Channel/ChannelState.swift b/Packages/_Realtime/Sources/_Realtime/Channel/ChannelState.swift new file mode 100644 index 000000000..13ce70b98 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Channel/ChannelState.swift @@ -0,0 +1,14 @@ +// +// ChannelState.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +public enum ChannelState: Sendable, Equatable { + case unsubscribed + case joining + case joined + case leaving + case closed(CloseReason) +} diff --git a/Packages/_Realtime/Sources/_Realtime/Client/ConnectionStatus.swift b/Packages/_Realtime/Sources/_Realtime/Client/ConnectionStatus.swift new file mode 100644 index 000000000..c355fe7e7 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Client/ConnectionStatus.swift @@ -0,0 +1,30 @@ +// +// ConnectionStatus.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation + +public struct ConnectionStatus: Sendable, Equatable { + public enum State: Sendable, Equatable { + case idle + case connecting(attempt: Int) + case connected + case reconnecting(attempt: Int) + case closed(CloseReason) + } + + public let state: State + public let since: Date + public let latency: Duration? + + public init(state: State, since: Date = Date(), latency: Duration? = nil) { + self.state = state + self.since = since + self.latency = latency + } + + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.state == rhs.state } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Client/Realtime.swift b/Packages/_Realtime/Sources/_Realtime/Client/Realtime.swift new file mode 100644 index 000000000..be33fd56e --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Client/Realtime.swift @@ -0,0 +1,399 @@ +// +// Realtime.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Clocks +import Foundation +import IssueReporting + +public final actor Realtime: Sendable { + let url: URL + private let apiKey: APIKeySource + public let configuration: Configuration + private let transport: any RealtimeTransport + + private var connection: (any RealtimeConnection)? + private var receiveTask: Task? + private var heartbeatTask: Task? + private var channelRegistry: [String: Channel] = [:] + private var pendingReplies: [String: OnceResumingContinuation] = [:] + private var refCounter: Int = 0 + private var statusContinuations: [UUID: AsyncStream.Continuation] = [:] + private var _currentStatus: ConnectionStatus.State = .idle + + public var currentStatus: ConnectionStatus.State { _currentStatus } + + public init( + url: URL, + apiKey: APIKeySource, + configuration: Configuration = .default, + transport: any RealtimeTransport = URLSessionTransport() + ) { + self.url = url + self.apiKey = apiKey + self.configuration = configuration + self.transport = transport + } + + // MARK: - Public API + + public var status: AsyncStream { + AsyncStream { continuation in + let id = UUID() + statusContinuations[id] = continuation + // Emit current state immediately so new subscribers don't miss existing state. + continuation.yield(ConnectionStatus(state: _currentStatus)) + continuation.onTermination = { [id] _ in + Task { [weak self] in + await self?.removeStatusContinuation(id: id) + } + } + } + } + + private func removeStatusContinuation(id: UUID) { + statusContinuations.removeValue(forKey: id) + } + + public func connect() async throws(RealtimeError) { + guard _currentStatus == .idle || _currentStatus == .closed(.userRequested) else { return } + try await _connect() + } + + private func _connect() async throws(RealtimeError) { + setStatus(.connecting(attempt: 1)) + + let token: String + do { + token = try await resolveToken() + } catch { + setStatus(.idle) + throw .authenticationFailed(reason: error.localizedDescription, underlying: nil) + } + + var headers = configuration.headers + headers["apikey"] = token + headers["Authorization"] = "Bearer \(token)" + headers["vsn"] = configuration.protocolVersion.rawValue + + let wsURL = buildWebSocketURL() + let conn: any RealtimeConnection + do { + conn = try await transport.connect(to: wsURL, headers: headers) + } catch { + setStatus(.closed(.transportFailure)) + throw .transportFailure(underlying: error) + } + self.connection = conn + setStatus(.connected) + startReceiving(conn) + startHeartbeat() + } + + public func disconnect() async { + receiveTask?.cancel() + receiveTask = nil + heartbeatTask?.cancel() + heartbeatTask = nil + await connection?.close(code: 1000, reason: "user requested") + connection = nil + setStatus(.closed(.userRequested)) + failAllPendingReplies(with: RealtimeError.disconnected) + } + + public func updateToken(_ newToken: String) async throws(RealtimeError) { + let msg = PhoenixMessage( + joinRef: nil, ref: nil, + topic: "phoenix", event: "access_token", + payload: ["access_token": .string(newToken)] + ) + _ = try await sendAndAwait(msg, timeout: configuration.joinTimeout) + } + + public func channel( + _ topic: String, + configure: (inout ChannelOptions) -> Void = { _ in } + ) -> Channel { + if let existing = channelRegistry[topic] { return existing } + var options = ChannelOptions() + configure(&options) + let ch = Channel(topic: topic, options: options, realtime: self) + channelRegistry[topic] = ch + return ch + } + + // MARK: - Internal API (used by Channel) + + func sendBinary(_ data: Data) async throws(RealtimeError) { + guard let connection else { throw .disconnected } + do { + try await connection.send(.binary(data)) + } catch let e as RealtimeError { + throw e + } catch { + throw .transportFailure(underlying: error) + } + } + + func nextRef() -> String { + refCounter += 1 + return String(refCounter) + } + + func send(_ message: PhoenixMessage) async throws(RealtimeError) { + guard let connection else { throw .disconnected } + do { + let text = try PhoenixSerializer.encodeText(message) + try await connection.send(.text(text)) + } catch let e as RealtimeError { + throw e + } catch { + throw .transportFailure(underlying: error) + } + } + + func sendAndAwait( + _ message: PhoenixMessage, + timeout: Duration + ) async throws(RealtimeError) -> PhoenixMessage { + guard connection != nil else { throw .disconnected } + let ref = nextRef() + var tagged = message + tagged.ref = ref + + // Pre-encode the text frame so we don't capture a var in a concurrent closure. + let text: String + do { + text = try PhoenixSerializer.encodeText(tagged) + } catch { + throw .encoding(underlying: error) + } + + return try await withRealtimeTimeout( + timeout, + clock: configuration.clock, + sendAndAwait: { [weak self] continuation in + guard let self else { + continuation.resume(throwing: RealtimeError.disconnected) + return + } + await self.registerAndSend(ref: ref, text: text, continuation: continuation) + }, + onTimeout: { [weak self] in + Task { [weak self] in await self?.cancelPendingReply(ref: ref) } + } + ) + } + + private func registerAndSend( + ref: String, + text: String, + continuation: OnceResumingContinuation + ) async { + pendingReplies[ref] = continuation + do { + guard let connection else { + pendingReplies.removeValue(forKey: ref) + continuation.resume(throwing: RealtimeError.disconnected) + return + } + try await connection.send(.text(text)) + } catch { + if let cont = pendingReplies.removeValue(forKey: ref) { + cont.resume(throwing: error) + } + } + } + + private func cancelPendingReply(ref: String) { + if let cont = pendingReplies.removeValue(forKey: ref) { + cont.resume(throwing: RealtimeError.channelJoinTimeout) + } + } + + func removeChannel(_ topic: String) { + channelRegistry.removeValue(forKey: topic) + } + + // MARK: - Private + + func resolveToken() async throws -> String { + switch apiKey { + case .literal(let key): return key + case .dynamic(let fn): return try await fn() + } + } + + private func buildWebSocketURL() -> URL { + guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } + var items = comps.queryItems ?? [] + items.append(URLQueryItem(name: "vsn", value: configuration.protocolVersion.rawValue)) + comps.queryItems = items + return comps.url ?? url + } + + private func setStatus(_ state: ConnectionStatus.State) { + _currentStatus = state + let s = ConnectionStatus(state: state) + for cont in statusContinuations.values { cont.yield(s) } + } + + private func startReceiving(_ conn: any RealtimeConnection) { + receiveTask = Task { [weak self] in + do { + for try await frame in conn.frames { + await self?.handle(frame) + } + // Stream ended cleanly — treat as connection loss. + await self?.handleConnectionLoss(error: URLError(.networkConnectionLost)) + } catch { + await self?.handleConnectionLoss(error: error) + } + } + } + + private func handle(_ frame: TransportFrame) async { + switch frame { + case .text(let text): + guard let msg = try? PhoenixSerializer.decodeText(text) else { return } + await route(msg) + case .binary(let data): + guard let broadcast = try? PhoenixSerializer.decodeBinary(data) else { return } + if let ch = channelRegistry[broadcast.topic] { + await ch.handleBinaryBroadcast(broadcast) + } + } + } + + private func route(_ msg: PhoenixMessage) async { + // Heartbeat reply + if msg.topic == "phoenix", msg.event == "phx_reply" { + if let ref = msg.ref, let cont = pendingReplies.removeValue(forKey: ref) { + cont.resume(returning: msg) + } + return + } + // Pending reply (join/leave/token ack) + if msg.event == "phx_reply", let ref = msg.ref, + let cont = pendingReplies.removeValue(forKey: ref) + { + cont.resume(returning: msg) + return + } + // Route to channel + if let ch = channelRegistry[msg.topic] { + await ch.handle(msg) + } + } + + private func handleConnectionLoss(error: any Error) async { + guard _currentStatus != .closed(.userRequested) else { return } + setStatus(.closed(.transportFailure)) + failAllPendingReplies(with: .disconnected) + for ch in channelRegistry.values { + await ch.handleConnectionLoss() + } + await attemptReconnect(lastError: error) + } + + private func attemptReconnect(lastError: any Error) async { + var attempt = 1 + while !Task.isCancelled { + guard let delay = configuration.reconnection.nextDelay(attempt, lastError) else { + setStatus(.closed(.transportFailure)) + return + } + setStatus(.reconnecting(attempt: attempt)) + try? await configuration.clock.sleep(for: delay) + guard !Task.isCancelled else { return } + do { + try await _connect() + for ch in channelRegistry.values { + try? await ch.rejoin() + } + return + } catch { + attempt += 1 + } + } + } + + private func startHeartbeat() { + heartbeatTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + do { + try await self.configuration.clock.sleep(for: self.configuration.heartbeat) + let msg = PhoenixMessage( + joinRef: nil, ref: nil, + topic: "phoenix", event: "heartbeat", + payload: [:] + ) + _ = try await self.sendAndAwait(msg, timeout: self.configuration.heartbeat) + } catch is CancellationError { + return + } catch { + // heartbeat failure triggers connection loss via the receive loop + } + } + } + } + + private func failAllPendingReplies(with error: RealtimeError) { + let replies = pendingReplies + pendingReplies.removeAll() + for cont in replies.values { + cont.resume(throwing: error) + } + } +} + +// MARK: - Timeout helper + +/// Runs `sendAndAwait` within a racing timeout. +/// +/// - Parameters: +/// - sendAndAwait: Called with a continuation. The callee must eventually resume the continuation. +/// - onTimeout: Called (from a nonisolated context) when the timeout fires first. +func withRealtimeTimeout( + _ duration: Duration, + clock: any Clock, + sendAndAwait: @escaping @Sendable (OnceResumingContinuation) async -> Void, + onTimeout: @escaping @Sendable () -> Void +) async throws(RealtimeError) -> T { + // We use a manual continuation + a racing Task instead of TaskGroup so the + // sendAndAwait closure can be called while already inside the actor. + do { + return try await withCheckedThrowingContinuation { rawContinuation in + let once = OnceResumingContinuation(rawContinuation) + Task { + await withTaskGroup(of: Void.self) { group in + // Timeout race. + group.addTask { + do { + try await clock.sleep(for: duration) + onTimeout() + once.resume(throwing: RealtimeError.channelJoinTimeout) + } catch { + // Task cancelled — operation already completed. + } + } + // Actual work. + group.addTask { + await sendAndAwait(once) + } + // Wait for the first one to finish, then cancel the other. + await group.next() + group.cancelAll() + } + } + } + } catch let e as RealtimeError { + throw e + } catch { + throw .transportFailure(underlying: error) + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Config/APIKeySource.swift b/Packages/_Realtime/Sources/_Realtime/Config/APIKeySource.swift new file mode 100644 index 000000000..68864d75f --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Config/APIKeySource.swift @@ -0,0 +1,5 @@ +public enum APIKeySource: Sendable { + case literal(String) + /// Called on connect and on `token_expired` server signal. + case dynamic(@Sendable () async throws -> String) +} diff --git a/Packages/_Realtime/Sources/_Realtime/Config/Configuration.swift b/Packages/_Realtime/Sources/_Realtime/Config/Configuration.swift new file mode 100644 index 000000000..9a6c4af01 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Config/Configuration.swift @@ -0,0 +1,50 @@ +import Clocks +import Foundation + +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) + ) + /// Socket stays open this long after the last channel leaves. `.zero` = immediate close. + public var disconnectOnEmptyChannelsAfter: Duration = .seconds(50) + public var handleAppLifecycle: Bool = Configuration.defaultHandleAppLifecycle + public var protocolVersion: RealtimeProtocolVersion = .v2 + public var clock: any Clock = ContinuousClock() + public var headers: [String: String] = [:] + public var logger: (any RealtimeLogger)? = nil + public var urlSession: URLSession = .shared + public var decoder: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() + public var encoder: JSONEncoder = { + let e = JSONEncoder() + e.dateEncodingStrategy = .iso8601 + return e + }() + + public static let `default` = Configuration() + public init() {} + + public init(_ configure: (inout Configuration) -> Void) { + var c = Configuration() + configure(&c) + self = c + } + + #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) + static let defaultHandleAppLifecycle = true + #else + static let defaultHandleAppLifecycle = false + #endif +} + +public enum RealtimeProtocolVersion: String, Sendable { + case v1 = "1.0.0" + case v2 = "2.0.0" +} diff --git a/Packages/_Realtime/Sources/_Realtime/Config/ReconnectionPolicy.swift b/Packages/_Realtime/Sources/_Realtime/Config/ReconnectionPolicy.swift new file mode 100644 index 000000000..bf9d71248 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Config/ReconnectionPolicy.swift @@ -0,0 +1,31 @@ +import Foundation + +public struct ReconnectionPolicy: Sendable { + /// Return `nil` to stop retrying. + public var nextDelay: @Sendable (_ attempt: Int, _ lastError: any Error & Sendable) -> Duration? + + public static let never = ReconnectionPolicy { _, _ in nil } + + public static func exponentialBackoff( + initial: Duration, + max: Duration, + jitter: Double = 0.2 + ) -> ReconnectionPolicy { + let initialSecs = + Double(initial.components.seconds) + Double(initial.components.attoseconds) / 1e18 + let maxSecs = Double(max.components.seconds) + Double(max.components.attoseconds) / 1e18 + return ReconnectionPolicy { attempt, _ in + let base = initialSecs * pow(2.0, Double(attempt - 1)) + let capped = Swift.min(base, maxSecs) + let noise = capped * Double.random(in: -jitter...jitter) + return .seconds(Swift.max(0, capped + noise)) + } + } + + public static func fixed(_ delay: Duration, maxAttempts: Int? = nil) -> ReconnectionPolicy { + ReconnectionPolicy { attempt, _ in + if let max = maxAttempts, attempt > max { return nil } + return delay + } + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Error/RealtimeError.swift b/Packages/_Realtime/Sources/_Realtime/Error/RealtimeError.swift new file mode 100644 index 000000000..3f32a8ca8 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Error/RealtimeError.swift @@ -0,0 +1,108 @@ +import Foundation + +public enum RealtimeError: Error, Sendable, Equatable { + // Connection + case disconnected + case transportFailure(underlying: any Error & Sendable) + case reconnectionGaveUp(lastError: any Error & Sendable) + + // Channel lifecycle + case channelNotJoined + case channelJoinTimeout + case channelJoinRejected(reason: String) + case channelClosed(CloseReason) + + // Auth + case authenticationFailed(reason: String, underlying: (any Error & Sendable)?) + case tokenExpired + + // Server + case rateLimited(retryAfter: Duration?) + case serverError(code: Int, message: String) + + // Broadcast + case broadcastFailed(reason: String) + case broadcastAckTimeout + + // Coding + case decoding(type: String, underlying: any Error & Sendable) + case encoding(underlying: any Error & Sendable) + + // Cancellation (Swift CancellationError folded here) + case cancelled +} + +public enum CloseReason: Sendable, Equatable { + case userRequested + case serverClosed(code: Int, message: String?) + case timeout + case unauthorized + case policyViolation(String) + case transportFailure +} + +extension RealtimeError { + public static func == (lhs: RealtimeError, rhs: RealtimeError) -> Bool { + switch (lhs, rhs) { + case (.disconnected, .disconnected): return true + case (.transportFailure, .transportFailure): return true + case (.reconnectionGaveUp, .reconnectionGaveUp): return true + case (.channelNotJoined, .channelNotJoined): return true + case (.channelJoinTimeout, .channelJoinTimeout): return true + case (.channelJoinRejected(let l), .channelJoinRejected(let r)): return l == r + case (.channelClosed(let l), .channelClosed(let r)): return l == r + case (.authenticationFailed(let lr, _), .authenticationFailed(let rr, _)): return lr == rr + case (.tokenExpired, .tokenExpired): return true + case (.rateLimited(let l), .rateLimited(let r)): return l == r + case (.serverError(let lc, let lm), .serverError(let rc, let rm)): return lc == rc && lm == rm + case (.broadcastFailed(let l), .broadcastFailed(let r)): return l == r + case (.broadcastAckTimeout, .broadcastAckTimeout): return true + case (.decoding(let lt, _), .decoding(let rt, _)): return lt == rt + case (.encoding, .encoding): return true + case (.cancelled, .cancelled): return true + default: return false + } + } +} + +extension RealtimeError: LocalizedError { + public var errorDescription: String? { + switch self { + case .disconnected: + return "Not connected to the Realtime server." + case .transportFailure(let underlying): + return "Transport failure: \(underlying.localizedDescription)" + case .reconnectionGaveUp(let lastError): + return "Reconnection exhausted: \(lastError.localizedDescription)" + case .channelNotJoined: + return "Channel is not joined." + case .channelJoinTimeout: + return "Channel join timed out." + case .channelJoinRejected(let reason): + return "Channel join rejected: \(reason)" + case .channelClosed(let reason): + return "Channel closed: \(reason)" + case .authenticationFailed(let reason, _): + return "Authentication failed: \(reason)" + case .tokenExpired: + return "Authentication token expired." + case .rateLimited(let retryAfter): + if let d = retryAfter { + return "Rate limited. Retry after \(d)." + } + return "Rate limited." + case .serverError(let code, let message): + return "Server error \(code): \(message)" + case .broadcastFailed(let reason): + return "Broadcast failed: \(reason)" + case .broadcastAckTimeout: + return "Broadcast acknowledgement timed out." + case .decoding(let type, let underlying): + return "Failed to decode \(type): \(underlying.localizedDescription)" + case .encoding(let underlying): + return "Encoding error: \(underlying.localizedDescription)" + case .cancelled: + return "Operation was cancelled." + } + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Error/RealtimeLogger.swift b/Packages/_Realtime/Sources/_Realtime/Error/RealtimeLogger.swift new file mode 100644 index 000000000..ca8a2278c --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Error/RealtimeLogger.swift @@ -0,0 +1,30 @@ +import Foundation + +public protocol RealtimeLogger: Sendable { + func log(_ event: LogEvent) +} + +public struct LogEvent: Sendable { + public let level: LogLevel + public let category: LogCategory + public let message: String + public let metadata: [String: String] + public let timestamp: Date + + public init( + level: LogLevel, + category: LogCategory, + message: String, + metadata: [String: String] = [:], + timestamp: Date = Date() + ) { + self.level = level + self.category = category + self.message = message + self.metadata = metadata + self.timestamp = timestamp + } +} + +public enum LogLevel: Sendable { case debug, info, warn, error } +public enum LogCategory: Sendable { case connection, channel, broadcast, presence, postgres } diff --git a/Packages/_Realtime/Sources/_Realtime/Internal/Atomic.swift b/Packages/_Realtime/Sources/_Realtime/Internal/Atomic.swift new file mode 100644 index 000000000..a2f3cb168 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Internal/Atomic.swift @@ -0,0 +1,38 @@ +// +// Atomic.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation + +final class _Atomic: @unchecked Sendable { + private var _value: T + private let lock = NSLock() + init(_ value: T) { _value = value } + var value: T { lock.withLock { _value } } + @discardableResult func exchange(_ newValue: T) -> T { + lock.withLock { let old = _value; _value = newValue; return old } + } +} + +/// Wraps a CheckedContinuation so that only the first resume call wins. +final class OnceResumingContinuation: @unchecked Sendable { + private let inner: CheckedContinuation + private let claimed = _Atomic(false) + + init(_ continuation: CheckedContinuation) { + self.inner = continuation + } + + func resume(returning value: T) { + guard !claimed.exchange(true) else { return } + inner.resume(returning: value) + } + + func resume(throwing error: any Error) { + guard !claimed.exchange(true) else { return } + inner.resume(throwing: error) + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Internal/JSONValue.swift b/Packages/_Realtime/Sources/_Realtime/Internal/JSONValue.swift new file mode 100644 index 000000000..c57de40ad --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Internal/JSONValue.swift @@ -0,0 +1,91 @@ +// +// JSONValue.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/26. +// + +import Foundation + +/// Codable JSON value without external dependencies. +public enum JSONValue: Codable, Sendable, Equatable { + case string(String) + case double(Double) + case int(Int) + case bool(Bool) + case null + indirect case array([JSONValue]) + indirect case object([String: JSONValue]) + + public init(from decoder: any Decoder) throws { + let c = try decoder.singleValueContainer() + if let v = try? c.decode(Bool.self) { + self = .bool(v) + return + } + if let v = try? c.decode(Int.self) { + self = .int(v) + return + } + if let v = try? c.decode(Double.self) { + self = .double(v) + return + } + if let v = try? c.decode(String.self) { + self = .string(v) + return + } + if c.decodeNil() { + self = .null + return + } + if let v = try? c.decode([JSONValue].self) { + self = .array(v) + return + } + if let v = try? c.decode([String: JSONValue].self) { + self = .object(v) + return + } + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Cannot decode JSONValue") + ) + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .string(let v): try c.encode(v) + case .double(let v): try c.encode(v) + case .int(let v): try c.encode(v) + case .bool(let v): try c.encode(v) + case .null: try c.encodeNil() + case .array(let v): try c.encode(v) + case .object(let v): try c.encode(v) + } + } +} + +extension JSONValue: ExpressibleByStringLiteral { + public init(stringLiteral v: String) { self = .string(v) } +} +extension JSONValue: ExpressibleByIntegerLiteral { + public init(integerLiteral v: Int) { self = .int(v) } +} +extension JSONValue: ExpressibleByFloatLiteral { + public init(floatLiteral v: Double) { self = .double(v) } +} +extension JSONValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral v: Bool) { self = .bool(v) } +} +extension JSONValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { self = .null } +} +extension JSONValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { self = .array(elements) } +} +extension JSONValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(Dictionary(uniqueKeysWithValues: elements)) + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Internal/PhoenixMessage.swift b/Packages/_Realtime/Sources/_Realtime/Internal/PhoenixMessage.swift new file mode 100644 index 000000000..504bfdd54 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Internal/PhoenixMessage.swift @@ -0,0 +1,28 @@ +// +// PhoenixMessage.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/26. +// + +import Foundation + +/// Decoded Phoenix protocol message (text array format or binary broadcast). +struct PhoenixMessage: Sendable { + var joinRef: String? + var ref: String? + var topic: String + var event: String + var payload: [String: JSONValue] +} + +/// Decoded server-to-client binary broadcast (type 0x04). +struct BinaryBroadcast: Sendable { + let topic: String + let event: String + enum Payload: Sendable { + case json([String: JSONValue]) + case binary(Data) + } + let payload: Payload +} diff --git a/Packages/_Realtime/Sources/_Realtime/Internal/PhoenixSerializer.swift b/Packages/_Realtime/Sources/_Realtime/Internal/PhoenixSerializer.swift new file mode 100644 index 000000000..556543e17 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Internal/PhoenixSerializer.swift @@ -0,0 +1,211 @@ +// +// PhoenixSerializer.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/26. +// + +import Foundation + +enum PhoenixSerializer { + enum BinaryKind: UInt8 { + case clientBroadcastPush = 3 + case serverBroadcast = 4 + } + enum PayloadEncoding: UInt8 { + case binary = 0 + case json = 1 + } + + // MARK: Text encode/decode + + static func encodeText(_ msg: PhoenixMessage) throws -> String { + let array: [JSONValue] = [ + msg.joinRef.map { .string($0) } ?? .null, + msg.ref.map { .string($0) } ?? .null, + .string(msg.topic), + .string(msg.event), + .object(msg.payload), + ] + let data = try JSONEncoder().encode(array) + guard let text = String(data: data, encoding: .utf8) else { + throw RealtimeError.encoding( + underlying: EncodingError.invalidValue( + array, + .init(codingPath: [], debugDescription: "UTF-8 conversion failed") + ) + ) + } + return text + } + + static func decodeText(_ text: String) throws -> PhoenixMessage { + let data = Data(text.utf8) + let array = try JSONDecoder().decode([JSONValue].self, from: data) + guard array.count >= 5 else { + throw RealtimeError.decoding( + type: "PhoenixMessage", + underlying: DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Expected 5-element array, got \(array.count)") + ) + ) + } + let joinRef: String? = if case .string(let s) = array[0] { s } else { nil } + let ref: String? = if case .string(let s) = array[1] { s } else { nil } + guard case .string(let topic) = array[2], + case .string(let event) = array[3], + case .object(let payload) = array[4] + else { + throw RealtimeError.decoding( + type: "PhoenixMessage", + underlying: DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Unexpected element types in Phoenix array") + ) + ) + } + return PhoenixMessage(joinRef: joinRef, ref: ref, topic: topic, event: event, payload: payload) + } + + // MARK: Binary decode (server->client, type 0x04) + + static func decodeBinary(_ data: Data) throws -> BinaryBroadcast { + guard data.count >= 5 else { + throw RealtimeError.decoding( + type: "BinaryBroadcast", + underlying: DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Binary frame too short: \(data.count)") + ) + ) + } + let kind = data[data.startIndex] + guard kind == BinaryKind.serverBroadcast.rawValue else { + throw RealtimeError.decoding( + type: "BinaryBroadcast", + underlying: DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Unexpected kind byte: \(kind)") + ) + ) + } + let topicLen = Int(data[data.startIndex + 1]) + let eventLen = Int(data[data.startIndex + 2]) + let metaLen = Int(data[data.startIndex + 3]) + let encByte = data[data.startIndex + 4] + guard let encoding = PayloadEncoding(rawValue: encByte) else { + throw RealtimeError.decoding( + type: "BinaryBroadcast", + underlying: DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Unknown encoding byte: \(encByte)") + ) + ) + } + let headerSize = 5 + guard data.count >= headerSize + topicLen + eventLen + metaLen else { + throw RealtimeError.decoding( + type: "BinaryBroadcast", + underlying: DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Frame too short for declared field lengths") + ) + ) + } + var offset = data.startIndex + headerSize + let topicData = data[offset..<(offset + topicLen)] + offset += topicLen + let eventData = data[offset..<(offset + eventLen)] + offset += eventLen + offset += metaLen + let payloadData = data[offset...] + guard let topic = String(data: topicData, encoding: .utf8), + let event = String(data: eventData, encoding: .utf8) + else { + throw RealtimeError.decoding( + type: "BinaryBroadcast", + underlying: DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "UTF-8 decode failure in topic or event") + ) + ) + } + let payload: BinaryBroadcast.Payload + switch encoding { + case .json: + let obj = try JSONDecoder().decode([String: JSONValue].self, from: Data(payloadData)) + payload = .json(obj) + case .binary: + payload = .binary(Data(payloadData)) + } + return BinaryBroadcast(topic: topic, event: event, payload: payload) + } + + // MARK: Binary encode (client->server, type 0x03) + + static func encodeBroadcastPush( + joinRef: String?, ref: String?, + topic: String, event: String, + payload: [String: JSONValue] + ) throws -> Data { + let payloadData = try JSONEncoder().encode(payload) + return try _encodePush( + joinRef: joinRef, ref: ref, topic: topic, event: event, + encoding: .json, payload: payloadData + ) + } + + static func encodeBroadcastPush( + joinRef: String?, ref: String?, + topic: String, event: String, + binaryPayload: Data + ) throws -> Data { + try _encodePush( + joinRef: joinRef, ref: ref, topic: topic, event: event, + encoding: .binary, payload: binaryPayload + ) + } + + private static func _encodePush( + joinRef: String?, ref: String?, + topic: String, event: String, + encoding: PayloadEncoding, payload: Data + ) throws -> Data { + let jrBytes = Data((joinRef ?? "").utf8) + let rBytes = Data((ref ?? "").utf8) + let tBytes = Data(topic.utf8) + let eBytes = Data(event.utf8) + guard jrBytes.count <= 255 else { + throw RealtimeError.encoding( + underlying: EncodingError.invalidValue( + joinRef ?? "", + .init(codingPath: [], debugDescription: "joinRef exceeds 255 bytes (\(jrBytes.count))"))) + } + guard rBytes.count <= 255 else { + throw RealtimeError.encoding( + underlying: EncodingError.invalidValue( + ref ?? "", + .init(codingPath: [], debugDescription: "ref exceeds 255 bytes (\(rBytes.count))"))) + } + guard tBytes.count <= 255 else { + throw RealtimeError.encoding( + underlying: EncodingError.invalidValue( + topic, + .init(codingPath: [], debugDescription: "topic exceeds 255 bytes (\(tBytes.count))"))) + } + guard eBytes.count <= 255 else { + throw RealtimeError.encoding( + underlying: EncodingError.invalidValue( + event, + .init(codingPath: [], debugDescription: "event exceeds 255 bytes (\(eBytes.count))"))) + } + var out = Data() + out.append(BinaryKind.clientBroadcastPush.rawValue) + out.append(UInt8(jrBytes.count)) + out.append(UInt8(rBytes.count)) + out.append(UInt8(tBytes.count)) + out.append(UInt8(eBytes.count)) + out.append(0x00) // meta_len = 0 + out.append(encoding.rawValue) + out.append(jrBytes) + out.append(rBytes) + out.append(tBytes) + out.append(eBytes) + out.append(payload) + return out + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Macros/RealtimeTable+Macro.swift b/Packages/_Realtime/Sources/_Realtime/Macros/RealtimeTable+Macro.swift new file mode 100644 index 000000000..7cabeed9d --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Macros/RealtimeTable+Macro.swift @@ -0,0 +1,27 @@ +// +// RealtimeTable+Macro.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import _RealtimeTableMacros + +/// Synthesizes `RealtimeTable` conformance for a struct, enabling typed `Filter` in postgres change streams. +/// +/// ```swift +/// @RealtimeTable(schema: "public", table: "messages") +/// struct Message: Codable, Sendable { +/// var id: UUID +/// var roomId: UUID +/// var text: String +/// } +/// +/// // Enables typed filters: +/// channel.changes(to: Message.self, where: .eq(\.roomId, roomId)) +/// ``` +/// +/// Column names follow `CodingKeys` if defined; otherwise camelCase is converted to snake_case. +@attached(extension, conformances: RealtimeTable, names: named(schema), named(tableName), named(columnName)) +public macro RealtimeTable(schema: String, table: String) = + #externalMacro(module: "_RealtimeTableMacroPlugin", type: "RealtimeTableMacro") diff --git a/Packages/_Realtime/Sources/_Realtime/Postgres/Channel+Postgres.swift b/Packages/_Realtime/Sources/_Realtime/Postgres/Channel+Postgres.swift new file mode 100644 index 000000000..532b94bac --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Postgres/Channel+Postgres.swift @@ -0,0 +1,175 @@ +// +// Channel+Postgres.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation + +extension Channel { + // MARK: - Typed streams + + public func changes( + to _: T.Type = T.self, + where filter: Filter? = nil, + decoder: JSONDecoder = JSONDecoder() + ) -> AsyncThrowingStream, any Error> { + let subscriptionId = UUID() + return AsyncThrowingStream { continuation in + let id = UUID() + Task { + await self.registerPostgresHandler(id: id) { payload in + do { + let change = try PostgresChange.decode(from: payload) + continuation.yield(change) + } catch { + continuation.finish( + throwing: RealtimeError.decoding(type: String(describing: T.self), underlying: error) + ) + } + } finish: { + continuation.finish() + } + continuation.onTermination = { [id] _ in + Task { await self.unregisterPostgresHandlers(id: id) } + } + do { + try await self.joinWithPostgresFilter( + schema: T.schema, table: T.tableName, + filter: filter?.wireValue, + subscriptionId: subscriptionId + ) + } catch let e as RealtimeError { + continuation.finish(throwing: e) + } + } + } + } + + public func inserts( + into _: T.Type = T.self, + where filter: Filter? = nil + ) -> AsyncThrowingStream { + let raw = changes(to: T.self, where: filter) + return AsyncThrowingStream { continuation in + let task = Task { + do { + for try await change in raw { + if case .insert(let row) = change { continuation.yield(row) } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + public func updates( + of _: T.Type = T.self, + where filter: Filter? = nil + ) -> AsyncThrowingStream<(old: T, new: T), any Error> { + let raw = changes(to: T.self, where: filter) + return AsyncThrowingStream { continuation in + let task = Task { + do { + for try await change in raw { + if case .update(let old, let new) = change { continuation.yield((old, new)) } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + public func deletes( + from _: T.Type = T.self, + where filter: Filter? = nil + ) -> AsyncThrowingStream { + let raw = changes(to: T.self, where: filter) + return AsyncThrowingStream { continuation in + let task = Task { + do { + for try await change in raw { + if case .delete(let old) = change { continuation.yield(old) } + } + continuation.finish() + } catch { + continuation.finish(throwing: error) + } + } + continuation.onTermination = { _ in task.cancel() } + } + } + + // MARK: - Untyped escape hatch + + public func changes( + schema: String = "public", + table: String, + filter: UntypedFilter? = nil + ) -> AsyncThrowingStream, any Error> { + let subscriptionId = UUID() + return AsyncThrowingStream { continuation in + let id = UUID() + Task { + await self.registerPostgresHandler(id: id) { payload in + do { + let change = try PostgresChange<[String: JSONValue]>.decode(from: payload) + continuation.yield(change) + } catch { + continuation.finish( + throwing: RealtimeError.decoding( + type: "[String: JSONValue]", underlying: error) + ) + } + } finish: { + continuation.finish() + } + continuation.onTermination = { [id] _ in + Task { await self.unregisterPostgresHandlers(id: id) } + } + do { + try await self.joinWithPostgresFilter( + schema: schema, table: table, + filter: filter?.wireValue, + subscriptionId: subscriptionId + ) + } catch let e as RealtimeError { + continuation.finish(throwing: e) + } + } + } + } + + // MARK: - Internal registration helpers + + func registerPostgresHandler( + id: UUID, + onPayload: @escaping @Sendable ([String: JSONValue]) -> Void, + finish: @escaping @Sendable () -> Void + ) { + postgresHandlers[id] = onPayload + postgresFinishHandlers[id] = finish + } + + func unregisterPostgresHandlers(id: UUID) { + postgresHandlers.removeValue(forKey: id) + postgresFinishHandlers.removeValue(forKey: id) + } + + private func joinWithPostgresFilter( + schema: String, table: String, + filter: String?, subscriptionId: UUID + ) async throws(RealtimeError) { + _postgresSubscriptions[subscriptionId] = PostgresSubscription( + id: subscriptionId, schema: schema, table: table, filter: filter + ) + try await joinIfNeeded() + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Postgres/Filter.swift b/Packages/_Realtime/Sources/_Realtime/Postgres/Filter.swift new file mode 100644 index 000000000..cb424680d --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Postgres/Filter.swift @@ -0,0 +1,74 @@ +// +// Filter.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +public struct Filter: Sendable { + public let wireValue: String + + public static func eq(_ kp: KeyPath, _ v: V) -> Filter { + Filter(wireValue: "\(T.columnName(for: kp))=eq.\(v.filterString)") + } + + public static func neq(_ kp: KeyPath, _ v: V) -> Filter { + Filter(wireValue: "\(T.columnName(for: kp))=neq.\(v.filterString)") + } + + public static func gt(_ kp: KeyPath, _ v: V) -> Filter { + Filter(wireValue: "\(T.columnName(for: kp))=gt.\(v.filterString)") + } + + public static func gte(_ kp: KeyPath, _ v: V) -> Filter { + Filter(wireValue: "\(T.columnName(for: kp))=gte.\(v.filterString)") + } + + public static func lt(_ kp: KeyPath, _ v: V) -> Filter { + Filter(wireValue: "\(T.columnName(for: kp))=lt.\(v.filterString)") + } + + public static func lte(_ kp: KeyPath, _ v: V) -> Filter { + Filter(wireValue: "\(T.columnName(for: kp))=lte.\(v.filterString)") + } + + public static func `in`(_ kp: KeyPath, _ values: [V]) -> Filter + { + let list = values.map(\.filterString).joined(separator: ",") + return Filter(wireValue: "\(T.columnName(for: kp))=in.(\(list))") + } +} + +public struct UntypedFilter: Sendable { + public let wireValue: String + + public static func eq(_ column: String, _ value: some RealtimeFilterValue) -> UntypedFilter { + UntypedFilter(wireValue: "\(column)=eq.\(value.filterString)") + } + + public static func neq(_ column: String, _ value: some RealtimeFilterValue) -> UntypedFilter { + UntypedFilter(wireValue: "\(column)=neq.\(value.filterString)") + } + + public static func gt(_ column: String, _ value: some RealtimeFilterValue) -> UntypedFilter { + UntypedFilter(wireValue: "\(column)=gt.\(value.filterString)") + } + + public static func gte(_ column: String, _ value: some RealtimeFilterValue) -> UntypedFilter { + UntypedFilter(wireValue: "\(column)=gte.\(value.filterString)") + } + + public static func lt(_ column: String, _ value: some RealtimeFilterValue) -> UntypedFilter { + UntypedFilter(wireValue: "\(column)=lt.\(value.filterString)") + } + + public static func lte(_ column: String, _ value: some RealtimeFilterValue) -> UntypedFilter { + UntypedFilter(wireValue: "\(column)=lte.\(value.filterString)") + } + + public static func `in`(_ column: String, _ values: [some RealtimeFilterValue]) -> UntypedFilter + { + let list = values.map(\.filterString).joined(separator: ",") + return UntypedFilter(wireValue: "\(column)=in.(\(list))") + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Postgres/PostgresChange.swift b/Packages/_Realtime/Sources/_Realtime/Postgres/PostgresChange.swift new file mode 100644 index 000000000..27e5dbf78 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Postgres/PostgresChange.swift @@ -0,0 +1,60 @@ +// +// PostgresChange.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation + +public enum PostgresChange: Sendable { + case insert(T) + case update(old: T, new: T) + case delete(old: T) + + static func decode(from payload: [String: JSONValue]) throws -> PostgresChange + where T: Decodable { + guard case .object(let data) = payload["data"] else { + throw DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Missing 'data'") + ) + } + guard case .string(let type) = data["type"] else { + throw DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Missing 'type'") + ) + } + + func decodeRecord(_ key: String) throws -> T { + guard case .object(let obj) = data[key] else { + throw DecodingError.keyNotFound( + _AnyKey(stringValue: key), + .init(codingPath: [], debugDescription: "Missing '\(key)'") + ) + } + let recordData = try JSONEncoder().encode(obj) + return try JSONDecoder().decode(T.self, from: recordData) + } + + switch type { + case "INSERT": + return .insert(try decodeRecord("record")) + case "UPDATE": + return .update(old: try decodeRecord("old_record"), new: try decodeRecord("record")) + case "DELETE": + return .delete(old: try decodeRecord("old_record")) + default: + throw DecodingError.dataCorrupted( + .init(codingPath: [], debugDescription: "Unknown type: \(type)") + ) + } + } +} + +// Helper to satisfy DecodingError.keyNotFound CodingKey requirement. +private struct _AnyKey: CodingKey { + var stringValue: String + var intValue: Int? { nil } + init(stringValue: String) { self.stringValue = stringValue } + init?(intValue: Int) { return nil } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Postgres/RealtimeTable.swift b/Packages/_Realtime/Sources/_Realtime/Postgres/RealtimeTable.swift new file mode 100644 index 000000000..73420f987 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Postgres/RealtimeTable.swift @@ -0,0 +1,26 @@ +// +// RealtimeTable.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation + +public protocol RealtimeTable { + static var schema: String { get } + static var tableName: String { get } + static func columnName(for keyPath: KeyPath) -> String +} + +public protocol RealtimeFilterValue { + var filterString: String { get } +} + +extension String: RealtimeFilterValue { public var filterString: String { self } } +extension Int: RealtimeFilterValue { public var filterString: String { String(self) } } +extension Double: RealtimeFilterValue { public var filterString: String { String(self) } } +extension Bool: RealtimeFilterValue { public var filterString: String { String(self) } } +extension UUID: RealtimeFilterValue { + public var filterString: String { uuidString.lowercased() } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Presence/Channel+Presence.swift b/Packages/_Realtime/Sources/_Realtime/Presence/Channel+Presence.swift new file mode 100644 index 000000000..e01cd1f58 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Presence/Channel+Presence.swift @@ -0,0 +1,49 @@ +// +// Channel+Presence.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation + +extension Channel { + public var presence: Presence { Presence(channel: self) } + + /// Returns topic and joinRef atomically (both read while holding actor isolation). + func presenceTrackInfo() -> (topic: String, joinRef: String?) { + (topic, joinRef) + } + + func registerTrack(id: UUID, state: [String: JSONValue]) { + trackedStates[id] = state + } + + func unregisterTrack(id: UUID) { + trackedStates.removeValue(forKey: id) + } + + func registerSnapshotHandler( + id: UUID, + onSnapshot: @escaping @Sendable ([String: JSONValue]) -> Void, + finish: @escaping @Sendable () -> Void + ) { + presenceSnapshotHandlers[id] = onSnapshot + presenceFinishHandlers[id] = finish + } + + func registerDiffHandler( + id: UUID, + onDiff: @escaping @Sendable ([String: JSONValue]) -> Void, + finish: @escaping @Sendable () -> Void + ) { + presenceDiffHandlers[id] = onDiff + presenceFinishHandlers[id] = finish + } + + func unregisterPresenceHandlers(id: UUID) { + presenceSnapshotHandlers.removeValue(forKey: id) + presenceDiffHandlers.removeValue(forKey: id) + presenceFinishHandlers.removeValue(forKey: id) + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Presence/Presence.swift b/Packages/_Realtime/Sources/_Realtime/Presence/Presence.swift new file mode 100644 index 000000000..81b7739f2 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Presence/Presence.swift @@ -0,0 +1,126 @@ +// +// Presence.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation + +public struct Presence: Sendable { + private let channel: Channel + + init(channel: Channel) { self.channel = channel } + + // MARK: - Track + + public func track(_ state: T) async throws(RealtimeError) -> PresenceHandle { + guard let realtime = await channel.realtime else { throw .disconnected } + + let data: Data + do { data = try realtime.configuration.encoder.encode(state) } catch { + throw .encoding(underlying: error) + } + let obj: [String: JSONValue] + do { obj = try JSONDecoder().decode([String: JSONValue].self, from: data) } catch { + throw .encoding(underlying: error) + } + + let (topic, joinRef) = await channel.presenceTrackInfo() + let trackMsg = PhoenixMessage( + joinRef: joinRef, ref: nil, + topic: topic, event: "presence", + payload: ["event": "track", "payload": .object(obj)] + ) + _ = try await realtime.sendAndAwait(trackMsg, timeout: realtime.configuration.joinTimeout) + + let trackId = UUID() + await channel.registerTrack(id: trackId, state: obj) + + let cancelClosure: @Sendable () async throws(RealtimeError) -> Void = { + await channel.unregisterTrack(id: trackId) + let (cancelTopic, cancelJoinRef) = await channel.presenceTrackInfo() + let untrackMsg = PhoenixMessage( + joinRef: cancelJoinRef, ref: nil, + topic: cancelTopic, event: "presence", + payload: ["event": "untrack"] + ) + _ = try await realtime.sendAndAwait(untrackMsg, timeout: realtime.configuration.joinTimeout) + } + return PresenceHandle(cancel: cancelClosure) + } + + // MARK: - Observe (snapshot stream) + + public func observe(_: T.Type = T.self) -> AsyncStream> { + AsyncStream { continuation in + let id = UUID() + Task { + await channel.registerSnapshotHandler(id: id) { rawPayload in + let state = decodePresenceState(rawPayload, as: T.self) + continuation.yield(state) + } finish: { + continuation.finish() + } + continuation.onTermination = { [id] _ in + Task { await channel.unregisterPresenceHandlers(id: id) } + } + do { try await channel.joinIfNeeded() } catch { continuation.finish() } + } + } + } + + // MARK: - Diffs (incremental stream) + + public func diffs(_: T.Type = T.self) -> AsyncStream> { + AsyncStream { continuation in + let id = UUID() + Task { + await channel.registerDiffHandler(id: id) { rawPayload in + let diff = decodePresenceDiff(rawPayload, as: T.self) + continuation.yield(diff) + } finish: { + continuation.finish() + } + continuation.onTermination = { [id] _ in + Task { await channel.unregisterPresenceHandlers(id: id) } + } + do { try await channel.joinIfNeeded() } catch { continuation.finish() } + } + } + } +} + +// MARK: - Decoding + +private func decodePresenceState(_ raw: [String: JSONValue], as: T.Type) -> PresenceState { + var active: [PresenceKey: [T]] = [:] + for (key, val) in raw { + guard case .object(let entry) = val, + case .array(let metas) = entry["metas"] else { continue } + active[key] = metas.compactMap { metaVal -> T? in + guard case .object(let metaObj) = metaVal, + let data = try? JSONEncoder().encode(metaObj), + let decoded = try? JSONDecoder().decode(T.self, from: data) else { return nil } + return decoded + } + } + return PresenceState(active: active, lastDiff: nil) +} + +private func decodePresenceDiff(_ raw: [String: JSONValue], as: T.Type) -> PresenceDiff { + func extractEntries(_ val: JSONValue?) -> [(PresenceKey, T)] { + guard case .object(let dict) = val else { return [] } + return dict.flatMap { key, entry -> [(PresenceKey, T)] in + guard case .object(let entryObj) = entry, + case .array(let metas) = entryObj["metas"] else { return [] } + return metas.compactMap { metaVal -> (PresenceKey, T)? in + guard case .object(let metaObj) = metaVal, + let data = try? JSONEncoder().encode(metaObj), + let decoded = try? JSONDecoder().decode(T.self, from: data) else { return nil } + return (key, decoded) + } + } + } + return PresenceDiff(joined: extractEntries(raw["joins"]), left: extractEntries(raw["leaves"])) +} diff --git a/Packages/_Realtime/Sources/_Realtime/Presence/PresenceHandle.swift b/Packages/_Realtime/Sources/_Realtime/Presence/PresenceHandle.swift new file mode 100644 index 000000000..9a0130046 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Presence/PresenceHandle.swift @@ -0,0 +1,31 @@ +// +// PresenceHandle.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation +import IssueReporting + +public final class PresenceHandle: Sendable { + private let _cancel: @Sendable () async throws(RealtimeError) -> Void + private let isCancelled = _Atomic(false) + + init(cancel: @escaping @Sendable () async throws(RealtimeError) -> Void) { + self._cancel = cancel + } + + deinit { + if !isCancelled.value { + reportIssue( + "PresenceHandle deallocated without calling cancel() — presence was not untracked." + ) + } + } + + public func cancel() async throws(RealtimeError) { + guard !isCancelled.exchange(true) else { return } + try await _cancel() + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Presence/PresenceState.swift b/Packages/_Realtime/Sources/_Realtime/Presence/PresenceState.swift new file mode 100644 index 000000000..045c06e1b --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Presence/PresenceState.swift @@ -0,0 +1,18 @@ +// +// PresenceState.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +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)] +} diff --git a/Packages/_Realtime/Sources/_Realtime/Testing/InMemoryTransport.swift b/Packages/_Realtime/Sources/_Realtime/Testing/InMemoryTransport.swift new file mode 100644 index 000000000..8fe2f4b2d --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Testing/InMemoryTransport.swift @@ -0,0 +1,115 @@ +// +// InMemoryTransport.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation + +/// A paired in-memory transport for deterministic tests. No real I/O. +/// +/// Usage: +/// ```swift +/// let (transport, server) = InMemoryTransport.pair() +/// let realtime = Realtime(url: testURL, apiKey: .literal("key"), transport: transport) +/// ``` +public final class InMemoryTransport: RealtimeTransport, @unchecked Sendable { + // @unchecked Sendable: all stored properties are `let` immutable streams/continuations. + // server -> client + private let serverToClientStream: AsyncThrowingStream + private let serverToClientCont: AsyncThrowingStream.Continuation + // client -> server + private let clientToServerStream: AsyncThrowingStream + private let clientToServerCont: AsyncThrowingStream.Continuation + + private init() { + let (s2cStream, s2cCont) = AsyncThrowingStream.makeStream() + let (c2sStream, c2sCont) = AsyncThrowingStream.makeStream() + self.serverToClientStream = s2cStream + self.serverToClientCont = s2cCont + self.clientToServerStream = c2sStream + self.clientToServerCont = c2sCont + } + + public static func pair() -> (client: InMemoryTransport, server: InMemoryServer) { + let t = InMemoryTransport() + let s = InMemoryServer( + receivedFrames: t.clientToServerStream, + sendContinuation: t.serverToClientCont + ) + return (t, s) + } + + public func connect(to url: URL, headers: [String: String]) async throws -> any RealtimeConnection + { + InMemoryConnection( + inbound: serverToClientStream, + outbound: clientToServerCont + ) + } +} + +/// The server side of an `InMemoryTransport` pair. +/// +/// **API contract — choose one per test:** +/// - Use `receivedFrames` when you need a continuous stream of client frames (e.g., auto-reply loop in a `Task`). +/// - Use `receive()` when you need to await exactly one frame at a time. +/// +/// Do NOT use both APIs concurrently on the same `InMemoryServer` instance — they share the same +/// underlying `AsyncThrowingStream` buffer and concurrent use will cause frames to be dropped. +public final class InMemoryServer: Sendable { + // All stored properties are `let` — Sendable is safe. + private let _receivedFrames: AsyncThrowingStream + private let sendContinuation: AsyncThrowingStream.Continuation + + /// Stream of frames the client has sent, for tests that need to inspect them. + public var receivedFrames: AsyncThrowingStream { _receivedFrames } + + init( + receivedFrames: AsyncThrowingStream, + sendContinuation: AsyncThrowingStream.Continuation + ) { + self._receivedFrames = receivedFrames + self.sendContinuation = sendContinuation + } + + /// Awaits the next frame the client sent. + public func receive() async -> TransportFrame? { + try? await _receivedFrames.first(where: { _ in true }) + } + + /// Push a frame to the client. + public func send(_ frame: TransportFrame) async { + sendContinuation.yield(frame) + } + + /// Simulate server-initiated close. + /// + /// The `code` and `reason` parameters are accepted for API compatibility but are not propagated — + /// the client always sees a `URLError(.networkConnectionLost)`. + public func close(code: Int = 1000, reason: String = "") { + sendContinuation.finish(throwing: URLError(.networkConnectionLost)) + } +} + +private struct InMemoryConnection: RealtimeConnection, Sendable { + let frames: AsyncThrowingStream + private let outbound: AsyncThrowingStream.Continuation + + init( + inbound: AsyncThrowingStream, + outbound: AsyncThrowingStream.Continuation + ) { + self.frames = inbound + self.outbound = outbound + } + + func send(_ frame: TransportFrame) async throws { + outbound.yield(frame) + } + + func close(code: Int, reason: String) async { + outbound.finish() + } +} diff --git a/Packages/_Realtime/Sources/_Realtime/Transport/RealtimeTransport.swift b/Packages/_Realtime/Sources/_Realtime/Transport/RealtimeTransport.swift new file mode 100644 index 000000000..a4aae21b2 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Transport/RealtimeTransport.swift @@ -0,0 +1,17 @@ +import Foundation + +public protocol RealtimeTransport: Sendable { + func connect(to url: URL, headers: [String: String]) async throws -> any RealtimeConnection +} + +public protocol RealtimeConnection: Sendable { + /// Incoming frames from the server. Finishes (possibly with error) when the connection closes. + var frames: AsyncThrowingStream { get } + func send(_ frame: TransportFrame) async throws + func close(code: Int, reason: String) async +} + +public enum TransportFrame: Sendable, Equatable { + case text(String) + case binary(Data) +} diff --git a/Packages/_Realtime/Sources/_Realtime/Transport/URLSessionTransport.swift b/Packages/_Realtime/Sources/_Realtime/Transport/URLSessionTransport.swift new file mode 100644 index 000000000..65a159860 --- /dev/null +++ b/Packages/_Realtime/Sources/_Realtime/Transport/URLSessionTransport.swift @@ -0,0 +1,69 @@ +import Foundation + +public struct URLSessionTransport: RealtimeTransport { + private let session: URLSession + + public init(session: URLSession = .shared) { + self.session = session + } + + public func connect(to url: URL, headers: [String: String]) async throws -> any RealtimeConnection + { + var request = URLRequest(url: url) + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + let task = session.webSocketTask(with: request) + task.resume() + return URLSessionConnection(task: task) + } +} + +private final class URLSessionConnection: RealtimeConnection, @unchecked Sendable { + // @unchecked Sendable: all stored properties are `let`; URLSessionWebSocketTask's + // send/cancel APIs are thread-safe. + private let task: URLSessionWebSocketTask + private let receiveTask: Task + let frames: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + + init(task: URLSessionWebSocketTask) { + self.task = task + let (stream, cont) = AsyncThrowingStream.makeStream() + self.frames = stream + self.continuation = cont + let wsTask = task + let c = cont + self.receiveTask = Task { + do { + while true { + let message = try await wsTask.receive() + switch message { + case .string(let text): c.yield(.text(text)) + case .data(let data): c.yield(.binary(data)) + @unknown default: break + } + } + } catch { + c.finish(throwing: error) + } + } + } + + deinit { + receiveTask.cancel() + task.cancel(with: .normalClosure, reason: nil) + } + + func send(_ frame: TransportFrame) async throws { + 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 { + receiveTask.cancel() + task.cancel(with: .normalClosure, reason: reason.data(using: .utf8)) + } +} diff --git a/Packages/_Realtime/Tests/_RealtimeTests/BroadcastTests.swift b/Packages/_Realtime/Tests/_RealtimeTests/BroadcastTests.swift new file mode 100644 index 000000000..ebbb79a3e --- /dev/null +++ b/Packages/_Realtime/Tests/_RealtimeTests/BroadcastTests.swift @@ -0,0 +1,291 @@ +// +// BroadcastTests.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import ConcurrencyExtras +import Foundation +import Testing + +@testable import _Realtime + +@Suite struct BroadcastTests { + static let testURL = URL(string: "ws://localhost:4000/realtime/v1")! + + // MARK: - Helpers + + /// Returns a connected Realtime + InMemoryServer pair with an auto-reply task for phx_join/phx_leave. + private func makeConnectedRealtime() async throws -> ( + realtime: Realtime, + server: InMemoryServer, + autoReplyTask: Task + ) { + let (transport, server) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + + let autoReplyTask = Task { + do { + for try await frame in server.receivedFrames { + guard case .text(let text) = frame, + let msg = try? PhoenixSerializer.decodeText(text), + msg.event == "phx_join" || msg.event == "phx_leave" + else { continue } + let reply = PhoenixMessage( + joinRef: msg.joinRef, ref: msg.ref, topic: msg.topic, + event: "phx_reply", + payload: ["status": .string("ok"), "response": .object([:])] + ) + if let replyText = try? PhoenixSerializer.encodeText(reply) { + await server.send(.text(replyText)) + } + } + } catch { + // stream ended + } + } + return (realtime, server, autoReplyTask) + } + + // MARK: - Text broadcast delivery + + @Test func broadcastDeliveredToUntypedStream() async throws { + let (realtime, server, autoReplyTask) = try await makeConnectedRealtime() + defer { autoReplyTask.cancel() } + + let channel = await realtime.channel("room:1") + let stream = await channel.broadcasts() + let received = LockIsolated<[BroadcastMessage]>([]) + + let collectTask = Task { + do { + for try await msg in stream { + received.withValue { $0.append(msg) } + } + } catch { + // stream finished + } + } + defer { collectTask.cancel() } + + // Wait for auto-join to complete + try await Task.sleep(for: .milliseconds(100)) + + // Server pushes a broadcast + let push = PhoenixMessage( + joinRef: nil, ref: nil, topic: "room:1", event: "broadcast", + payload: [ + "type": "broadcast", + "event": .string("chat"), + "payload": .object(["text": .string("hello")]), + ] + ) + await server.send(.text(try PhoenixSerializer.encodeText(push))) + + try await Task.sleep(for: .milliseconds(100)) + + #expect(received.value.count == 1) + #expect(received.value.first?.event == "chat") + if case .object(let obj) = received.value.first?.payload { + #expect(obj["text"] == .string("hello")) + } else { + Issue.record("Expected object payload") + } + } + + // MARK: - Fan-out to multiple subscribers + + @Test func broadcastFanoutToMultipleSubscribers() async throws { + let (realtime, server, autoReplyTask) = try await makeConnectedRealtime() + defer { autoReplyTask.cancel() } + + let channel = await realtime.channel("room:2") + let count1 = LockIsolated(0) + let count2 = LockIsolated(0) + + let s1 = await channel.broadcasts() + let s2 = await channel.broadcasts() + + let t1 = Task { + do { + for try await _ in s1 { count1.withValue { $0 += 1 } } + } catch { /* finished */ } + } + let t2 = Task { + do { + for try await _ in s2 { count2.withValue { $0 += 1 } } + } catch { /* finished */ } + } + defer { + t1.cancel() + t2.cancel() + } + + // Wait for joins to complete + try await Task.sleep(for: .milliseconds(150)) + + let push = PhoenixMessage( + joinRef: nil, ref: nil, topic: "room:2", event: "broadcast", + payload: [ + "type": "broadcast", + "event": .string("ping"), + "payload": .object([:]), + ] + ) + await server.send(.text(try PhoenixSerializer.encodeText(push))) + + try await Task.sleep(for: .milliseconds(100)) + + #expect(count1.value == 1) + #expect(count2.value == 1) + } + + // MARK: - Typed broadcasts stream + + @Test func typedBroadcastsDecodesPayload() async throws { + struct ChatMessage: Decodable, Sendable { let text: String } + + let (realtime, server, autoReplyTask) = try await makeConnectedRealtime() + defer { autoReplyTask.cancel() } + + let channel = await realtime.channel("room:typed") + let received = LockIsolated<[ChatMessage]>([]) + + let stream = await channel.broadcasts(of: ChatMessage.self, event: "chat") + let collectTask = Task { + do { + for try await msg in stream { + received.withValue { $0.append(msg) } + } + } catch { /* finished */ } + } + defer { collectTask.cancel() } + + try await Task.sleep(for: .milliseconds(100)) + + let push = PhoenixMessage( + joinRef: nil, ref: nil, topic: "room:typed", event: "broadcast", + payload: [ + "type": "broadcast", + "event": .string("chat"), + "payload": .object(["text": .string("world")]), + ] + ) + await server.send(.text(try PhoenixSerializer.encodeText(push))) + + // Different-event message should be filtered + let otherPush = PhoenixMessage( + joinRef: nil, ref: nil, topic: "room:typed", event: "broadcast", + payload: [ + "type": "broadcast", + "event": .string("other"), + "payload": .object(["text": .string("ignored")]), + ] + ) + await server.send(.text(try PhoenixSerializer.encodeText(otherPush))) + + try await Task.sleep(for: .milliseconds(100)) + + #expect(received.value.count == 1) + #expect(received.value.first?.text == "world") + } + + // MARK: - Send: not joined + + @Test func broadcastThrowsChannelNotJoinedIfNotJoined() async throws { + let (transport, _) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + let channel = await realtime.channel("room:3") + + struct Msg: Encodable, Sendable { let x: Int } + do { + try await channel.broadcast(Msg(x: 1), as: "event") + Issue.record("Expected channelNotJoined error") + } catch RealtimeError.channelNotJoined { + // expected + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + // MARK: - Binary broadcast delivery + + @Test func binaryBroadcastDeliveredAsJSONPayload() async throws { + let (realtime, server, autoReplyTask) = try await makeConnectedRealtime() + defer { autoReplyTask.cancel() } + + let channel = await realtime.channel("room:binary") + let received = LockIsolated<[BroadcastMessage]>([]) + let stream = await channel.broadcasts() + let collectTask = Task { + do { + for try await msg in stream { + received.withValue { $0.append(msg) } + } + } catch { /* finished */ } + } + defer { collectTask.cancel() } + + try await Task.sleep(for: .milliseconds(100)) + + // Build a binary broadcast frame (server->client, type 0x04). + // Layout: kind(1) | topicLen(1) | eventLen(1) | metaLen(1) | encoding(1) | topic | event | payload + let topicBytes = Data("room:binary".utf8) + let eventBytes = Data("update".utf8) + let payloadDict: [String: JSONValue] = ["val": .int(42)] + let payloadData = try JSONEncoder().encode(payloadDict) + var frame = Data() + frame.append(0x04) // kind = serverBroadcast + frame.append(UInt8(topicBytes.count)) // topicLen + frame.append(UInt8(eventBytes.count)) // eventLen + frame.append(0x00) // metaLen + frame.append(0x01) // encoding = json + frame.append(topicBytes) + frame.append(eventBytes) + // meta (0 bytes) + frame.append(payloadData) + + await server.send(.binary(frame)) + + try await Task.sleep(for: .milliseconds(100)) + + #expect(received.value.count == 1) + #expect(received.value.first?.event == "update") + if case .object(let obj) = received.value.first?.payload { + #expect(obj["val"] == .int(42)) + } else { + Issue.record("Expected object payload") + } + } + + // MARK: - Stream finishes on leave + + @Test(.timeLimit(.minutes(1))) + func broadcastStreamFinishesOnLeave() async throws { + let (realtime, _, autoReplyTask) = try await makeConnectedRealtime() + defer { autoReplyTask.cancel() } + + let channel = await realtime.channel("room:leave") + let stream = await channel.broadcasts() + let caughtError = LockIsolated(nil) + + let collectTask = Task { + do { + for try await _ in stream { /* drain */ } + } catch let e as RealtimeError { + caughtError.withValue { $0 = e } + } + } + + // Wait for join + try await Task.sleep(for: .milliseconds(100)) + + try await channel.leave() + _ = await collectTask.result + + #expect(caughtError.value == .channelClosed(.userRequested)) + } +} diff --git a/Packages/_Realtime/Tests/_RealtimeTests/ChannelTests.swift b/Packages/_Realtime/Tests/_RealtimeTests/ChannelTests.swift new file mode 100644 index 000000000..0ea09ba7d --- /dev/null +++ b/Packages/_Realtime/Tests/_RealtimeTests/ChannelTests.swift @@ -0,0 +1,105 @@ +// +// ChannelTests.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import ConcurrencyExtras +import Foundation +import Testing + +@testable import _Realtime + +@Suite struct ChannelTests { + static let testURL = URL(string: "ws://localhost:4000/realtime/v1")! + + // Helper: auto-reply to phx_join and phx_leave with ok + private func makeAutoReplyServer(_ server: InMemoryServer) -> Task { + Task { + do { + for try await frame in server.receivedFrames { + guard case .text(let text) = frame, + let msg = try? PhoenixSerializer.decodeText(text), + msg.event == "phx_join" || msg.event == "phx_leave" + else { continue } + let reply = PhoenixMessage( + joinRef: msg.joinRef, ref: msg.ref, topic: msg.topic, + event: "phx_reply", + payload: ["status": .string("ok"), "response": .object([:])] + ) + if let replyText = try? PhoenixSerializer.encodeText(reply) { + await server.send(.text(replyText)) + } + } + } catch { + // stream ended + } + } + } + + @Test func joinTransitionsToJoined() async throws { + let (transport, server) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + + let autoReply = makeAutoReplyServer(server) + defer { autoReply.cancel() } + + let channel = await realtime.channel("room:1") + try await channel.join() + let state = await channel.currentState + #expect(state == .joined) + } + + @Test func sameTopicReturnsSameChannelActor() async throws { + let (transport, _) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + + let ch1 = await realtime.channel("room:42") + let ch2 = await realtime.channel("room:42") + #expect(ch1 === ch2) + } + + @Test func firstCallWinsOnOptions() async throws { + let (transport, _) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + + let ch1 = await realtime.channel("room:1") { $0.isPrivate = true } + let ch2 = await realtime.channel("room:1") { $0.isPrivate = false } + let opts = await ch1.options + #expect(opts.isPrivate == true) + #expect(ch1 === ch2) + } + + @Test(.timeLimit(.minutes(1))) + func leaveFinishesActiveStreams() async throws { + let (transport, server) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + + let autoReply = makeAutoReplyServer(server) + defer { autoReply.cancel() } + + let channel = await realtime.channel("room:1") + try await channel.join() + + // Get a broadcast stream — it should close with channelClosed when leave() is called + let broadcastStream = await channel.broadcasts() + let caughtError = LockIsolated(nil) + + let collectTask = Task { + do { + for try await _ in broadcastStream { /* drain */ } + } catch let e as RealtimeError { + caughtError.withValue { $0 = e } + } catch { + // unexpected error type + } + } + + try await channel.leave() + await collectTask.value + #expect(caughtError.value == .channelClosed(.userRequested)) + } +} diff --git a/Packages/_Realtime/Tests/_RealtimeTests/PhoenixSerializerTests.swift b/Packages/_Realtime/Tests/_RealtimeTests/PhoenixSerializerTests.swift new file mode 100644 index 000000000..56526ed59 --- /dev/null +++ b/Packages/_Realtime/Tests/_RealtimeTests/PhoenixSerializerTests.swift @@ -0,0 +1,94 @@ +// +// PhoenixSerializerTests.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/26. +// + +import Foundation +import Testing + +@testable import _Realtime + +@Suite struct PhoenixSerializerTests { + @Test func roundTripTextFrame() throws { + let msg = PhoenixMessage( + joinRef: "1", ref: "2", topic: "room:1", + event: "phx_join", payload: ["status": .string("ok")] + ) + let frame = try PhoenixSerializer.encodeText(msg) + let decoded = try PhoenixSerializer.decodeText(frame) + #expect(decoded.joinRef == "1") + #expect(decoded.ref == "2") + #expect(decoded.topic == "room:1") + #expect(decoded.event == "phx_join") + #expect(decoded.payload["status"] == .string("ok")) + } + + @Test func decodeTextWithNullRefs() throws { + // [null, null, "phoenix", "heartbeat", {}] + let json = "[null,null,\"phoenix\",\"heartbeat\",{}]" + let decoded = try PhoenixSerializer.decodeText(json) + #expect(decoded.joinRef == nil) + #expect(decoded.ref == nil) + #expect(decoded.topic == "phoenix") + #expect(decoded.event == "heartbeat") + } + + @Test func decodeBinaryBroadcast() throws { + // Build a minimal type-0x04 binary frame + let topic = "room:1" + let event = "chat" + let payload = Data("{\"msg\":\"hi\"}".utf8) + + var data = Data() + data.append(0x04) // kind = server broadcast + data.append(UInt8(topic.utf8.count)) // topic_len + data.append(UInt8(event.utf8.count)) // event_len + data.append(0x00) // meta_len + data.append(0x01) // encoding = json + data.append(contentsOf: topic.utf8) + data.append(contentsOf: event.utf8) + data.append(payload) + + let broadcast = try PhoenixSerializer.decodeBinary(data) + #expect(broadcast.topic == "room:1") + #expect(broadcast.event == "chat") + if case .json(let obj) = broadcast.payload { + #expect(obj["msg"] == .string("hi")) + } else { + #expect(Bool(false), "Expected .json payload, got .binary") + } + } + + @Test func encodeBroadcastPushRoundTrip() throws { + let encoded = try PhoenixSerializer.encodeBroadcastPush( + joinRef: "1", ref: "2", + topic: "room:1", event: "chat", + payload: ["text": .string("hello"), "count": .int(42)] + ) + // Decode it — but our decodeBinary expects type 0x04 (server), not 0x03 (client). + // The client-encode format differs in header layout. Instead, verify the raw bytes. + // Kind byte should be 0x03 + #expect(encoded[encoded.startIndex] == 0x03) + // Verify the data can be re-parsed by checking the header fields + let joinRefLen = Int(encoded[encoded.startIndex + 1]) + let refLen = Int(encoded[encoded.startIndex + 2]) + let topicLen = Int(encoded[encoded.startIndex + 3]) + let eventLen = Int(encoded[encoded.startIndex + 4]) + let metaLen = Int(encoded[encoded.startIndex + 5]) + let encByte = encoded[encoded.startIndex + 6] + #expect(joinRefLen == 1) // "1" + #expect(refLen == 1) // "2" + #expect(topicLen == 6) // "room:1" + #expect(eventLen == 4) // "chat" + #expect(metaLen == 0) + #expect(encByte == 0x01) // json encoding + // Verify the payload section contains valid JSON + let headerAndFieldsSize = 7 + joinRefLen + refLen + topicLen + eventLen + metaLen + let payloadData = Data(encoded.dropFirst(headerAndFieldsSize)) + let decoded = try JSONDecoder().decode([String: JSONValue].self, from: payloadData) + #expect(decoded["text"] == .string("hello")) + #expect(decoded["count"] == .int(42)) + } +} diff --git a/Packages/_Realtime/Tests/_RealtimeTests/PostgresChangesTests.swift b/Packages/_Realtime/Tests/_RealtimeTests/PostgresChangesTests.swift new file mode 100644 index 000000000..9df555e7f --- /dev/null +++ b/Packages/_Realtime/Tests/_RealtimeTests/PostgresChangesTests.swift @@ -0,0 +1,370 @@ +// +// PostgresChangesTests.swift +// _RealtimeTests +// +// Created by Guilherme Souza on 24/04/25. +// + +import ConcurrencyExtras +import Foundation +import Testing +@testable import _Realtime + +// File-scope types for RealtimeTable conformances — required for Swift Testing macro compatibility. +private struct PGUser: RealtimeTable { + static let schema = "public" + static let tableName = "users" + static func columnName(for kp: KeyPath) -> String { + switch kp { + case \Self.id: return "id" + case \Self.name: return "name" + default: return "unknown" + } + } + var id: UUID + var name: String +} + +private struct PGItem: RealtimeTable { + static let schema = "public" + static let tableName = "items" + static func columnName(for kp: KeyPath) -> String { "status" } + var status: String +} + +private struct PGMessage: Codable, Sendable { + let id: Int + let text: String +} + +@Suite struct PostgresChangesTests { + + @Test func filterEqEncodesCorrectly() { + let filter = Filter.eq(\PGUser.id, UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) + #expect(filter.wireValue == "id=eq.00000000-0000-0000-0000-000000000001") + } + + @Test func filterInEncodesCorrectly() { + let filter = Filter.in(\PGItem.status, ["active", "pending"]) + #expect(filter.wireValue == "status=in.(active,pending)") + } + + @Test func untypedFilterEncodesCorrectly() { + let filter = UntypedFilter.eq("room_id", "abc-123") + #expect(filter.wireValue == "room_id=eq.abc-123") + } + + @Test func postgresChangeDecodesInsert() throws { + let payload: [String: JSONValue] = [ + "data": .object([ + "type": "INSERT", + "record": .object(["id": .int(1), "text": .string("hello")]), + "old_record": .null, + "columns": .array([]), + "commit_timestamp": .string("2026-01-01T00:00:00Z"), + ]), + "ids": .array([.int(1)]), + ] + + let change = try PostgresChange.decode(from: payload) + if case .insert(let row) = change { + #expect(row.id == 1) + #expect(row.text == "hello") + } else { + Issue.record("Expected .insert, got \(change)") + } + } + + @Test func postgresChangeDecodesUpdate() throws { + let payload: [String: JSONValue] = [ + "data": .object([ + "type": "UPDATE", + "record": .object(["id": .int(1), "text": .string("updated")]), + "old_record": .object(["id": .int(1), "text": .string("original")]), + "columns": .array([]), + "commit_timestamp": .string("2026-01-01T00:00:00Z"), + ]) + ] + let change = try PostgresChange.decode(from: payload) + if case .update(let old, let new) = change { + #expect(old.text == "original") + #expect(new.text == "updated") + } else { + Issue.record("Expected .update") + } + } + + @Test func postgresChangeDecodesDelete() throws { + let payload: [String: JSONValue] = [ + "data": .object([ + "type": "DELETE", + "record": .null, + "old_record": .object(["id": .int(1), "text": .string("deleted")]), + "columns": .array([]), + "commit_timestamp": .string("2026-01-01T00:00:00Z"), + ]) + ] + let change = try PostgresChange.decode(from: payload) + if case .delete(let old) = change { + #expect(old.text == "deleted") + } else { + Issue.record("Expected .delete") + } + } + + // MARK: - Test 1: Additional filter operator encoding + + @Test func filterNeqAndGtEncode() { + struct Post: RealtimeTable { + static let schema = "public" + static let tableName = "posts" + static func columnName(for kp: KeyPath) -> String { + switch kp { + case \Self.status: return "status" + case \Self.viewCount: return "view_count" + default: return "unknown" + } + } + var status: String + var viewCount: Int + } + let neqFilter = Filter.neq(\Post.status, "draft") + #expect(neqFilter.wireValue == "status=neq.draft") + + let gtFilter = Filter.gt(\Post.viewCount, 100) + #expect(gtFilter.wireValue == "view_count=gt.100") + } + + // MARK: - Test 2: Per-event convenience streams + + @Test func perEventConvenienceStreams() async throws { + struct ConvMessage: Codable, Sendable, RealtimeTable { + static let schema = "public" + static let tableName = "messages" + static func columnName(for kp: KeyPath) -> String { "id" } + let id: Int + let text: String + } + + let testURL = URL(string: "ws://localhost:4000/realtime/v1")! + let (transport, server) = InMemoryTransport.pair() + let realtime = Realtime(url: testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + + let autoReplyTask = Task { [server] in + do { + for try await frame in server.receivedFrames { + guard case .text(let text) = frame, + let msg = try? PhoenixSerializer.decodeText(text), + msg.event == "phx_join" + else { continue } + let reply = PhoenixMessage( + joinRef: msg.joinRef, ref: msg.ref, topic: msg.topic, + event: "phx_reply", payload: ["status": "ok", "response": .object([:])] + ) + await server.send(.text(try! PhoenixSerializer.encodeText(reply))) + } + } catch { + // stream ended + } + } + defer { autoReplyTask.cancel() } + + let channel = await realtime.channel("room:convenience") + let inserts = LockIsolated<[ConvMessage]>([]) + let updates = LockIsolated<[(old: ConvMessage, new: ConvMessage)]>([]) + let deletes = LockIsolated<[ConvMessage]>([]) + + let insertsStream = await channel.inserts(into: ConvMessage.self) + let updatesStream = await channel.updates(of: ConvMessage.self) + let deletesStream = await channel.deletes(from: ConvMessage.self) + + let t1 = Task { + for try await m in insertsStream { + inserts.withValue { $0.append(m) } + } + } + let t2 = Task { + for try await p in updatesStream { + updates.withValue { $0.append(p) } + } + } + let t3 = Task { + for try await m in deletesStream { + deletes.withValue { $0.append(m) } + } + } + defer { t1.cancel(); t2.cancel(); t3.cancel() } + + try await Task.sleep(for: .milliseconds(50)) + + let record: JSONValue = .object(["id": .int(1), "text": .string("hello")]) + let oldRecord: JSONValue = .object(["id": .int(1), "text": .string("old")]) + + func makePayload(type: String, rec: JSONValue, old: JSONValue) -> [String: JSONValue] { + [ + "data": .object([ + "type": .string(type), + "record": rec, + "old_record": old, + "columns": .array([]), + "commit_timestamp": .string("2026-01-01T00:00:00Z"), + ]) + ] + } + + let events: [(String, JSONValue, JSONValue)] = [ + ("INSERT", record, .null), + ("UPDATE", record, oldRecord), + ("DELETE", .null, oldRecord), + ] + for (eventType, rec, old) in events { + let push = PhoenixMessage( + joinRef: nil, ref: nil, topic: "room:convenience", + event: "postgres_changes", + payload: makePayload(type: eventType, rec: rec, old: old) + ) + await server.send(.text(try! PhoenixSerializer.encodeText(push))) + } + + try await Task.sleep(for: .milliseconds(100)) + + #expect(inserts.value.count == 1) + #expect(inserts.value.first?.text == "hello") + #expect(updates.value.count == 1) + #expect(updates.value.first?.new.text == "hello") + #expect(deletes.value.count == 1) + #expect(deletes.value.first?.text == "old") + } + + // MARK: - Test 3: Typed changes(to:) live stream + + @Test func typedChangesStreamDecodesLivePayload() async throws { + struct TypedMessage: Codable, Sendable, RealtimeTable { + static let schema = "public" + static let tableName = "messages" + static func columnName(for kp: KeyPath) -> String { "id" } + let id: Int + let body: String + } + + let testURL = URL(string: "ws://localhost:4000/realtime/v1")! + let (transport, server) = InMemoryTransport.pair() + let realtime = Realtime(url: testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + + let autoReplyTask = Task { [server] in + do { + for try await frame in server.receivedFrames { + guard case .text(let text) = frame, + let msg = try? PhoenixSerializer.decodeText(text), + msg.event == "phx_join" + else { continue } + let reply = PhoenixMessage( + joinRef: msg.joinRef, ref: msg.ref, topic: msg.topic, + event: "phx_reply", payload: ["status": "ok", "response": .object([:])] + ) + await server.send(.text(try! PhoenixSerializer.encodeText(reply))) + } + } catch { + // stream ended + } + } + defer { autoReplyTask.cancel() } + + let channel = await realtime.channel("room:typed") + let stream = await channel.changes(to: TypedMessage.self) + let received = LockIsolated<[PostgresChange]>([]) + let task = Task { + for try await c in stream { received.withValue { $0.append(c) } } + } + defer { task.cancel() } + + try await Task.sleep(for: .milliseconds(50)) + + let insertPayload: [String: JSONValue] = [ + "data": .object([ + "type": "INSERT", + "record": .object(["id": .int(7), "body": .string("typed!")]), + "old_record": .null, + "columns": .array([]), + "commit_timestamp": .string("2026-01-01T00:00:00Z"), + ]) + ] + let push = PhoenixMessage( + joinRef: nil, ref: nil, topic: "room:typed", + event: "postgres_changes", payload: insertPayload + ) + await server.send(.text(try! PhoenixSerializer.encodeText(push))) + + try await Task.sleep(for: .milliseconds(50)) + + #expect(received.value.count == 1) + if case .insert(let row) = received.value.first { + #expect(row.id == 7) + #expect(row.body == "typed!") + } else { + Issue.record("Expected .insert, got \(String(describing: received.value.first))") + } + } + + @Test func untypedChangesStreamReceivesPayload() async throws { + let testURL = URL(string: "ws://localhost:4000/realtime/v1")! + let (transport, server) = InMemoryTransport.pair() + let realtime = Realtime(url: testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + + let autoReplyTask = Task { [server] in + do { + for try await frame in server.receivedFrames { + guard case .text(let text) = frame, + let msg = try? PhoenixSerializer.decodeText(text), + msg.event == "phx_join" + else { continue } + let reply = PhoenixMessage( + joinRef: msg.joinRef, ref: msg.ref, topic: msg.topic, + event: "phx_reply", payload: ["status": "ok", "response": .object([:])] + ) + await server.send(.text(try! PhoenixSerializer.encodeText(reply))) + } + } catch { + // stream ended + } + } + defer { autoReplyTask.cancel() } + + let channel = await realtime.channel("room:pg") + let stream = await channel.changes(schema: "public", table: "messages") + let received = LockIsolated<[PostgresChange<[String: JSONValue]>]>([]) + let task = Task { + for try await change in stream { received.withValue { $0.append(change) } } + } + defer { task.cancel() } + + try await Task.sleep(for: .milliseconds(50)) + + // Server pushes a postgres_changes event + let insertPayload: [String: JSONValue] = [ + "data": .object([ + "type": "INSERT", + "record": .object(["id": .int(42), "body": .string("test")]), + "old_record": .null, + "columns": .array([]), + "commit_timestamp": .string("2026-01-01T00:00:00Z"), + ]) + ] + let push = PhoenixMessage( + joinRef: nil, ref: nil, topic: "room:pg", + event: "postgres_changes", payload: insertPayload + ) + await server.send(.text(try! PhoenixSerializer.encodeText(push))) + + try await Task.sleep(for: .milliseconds(50)) + #expect(received.value.count == 1) + if case .insert(let row) = received.value.first { + #expect(row["id"] == .int(42)) + } else { + Issue.record("Expected insert") + } + } +} diff --git a/Packages/_Realtime/Tests/_RealtimeTests/PresenceTests.swift b/Packages/_Realtime/Tests/_RealtimeTests/PresenceTests.swift new file mode 100644 index 000000000..fa0f34da3 --- /dev/null +++ b/Packages/_Realtime/Tests/_RealtimeTests/PresenceTests.swift @@ -0,0 +1,223 @@ +// +// PresenceTests.swift +// _RealtimeTests +// +// Created by Guilherme Souza on 24/04/25. +// + +import ConcurrencyExtras +import Foundation +import Testing +@testable import _Realtime + +// Types used in PresenceTests — must be at file scope for Swift Testing macro compatibility. +private struct TrackState: Codable, Sendable { let userId: String } +private struct PresenceUserState: Decodable, Sendable { let name: String } + +@Suite struct PresenceTests { + static let testURL = URL(string: "ws://localhost:4000/realtime/v1")! + + /// Builds a connected Realtime + channel pair. The returned auto-reply task responds to + /// phx_join and presence pushes. All frames received are also appended to `receivedTexts` + /// so tests can inspect them without competing with the auto-reply task. + func makeConnectedChannel(topic: String = "room:1") async throws -> ( + realtime: Realtime, + channel: Channel, + server: InMemoryServer, + receivedTexts: LockIsolated<[String]>, + autoReplyTask: Task + ) { + let (transport, server) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + + let receivedTexts = LockIsolated<[String]>([]) + + let autoReplyTask = Task { [server, receivedTexts] in + do { + for try await frame in server.receivedFrames { + guard case .text(let text) = frame, + let msg = try? PhoenixSerializer.decodeText(text) else { continue } + receivedTexts.withValue { $0.append(text) } + if msg.event == "phx_join" || msg.event == "presence" { + let reply = PhoenixMessage( + joinRef: msg.joinRef, ref: msg.ref, topic: msg.topic, + event: "phx_reply", + payload: ["status": "ok", "response": .object([:])] + ) + await server.send(.text(try! PhoenixSerializer.encodeText(reply))) + } + } + } catch { + // stream ended + } + } + + let channel = await realtime.channel(topic) + return (realtime, channel, server, receivedTexts, autoReplyTask) + } + + @Test func trackSendsPresenceEvent() async throws { + let (realtime, channel, _, receivedTexts, autoReplyTask) = try await makeConnectedChannel() + defer { autoReplyTask.cancel() } + _ = realtime // retain + + try await channel.join() + try await Task.sleep(for: .milliseconds(50)) + + let handle = try await channel.presence.track(TrackState(userId: "u1")) + try await Task.sleep(for: .milliseconds(50)) + + // Auto-reply task captures all received texts — check for presence push + let texts = receivedTexts.value + #expect(texts.contains { $0.contains("presence") }) + + try await handle.cancel() + // Verify trackedStates is empty after cancel + let tracked = await channel.trackedStates + #expect(tracked.isEmpty) + } + + @Test func autoRetrackOnRejoin() async throws { + let (transport, server) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + + // Auto-reply to phx_join and presence events + let autoReplyTask = Task { [server] in + do { + for try await frame in server.receivedFrames { + guard case .text(let text) = frame, + let msg = try? PhoenixSerializer.decodeText(text) else { continue } + if msg.event == "phx_join" || msg.event == "presence" { + let reply = PhoenixMessage( + joinRef: msg.joinRef, ref: msg.ref, topic: msg.topic, + event: "phx_reply", + payload: ["status": "ok", "response": .object([:])] + ) + await server.send(.text(try! PhoenixSerializer.encodeText(reply))) + } + } + } catch { + // stream ended + } + } + defer { autoReplyTask.cancel() } + + let channel = await realtime.channel("room:retrack") + try await channel.join() + try await Task.sleep(for: .milliseconds(50)) + + // Track a state + let handle = try await channel.presence.track(TrackState(userId: "u-retrack")) + try await Task.sleep(for: .milliseconds(50)) + + // Simulate disconnect/rejoin + await channel.handleConnectionLoss() + try await channel.rejoin() + try await Task.sleep(for: .milliseconds(50)) + + // The trackedStates should still be populated (re-track was sent after rejoin) + let tracked = await channel.trackedStates + #expect(!tracked.isEmpty) + + try await handle.cancel() + } + + @Test func multiTrackMultipleMetasPerKey() async throws { + struct State: Codable, Sendable { let device: String } + let (realtime, channel, _, _, autoReplyTask) = try await makeConnectedChannel(topic: "room:multi") + defer { autoReplyTask.cancel() } + _ = realtime // retain + + try await channel.join() + try await Task.sleep(for: .milliseconds(50)) + + let h1 = try await channel.presence.track(State(device: "phone")) + let h2 = try await channel.presence.track(State(device: "tablet")) + + let tracked = await channel.trackedStates + #expect(tracked.count == 2) + + try await h1.cancel() + try await h2.cancel() + + let afterCancel = await channel.trackedStates + #expect(afterCancel.isEmpty) + } + + @Test func handleLeakWarningDoesNotCrash() async throws { + struct State: Codable, Sendable { let x: Int } + let (realtime, channel, _, _, autoReplyTask) = try await makeConnectedChannel(topic: "room:leak") + defer { autoReplyTask.cancel() } + _ = realtime // retain + + try await channel.join() + try await Task.sleep(for: .milliseconds(50)) + + // Create handle and let it be deallocated without cancel. + // reportIssue in deinit is expected — mark it known so the test passes. + await withKnownIssue("PresenceHandle deallocated without cancel fires reportIssue") { + let handle = try await channel.presence.track(State(x: 99)) + // Intentionally not calling handle.cancel() + _ = handle // keep reference until end of closure; deinit fires after + } + } + + @Test func observeDeliversPresenceSnapshot() async throws { + let (realtime, channel, server, _, autoReplyTask) = try await makeConnectedChannel() + defer { autoReplyTask.cancel() } + _ = realtime // retain + + try await channel.join() + try await Task.sleep(for: .milliseconds(50)) + + let states = await channel.presence.observe(PresenceUserState.self) + let snapshots = LockIsolated<[PresenceState]>([]) + let task = Task { + for await s in states { snapshots.withValue { $0.append(s) } } + } + defer { task.cancel() } + + // Server pushes presence_state + let presenceMsg = PhoenixMessage( + joinRef: nil, ref: nil, topic: "room:1", event: "presence_state", + payload: ["alice": .object(["metas": .array([.object(["name": "Alice"])])])] + ) + await server.send(.text(try! PhoenixSerializer.encodeText(presenceMsg))) + + try await Task.sleep(for: .milliseconds(50)) + #expect(!snapshots.value.isEmpty) + #expect(snapshots.value.first?.active["alice"]?.first?.name == "Alice") + } + + @Test func diffsDeliversPresenceDiff() async throws { + let (realtime, channel, server, _, autoReplyTask) = try await makeConnectedChannel() + defer { autoReplyTask.cancel() } + _ = realtime // retain + + try await channel.join() + try await Task.sleep(for: .milliseconds(50)) + + let diffStream = await channel.presence.diffs(PresenceUserState.self) + let diffs = LockIsolated<[PresenceDiff]>([]) + let task = Task { + for await d in diffStream { diffs.withValue { $0.append(d) } } + } + defer { task.cancel() } + + // Server pushes presence_diff + let diffMsg = PhoenixMessage( + joinRef: nil, ref: nil, topic: "room:1", event: "presence_diff", + payload: [ + "joins": .object(["bob": .object(["metas": .array([.object(["name": "Bob"])])])]), + "leaves": .object([:]), + ] + ) + await server.send(.text(try! PhoenixSerializer.encodeText(diffMsg))) + + try await Task.sleep(for: .milliseconds(50)) + #expect(!diffs.value.isEmpty) + #expect(diffs.value.first?.joined.first?.1.name == "Bob") + } +} diff --git a/Packages/_Realtime/Tests/_RealtimeTests/RealtimeClientTests.swift b/Packages/_Realtime/Tests/_RealtimeTests/RealtimeClientTests.swift new file mode 100644 index 000000000..9095293d1 --- /dev/null +++ b/Packages/_Realtime/Tests/_RealtimeTests/RealtimeClientTests.swift @@ -0,0 +1,66 @@ +// +// RealtimeClientTests.swift +// _Realtime +// +// Created by Guilherme Souza on 24/04/25. +// + +import Clocks +import ConcurrencyExtras +import Foundation +import Testing + +@testable import _Realtime + +@Suite struct RealtimeClientTests { + static let testURL = URL(string: "ws://localhost:4000/realtime/v1")! + + @Test func connectTransitionsToConnected() async throws { + let (transport, _) = InMemoryTransport.pair() + let realtime = Realtime( + url: Self.testURL, + apiKey: .literal("anon-key"), + transport: transport + ) + + try await realtime.connect() + let snapshot = await realtime.currentStatus + #expect(snapshot == .connected) + } + + @Test func disconnectTransitionsToClosed() async throws { + let clock = TestClock() + let (transport, _) = InMemoryTransport.pair() + let config = Configuration { $0.clock = clock } + let realtime = Realtime( + url: Self.testURL, + apiKey: .literal("key"), + configuration: config, + transport: transport + ) + + try await realtime.connect() + await realtime.disconnect() + + let snapshot = await realtime.currentStatus + #expect(snapshot == .closed(.userRequested)) + } + + @Test func channelSameTopicReturnsSameActor() async throws { + let (transport, _) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + + let ch1 = await realtime.channel("room:1") + let ch2 = await realtime.channel("room:1") + #expect(ch1 === ch2) + } + + @Test func channelDifferentTopicsReturnDifferentActors() async throws { + let (transport, _) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + + let ch1 = await realtime.channel("room:1") + let ch2 = await realtime.channel("room:2") + #expect(ch1 !== ch2) + } +} diff --git a/Packages/_Realtime/Tests/_RealtimeTests/RealtimeV3IntegrationTests.swift b/Packages/_Realtime/Tests/_RealtimeTests/RealtimeV3IntegrationTests.swift new file mode 100644 index 000000000..73df14292 --- /dev/null +++ b/Packages/_Realtime/Tests/_RealtimeTests/RealtimeV3IntegrationTests.swift @@ -0,0 +1,68 @@ +// +// RealtimeV3IntegrationTests.swift +// _RealtimeTests +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation +import Testing + +@testable import _Realtime + +// Integration tests require a running local Supabase instance. +// Start with: cd Tests/IntegrationTests && supabase start +// These are disabled by default so the suite runs cleanly without a local stack. + +@Suite(.disabled("Requires local Supabase — enable by removing .disabled")) +struct RealtimeV3IntegrationTests { + static let url = URL( + string: ProcessInfo.processInfo.environment["SUPABASE_REALTIME_URL"] + ?? "ws://localhost:54321/realtime/v1" + )! + static let anonKey = + ProcessInfo.processInfo.environment["SUPABASE_ANON_KEY"] + ?? "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZS1kZW1vIiwicm9sZSI6ImFub24iLCJleHAiOjE5ODM4MTI5OTZ9.CRFA0NiK7kyqd6sDnYoIkejqjsoBJarVLL6PB-dADMI" + + func makeRealtime() -> Realtime { + Realtime(url: Self.url, apiKey: .literal(Self.anonKey)) + } + + @Test func connectAndDisconnect() async throws { + let realtime = makeRealtime() + try await realtime.connect() + #expect(await realtime.currentStatus == .connected) + await realtime.disconnect() + } + + @Test func broadcastRoundTrip() async throws { + struct Msg: Codable, Sendable, Equatable { let text: String } + let r1 = makeRealtime() + let r2 = makeRealtime() + try await r1.connect() + try await r2.connect() + + let sender = await r1.channel("integration:broadcast") + let receiver = await r2.channel("integration:broadcast") { + $0.broadcast.receiveOwnBroadcasts = false + } + + nonisolated(unsafe) var received: [Msg] = [] + let listenTask = Task { + for try await msg in await receiver.broadcasts(of: Msg.self, event: "test") { + received.append(msg) + } + } + try await Task.sleep(for: .milliseconds(500)) + + try await sender.join() + try await sender.broadcast(Msg(text: "hello integration"), as: "test") + try await Task.sleep(for: .seconds(1)) + + #expect(received.contains(Msg(text: "hello integration"))) + + listenTask.cancel() + try await sender.leave() + try await receiver.leave() + } +} diff --git a/Packages/_Realtime/Tests/_RealtimeTests/ReconnectionPolicyTests.swift b/Packages/_Realtime/Tests/_RealtimeTests/ReconnectionPolicyTests.swift new file mode 100644 index 000000000..f14cd0705 --- /dev/null +++ b/Packages/_Realtime/Tests/_RealtimeTests/ReconnectionPolicyTests.swift @@ -0,0 +1,53 @@ +import Foundation +import Testing + +@testable import _Realtime + +@Suite struct ReconnectionPolicyTests { + @Test func neverPolicyReturnsNilImmediately() { + let policy = ReconnectionPolicy.never + let delay = policy.nextDelay(1, URLError(.notConnectedToInternet)) + #expect(delay == nil) + } + + @Test func fixedPolicyReturnsDelayUntilMax() { + let policy = ReconnectionPolicy.fixed(.seconds(2), maxAttempts: 3) + #expect(policy.nextDelay(1, URLError(.notConnectedToInternet)) == .seconds(2)) + #expect(policy.nextDelay(3, URLError(.notConnectedToInternet)) == .seconds(2)) + #expect(policy.nextDelay(4, URLError(.notConnectedToInternet)) == nil) + } + + @Test func exponentialBackoffGrowsWithAttempts() { + let policy = ReconnectionPolicy.exponentialBackoff( + initial: .seconds(1), max: .seconds(16), jitter: 0 + ) + let d1 = policy.nextDelay(1, URLError(.notConnectedToInternet))! + let d2 = policy.nextDelay(2, URLError(.notConnectedToInternet))! + let d3 = policy.nextDelay(3, URLError(.notConnectedToInternet))! + #expect(d1.components.seconds == 1) + #expect(d2.components.seconds == 2) + #expect(d3.components.seconds == 4) + } + + @Test func exponentialBackoffCapsAtMax() { + let policy = ReconnectionPolicy.exponentialBackoff( + initial: .seconds(1), max: .seconds(5), jitter: 0 + ) + let d10 = policy.nextDelay(10, URLError(.notConnectedToInternet))! + #expect(d10.components.seconds <= 5) + } + + @Test func exponentialBackoffHandlesSubSecondInitial() { + let policy = ReconnectionPolicy.exponentialBackoff( + initial: .milliseconds(500), max: .seconds(16), jitter: 0 + ) + let d1 = policy.nextDelay(1, URLError(.notConnectedToInternet))! + // Should be ~0.5 seconds, not zero + #expect(d1 >= .milliseconds(400) && d1 <= .milliseconds(600)) + } + + @Test func fixedPolicyWithNoMaxAttemptsRetriresForever() { + let policy = ReconnectionPolicy.fixed(.seconds(1)) + #expect(policy.nextDelay(1000, URLError(.notConnectedToInternet)) == .seconds(1)) + } +} diff --git a/Packages/_Realtime/Tests/_RealtimeTests/TransportTests.swift b/Packages/_Realtime/Tests/_RealtimeTests/TransportTests.swift new file mode 100644 index 000000000..b362e77a2 --- /dev/null +++ b/Packages/_Realtime/Tests/_RealtimeTests/TransportTests.swift @@ -0,0 +1,44 @@ +import ConcurrencyExtras +import Foundation +import Testing + +@testable import _Realtime + +@Suite struct TransportTests { + @Test func framesFlowClientToServer() async throws { + let (transport, server) = InMemoryTransport.pair() + let connection = try await transport.connect(to: URL(string: "ws://test")!, headers: [:]) + + try await connection.send(.text("hello")) + let received = await server.receive() + #expect(received == .text("hello")) + } + + @Test func framesFlowServerToClient() async throws { + let (transport, server) = InMemoryTransport.pair() + let connection = try await transport.connect(to: URL(string: "ws://test")!, headers: [:]) + + Task { await server.send(.text("from server")) } + + var iter = connection.frames.makeAsyncIterator() + let frame = try await iter.next() + #expect(frame == .text("from server")) + } + + @Test func serverCloseFinishesClientFrames() async throws { + let (transport, server) = InMemoryTransport.pair() + let connection = try await transport.connect(to: URL(string: "ws://test")!, headers: [:]) + + Task { server.close() } + + var receivedFrames: [TransportFrame] = [] + do { + for try await frame in connection.frames { + receivedFrames.append(frame) + } + } catch { + // close with error is fine + } + #expect(receivedFrames.isEmpty) + } +} diff --git a/Packages/_RealtimeTableMacros/Package.resolved b/Packages/_RealtimeTableMacros/Package.resolved new file mode 100644 index 000000000..9cc5aca59 --- /dev/null +++ b/Packages/_RealtimeTableMacros/Package.resolved @@ -0,0 +1,15 @@ +{ + "originHash" : "c3fe0750a2da318e93dda47ec73754792bfdc51d039a37a5af5b91ffbbf6096b", + "pins" : [ + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "f99ae8aa18f0cf0d53481901f88a0991dc3bd4a2", + "version" : "601.0.1" + } + } + ], + "version" : 3 +} diff --git a/Packages/_RealtimeTableMacros/Package.swift b/Packages/_RealtimeTableMacros/Package.swift new file mode 100644 index 000000000..ed3217386 --- /dev/null +++ b/Packages/_RealtimeTableMacros/Package.swift @@ -0,0 +1,37 @@ +// swift-tools-version: 6.0 +import CompilerPluginSupport +import PackageDescription + +let package = Package( + name: "_RealtimeTableMacros", + platforms: [.iOS(.v17), .macOS(.v14), .tvOS(.v17), .watchOS(.v10), .visionOS(.v1)], + products: [ + .library(name: "_RealtimeTableMacros", targets: ["_RealtimeTableMacros"]), + ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "601.0.0"), + ], + targets: [ + .macro( + name: "_RealtimeTableMacroPlugin", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), + .target( + name: "_RealtimeTableMacros", + dependencies: [ + .target(name: "_RealtimeTableMacroPlugin"), + ] + ), + .testTarget( + name: "_RealtimeTableMacrosTests", + dependencies: [ + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + "_RealtimeTableMacros", + "_RealtimeTableMacroPlugin", + ] + ), + ] +) diff --git a/Packages/_RealtimeTableMacros/Sources/_RealtimeTableMacroPlugin/RealtimeTableMacro.swift b/Packages/_RealtimeTableMacros/Sources/_RealtimeTableMacroPlugin/RealtimeTableMacro.swift new file mode 100644 index 000000000..9ff7ec58e --- /dev/null +++ b/Packages/_RealtimeTableMacros/Sources/_RealtimeTableMacroPlugin/RealtimeTableMacro.swift @@ -0,0 +1,119 @@ +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct RealtimeTableMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard let structDecl = declaration.as(StructDeclSyntax.self) else { + throw MacroError(message: "@RealtimeTable can only be applied to structs") + } + + guard let args = node.arguments?.as(LabeledExprListSyntax.self), + let schemaExpr = args.first(where: { $0.label?.text == "schema" })?.expression, + let tableExpr = args.first(where: { $0.label?.text == "table" })?.expression, + let schema = schemaExpr.as(StringLiteralExprSyntax.self)?.representedLiteralValue, + let table = tableExpr.as(StringLiteralExprSyntax.self)?.representedLiteralValue + else { + throw MacroError(message: "@RealtimeTable requires schema: and table: arguments") + } + + let typeName = structDecl.name.text + let columnNames = extractColumnNames(from: structDecl) + + var caseLines: [String] = [] + for (prop, column) in columnNames { + caseLines.append(" case \\\(typeName).\(prop): return \"\(column)\"") + } + let cases = caseLines.joined(separator: "\n") + + let extensionSource = """ + extension \(typeName): RealtimeTable { + static let schema: String = "\(schema)" + static let tableName: String = "\(table)" + static func columnName(for keyPath: KeyPath<\(typeName), V>) -> String { + switch keyPath { + \(cases) + default: fatalError("Unknown keypath for RealtimeTable \\(keyPath)") + } + } + } + """ + + let extensionDecl = try ExtensionDeclSyntax("\(raw: extensionSource)") + return [extensionDecl] + } + + private static func extractColumnNames(from decl: StructDeclSyntax) -> [(String, String)] { + var codingKeyMap: [String: String] = [:] + for member in decl.memberBlock.members { + if let enumDecl = member.decl.as(EnumDeclSyntax.self), + enumDecl.name.text == "CodingKeys" { + for enumMember in enumDecl.memberBlock.members { + if let caseDecl = enumMember.decl.as(EnumCaseDeclSyntax.self) { + for element in caseDecl.elements { + let propName = element.name.text + if let rawValue = element.rawValue?.value + .as(StringLiteralExprSyntax.self)?.representedLiteralValue { + codingKeyMap[propName] = rawValue + } else { + codingKeyMap[propName] = propName + } + } + } + } + } + } + + var result: [(String, String)] = [] + for member in decl.memberBlock.members { + guard let varDecl = member.decl.as(VariableDeclSyntax.self), + varDecl.bindingSpecifier.text == "var" else { continue } + for binding in varDecl.bindings { + guard let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text else { + continue + } + // Skip computed properties (they have an accessor block) + if binding.accessorBlock != nil { continue } + let column: String + if let mapped = codingKeyMap[name] { + column = mapped + } else if codingKeyMap.isEmpty { + column = camelToSnake(name) + } else { + column = name + } + result.append((name, column)) + } + } + return result + } + + private static func camelToSnake(_ s: String) -> String { + var result = "" + for (i, char) in s.enumerated() { + if char.isUppercase && i > 0 { + result.append("_") + result.append(char.lowercased()) + } else { + result.append(char) + } + } + return result + } +} + +struct MacroError: Error, CustomStringConvertible { + let message: String + var description: String { message } +} + +@main struct _RealtimeTableMacroPlugin: CompilerPlugin { + var providingMacros: [any Macro.Type] = [RealtimeTableMacro.self] +} diff --git a/Packages/_RealtimeTableMacros/Sources/_RealtimeTableMacros/Exports.swift b/Packages/_RealtimeTableMacros/Sources/_RealtimeTableMacros/Exports.swift new file mode 100644 index 000000000..e30777d07 --- /dev/null +++ b/Packages/_RealtimeTableMacros/Sources/_RealtimeTableMacros/Exports.swift @@ -0,0 +1,9 @@ +// +// Exports.swift +// _RealtimeTableMacros +// +// Created by Guilherme Souza on 24/04/25. +// + +// Intentionally empty — this library exists to re-export the macro plugin target +// so downstream modules can depend on it via a single product. diff --git a/Packages/_RealtimeTableMacros/Tests/_RealtimeTableMacrosTests/RealtimeTableMacroTests.swift b/Packages/_RealtimeTableMacros/Tests/_RealtimeTableMacrosTests/RealtimeTableMacroTests.swift new file mode 100644 index 000000000..125c113da --- /dev/null +++ b/Packages/_RealtimeTableMacros/Tests/_RealtimeTableMacrosTests/RealtimeTableMacroTests.swift @@ -0,0 +1,87 @@ +import SwiftSyntaxMacrosTestSupport +import XCTest +@testable import _RealtimeTableMacroPlugin + +final class RealtimeTableMacroTests: XCTestCase { + func testBasicExpansion() throws { + assertMacroExpansion( + """ + @RealtimeTable(schema: "public", table: "messages") + struct Message: Codable, Sendable { + var id: UUID + var roomId: UUID + var text: String + } + """, + expandedSource: """ + struct Message: Codable, Sendable { + var id: UUID + var roomId: UUID + var text: String + } + + extension Message: RealtimeTable { + static let schema: String = "public" + static let tableName: String = "messages" + static func columnName(for keyPath: KeyPath) -> String { + switch keyPath { + case \\Message.id: + return "id" + case \\Message.roomId: + return "room_id" + case \\Message.text: + return "text" + default: + fatalError("Unknown keypath for RealtimeTable \\(keyPath)") + } + } + } + """, + macros: ["RealtimeTable": RealtimeTableMacro.self] + ) + } + + func testCustomCodingKeysRespected() throws { + assertMacroExpansion( + """ + @RealtimeTable(schema: "public", table: "users") + struct User: Codable, Sendable { + var id: UUID + var createdAt: Date + + enum CodingKeys: String, CodingKey { + case id + case createdAt = "created_at" + } + } + """, + expandedSource: """ + struct User: Codable, Sendable { + var id: UUID + var createdAt: Date + + enum CodingKeys: String, CodingKey { + case id + case createdAt = "created_at" + } + } + + extension User: RealtimeTable { + static let schema: String = "public" + static let tableName: String = "users" + static func columnName(for keyPath: KeyPath) -> String { + switch keyPath { + case \\User.id: + return "id" + case \\User.createdAt: + return "created_at" + default: + fatalError("Unknown keypath for RealtimeTable \\(keyPath)") + } + } + } + """, + macros: ["RealtimeTable": RealtimeTableMacro.self] + ) + } +} diff --git a/Sources/Auth/AuthClientConfiguration.swift b/Sources/Auth/AuthClientConfiguration.swift index 455c92872..4baec5b52 100644 --- a/Sources/Auth/AuthClientConfiguration.swift +++ b/Sources/Auth/AuthClientConfiguration.swift @@ -13,9 +13,10 @@ import Foundation extension AuthClient { /// FetchHandler is a type alias for asynchronous network request handling. - public typealias FetchHandler = @Sendable ( - _ request: URLRequest - ) async throws -> (Data, URLResponse) + public typealias FetchHandler = + @Sendable ( + _ request: URLRequest + ) async throws -> (Data, URLResponse) /// Configuration struct represents the client configuration. public struct Configuration: Sendable { diff --git a/Sources/Auth/AuthError.swift b/Sources/Auth/AuthError.swift index 991100bf5..2e0946256 100644 --- a/Sources/Auth/AuthError.swift +++ b/Sources/Auth/AuthError.swift @@ -268,11 +268,11 @@ public enum AuthError: LocalizedError, Equatable { public var message: String { switch self { case .sessionMissing: "Auth session missing." - case let .weakPassword(message, _), - let .api(message, _, _, _), - let .pkceGrantCodeExchange(message, _, _), - let .implicitGrantRedirect(message), - let .jwtVerificationFailed(message): + case .weakPassword(let message, _), + .api(let message, _, _, _), + .pkceGrantCodeExchange(let message, _, _), + .implicitGrantRedirect(let message), + .jwtVerificationFailed(let message): message // Deprecated cases case .missingExpClaim: "Missing expiration claim in the access token." @@ -286,7 +286,7 @@ public enum AuthError: LocalizedError, Equatable { switch self { case .sessionMissing: .sessionNotFound case .weakPassword: .weakPassword - case let .api(_, errorCode, _, _): errorCode + case .api(_, let errorCode, _, _): errorCode case .pkceGrantCodeExchange, .implicitGrantRedirect: .unknown case .jwtVerificationFailed: .invalidJWT // Deprecated cases diff --git a/Sources/Auth/AuthMFA.swift b/Sources/Auth/AuthMFA.swift index bf6390b2d..3311e0472 100644 --- a/Sources/Auth/AuthMFA.swift +++ b/Sources/Auth/AuthMFA.swift @@ -122,7 +122,9 @@ public struct AuthMFA: Sendable { /// Returns the Authenticator Assurance Level (AAL) for the active session. /// /// - Returns: An authentication response with the Authenticator Assurance Level. - public func getAuthenticatorAssuranceLevel() async throws -> AuthMFAGetAuthenticatorAssuranceLevelResponse { + public func getAuthenticatorAssuranceLevel() async throws + -> AuthMFAGetAuthenticatorAssuranceLevelResponse + { do { let session = try await sessionManager.session() let payload = JWT.decodePayload(session.accessToken) diff --git a/Sources/Auth/AuthStateChangeListener.swift b/Sources/Auth/AuthStateChangeListener.swift index c0d794154..c4e48f65d 100644 --- a/Sources/Auth/AuthStateChangeListener.swift +++ b/Sources/Auth/AuthStateChangeListener.swift @@ -8,7 +8,6 @@ import ConcurrencyExtras import Foundation - /// A listener that can be removed by calling ``AuthStateChangeListenerRegistration/remove()``. /// /// - Note: Listener is automatically removed on deinit. @@ -19,7 +18,8 @@ public protocol AuthStateChangeListenerRegistration: Sendable { extension ObservationToken: AuthStateChangeListenerRegistration {} -public typealias AuthStateChangeListener = @Sendable ( - _ event: AuthChangeEvent, - _ session: Session? -) -> Void +public typealias AuthStateChangeListener = + @Sendable ( + _ event: AuthChangeEvent, + _ session: Session? + ) -> Void diff --git a/Sources/Auth/Internal/CodeVerifierStorage.swift b/Sources/Auth/Internal/CodeVerifierStorage.swift index d4a1f4b41..480e32324 100644 --- a/Sources/Auth/Internal/CodeVerifierStorage.swift +++ b/Sources/Auth/Internal/CodeVerifierStorage.swift @@ -20,7 +20,8 @@ extension CodeVerifierStorage { } return String(decoding: data, as: UTF8.self) } catch { - configuration.logger?.error("Failure loading code verifier: \(error.localizedDescription)") + configuration.logger?.error( + "Failure loading code verifier: \(error.localizedDescription)") return nil } }, @@ -34,7 +35,8 @@ extension CodeVerifierStorage { configuration.logger?.error("Code verifier is not a valid UTF8 string.") } } catch { - configuration.logger?.error("Failure storing code verifier: \(error.localizedDescription)") + configuration.logger?.error( + "Failure storing code verifier: \(error.localizedDescription)") } } ) diff --git a/Sources/Auth/Internal/EventEmitter.swift b/Sources/Auth/Internal/EventEmitter.swift index 157b25836..9a5dd72e3 100644 --- a/Sources/Auth/Internal/EventEmitter.swift +++ b/Sources/Auth/Internal/EventEmitter.swift @@ -2,7 +2,8 @@ import ConcurrencyExtras import Foundation struct AuthStateChangeEventEmitter { - var emitter = EventEmitter<(AuthChangeEvent, Session?)?>(initialEvent: nil, emitsLastEventWhenAttaching: false) + var emitter = EventEmitter<(AuthChangeEvent, Session?)?>( + initialEvent: nil, emitsLastEventWhenAttaching: false) var logger: (any SupabaseLogger)? func attach(_ listener: @escaping AuthStateChangeListener) -> ObservationToken { diff --git a/Sources/Auth/Internal/FixedWidthInteger+Random.swift b/Sources/Auth/Internal/FixedWidthInteger+Random.swift index c16dec4ff..20973d550 100644 --- a/Sources/Auth/Internal/FixedWidthInteger+Random.swift +++ b/Sources/Auth/Internal/FixedWidthInteger+Random.swift @@ -15,13 +15,13 @@ extension FixedWidthInteger { extension Array where Element: FixedWidthInteger { static func random(count: Int) -> [Element] { var array: [Element] = .init(repeating: 0, count: count) - (0 ..< count).forEach { array[$0] = Element.random() } + (0.. [Element] { var array: [Element] = .init(repeating: 0, count: count) - (0 ..< count).forEach { array[$0] = Element.random(using: &generator) } + (0.. [String: String] { private func extractParams(from fragment: String) -> [URLQueryItem] { let components = fragment - .split(separator: "&") - .map { $0.split(separator: "=") } + .split(separator: "&") + .map { $0.split(separator: "=") } return components - .compactMap { - $0.count == 2 - ? URLQueryItem(name: String($0[0]), value: String($0[1])) - : nil - } + .compactMap { + $0.count == 2 + ? URLQueryItem(name: String($0[0]), value: String($0[1])) + : nil + } } diff --git a/Sources/Auth/Internal/Keychain.swift b/Sources/Auth/Internal/Keychain.swift index 2735e26db..0c6bf07cf 100644 --- a/Sources/Auth/Internal/Keychain.swift +++ b/Sources/Auth/Internal/Keychain.swift @@ -40,7 +40,8 @@ if addStatus == KeychainError.duplicateItem.status { let updateQuery = baseQuery(withKey: key) let updateAttributes: [String: Any] = [kSecValueData as String: data] - let updateStatus = SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary) + let updateStatus = SecItemUpdate( + updateQuery as CFDictionary, updateAttributes as CFDictionary) try assertSuccess(forStatus: updateStatus) } else { try assertSuccess(forStatus: addStatus) @@ -128,8 +129,8 @@ case .itemNotFound: errSecItemNotFound case .interactionNotAllowed: errSecInteractionNotAllowed case .decodeFailed: errSecDecode - case let .other(status): status - case .unknown: errSecSuccess // This is not a Keychain error + case .other(let status): status + case .unknown: errSecSuccess // This is not a Keychain error } } } @@ -170,7 +171,7 @@ "errSecDecode: Unable to decode the provided data." case .other: "Unspecified Keychain error: \(status)." - case let .unknown(message): + case .unknown(let message): "Unknown error: \(message)." } } diff --git a/Sources/Auth/Storage/WinCredLocalStorage.swift b/Sources/Auth/Storage/WinCredLocalStorage.swift index d132fb7ac..b289d6bc8 100644 --- a/Sources/Auth/Storage/WinCredLocalStorage.swift +++ b/Sources/Auth/Storage/WinCredLocalStorage.swift @@ -56,7 +56,7 @@ } guard let foundCredential = credential, - let blob = foundCredential.pointee.CredentialBlob + let blob = foundCredential.pointee.CredentialBlob else { throw WinCredLocalStorageError.other(-1) } diff --git a/Sources/Functions/Types.swift b/Sources/Functions/Types.swift index e53f06fdd..3848c0fd7 100644 --- a/Sources/Functions/Types.swift +++ b/Sources/Functions/Types.swift @@ -13,7 +13,7 @@ public enum FunctionsError: Error, LocalizedError { switch self { case .relayError: "Relay Error invoking the Edge Function" - case let .httpError(code, _): + case .httpError(let code, _): "Edge Function returned a non-2xx status code: \(code)" } } diff --git a/Sources/Helpers/AnyJSON/AnyJSON.swift b/Sources/Helpers/AnyJSON/AnyJSON.swift index afe6ae730..d3f07af59 100644 --- a/Sources/Helpers/AnyJSON/AnyJSON.swift +++ b/Sources/Helpers/AnyJSON/AnyJSON.swift @@ -26,12 +26,12 @@ public enum AnyJSON: Sendable, Codable, Hashable { public var value: Any { switch self { case .null: NSNull() - case let .string(string): string - case let .integer(val): val - case let .double(val): val - case let .object(dictionary): dictionary.mapValues(\.value) - case let .array(array): array.map(\.value) - case let .bool(bool): bool + case .string(let string): string + case .integer(let val): val + case .double(let val): val + case .object(let dictionary): dictionary.mapValues(\.value) + case .array(let array): array.map(\.value) + case .bool(let bool): bool } } @@ -44,42 +44,42 @@ public enum AnyJSON: Sendable, Codable, Hashable { } public var boolValue: Bool? { - if case let .bool(val) = self { + if case .bool(let val) = self { return val } return nil } public var objectValue: JSONObject? { - if case let .object(dictionary) = self { + if case .object(let dictionary) = self { return dictionary } return nil } public var arrayValue: JSONArray? { - if case let .array(array) = self { + if case .array(let array) = self { return array } return nil } public var stringValue: String? { - if case let .string(string) = self { + if case .string(let string) = self { return string } return nil } public var intValue: Int? { - if case let .integer(val) = self { + if case .integer(let val) = self { return val } return nil } public var doubleValue: Double? { - if case let .double(val) = self { + if case .double(let val) = self { return val } return nil @@ -113,12 +113,12 @@ public enum AnyJSON: Sendable, Codable, Hashable { var container = encoder.singleValueContainer() switch self { case .null: try container.encodeNil() - case let .array(val): try container.encode(val) - case let .object(val): try container.encode(val) - case let .string(val): try container.encode(val) - case let .integer(val): try container.encode(val) - case let .double(val): try container.encode(val) - case let .bool(val): try container.encode(val) + case .array(let val): try container.encode(val) + case .object(let val): try container.encode(val) + case .string(let val): try container.encode(val) + case .integer(let val): try container.encode(val) + case .double(let val): try container.encode(val) + case .bool(let val): try container.encode(val) } } } diff --git a/Sources/Helpers/FoundationExtensions.swift b/Sources/Helpers/FoundationExtensions.swift index 00b1ba83a..8832c34b5 100644 --- a/Sources/Helpers/FoundationExtensions.swift +++ b/Sources/Helpers/FoundationExtensions.swift @@ -10,13 +10,13 @@ import Foundation #if canImport(FoundationNetworking) import FoundationNetworking - package let NSEC_PER_SEC: UInt64 = 1000000000 - package let NSEC_PER_MSEC: UInt64 = 1000000 + package let NSEC_PER_SEC: UInt64 = 1_000_000_000 + package let NSEC_PER_MSEC: UInt64 = 1_000_000 #endif extension Result { package var value: Success? { - if case let .success(value) = self { + if case .success(let value) = self { value } else { nil @@ -24,7 +24,7 @@ extension Result { } package var error: Failure? { - if case let .failure(error) = self { + if case .failure(let error) = self { error } else { nil @@ -44,12 +44,14 @@ extension URL { let currentQueryItems = components.percentEncodedQueryItems ?? [] - components.percentEncodedQueryItems = currentQueryItems + queryItems.map { - URLQueryItem( - name: escape($0.name), - value: $0.value.map(escape) - ) - } + components.percentEncodedQueryItems = + currentQueryItems + + queryItems.map { + URLQueryItem( + name: escape($0.name), + value: $0.value.map(escape) + ) + } if let newURL = components.url { self = newURL @@ -79,9 +81,10 @@ extension CharacterSet { /// query strings to include a URL. Therefore, all "reserved" characters with the exception of "?" and "/" /// should be percent-escaped in the query string. static let sbURLQueryAllowed: CharacterSet = { - let generalDelimitersToEncode = ":#[]@" // does not include "?" or "/" due to RFC 3986 - Section 3.4 + let generalDelimitersToEncode = ":#[]@" // does not include "?" or "/" due to RFC 3986 - Section 3.4 let subDelimitersToEncode = "!$&'()*+,;=" - let encodableDelimiters = CharacterSet(charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)") + let encodableDelimiters = CharacterSet( + charactersIn: "\(generalDelimitersToEncode)\(subDelimitersToEncode)") return CharacterSet.urlQueryAllowed.subtracting(encodableDelimiters) }() diff --git a/Sources/Helpers/HTTP/HTTPRequest.swift b/Sources/Helpers/HTTP/HTTPRequest.swift index c67f78aae..956309b71 100644 --- a/Sources/Helpers/HTTP/HTTPRequest.swift +++ b/Sources/Helpers/HTTP/HTTPRequest.swift @@ -45,15 +45,18 @@ package struct HTTPRequest: Sendable { timeoutInterval: TimeInterval = 60 ) { guard let url = URL(string: urlString) else { return nil } - self.init(url: url, method: method, query: query, headers: headers, body: body, timeoutInterval: timeoutInterval) + self.init( + url: url, method: method, query: query, headers: headers, body: body, + timeoutInterval: timeoutInterval) } package var urlRequest: URLRequest { - var urlRequest = URLRequest(url: query.isEmpty ? url : url.appendingQueryItems(query), timeoutInterval: timeoutInterval) + var urlRequest = URLRequest( + url: query.isEmpty ? url : url.appendingQueryItems(query), timeoutInterval: timeoutInterval) urlRequest.httpMethod = method.rawValue urlRequest.allHTTPHeaderFields = .init(headers.map { ($0.name.rawName, $0.value) }) { $1 } urlRequest.httpBody = body - + if urlRequest.httpBody != nil, urlRequest.value(forHTTPHeaderField: "Content-Type") == nil { urlRequest.setValue("application/json", forHTTPHeaderField: "Content-Type") } diff --git a/Sources/Helpers/HTTP/HTTPResponse.swift b/Sources/Helpers/HTTP/HTTPResponse.swift index bc8a72713..5a2ced6cf 100644 --- a/Sources/Helpers/HTTP/HTTPResponse.swift +++ b/Sources/Helpers/HTTP/HTTPResponse.swift @@ -28,7 +28,9 @@ package struct HTTPResponse: Sendable { } extension HTTPResponse { - package func decoded(as _: T.Type = T.self, decoder: JSONDecoder = JSONDecoder()) throws -> T { + package func decoded(as _: T.Type = T.self, decoder: JSONDecoder = JSONDecoder()) + throws -> T + { try decoder.decode(T.self, from: data) } } diff --git a/Sources/Helpers/HTTP/LoggerInterceptor.swift b/Sources/Helpers/HTTP/LoggerInterceptor.swift index e58819535..39ef671ed 100644 --- a/Sources/Helpers/HTTP/LoggerInterceptor.swift +++ b/Sources/Helpers/HTTP/LoggerInterceptor.swift @@ -19,7 +19,9 @@ package struct LoggerInterceptor: HTTPClientInterceptor { next: @Sendable (HTTPRequest) async throws -> HTTPResponse ) async throws -> HTTPResponse { let id = UUID().uuidString - return try await SupabaseLoggerTaskLocal.$additionalContext.withValue(merging: ["requestID": .string(id)]) { + return try await SupabaseLoggerTaskLocal.$additionalContext.withValue(merging: [ + "requestID": .string(id) + ]) { let urlRequest = request.urlRequest logger.verbose( diff --git a/Sources/Helpers/JWT.swift b/Sources/Helpers/JWT.swift index 983e231a9..4f99ee16e 100644 --- a/Sources/Helpers/JWT.swift +++ b/Sources/Helpers/JWT.swift @@ -46,8 +46,10 @@ package enum JWT { let headerData = Base64URL.decode(headerString), let payloadData = Base64URL.decode(payloadString), let signatureData = Base64URL.decode(signatureString), - let headerJSON = try? JSONSerialization.jsonObject(with: headerData, options: []) as? [String: Any], - let payloadJSON = try? JSONSerialization.jsonObject(with: payloadData, options: []) as? [String: Any] + let headerJSON = try? JSONSerialization.jsonObject(with: headerData, options: []) + as? [String: Any], + let payloadJSON = try? JSONSerialization.jsonObject(with: payloadData, options: []) + as? [String: Any] else { return nil } diff --git a/Sources/Helpers/Logger/SupabaseLogger.swift b/Sources/Helpers/Logger/SupabaseLogger.swift index d5732c018..313b5a923 100644 --- a/Sources/Helpers/Logger/SupabaseLogger.swift +++ b/Sources/Helpers/Logger/SupabaseLogger.swift @@ -214,4 +214,3 @@ extension SupabaseLogger { } } #endif - diff --git a/Sources/Helpers/URLSession+AsyncAwait.swift b/Sources/Helpers/URLSession+AsyncAwait.swift index 5bc0577d5..cff1a8b08 100644 --- a/Sources/Helpers/URLSession+AsyncAwait.swift +++ b/Sources/Helpers/URLSession+AsyncAwait.swift @@ -57,7 +57,7 @@ private func actuallyCancel() { // Handle whatever needs to be done based on the current state switch state { - case let .registered(task): + case .registered(let task): task.cancel() case .cancelled: break diff --git a/Sources/PostgREST/PostgrestFilterValue.swift b/Sources/PostgREST/PostgrestFilterValue.swift index 1d26ca7de..b3bef56f1 100644 --- a/Sources/PostgREST/PostgrestFilterValue.swift +++ b/Sources/PostgREST/PostgrestFilterValue.swift @@ -47,12 +47,12 @@ extension Array: PostgrestFilterValue where Element: PostgrestFilterValue { extension AnyJSON: PostgrestFilterValue { public var rawValue: String { switch self { - case let .array(array): array.rawValue - case let .object(object): object.rawValue - case let .string(string): string.rawValue - case let .double(double): double.rawValue - case let .integer(integer): integer.rawValue - case let .bool(bool): bool.rawValue + case .array(let array): array.rawValue + case .object(let object): object.rawValue + case .string(let string): string.rawValue + case .double(let double): double.rawValue + case .integer(let integer): integer.rawValue + case .bool(let bool): bool.rawValue case .null: "NULL" } } diff --git a/Sources/Realtime/Deprecated/Defaults.swift b/Sources/Realtime/Deprecated/Defaults.swift index e74f08bc7..84f443627 100644 --- a/Sources/Realtime/Deprecated/Defaults.swift +++ b/Sources/Realtime/Deprecated/Defaults.swift @@ -57,7 +57,7 @@ public enum Defaults { public static let decode: (Data) -> Any? = { data in guard let json = - try? JSONSerialization + try? JSONSerialization .jsonObject( with: data, options: JSONSerialization.ReadingOptions() diff --git a/Sources/Realtime/Deprecated/PhoenixTransport.swift b/Sources/Realtime/Deprecated/PhoenixTransport.swift index 79c854005..b027fd808 100644 --- a/Sources/Realtime/Deprecated/PhoenixTransport.swift +++ b/Sources/Realtime/Deprecated/PhoenixTransport.swift @@ -176,8 +176,8 @@ open class URLSessionTransport: NSObject, PhoenixTransport, URLSessionWebSocketD let endpoint = url.absoluteString let wsEndpoint = endpoint - .replacingOccurrences(of: "http://", with: "ws://") - .replacingOccurrences(of: "https://", with: "wss://") + .replacingOccurrences(of: "http://", with: "ws://") + .replacingOccurrences(of: "https://", with: "wss://") // Force unwrapping should be safe here since a valid URL came in and we just // replaced the protocol. @@ -282,7 +282,7 @@ open class URLSessionTransport: NSObject, PhoenixTransport, URLSessionWebSocketD switch result { case .data: print("Data received. This method is unsupported by the Client") - case let .string(text): + case .string(let text): self.delegate?.onMessage(message: text) default: fatalError("Unknown result was received. [\(String(describing: result))]") diff --git a/Sources/Realtime/Deprecated/Presence.swift b/Sources/Realtime/Deprecated/Presence.swift index 2370697f7..13fd6898e 100644 --- a/Sources/Realtime/Deprecated/Presence.swift +++ b/Sources/Realtime/Deprecated/Presence.swift @@ -94,7 +94,8 @@ import Foundation *, deprecated, renamed: "PresenceV2", - message: "Presence class is deprecated in favor of PresenceV2. See migration guide: https://github.com/supabase-community/supabase-swift/blob/main/docs/migrations/RealtimeV2%20Migration%20Guide.md" + message: + "Presence class is deprecated in favor of PresenceV2. See migration guide: https://github.com/supabase-community/supabase-swift/blob/main/docs/migrations/RealtimeV2%20Migration%20Guide.md" ) public final class Presence { // ---------------------------------------------------------------------- @@ -228,7 +229,7 @@ public final class Presence { joinRef = nil caller = Caller() - guard // Do not subscribe to events if they were not provided + guard // Do not subscribe to events if they were not provided let stateEvent = opts.events[.state], let diffEvent = opts.events[.diff] else { return } diff --git a/Sources/Realtime/Deprecated/RealtimeChannel.swift b/Sources/Realtime/Deprecated/RealtimeChannel.swift index 22169bc19..37c23a730 100644 --- a/Sources/Realtime/Deprecated/RealtimeChannel.swift +++ b/Sources/Realtime/Deprecated/RealtimeChannel.swift @@ -20,8 +20,8 @@ import ConcurrencyExtras import Foundation -import Swift import HTTPTypes +import Swift /// Container class of bindings to the channel struct Binding { @@ -94,13 +94,13 @@ public struct RealtimeChannelOptions { [ "config": [ "presence": [ - "key": presenceKey ?? "", + "key": presenceKey ?? "" ], "broadcast": [ "ack": broadcastAcknowledge, "self": broadcastSelf, ], - ], + ] ] } } @@ -135,7 +135,8 @@ public enum RealtimeSubscribeStates { @available( *, deprecated, - message: "Use new RealtimeChannelV2 class instead. See migration guide: https://github.com/supabase-community/supabase-swift/blob/main/docs/migrations/RealtimeV2%20Migration%20Guide.md" + message: + "Use new RealtimeChannelV2 class instead. See migration guide: https://github.com/supabase-community/supabase-swift/blob/main/docs/migrations/RealtimeV2%20Migration%20Guide.md" ) public class RealtimeChannel { /// The topic of the RealtimeChannel. e.g. "rooms:friends" @@ -377,7 +378,7 @@ public class RealtimeChannel { var accessTokenPayload: Payload = [:] var config: Payload = [ - "postgres_changes": bindings.value["postgres_changes"]?.map(\.filter) ?? [], + "postgres_changes": bindings.value["postgres_changes"]?.map(\.filter) ?? [] ] config["broadcast"] = broadcast @@ -408,7 +409,7 @@ public class RealtimeChannel { let bindingsCount = clientPostgresBindings.count var newPostgresBindings: [Binding] = [] - for i in 0 ..< bindingsCount { + for i in 0.. Void)] = [] /// Ref counter for messages - var ref: UInt64 = .min // 0 (max: 18,446,744,073,709,551,615) + var ref: UInt64 = .min // 0 (max: 18,446,744,073,709,551,615) /// Timer that triggers sending new Heartbeat messages var heartbeatTimer: HeartbeatTimer? @@ -939,7 +940,7 @@ public class RealtimeClient: PhoenixTransportDelegate { // If there is a pending heartbeat ref, then the last heartbeat was // never acknowledged by the server. Close the connection and attempt // to reconnect. - if let _ = pendingHeartbeatRef { + if pendingHeartbeatRef != nil { pendingHeartbeatRef = nil logItems( "transport", diff --git a/Sources/Realtime/Deprecated/RealtimeMessage.swift b/Sources/Realtime/Deprecated/RealtimeMessage.swift index a993ae2d1..e3e38b04d 100644 --- a/Sources/Realtime/Deprecated/RealtimeMessage.swift +++ b/Sources/Realtime/Deprecated/RealtimeMessage.swift @@ -73,8 +73,8 @@ public struct RealtimeMessage { ref = json[1] as? String ?? "" if let topic = json[2] as? String, - let event = json[3] as? String, - let payload = json[4] as? Payload + let event = json[3] as? String, + let payload = json[4] as? Payload { self.topic = topic self.event = event diff --git a/Sources/Realtime/PostgresAction.swift b/Sources/Realtime/PostgresAction.swift index 265bd2bfa..3824c7b80 100644 --- a/Sources/Realtime/PostgresAction.swift +++ b/Sources/Realtime/PostgresAction.swift @@ -64,9 +64,9 @@ public enum AnyAction: PostgresAction, HasRawMessage { var wrappedAction: any PostgresAction & HasRawMessage { switch self { - case let .insert(action): action - case let .update(action): action - case let .delete(action): action + case .insert(let action): action + case .update(let action): action + case .delete(let action): action } } diff --git a/Sources/Realtime/PresenceAction.swift b/Sources/Realtime/PresenceAction.swift index f8753afb5..fb6a48139 100644 --- a/Sources/Realtime/PresenceAction.swift +++ b/Sources/Realtime/PresenceAction.swift @@ -41,10 +41,11 @@ extension PresenceV2: Codable { let json = try container.decode(JSONObject.self) - let codingPath = container.codingPath + [ - _StringCodingKey("metas"), - _StringCodingKey(intValue: 0)!, - ] + let codingPath = + container.codingPath + [ + _StringCodingKey("metas"), + _StringCodingKey(intValue: 0)!, + ] guard var meta = json["metas"]?.arrayValue?.first?.objectValue else { throw DecodingError.typeMismatch( diff --git a/Sources/Realtime/RealtimePostgresFilter.swift b/Sources/Realtime/RealtimePostgresFilter.swift index 8b8c77906..976d570ad 100644 --- a/Sources/Realtime/RealtimePostgresFilter.swift +++ b/Sources/Realtime/RealtimePostgresFilter.swift @@ -17,19 +17,19 @@ public enum RealtimePostgresFilter { var value: String { switch self { - case let .eq(column, value): + case .eq(let column, let value): return "\(column)=eq.\(value.rawValue)" - case let .neq(column, value): + case .neq(let column, let value): return "\(column)=neq.\(value.rawValue)" - case let .gt(column, value): + case .gt(let column, let value): return "\(column)=gt.\(value.rawValue)" - case let .gte(column, value): + case .gte(let column, let value): return "\(column)=gte.\(value.rawValue)" - case let .lt(column, value): + case .lt(let column, let value): return "\(column)=lt.\(value.rawValue)" - case let .lte(column, value): + case .lte(let column, let value): return "\(column)=lte.\(value.rawValue)" - case let .in(column, values): + case .in(let column, let values): return "\(column)=in.(\(values.map(\.rawValue).joined(separator: ",")))" } } diff --git a/Sources/Storage/StorageHTTPClient.swift b/Sources/Storage/StorageHTTPClient.swift index b078f7011..af4e2bb76 100644 --- a/Sources/Storage/StorageHTTPClient.swift +++ b/Sources/Storage/StorageHTTPClient.swift @@ -11,9 +11,10 @@ public struct StorageHTTPSession: Sendable { public init( fetch: @escaping @Sendable (_ request: URLRequest) async throws -> (Data, URLResponse), - upload: @escaping @Sendable (_ request: URLRequest, _ data: Data) async throws -> ( - Data, URLResponse - ) + upload: + @escaping @Sendable (_ request: URLRequest, _ data: Data) async throws -> ( + Data, URLResponse + ) ) { self.fetch = fetch self.upload = upload diff --git a/Sources/Supabase/Deprecated.swift b/Sources/Supabase/Deprecated.swift index 5043e4119..e6babbb41 100644 --- a/Sources/Supabase/Deprecated.swift +++ b/Sources/Supabase/Deprecated.swift @@ -12,7 +12,8 @@ extension SupabaseClient { @available( *, deprecated, - message: "Direct access to database is deprecated, please use one of the available methods such as, SupabaseClient.from(_:), SupabaseClient.rpc(_:params:), or SupabaseClient.schema(_:)." + message: + "Direct access to database is deprecated, please use one of the available methods such as, SupabaseClient.from(_:), SupabaseClient.rpc(_:params:), or SupabaseClient.schema(_:)." ) public var database: PostgrestClient { rest diff --git a/Sources/Supabase/Types.swift b/Sources/Supabase/Types.swift index eb0d6f69e..f0a8b9083 100644 --- a/Sources/Supabase/Types.swift +++ b/Sources/Supabase/Types.swift @@ -131,7 +131,7 @@ public struct SupabaseClientOptions: Sendable { public struct StorageOptions: Sendable { /// Whether storage client should be initialized with the new hostname format, i.e. `project-ref.storage.supabase.co` public let useNewHostname: Bool - + public init(useNewHostname: Bool = false) { self.useNewHostname = useNewHostname } diff --git a/Tests/AuthTests/AuthErrorTests.swift b/Tests/AuthTests/AuthErrorTests.swift index 253fc7921..c98a89de8 100644 --- a/Tests/AuthTests/AuthErrorTests.swift +++ b/Tests/AuthTests/AuthErrorTests.swift @@ -78,7 +78,7 @@ final class AuthErrorTests: XCTestCase { .userNotFound, .invalidCredentials, .emailExists, - .overRequestRateLimit + .overRequestRateLimit, ] for code in errorCodes { diff --git a/Tests/AuthTests/JWTCryptoTests.swift b/Tests/AuthTests/JWTCryptoTests.swift index 60f51fee4..be69d0ee6 100644 --- a/Tests/AuthTests/JWTCryptoTests.swift +++ b/Tests/AuthTests/JWTCryptoTests.swift @@ -6,170 +6,172 @@ // import XCTest + @testable import Auth @testable import Helpers #if canImport(Security) -final class JWTCryptoTests: XCTestCase { - - // MARK: - JWK+RSA Tests - - func testRSAPublishKeyGeneration() { - // Test data from a real RS256 JWT (modulus and exponent) - // This is a sample RSA256 public key - let jwk = JWK( - kty: "RSA", - keyOps: ["verify"], - alg: "RS256", - kid: "test-key-1", - n: "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", - e: "AQAB", - crv: nil, - x: nil, - y: nil, - k: nil - ) - - // Test valid RSA key generation - let rsaKey = jwk.rsaPublishKey - XCTAssertNotNil(rsaKey, "RSA public key should be generated successfully") - } + final class JWTCryptoTests: XCTestCase { + + // MARK: - JWK+RSA Tests + + func testRSAPublishKeyGeneration() { + // Test data from a real RS256 JWT (modulus and exponent) + // This is a sample RSA256 public key + let jwk = JWK( + kty: "RSA", + keyOps: ["verify"], + alg: "RS256", + kid: "test-key-1", + n: + "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + e: "AQAB", + crv: nil, + x: nil, + y: nil, + k: nil + ) + + // Test valid RSA key generation + let rsaKey = jwk.rsaPublishKey + XCTAssertNotNil(rsaKey, "RSA public key should be generated successfully") + } - func testRSAPublishKeyInvalidAlgorithm() { - // Test with invalid algorithm - let jwk = JWK( - kty: "RSA", - keyOps: nil, - alg: "ES256", // Wrong algorithm - should be RS256 - kid: "test-key-2", - n: "test-modulus", - e: "AQAB", - crv: nil, - x: nil, - y: nil, - k: nil - ) - - let rsaKey = jwk.rsaPublishKey - XCTAssertNil(rsaKey, "RSA public key should be nil with wrong algorithm") - } + func testRSAPublishKeyInvalidAlgorithm() { + // Test with invalid algorithm + let jwk = JWK( + kty: "RSA", + keyOps: nil, + alg: "ES256", // Wrong algorithm - should be RS256 + kid: "test-key-2", + n: "test-modulus", + e: "AQAB", + crv: nil, + x: nil, + y: nil, + k: nil + ) + + let rsaKey = jwk.rsaPublishKey + XCTAssertNil(rsaKey, "RSA public key should be nil with wrong algorithm") + } - func testRSAPublishKeyInvalidKeyType() { - // Test with invalid key type - let jwk = JWK( - kty: "EC", // Wrong type - should be RSA - keyOps: nil, - alg: "RS256", - kid: "test-key-3", - n: "test-modulus", - e: "AQAB", - crv: nil, - x: nil, - y: nil, - k: nil - ) - - let rsaKey = jwk.rsaPublishKey - XCTAssertNil(rsaKey, "RSA public key should be nil with wrong key type") - } + func testRSAPublishKeyInvalidKeyType() { + // Test with invalid key type + let jwk = JWK( + kty: "EC", // Wrong type - should be RSA + keyOps: nil, + alg: "RS256", + kid: "test-key-3", + n: "test-modulus", + e: "AQAB", + crv: nil, + x: nil, + y: nil, + k: nil + ) + + let rsaKey = jwk.rsaPublishKey + XCTAssertNil(rsaKey, "RSA public key should be nil with wrong key type") + } - func testRSAPublishKeyMissingModulus() { - // Test with missing modulus - let jwk = JWK( - kty: "RSA", - keyOps: nil, - alg: "RS256", - kid: "test-key-4", - n: nil, // Missing modulus - e: "AQAB", - crv: nil, - x: nil, - y: nil, - k: nil - ) - - let rsaKey = jwk.rsaPublishKey - XCTAssertNil(rsaKey, "RSA public key should be nil with missing modulus") - } + func testRSAPublishKeyMissingModulus() { + // Test with missing modulus + let jwk = JWK( + kty: "RSA", + keyOps: nil, + alg: "RS256", + kid: "test-key-4", + n: nil, // Missing modulus + e: "AQAB", + crv: nil, + x: nil, + y: nil, + k: nil + ) + + let rsaKey = jwk.rsaPublishKey + XCTAssertNil(rsaKey, "RSA public key should be nil with missing modulus") + } - func testRSAPublishKeyMissingExponent() { - // Test with missing exponent - let jwk = JWK( - kty: "RSA", - keyOps: nil, - alg: "RS256", - kid: "test-key-5", - n: "test-modulus", - e: nil, // Missing exponent - crv: nil, - x: nil, - y: nil, - k: nil - ) - - let rsaKey = jwk.rsaPublishKey - XCTAssertNil(rsaKey, "RSA public key should be nil with missing exponent") - } + func testRSAPublishKeyMissingExponent() { + // Test with missing exponent + let jwk = JWK( + kty: "RSA", + keyOps: nil, + alg: "RS256", + kid: "test-key-5", + n: "test-modulus", + e: nil, // Missing exponent + crv: nil, + x: nil, + y: nil, + k: nil + ) + + let rsaKey = jwk.rsaPublishKey + XCTAssertNil(rsaKey, "RSA public key should be nil with missing exponent") + } - func testRSAPublishKeyInvalidBase64() { - // Test with invalid Base64URL data - let jwk = JWK( - kty: "RSA", - keyOps: nil, - alg: "RS256", - kid: "test-key-6", - n: "!!!invalid-base64!!!", - e: "AQAB", - crv: nil, - x: nil, - y: nil, - k: nil - ) - - let rsaKey = jwk.rsaPublishKey - XCTAssertNil(rsaKey, "RSA public key should be nil with invalid base64 modulus") - } + func testRSAPublishKeyInvalidBase64() { + // Test with invalid Base64URL data + let jwk = JWK( + kty: "RSA", + keyOps: nil, + alg: "RS256", + kid: "test-key-6", + n: "!!!invalid-base64!!!", + e: "AQAB", + crv: nil, + x: nil, + y: nil, + k: nil + ) + + let rsaKey = jwk.rsaPublishKey + XCTAssertNil(rsaKey, "RSA public key should be nil with invalid base64 modulus") + } - // MARK: - JWTAlgorithm Tests + // MARK: - JWTAlgorithm Tests - func testRS256VerificationWithValidSignature() { - // Create a sample JWT token (this would normally come from a real auth server) - // For testing, we'll use a known-good JWT - let header = #"{"alg":"RS256","typ":"JWT"}"# - let payload = #"{"sub":"1234567890","name":"Test User","iat":1516239022}"# + func testRS256VerificationWithValidSignature() { + // Create a sample JWT token (this would normally come from a real auth server) + // For testing, we'll use a known-good JWT + let header = #"{"alg":"RS256","typ":"JWT"}"# + let payload = #"{"sub":"1234567890","name":"Test User","iat":1516239022}"# - guard - let headerData = header.data(using: .utf8), - let payloadData = payload.data(using: .utf8) - else { - XCTFail("Failed to create test data") - return - } + guard + let headerData = header.data(using: .utf8), + let payloadData = payload.data(using: .utf8) + else { + XCTFail("Failed to create test data") + return + } - let headerB64 = Base64URL.encode(headerData) - let payloadB64 = Base64URL.encode(payloadData) + let headerB64 = Base64URL.encode(headerData) + let payloadB64 = Base64URL.encode(payloadData) - // Create a mock signature (in real scenario, this would be a proper RSA signature) - let mockSignature = Data([0x00, 0x01, 0x02, 0x03]) - let signatureB64 = Base64URL.encode(mockSignature) + // Create a mock signature (in real scenario, this would be a proper RSA signature) + let mockSignature = Data([0x00, 0x01, 0x02, 0x03]) + let signatureB64 = Base64URL.encode(mockSignature) - let jwtString = "\(headerB64).\(payloadB64).\(signatureB64)" + let jwtString = "\(headerB64).\(payloadB64).\(signatureB64)" - // Decode the JWT - guard let decoded = JWT.decode(jwtString) else { - XCTFail("Failed to decode JWT") - return + // Decode the JWT + guard let decoded = JWT.decode(jwtString) else { + XCTFail("Failed to decode JWT") + return + } + + XCTAssertEqual(decoded.raw.header, headerB64) + XCTAssertEqual(decoded.raw.payload, payloadB64) + XCTAssertEqual(decoded.signature, mockSignature) } - XCTAssertEqual(decoded.raw.header, headerB64) - XCTAssertEqual(decoded.raw.payload, payloadB64) - XCTAssertEqual(decoded.signature, mockSignature) - } + func testRS256AlgorithmType() { + let algorithm = JWTAlgorithm.rs256 + XCTAssertEqual(algorithm.rawValue, "RS256") + } - func testRS256AlgorithmType() { - let algorithm = JWTAlgorithm.rs256 - XCTAssertEqual(algorithm.rawValue, "RS256") } - -} #endif diff --git a/Tests/AuthTests/StoredSessionTests.swift b/Tests/AuthTests/StoredSessionTests.swift index 5053e083d..afc519c3b 100644 --- a/Tests/AuthTests/StoredSessionTests.swift +++ b/Tests/AuthTests/StoredSessionTests.swift @@ -10,7 +10,7 @@ final class StoredSessionTests: XCTestCase { func testStoredSession() throws { #if os(Android) - throw XCTSkip("Disabled for android due to #filePath not existing on emulator") + throw XCTSkip("Disabled for android due to #filePath not existing on emulator") #endif Dependencies[clientID] = Dependencies( diff --git a/Tests/AuthTests/URLOpenerTests.swift b/Tests/AuthTests/URLOpenerTests.swift index 43389a9ec..821f450f4 100644 --- a/Tests/AuthTests/URLOpenerTests.swift +++ b/Tests/AuthTests/URLOpenerTests.swift @@ -6,6 +6,7 @@ // import XCTest + @testable import Auth final class URLOpenerTests: XCTestCase { @@ -124,7 +125,9 @@ final class URLOpenerTests: XCTestCase { capture.url = url } - let testURL = URL(string: "myapp://auth/callback?access_token=abc123&refresh_token=def456&expires_in=3600#success")! + let testURL = URL( + string: + "myapp://auth/callback?access_token=abc123&refresh_token=def456&expires_in=3600#success")! await customOpener.open(testURL) diff --git a/Tests/FunctionsTests/RequestTests.swift b/Tests/FunctionsTests/RequestTests.swift index 00b4c7896..6b01573e2 100644 --- a/Tests/FunctionsTests/RequestTests.swift +++ b/Tests/FunctionsTests/RequestTests.swift @@ -5,10 +5,11 @@ // Created by Guilherme Souza on 23/04/24. // -@testable import Functions import SnapshotTesting import XCTest +@testable import Functions + final class RequestTests: XCTestCase { let url = URL(string: "http://localhost:5432/functions/v1")! let apiKey = "supabase.anon.key" @@ -56,10 +57,11 @@ final class RequestTests: XCTestCase { ) { request in await MainActor.run { #if os(Android) - // missing snapshots for Android - return + // missing snapshots for Android + return #endif - assertSnapshot(of: request, as: .curl, record: record, file: file, testName: testName, line: line) + assertSnapshot( + of: request, as: .curl, record: record, file: file, testName: testName, line: line) } throw NSError(domain: "Error", code: 0, userInfo: nil) } diff --git a/Tests/HelpersTests/AnyJSONTests.swift b/Tests/HelpersTests/AnyJSONTests.swift index bd57dea9c..7cfaea757 100644 --- a/Tests/HelpersTests/AnyJSONTests.swift +++ b/Tests/HelpersTests/AnyJSONTests.swift @@ -7,8 +7,8 @@ import CustomDump import Foundation -import XCTest import Helpers +import XCTest final class AnyJSONTests: XCTestCase { let jsonString = """ @@ -112,25 +112,25 @@ final class AnyJSONTests: XCTestCase { func testValueProperty() { // Test null value XCTAssertTrue(AnyJSON.null.value is NSNull) - + // Test string value XCTAssertEqual(AnyJSON.string("test").value as? String, "test") - + // Test integer value XCTAssertEqual(AnyJSON.integer(42).value as? Int, 42) - + // Test double value XCTAssertEqual(AnyJSON.double(3.14).value as? Double, 3.14) - + // Test bool value XCTAssertEqual(AnyJSON.bool(true).value as? Bool, true) XCTAssertEqual(AnyJSON.bool(false).value as? Bool, false) - + // Test object value let object: AnyJSON = ["key": "value"] let objectValue = object.value as? [String: Any] XCTAssertEqual(objectValue?["key"] as? String, "value") - + // Test array value let array: AnyJSON = [1, 2, 3] let arrayValue = array.value as? [Any] @@ -239,7 +239,7 @@ final class AnyJSONTests: XCTestCase { func testExpressibleByBooleanLiteral() { let json: AnyJSON = true XCTAssertEqual(json, .bool(true)) - + let jsonFalse: AnyJSON = false XCTAssertEqual(jsonFalse, .bool(false)) } @@ -254,7 +254,7 @@ final class AnyJSONTests: XCTestCase { let expected: AnyJSON = .object([ "key1": .string("value1"), "key2": .integer(42), - "key3": .bool(true) + "key3": .bool(true), ]) XCTAssertEqual(json, expected) } @@ -268,12 +268,12 @@ final class AnyJSONTests: XCTestCase { XCTAssertEqual(AnyJSON.double(3.14).description, "3.14") XCTAssertEqual(AnyJSON.bool(true).description, "true") XCTAssertEqual(AnyJSON.bool(false).description, "false") - + // Test object description let object: AnyJSON = ["key": "value"] XCTAssertTrue(object.description.contains("key")) XCTAssertTrue(object.description.contains("value")) - + // Test array description let array: AnyJSON = [1, 2, 3] XCTAssertTrue(array.description.contains("1")) @@ -291,25 +291,25 @@ final class AnyJSONTests: XCTestCase { XCTAssertEqual(AnyJSON.double(3.14), AnyJSON.double(3.14)) XCTAssertEqual(AnyJSON.bool(true), AnyJSON.bool(true)) XCTAssertEqual(AnyJSON.bool(false), AnyJSON.bool(false)) - + // Test different values XCTAssertNotEqual(AnyJSON.string("test"), AnyJSON.string("different")) XCTAssertNotEqual(AnyJSON.integer(42), AnyJSON.integer(43)) XCTAssertNotEqual(AnyJSON.double(3.14), AnyJSON.double(3.15)) XCTAssertNotEqual(AnyJSON.bool(true), AnyJSON.bool(false)) - + // Test different types XCTAssertNotEqual(AnyJSON.string("42"), AnyJSON.integer(42)) XCTAssertNotEqual(AnyJSON.integer(42), AnyJSON.double(42.0)) XCTAssertNotEqual(AnyJSON.null, AnyJSON.string("")) - + // Test objects let object1: AnyJSON = ["key": "value"] let object2: AnyJSON = ["key": "value"] let object3: AnyJSON = ["key": "different"] XCTAssertEqual(object1, object2) XCTAssertNotEqual(object1, object3) - + // Test arrays let array1: AnyJSON = [1, 2, 3] let array2: AnyJSON = [1, 2, 3] @@ -326,9 +326,9 @@ final class AnyJSONTests: XCTestCase { .double(3.14), .bool(true), .object(["key": "value"]), - .array([1, 2, 3]) + .array([1, 2, 3]), ] - + XCTAssertEqual(set.count, 7) XCTAssertTrue(set.contains(.null)) XCTAssertTrue(set.contains(.string("test"))) @@ -365,7 +365,7 @@ final class AnyJSONTests: XCTestCase { func testJSONObjectInitFromCodableFailure() { // Test with a simple string, which should fail because it's not an object XCTAssertThrowsError(try JSONObject("not an object")) - + // Test with an integer, which should also fail XCTAssertThrowsError(try JSONObject(42)) } @@ -375,14 +375,14 @@ final class AnyJSONTests: XCTestCase { func testInvalidJSONDecoding() { let invalidJSON = "invalid json" let data = invalidJSON.data(using: .utf8)! - + XCTAssertThrowsError(try AnyJSON.decoder.decode(AnyJSON.self, from: data)) } func testDecodeWithCustomDecoder() throws { let customDecoder = JSONDecoder() customDecoder.keyDecodingStrategy = .convertFromSnakeCase - + let json: AnyJSON = ["user_name": "John", "user_age": 30] let decoded: CustomPerson = try json.decode(as: CustomPerson.self, decoder: customDecoder) XCTAssertEqual(decoded.userName, "John") @@ -394,10 +394,10 @@ final class AnyJSONTests: XCTestCase { func testEmptyObjectAndArray() { let emptyObject: AnyJSON = [:] let emptyArray: AnyJSON = [] - + XCTAssertEqual(emptyObject, .object([:])) XCTAssertEqual(emptyArray, .array([])) - + XCTAssertTrue(emptyObject.objectValue?.isEmpty == true) XCTAssertTrue(emptyArray.arrayValue?.isEmpty == true) } @@ -412,18 +412,18 @@ final class AnyJSONTests: XCTestCase { ] ] ] - + let level1 = nested.objectValue?["level1"] let level2 = level1?.objectValue?["level2"] let level3 = level2?.objectValue?["level3"] let deep = level3?.objectValue?["deep"] - + XCTAssertEqual(deep, .string("value")) } func testMixedArrayTypes() { let mixedArray: AnyJSON = [1, "string", true, nil, ["nested": "value"]] - + XCTAssertEqual(mixedArray.arrayValue?[0], .integer(1)) XCTAssertEqual(mixedArray.arrayValue?[1], .string("string")) XCTAssertEqual(mixedArray.arrayValue?[2], .bool(true)) @@ -432,10 +432,10 @@ final class AnyJSONTests: XCTestCase { } func testLargeNumbers() { - let largeInt: AnyJSON = 9223372036854775807 // Int.max - let largeDouble: AnyJSON = 1.7976931348623157e+308 // Double.max - - XCTAssertEqual(largeInt.intValue, 9223372036854775807) + let largeInt: AnyJSON = 9_223_372_036_854_775_807 // Int.max + let largeDouble: AnyJSON = 1.7976931348623157e+308 // Double.max + + XCTAssertEqual(largeInt.intValue, 9_223_372_036_854_775_807) XCTAssertEqual(largeDouble.doubleValue, 1.7976931348623157e+308) } @@ -443,7 +443,7 @@ final class AnyJSONTests: XCTestCase { let emptyString: AnyJSON = "" let unicodeString: AnyJSON = "Hello, 世界! 🌍" let escapedString: AnyJSON = "Line 1\nLine 2\tTab" - + XCTAssertEqual(emptyString.stringValue, "") XCTAssertEqual(unicodeString.stringValue, "Hello, 世界! 🌍") XCTAssertEqual(escapedString.stringValue, "Line 1\nLine 2\tTab") diff --git a/Tests/HelpersTests/DateFormatterTests.swift b/Tests/HelpersTests/DateFormatterTests.swift index 1cdb7d278..d0fe4acad 100644 --- a/Tests/HelpersTests/DateFormatterTests.swift +++ b/Tests/HelpersTests/DateFormatterTests.swift @@ -6,6 +6,7 @@ // import XCTest + @testable import Helpers final class DateFormatterTests: XCTestCase { @@ -21,7 +22,7 @@ final class DateFormatterTests: XCTestCase { components.hour = 10 components.minute = 30 components.second = 45 - components.nanosecond = 123_000_000 // 123 milliseconds + components.nanosecond = 123_000_000 // 123 milliseconds components.timeZone = TimeZone(secondsFromGMT: 0) let calendar = Calendar(identifier: .iso8601) @@ -43,9 +44,9 @@ final class DateFormatterTests: XCTestCase { // Verify it's not empty and has proper format XCTAssertFalse(iso8601String.isEmpty) - XCTAssertTrue(iso8601String.contains("T")) // Should have date-time separator - XCTAssertTrue(iso8601String.contains("-")) // Should have date separators - XCTAssertTrue(iso8601String.contains(":")) // Should have time separators + XCTAssertTrue(iso8601String.contains("T")) // Should have date-time separator + XCTAssertTrue(iso8601String.contains("-")) // Should have date separators + XCTAssertTrue(iso8601String.contains(":")) // Should have time separators } // MARK: - String to Date Parsing Tests @@ -95,11 +96,11 @@ final class DateFormatterTests: XCTestCase { func testParseInvalidDateString() { let invalidStrings = [ "not a date", - "2024-13-45", // Invalid month and day - "2024/01/15", // Wrong separator - "15-01-2024", // Wrong order + "2024-13-45", // Invalid month and day + "2024/01/15", // Wrong separator + "15-01-2024", // Wrong order "", - "2024-01-15 10:30:45", // Space instead of T + "2024-01-15 10:30:45", // Space instead of T ] for invalidString in invalidStrings { @@ -199,7 +200,7 @@ final class DateFormatterTests: XCTestCase { } func testParseLeapYearDate() { - let dateString = "2024-02-29T12:00:00" // 2024 is a leap year + let dateString = "2024-02-29T12:00:00" // 2024 is a leap year XCTAssertNotNil(dateString.date, "Should parse leap year date") } @@ -225,7 +226,7 @@ final class DateFormatterTests: XCTestCase { "2021-06-15T12:30:45", "2022-12-31T23:59:59", "2023-07-04T16:20:30.500", - "2024-02-29T08:15:22", // Leap year + "2024-02-29T08:15:22", // Leap year ] for dateString in dates { diff --git a/Tests/HelpersTests/JWTTests.swift b/Tests/HelpersTests/JWTTests.swift index 43d11f2ca..77072181f 100644 --- a/Tests/HelpersTests/JWTTests.swift +++ b/Tests/HelpersTests/JWTTests.swift @@ -1,5 +1,5 @@ -import XCTest import Helpers +import XCTest final class JWTTests: XCTestCase { func testDecodeJWT() throws { diff --git a/Tests/HelpersTests/PostgrestErrorTests.swift b/Tests/HelpersTests/PostgrestErrorTests.swift index 9f02cd292..451fc33b1 100644 --- a/Tests/HelpersTests/PostgrestErrorTests.swift +++ b/Tests/HelpersTests/PostgrestErrorTests.swift @@ -11,9 +11,9 @@ import XCTest final class PostgrestErrorTests: XCTestCase { - func testLocalizedErrorConformance() { - let error = PostgrestError(message: "test error message") - XCTAssertEqual(error.errorDescription, "test error message") - } + func testLocalizedErrorConformance() { + let error = PostgrestError(message: "test error message") + XCTAssertEqual(error.errorDescription, "test error message") + } -} \ No newline at end of file +} diff --git a/Tests/PostgRESTTests/JSONTests.swift b/Tests/PostgRESTTests/JSONTests.swift index dabf2d97f..5b3399331 100644 --- a/Tests/PostgRESTTests/JSONTests.swift +++ b/Tests/PostgRESTTests/JSONTests.swift @@ -5,16 +5,17 @@ // Created by Guilherme Souza on 01/07/24. // -@testable import PostgREST import XCTest +@testable import PostgREST + final class JSONTests: XCTestCase { func testDecodeJSON() throws { let json = """ - { - "created_at": "2024-06-15T18:12:04+00:00" - } - """.data(using: .utf8)! + { + "created_at": "2024-06-15T18:12:04+00:00" + } + """.data(using: .utf8)! struct Value: Decodable { var createdAt: Date diff --git a/Tests/RealtimeTests/PostgresActionTests.swift b/Tests/RealtimeTests/PostgresActionTests.swift index 643f47b92..c89045060 100644 --- a/Tests/RealtimeTests/PostgresActionTests.swift +++ b/Tests/RealtimeTests/PostgresActionTests.swift @@ -113,7 +113,7 @@ final class PostgresActionTests: XCTestCase { let anyAction = AnyAction.insert(insertAction) XCTAssertEqual(anyAction.rawMessage.topic, "test:table") - if case let .insert(wrappedAction) = anyAction { + if case .insert(let wrappedAction) = anyAction { XCTAssertEqual(wrappedAction.record, record) } else { XCTFail("Expected insert case") @@ -135,7 +135,7 @@ final class PostgresActionTests: XCTestCase { let anyAction = AnyAction.update(updateAction) XCTAssertEqual(anyAction.rawMessage.topic, "test:table") - if case let .update(wrappedAction) = anyAction { + if case .update(let wrappedAction) = anyAction { XCTAssertEqual(wrappedAction.record, record) XCTAssertEqual(wrappedAction.oldRecord, oldRecord) } else { @@ -156,7 +156,7 @@ final class PostgresActionTests: XCTestCase { let anyAction = AnyAction.delete(deleteAction) XCTAssertEqual(anyAction.rawMessage.topic, "test:table") - if case let .delete(wrappedAction) = anyAction { + if case .delete(let wrappedAction) = anyAction { XCTAssertEqual(wrappedAction.oldRecord, oldRecord) } else { XCTFail("Expected delete case") diff --git a/Tests/RealtimeTests/PostgresJoinConfigTests.swift b/Tests/RealtimeTests/PostgresJoinConfigTests.swift index bb695d186..64db6c56c 100644 --- a/Tests/RealtimeTests/PostgresJoinConfigTests.swift +++ b/Tests/RealtimeTests/PostgresJoinConfigTests.swift @@ -5,9 +5,10 @@ // Created by Guilherme Souza on 26/12/23. // -@testable import Realtime import XCTest +@testable import Realtime + final class PostgresJoinConfigTests: XCTestCase { func testSameConfigButDifferentIdAreEqual() { let config1 = PostgresJoinConfig( diff --git a/Tests/RealtimeTests/PresenceActionTests.swift b/Tests/RealtimeTests/PresenceActionTests.swift index 16b7e11d4..914077113 100644 --- a/Tests/RealtimeTests/PresenceActionTests.swift +++ b/Tests/RealtimeTests/PresenceActionTests.swift @@ -10,438 +10,442 @@ import XCTest @testable import Realtime final class PresenceActionTests: XCTestCase { - + // MARK: - PresenceV2 Tests - + func testPresenceV2Initialization() { let ref = "test_ref_123" let state: JSONObject = [ "user_id": .string("user_123"), "username": .string("testuser"), - "status": .string("online") + "status": .string("online"), ] - + let presence = PresenceV2(ref: ref, state: state) - + XCTAssertEqual(presence.ref, ref) XCTAssertEqual(presence.state["user_id"]?.stringValue, "user_123") XCTAssertEqual(presence.state["username"]?.stringValue, "testuser") XCTAssertEqual(presence.state["status"]?.stringValue, "online") } - + func testPresenceV2Hashable() { let state: JSONObject = ["key": .string("value")] let presence1 = PresenceV2(ref: "ref1", state: state) let presence2 = PresenceV2(ref: "ref1", state: state) let presence3 = PresenceV2(ref: "ref2", state: state) - + XCTAssertEqual(presence1, presence2) XCTAssertNotEqual(presence1, presence3) - + let set = Set([presence1, presence2, presence3]) - XCTAssertEqual(set.count, 2) // presence1 and presence2 are equal + XCTAssertEqual(set.count, 2) // presence1 and presence2 are equal } - + // MARK: - PresenceV2 Codable Tests - + func testPresenceV2DecodingValidData() throws { let jsonData = """ - { - "metas": [ - { - "phx_ref": "presence_ref_123", - "user_id": "user_456", - "username": "johndoe", - "status": "active", - "extra_field": "extra_value" - } - ] - } - """.data(using: .utf8)! - + { + "metas": [ + { + "phx_ref": "presence_ref_123", + "user_id": "user_456", + "username": "johndoe", + "status": "active", + "extra_field": "extra_value" + } + ] + } + """.data(using: .utf8)! + let presence = try JSONDecoder().decode(PresenceV2.self, from: jsonData) - + XCTAssertEqual(presence.ref, "presence_ref_123") XCTAssertEqual(presence.state["user_id"]?.stringValue, "user_456") XCTAssertEqual(presence.state["username"]?.stringValue, "johndoe") XCTAssertEqual(presence.state["status"]?.stringValue, "active") XCTAssertEqual(presence.state["extra_field"]?.stringValue, "extra_value") - + // Ensure phx_ref is not in the state XCTAssertNil(presence.state["phx_ref"]) } - + func testPresenceV2DecodingWithMultipleMetas() throws { // Should use the first meta object let jsonData = """ - { - "metas": [ - { - "phx_ref": "first_ref", - "user_id": "first_user" - }, - { - "phx_ref": "second_ref", - "user_id": "second_user" - } - ] - } - """.data(using: .utf8)! - + { + "metas": [ + { + "phx_ref": "first_ref", + "user_id": "first_user" + }, + { + "phx_ref": "second_ref", + "user_id": "second_user" + } + ] + } + """.data(using: .utf8)! + let presence = try JSONDecoder().decode(PresenceV2.self, from: jsonData) - + XCTAssertEqual(presence.ref, "first_ref") XCTAssertEqual(presence.state["user_id"]?.stringValue, "first_user") } - + func testPresenceV2DecodingMissingMetas() { let jsonData = """ - { - "other_field": "value" - } - """.data(using: .utf8)! - + { + "other_field": "value" + } + """.data(using: .utf8)! + XCTAssertThrowsError(try JSONDecoder().decode(PresenceV2.self, from: jsonData)) { error in guard let decodingError = error as? DecodingError, - case .typeMismatch(let type, let context) = decodingError else { + case .typeMismatch(let type, let context) = decodingError + else { XCTFail("Expected DecodingError.typeMismatch, got \(error)") return } - + XCTAssertTrue(type == JSONObject.self) XCTAssertEqual(context.debugDescription, "A presence should at least have a phx_ref.") } } - + func testPresenceV2DecodingEmptyMetas() { let jsonData = """ - { - "metas": [] - } - """.data(using: .utf8)! - + { + "metas": [] + } + """.data(using: .utf8)! + XCTAssertThrowsError(try JSONDecoder().decode(PresenceV2.self, from: jsonData)) { error in guard let decodingError = error as? DecodingError, - case .typeMismatch(let type, let context) = decodingError else { + case .typeMismatch(let type, let context) = decodingError + else { XCTFail("Expected DecodingError.typeMismatch, got \(error)") return } - + XCTAssertTrue(type == JSONObject.self) XCTAssertEqual(context.debugDescription, "A presence should at least have a phx_ref.") } } - + func testPresenceV2DecodingMissingPhxRef() { let jsonData = """ - { - "metas": [ - { - "user_id": "user_123", - "username": "testuser" - } - ] - } - """.data(using: .utf8)! - + { + "metas": [ + { + "user_id": "user_123", + "username": "testuser" + } + ] + } + """.data(using: .utf8)! + XCTAssertThrowsError(try JSONDecoder().decode(PresenceV2.self, from: jsonData)) { error in guard let decodingError = error as? DecodingError, - case .typeMismatch(let type, let context) = decodingError else { + case .typeMismatch(let type, let context) = decodingError + else { XCTFail("Expected DecodingError.typeMismatch, got \(error)") return } - + XCTAssertTrue(type == String.self) XCTAssertEqual(context.debugDescription, "A presence should at least have a phx_ref.") } } - + func testPresenceV2DecodingNonStringPhxRef() { let jsonData = """ - { - "metas": [ - { - "phx_ref": 123, - "user_id": "user_123" - } - ] - } - """.data(using: .utf8)! - + { + "metas": [ + { + "phx_ref": 123, + "user_id": "user_123" + } + ] + } + """.data(using: .utf8)! + XCTAssertThrowsError(try JSONDecoder().decode(PresenceV2.self, from: jsonData)) { error in guard let decodingError = error as? DecodingError, - case .typeMismatch(let type, let context) = decodingError else { + case .typeMismatch(let type, let context) = decodingError + else { XCTFail("Expected DecodingError.typeMismatch, got \(error)") return } - + XCTAssertTrue(type == String.self) XCTAssertEqual(context.debugDescription, "A presence should at least have a phx_ref.") } } - + func testPresenceV2Encoding() throws { let state: JSONObject = [ "user_id": .string("user_789"), "status": .string("online"), - "count": .integer(42) + "count": .integer(42), ] let presence = PresenceV2(ref: "test_ref", state: state) - + let encodedData = try JSONEncoder().encode(presence) let decodedDict = try JSONSerialization.jsonObject(with: encodedData) as? [String: Any] - + XCTAssertNotNil(decodedDict) XCTAssertEqual(decodedDict?["phx_ref"] as? String, "test_ref") - + let stateDict = decodedDict?["state"] as? [String: Any] XCTAssertNotNil(stateDict) XCTAssertEqual(stateDict?["user_id"] as? String, "user_789") XCTAssertEqual(stateDict?["status"] as? String, "online") XCTAssertEqual(stateDict?["count"] as? Int, 42) } - + // MARK: - PresenceV2 decodeState Tests - + struct TestUser: Codable, Equatable { let id: String let name: String let age: Int } - + func testDecodeStateSuccess() throws { let state: JSONObject = [ "id": .string("user_123"), "name": .string("John Doe"), - "age": .integer(30) + "age": .integer(30), ] let presence = PresenceV2(ref: "ref", state: state) - + let user = try presence.decodeState(as: TestUser.self) - + XCTAssertEqual(user.id, "user_123") XCTAssertEqual(user.name, "John Doe") XCTAssertEqual(user.age, 30) } - + func testDecodeStateWithCustomDecoder() throws { let customDecoder = JSONDecoder() customDecoder.keyDecodingStrategy = .convertFromSnakeCase - + let state: JSONObject = [ "user_id": .string("user_456"), "user_name": .string("Jane Doe"), - "user_age": .integer(25) + "user_age": .integer(25), ] let presence = PresenceV2(ref: "ref", state: state) - + struct SnakeCaseUser: Codable, Equatable { let userId: String let userName: String let userAge: Int } - + let user = try presence.decodeState(as: SnakeCaseUser.self, decoder: customDecoder) - + XCTAssertEqual(user.userId, "user_456") XCTAssertEqual(user.userName, "Jane Doe") XCTAssertEqual(user.userAge, 25) } - + func testDecodeStateFailure() { let state: JSONObject = [ "id": .string("user_123"), - "name": .string("John Doe") + "name": .string("John Doe"), // Missing required age field ] let presence = PresenceV2(ref: "ref", state: state) - + XCTAssertThrowsError(try presence.decodeState(as: TestUser.self)) } - + // MARK: - PresenceAction Protocol Extension Tests - + struct MockPresenceAction: PresenceAction { let joins: [String: PresenceV2] let leaves: [String: PresenceV2] let rawMessage: RealtimeMessageV2 } - + func testDecodeJoinsWithIgnoreOtherTypes() throws { let validState1: JSONObject = [ "id": .string("user_1"), "name": .string("Alice"), - "age": .integer(25) + "age": .integer(25), ] let validState2: JSONObject = [ "id": .string("user_2"), "name": .string("Bob"), - "age": .integer(30) + "age": .integer(30), ] let invalidState: JSONObject = [ "id": .string("user_3"), - "name": .string("Charlie") + "name": .string("Charlie"), // Missing age field ] - + let joins: [String: PresenceV2] = [ "key1": PresenceV2(ref: "ref1", state: validState1), "key2": PresenceV2(ref: "ref2", state: validState2), - "key3": PresenceV2(ref: "ref3", state: invalidState) + "key3": PresenceV2(ref: "ref3", state: invalidState), ] - + let rawMessage = RealtimeMessageV2( joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] ) - + let action = MockPresenceAction(joins: joins, leaves: [:], rawMessage: rawMessage) - + // With ignoreOtherTypes = true (default), should return only valid users let users = try action.decodeJoins(as: TestUser.self) XCTAssertEqual(users.count, 2) XCTAssertTrue(users.contains(TestUser(id: "user_1", name: "Alice", age: 25))) XCTAssertTrue(users.contains(TestUser(id: "user_2", name: "Bob", age: 30))) } - + func testDecodeJoinsWithoutIgnoreOtherTypes() { let validState: JSONObject = [ "id": .string("user_1"), "name": .string("Alice"), - "age": .integer(25) + "age": .integer(25), ] let invalidState: JSONObject = [ "id": .string("user_2"), - "name": .string("Bob") + "name": .string("Bob"), // Missing age field ] - + let joins: [String: PresenceV2] = [ "key1": PresenceV2(ref: "ref1", state: validState), - "key2": PresenceV2(ref: "ref2", state: invalidState) + "key2": PresenceV2(ref: "ref2", state: invalidState), ] - + let rawMessage = RealtimeMessageV2( joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] ) - + let action = MockPresenceAction(joins: joins, leaves: [:], rawMessage: rawMessage) - + // With ignoreOtherTypes = false, should throw on invalid data XCTAssertThrowsError(try action.decodeJoins(as: TestUser.self, ignoreOtherTypes: false)) } - + func testDecodeLeavesWithIgnoreOtherTypes() throws { let validState1: JSONObject = [ "id": .string("user_1"), "name": .string("Alice"), - "age": .integer(25) + "age": .integer(25), ] let validState2: JSONObject = [ "id": .string("user_2"), "name": .string("Bob"), - "age": .integer(30) + "age": .integer(30), ] let invalidState: JSONObject = [ "id": .string("user_3"), - "name": .string("Charlie") + "name": .string("Charlie"), // Missing age field ] - + let leaves: [String: PresenceV2] = [ "key1": PresenceV2(ref: "ref1", state: validState1), "key2": PresenceV2(ref: "ref2", state: validState2), - "key3": PresenceV2(ref: "ref3", state: invalidState) + "key3": PresenceV2(ref: "ref3", state: invalidState), ] - + let rawMessage = RealtimeMessageV2( joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] ) - + let action = MockPresenceAction(joins: [:], leaves: leaves, rawMessage: rawMessage) - + // With ignoreOtherTypes = true (default), should return only valid users let users = try action.decodeLeaves(as: TestUser.self) XCTAssertEqual(users.count, 2) XCTAssertTrue(users.contains(TestUser(id: "user_1", name: "Alice", age: 25))) XCTAssertTrue(users.contains(TestUser(id: "user_2", name: "Bob", age: 30))) } - + func testDecodeLeavesWithoutIgnoreOtherTypes() { let validState: JSONObject = [ "id": .string("user_1"), "name": .string("Alice"), - "age": .integer(25) + "age": .integer(25), ] let invalidState: JSONObject = [ "id": .string("user_2"), - "name": .string("Bob") + "name": .string("Bob"), // Missing age field ] - + let leaves: [String: PresenceV2] = [ "key1": PresenceV2(ref: "ref1", state: validState), - "key2": PresenceV2(ref: "ref2", state: invalidState) + "key2": PresenceV2(ref: "ref2", state: invalidState), ] - + let rawMessage = RealtimeMessageV2( joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] ) - + let action = MockPresenceAction(joins: [:], leaves: leaves, rawMessage: rawMessage) - + // With ignoreOtherTypes = false, should throw on invalid data XCTAssertThrowsError(try action.decodeLeaves(as: TestUser.self, ignoreOtherTypes: false)) } - + func testDecodeJoinsWithCustomDecoder() throws { let customDecoder = JSONDecoder() customDecoder.keyDecodingStrategy = .convertFromSnakeCase - + let state: JSONObject = [ "user_id": .string("user_123"), "user_name": .string("Test User"), - "user_age": .integer(28) + "user_age": .integer(28), ] - + let joins: [String: PresenceV2] = [ "key1": PresenceV2(ref: "ref1", state: state) ] - + let rawMessage = RealtimeMessageV2( joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] ) - + let action = MockPresenceAction(joins: joins, leaves: [:], rawMessage: rawMessage) - + struct SnakeCaseUser: Codable, Equatable { let userId: String let userName: String let userAge: Int } - + let users = try action.decodeJoins(as: SnakeCaseUser.self, decoder: customDecoder) XCTAssertEqual(users.count, 1) XCTAssertEqual(users.first?.userId, "user_123") XCTAssertEqual(users.first?.userName, "Test User") XCTAssertEqual(users.first?.userAge, 28) } - + func testDecodeEmptyJoinsAndLeaves() throws { let rawMessage = RealtimeMessageV2( joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] ) - + let action = MockPresenceAction(joins: [:], leaves: [:], rawMessage: rawMessage) - + let joinUsers = try action.decodeJoins(as: TestUser.self) let leaveUsers = try action.decodeLeaves(as: TestUser.self) - + XCTAssertEqual(joinUsers.count, 0) XCTAssertEqual(leaveUsers.count, 0) } - + // MARK: - PresenceActionImpl Tests - + func testPresenceActionImplInitialization() { let joins: [String: PresenceV2] = [ "user1": PresenceV2(ref: "ref1", state: ["name": .string("User 1")]) @@ -450,11 +454,12 @@ final class PresenceActionTests: XCTestCase { "user2": PresenceV2(ref: "ref2", state: ["name": .string("User 2")]) ] let rawMessage = RealtimeMessageV2( - joinRef: "join_ref", ref: "ref", topic: "topic", event: "event", payload: ["key": .string("value")] + joinRef: "join_ref", ref: "ref", topic: "topic", event: "event", + payload: ["key": .string("value")] ) - + let impl = PresenceActionImpl(joins: joins, leaves: leaves, rawMessage: rawMessage) - + XCTAssertEqual(impl.joins.count, 1) XCTAssertEqual(impl.leaves.count, 1) XCTAssertEqual(impl.joins["user1"]?.ref, "ref1") @@ -462,23 +467,23 @@ final class PresenceActionTests: XCTestCase { XCTAssertEqual(impl.rawMessage.topic, "topic") XCTAssertEqual(impl.rawMessage.event, "event") } - + func testPresenceActionImplConformsToProtocol() { let rawMessage = RealtimeMessageV2( joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] ) - + let impl = PresenceActionImpl(joins: [:], leaves: [:], rawMessage: rawMessage) - + // Test that it can be used as PresenceAction let presenceAction: any PresenceAction = impl XCTAssertEqual(presenceAction.joins.count, 0) XCTAssertEqual(presenceAction.leaves.count, 0) XCTAssertEqual(presenceAction.rawMessage.topic, "test") } - + // MARK: - Edge Cases and Complex Scenarios - + func testPresenceV2WithComplexNestedState() throws { let complexState: JSONObject = [ "user": .object([ @@ -487,19 +492,19 @@ final class PresenceActionTests: XCTestCase { "name": .string("John"), "preferences": .object([ "theme": .string("dark"), - "notifications": .bool(true) - ]) + "notifications": .bool(true), + ]), ]), - "tags": .array([.string("admin"), .string("developer")]) + "tags": .array([.string("admin"), .string("developer")]), ]), "metadata": .object([ "last_seen": .string("2024-01-01T00:00:00Z"), - "connection_count": .integer(3) - ]) + "connection_count": .integer(3), + ]), ] - + let presence = PresenceV2(ref: "complex_ref", state: complexState) - + XCTAssertEqual(presence.ref, "complex_ref") XCTAssertEqual(presence.state["user"]?.objectValue?["id"]?.stringValue, "123") XCTAssertEqual( @@ -507,13 +512,14 @@ final class PresenceActionTests: XCTestCase { "John" ) XCTAssertEqual( - presence.state["user"]?.objectValue?["profile"]?.objectValue?["preferences"]?.objectValue?["theme"]?.stringValue, + presence.state["user"]?.objectValue?["profile"]?.objectValue?["preferences"]?.objectValue?[ + "theme"]?.stringValue, "dark" ) XCTAssertEqual(presence.state["user"]?.objectValue?["tags"]?.arrayValue?.count, 2) XCTAssertEqual(presence.state["metadata"]?.objectValue?["connection_count"]?.intValue, 3) } - + func testPresenceV2RoundTripCoding() throws { let originalState: JSONObject = [ "user_id": .string("user_789"), @@ -521,24 +527,26 @@ final class PresenceActionTests: XCTestCase { "score": .double(98.5), "active": .bool(true), "tags": .array([.string("tag1"), .string("tag2")]), - "metadata": .object(["key": .string("value")]) + "metadata": .object(["key": .string("value")]), ] let originalPresence = PresenceV2(ref: "original_ref", state: originalState) - + // Test that encoding works (we don't need the actual data for this test) _ = try JSONEncoder().encode(originalPresence) - + // Create the expected server format manually by adding the state to metas with phx_ref - let stateWithRef = originalState.merging(["phx_ref": .string(originalPresence.ref)]) { _, new in new } + let stateWithRef = originalState.merging(["phx_ref": .string(originalPresence.ref)]) { _, new in + new + } let serverFormat: [String: Any] = [ "metas": [ stateWithRef.mapValues(\.value) ] ] - + let serverData = try JSONSerialization.data(withJSONObject: serverFormat) let decodedPresence = try JSONDecoder().decode(PresenceV2.self, from: serverData) - + XCTAssertEqual(decodedPresence.ref, originalPresence.ref) XCTAssertEqual(decodedPresence.state["user_id"]?.stringValue, "user_789") XCTAssertEqual(decodedPresence.state["status"]?.stringValue, "online") @@ -547,16 +555,16 @@ final class PresenceActionTests: XCTestCase { XCTAssertEqual(decodedPresence.state["tags"]?.arrayValue?.count, 2) XCTAssertNotNil(decodedPresence.state["metadata"]?.objectValue) } - + func testPresenceActionWithMixedValidAndInvalidData() throws { struct PartialUser: Codable { let id: String let name: String? } - + let validState: JSONObject = [ "id": .string("valid_user"), - "name": .string("Valid User") + "name": .string("Valid User"), ] let partialState: JSONObject = [ "id": .string("partial_user") @@ -566,24 +574,24 @@ final class PresenceActionTests: XCTestCase { "name": .string("No ID User") // missing required id field ] - + let joins: [String: PresenceV2] = [ "valid": PresenceV2(ref: "ref1", state: validState), "partial": PresenceV2(ref: "ref2", state: partialState), - "invalid": PresenceV2(ref: "ref3", state: invalidState) + "invalid": PresenceV2(ref: "ref3", state: invalidState), ] - + let rawMessage = RealtimeMessageV2( joinRef: nil, ref: nil, topic: "test", event: "test", payload: [:] ) - + let action = MockPresenceAction(joins: joins, leaves: [:], rawMessage: rawMessage) - + // With ignoreOtherTypes = true, should get valid and partial users let users = try action.decodeJoins(as: PartialUser.self, ignoreOtherTypes: true) XCTAssertEqual(users.count, 2) - + let userIds = users.map(\.id).sorted() XCTAssertEqual(userIds, ["partial_user", "valid_user"]) } -} \ No newline at end of file +} diff --git a/Tests/RealtimeTests/RealtimePostgresFilterTests.swift b/Tests/RealtimeTests/RealtimePostgresFilterTests.swift index 78b5e8e97..453988396 100644 --- a/Tests/RealtimeTests/RealtimePostgresFilterTests.swift +++ b/Tests/RealtimeTests/RealtimePostgresFilterTests.swift @@ -6,6 +6,7 @@ // import XCTest + @testable import Realtime final class RealtimePostgresFilterTests: XCTestCase { diff --git a/Tests/RealtimeTests/RealtimePostgresFilterValueTests.swift b/Tests/RealtimeTests/RealtimePostgresFilterValueTests.swift index a931cb1f8..71ef48efd 100644 --- a/Tests/RealtimeTests/RealtimePostgresFilterValueTests.swift +++ b/Tests/RealtimeTests/RealtimePostgresFilterValueTests.swift @@ -6,6 +6,7 @@ // import XCTest + @testable import Realtime final class RealtimePostgresFilterValueTests: XCTestCase { diff --git a/Tests/StorageTests/MultipartFormDataTests.swift b/Tests/StorageTests/MultipartFormDataTests.swift index bb836345e..8e7a5d978 100644 --- a/Tests/StorageTests/MultipartFormDataTests.swift +++ b/Tests/StorageTests/MultipartFormDataTests.swift @@ -102,43 +102,44 @@ final class MultipartFormDataTests: XCTestCase { try testContent.write(to: fileURL) defer { try? FileManager.default.removeItem(at: fileURL) } - formData.append(fileURL, withName: "data", fileName: "custom.json", mimeType: "application/json") + formData.append( + fileURL, withName: "data", fileName: "custom.json", mimeType: "application/json") XCTAssertGreaterThan(formData.contentLength, 0) } #if !os(Linux) && !os(Windows) && !os(Android) - func testAppendInvalidFileURL() { - let formData = MultipartFormData() - let invalidURL = URL(fileURLWithPath: "/nonexistent/path/file.txt") + func testAppendInvalidFileURL() { + let formData = MultipartFormData() + let invalidURL = URL(fileURLWithPath: "/nonexistent/path/file.txt") - formData.append(invalidURL, withName: "file") + formData.append(invalidURL, withName: "file") - // Should fail during encoding - XCTAssertThrowsError(try formData.encode()) - } + // Should fail during encoding + XCTAssertThrowsError(try formData.encode()) + } - func testAppendNonFileURL() { - let formData = MultipartFormData() - let httpURL = URL(string: "https://example.com/file.txt")! + func testAppendNonFileURL() { + let formData = MultipartFormData() + let httpURL = URL(string: "https://example.com/file.txt")! - formData.append(httpURL, withName: "file", fileName: "file.txt", mimeType: "text/plain") + formData.append(httpURL, withName: "file", fileName: "file.txt", mimeType: "text/plain") - // Should fail during encoding - XCTAssertThrowsError(try formData.encode()) - } + // Should fail during encoding + XCTAssertThrowsError(try formData.encode()) + } - func testAppendDirectory() throws { - let formData = MultipartFormData() + func testAppendDirectory() throws { + let formData = MultipartFormData() - // Use a known directory - let dirURL = FileManager.default.temporaryDirectory + // Use a known directory + let dirURL = FileManager.default.temporaryDirectory - formData.append(dirURL, withName: "dir") + formData.append(dirURL, withName: "dir") - // Should fail during encoding - XCTAssertThrowsError(try formData.encode()) - } + // Should fail during encoding + XCTAssertThrowsError(try formData.encode()) + } #endif func testAppendInputStream() { @@ -170,7 +171,8 @@ final class MultipartFormDataTests: XCTestCase { // Create 1MB of data let largeData = Data(repeating: 0xFF, count: 1024 * 1024) - formData.append(largeData, withName: "large", fileName: "large.bin", mimeType: "application/octet-stream") + formData.append( + largeData, withName: "large", fileName: "large.bin", mimeType: "application/octet-stream") let encoded = try formData.encode() XCTAssertGreaterThan(encoded.count, largeData.count) diff --git a/docs/migrations/RealtimeV3 Migration Guide.md b/docs/migrations/RealtimeV3 Migration Guide.md new file mode 100644 index 000000000..c238f0d3e --- /dev/null +++ b/docs/migrations/RealtimeV3 Migration Guide.md @@ -0,0 +1,96 @@ +# Realtime V2 → V3 Migration Guide + +Realtime V3 (`_Realtime`) is a greenfield redesign targeting Swift 6.0+ and iOS 17+. +Import it with `import _Realtime` (renamed to `import Realtime` at final release). + +## Platform requirements + +`_Realtime` requires iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+. Because the main +`Supabase` package still supports iOS 13+, the `realtimeV3` property cannot be exposed +on `SupabaseClient` directly within this repository while older platform support is kept. + +For the time being, consumers on iOS 17+ can add `_Realtime` as a direct dependency: + +```swift +.package(url: "https://github.com/supabase/supabase-swift", from: "x.y.z"), +// In your target dependencies: +.product(name: "_Realtime", package: "supabase-swift"), +``` + +A reference extension wiring `_Realtime` to `SupabaseClient` is provided as a template at +`docs/migrations/SupabaseClient+RealtimeV3.swift.template`. Copy it into your own target +that requires iOS 17+ and adjust access levels as needed. + +## Quick-reference mapping + +| V2 | V3 | +|----|-----| +| `import Realtime` | `import _Realtime` | +| `RealtimeClientV2(url:options:)` | `Realtime(url:apiKey:configuration:transport:)` | +| `client.channel("x", options: …)` | `realtime.channel("x") { $0.isPrivate = true }` | +| `await channel.subscribe()` | Implicit on first `broadcasts()` / `changes()` iteration | +| `await channel.unsubscribe()` | `try await channel.leave()` | +| `channel.broadcastStream(event:)` | `channel.broadcasts(of: T.self, event:)` | +| `await channel.broadcast(event:message:)` | `try await channel.broadcast(payload, as: event)` | +| `channel.postgresChange(.all, schema:table:filter:)` | `channel.changes(to: T.self, where: .eq(\.col, val))` | +| `channel.presenceChange()` | `channel.presence.diffs(T.self)` | +| `channel.track(state:)` | `try await channel.presence.track(state)` → `PresenceHandle` | +| `ObservationToken` / `subscription.cancel()` | Task cancellation ends `AsyncThrowingStream` iteration | +| `accessToken: () async -> String?` | `APIKeySource.dynamic { … }` | +| `any Error` at boundaries | `throws(RealtimeError)` everywhere | +| `RealtimeClientOptions.maxRetryAttempts` | `Configuration.reconnection: ReconnectionPolicy` | + +## Key behavioural differences + +### Explicit `leave()` — no auto-unsubscribe + +V2 unsubscribed on `ObservationToken` deallocation. V3 requires an explicit `try await channel.leave()`. +The channel is shared within a `Realtime` instance — `leave()` tears it down for **all** holders. + +### Channels shared by topic + +`realtime.channel("room:1")` always returns the same actor regardless of how many times it is called. +One server-side subscription per topic per `Realtime` instance. + +### `broadcast()` requires a joined channel + +`try await channel.broadcast(…)` throws `.channelNotJoined` if the channel has not joined. +For one-shot sends without joining, use `realtime.httpBroadcast(topic:event:payload:)`. + +### Stream lifecycle + +V2 callback-based: `channel.onBroadcast(event:) { … }` returning `ObservationToken`. +V3 `AsyncThrowingStream`: `for try await msg in channel.broadcasts(of: T.self, event: "chat") { … }`. +Cancel by cancelling the enclosing `Task`. + +### Typed errors + +Every throwing API uses `throws(RealtimeError)`. Call sites can switch exhaustively: + +```swift +do { + try await channel.broadcast(msg, as: "event") +} catch let error as RealtimeError { + switch error { + case .channelNotJoined: ... + case .disconnected: ... + default: ... + } +} +``` + +### `@RealtimeTable` macro + +Use the `@RealtimeTable` macro to synthesize `RealtimeTable` conformance automatically: + +```swift +@RealtimeTable(schema: "public", table: "messages") +struct Message: Codable, Sendable { + var id: UUID + var roomId: UUID // mapped to "room_id" automatically + var text: String +} + +// Enables typed filters: +channel.changes(to: Message.self, where: .eq(\.roomId, id)) +``` diff --git a/docs/migrations/SupabaseClient+RealtimeV3.swift.template b/docs/migrations/SupabaseClient+RealtimeV3.swift.template new file mode 100644 index 000000000..ad99ec4a2 --- /dev/null +++ b/docs/migrations/SupabaseClient+RealtimeV3.swift.template @@ -0,0 +1,107 @@ +// +// SupabaseClient+RealtimeV3.swift +// Supabase +// +// Created by Guilherme Souza on 24/04/25. +// + +import Foundation +import Supabase +import _Realtime + +#if canImport(ObjectiveC) + import ObjectiveC +#endif + +extension SupabaseClient { + /// Realtime v3 client — the new idiomatic Swift API targeting iOS 17+. + /// + /// Lazily created and cached. Shares the project URL and API key with the parent client. + /// For older OS versions, use ``realtimeV2`` instead. + @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) + public var realtimeV3: _Realtime.Realtime { + #if canImport(ObjectiveC) + if let existing = _realtimeV3Cache { return existing } + let client = _makeRealtimeV3() + _realtimeV3Cache = client + return client + #else + // On Linux / non-ObjC runtimes, associated objects are not available. + // Returning a fresh instance each call avoids retain semantics we can't enforce. + return _makeRealtimeV3() + #endif + } + + @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) + private func _makeRealtimeV3() -> _Realtime.Realtime { + let wsURL = _realtimeWebSocketURL + let key = supabaseKey + var config = _Realtime.Configuration() + config.headers = headers + if let logger = options.global.logger { + config.logger = RealtimeBridgedLogger(supabaseLogger: logger) + } + return _Realtime.Realtime( + url: wsURL, + apiKey: .dynamic { [weak self] in + // Use auth session token if available; fall back to anon key. + guard let self else { return key } + do { + return try await self.auth.session.accessToken + } catch { + return key + } + }, + configuration: config + ) + } + + #if canImport(ObjectiveC) + @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) + private var _realtimeV3Cache: _Realtime.Realtime? { + get { + objc_getAssociatedObject(self, &SupabaseClientRealtimeV3Key) as? _Realtime.Realtime + } + set { + objc_setAssociatedObject( + self, &SupabaseClientRealtimeV3Key, newValue, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) + } + } + #endif + + private var _realtimeWebSocketURL: URL { + var comps = URLComponents(url: supabaseURL, resolvingAgainstBaseURL: false)! + comps.scheme = comps.scheme == "https" ? "wss" : "ws" + // Append rather than replace path so deployments under a path prefix work correctly. + let base = comps.path.hasSuffix("/") ? comps.path : comps.path + "/" + comps.path = base + "realtime/v1" + return comps.url! + } +} + +#if canImport(ObjectiveC) + private nonisolated(unsafe) var SupabaseClientRealtimeV3Key: UInt8 = 0 +#endif + +// MARK: - Logger bridge + +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +private struct RealtimeBridgedLogger: _Realtime.RealtimeLogger, Sendable { + let supabaseLogger: any SupabaseLogger + + func log(_ event: _Realtime.LogEvent) { + let level: SupabaseLogLevel + switch event.level { + case .debug: level = .debug + case .info: level = .verbose + case .warn: level = .warning + case .error: level = .error + } + supabaseLogger.log( + level, + message: event.message + ) + } +} diff --git a/docs/superpowers/plans/2026-04-24-realtime-v3-phase-8.md b/docs/superpowers/plans/2026-04-24-realtime-v3-phase-8.md new file mode 100644 index 000000000..3270607e2 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-realtime-v3-phase-8.md @@ -0,0 +1,662 @@ +# Realtime v3 — Phase 8 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the `@RealtimeTable` macro, wire `_Realtime` into `SupabaseClient`, write integration tests, and produce the migration guide. + +**Architecture:** The macro lives in a separate `Packages/_RealtimeTableMacros/` Swift package (requires `swift-syntax`). The macro declaration (`@attached(extension)`) lives in `_Realtime` and references the macro plugin. `SupabaseClient` gains a `realtime: Realtime` computed property that reads from `supabaseURL` + `supabaseKey`. Integration tests target a local Supabase instance via `supabase start`. + +**Prerequisite:** Phases 1–7 complete and committed. + +--- + +## Task 1: `@RealtimeTable` macro package + +**Files:** +- Create: `Packages/_RealtimeTableMacros/Package.swift` +- Create: `Packages/_RealtimeTableMacros/Sources/_RealtimeTableMacros/RealtimeTableMacro.swift` +- Create: `Packages/_RealtimeTableMacros/Tests/_RealtimeTableMacrosTests/RealtimeTableMacroTests.swift` +- Modify: `Packages/_Realtime/Package.swift` (add macro dependency + plugin) +- Create: `Packages/_Realtime/Sources/_Realtime/Macros/RealtimeTable+Macro.swift` + +- [ ] **Step 1: Create macro package scaffold** + +```bash +mkdir -p Packages/_RealtimeTableMacros/Sources/_RealtimeTableMacros +mkdir -p Packages/_RealtimeTableMacros/Tests/_RealtimeTableMacrosTests +``` + +- [ ] **Step 2: Create `Packages/_RealtimeTableMacros/Package.swift`** + +```swift +// swift-tools-version: 6.0 +import CompilerPluginSupport +import PackageDescription + +let package = Package( + name: "_RealtimeTableMacros", + platforms: [.iOS(.v17), .macOS(.v14), .tvOS(.v17), .watchOS(.v10), .visionOS(.v1)], + products: [ + .library(name: "_RealtimeTableMacros", targets: ["_RealtimeTableMacros"]), + ], + dependencies: [ + .package(url: "https://github.com/swiftlang/swift-syntax.git", from: "601.0.0"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.0"), + ], + targets: [ + .macro( + name: "_RealtimeTableMacroPlugin", + dependencies: [ + .product(name: "SwiftSyntaxMacros", package: "swift-syntax"), + .product(name: "SwiftCompilerPlugin", package: "swift-syntax"), + ] + ), + .target( + name: "_RealtimeTableMacros", + dependencies: [ + .target(name: "_RealtimeTableMacroPlugin"), + ] + ), + .testTarget( + name: "_RealtimeTableMacrosTests", + dependencies: [ + .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + "_RealtimeTableMacros", + "_RealtimeTableMacroPlugin", + ] + ), + ] +) +``` + +- [ ] **Step 3: Write macro expansion test (TDD)** + +Create `Tests/_RealtimeTableMacrosTests/RealtimeTableMacroTests.swift`: + +```swift +import SwiftSyntaxMacrosTestSupport +import XCTest +@testable import _RealtimeTableMacroPlugin + +final class RealtimeTableMacroTests: XCTestCase { + func testBasicExpansion() { + assertMacroExpansion( + """ + @RealtimeTable(schema: "public", table: "messages") + struct Message: Codable, Sendable { + var id: UUID + var roomId: UUID + var text: String + } + """, + expandedSource: """ + struct Message: Codable, Sendable { + var id: UUID + var roomId: UUID + var text: String + } + + extension Message: RealtimeTable { + static let schema: String = "public" + static let tableName: String = "messages" + static func columnName(for keyPath: KeyPath) -> String { + switch keyPath { + case \\Message.id: return "id" + case \\Message.roomId: return "room_id" + case \\Message.text: return "text" + default: fatalError("Unknown keypath for RealtimeTable \\(keyPath)") + } + } + } + """, + macros: ["RealtimeTable": RealtimeTableMacro.self] + ) + } + + func testCustomCodingKeysRespected() { + assertMacroExpansion( + """ + @RealtimeTable(schema: "public", table: "users") + struct User: Codable, Sendable { + var id: UUID + var createdAt: Date + + enum CodingKeys: String, CodingKey { + case id + case createdAt = "created_at" + } + } + """, + expandedSource: """ + struct User: Codable, Sendable { + var id: UUID + var createdAt: Date + + enum CodingKeys: String, CodingKey { + case id + case createdAt = "created_at" + } + } + + extension User: RealtimeTable { + static let schema: String = "public" + static let tableName: String = "users" + static func columnName(for keyPath: KeyPath) -> String { + switch keyPath { + case \\User.id: return "id" + case \\User.createdAt: return "created_at" + default: fatalError("Unknown keypath for RealtimeTable \\(keyPath)") + } + } + } + """, + macros: ["RealtimeTable": RealtimeTableMacro.self] + ) + } +} +``` + +- [ ] **Step 4: Run macro test — expect compile failure** + +```bash +cd Packages/_RealtimeTableMacros && swift test 2>&1 | head -10 +``` + +- [ ] **Step 5: Create the macro implementation** + +Create `Sources/_RealtimeTableMacroPlugin/RealtimeTableMacro.swift`: + +```swift +import SwiftCompilerPlugin +import SwiftSyntax +import SwiftSyntaxBuilder +import SwiftSyntaxMacros + +public struct RealtimeTableMacro: ExtensionMacro { + public static func expansion( + of node: AttributeSyntax, + attachedTo declaration: some DeclGroupSyntax, + providingExtensionsOf type: some TypeSyntaxProtocol, + conformingTo protocols: [TypeSyntax], + in context: some MacroExpansionContext + ) throws -> [ExtensionDeclSyntax] { + guard let structDecl = declaration.as(StructDeclSyntax.self) else { + throw MacroError(message: "@RealtimeTable can only be applied to structs") + } + + // Extract schema and table from macro arguments + guard let args = node.arguments?.as(LabeledExprListSyntax.self), + args.count >= 2, + let schemaExpr = args.first(where: { $0.label?.text == "schema" })?.expression, + let tableExpr = args.first(where: { $0.label?.text == "table" })?.expression, + let schema = schemaExpr.as(StringLiteralExprSyntax.self)?.representedLiteralValue, + let table = tableExpr.as(StringLiteralExprSyntax.self)?.representedLiteralValue + else { + throw MacroError(message: "@RealtimeTable requires schema: and table: arguments") + } + + let typeName = structDecl.name.text + + // Find all stored properties and their column names (honoring CodingKeys) + let columnNames = extractColumnNames(from: structDecl) + + // Build the switch cases + let cases = columnNames.map { (prop, column) in + "case \\\\.\(typeName).\(prop): return \"\(column)\"" + }.joined(separator: "\n ") + + let extensionSource = """ + extension \(typeName): RealtimeTable { + static let schema: String = "\(schema)" + static let tableName: String = "\(table)" + static func columnName(for keyPath: KeyPath<\(typeName), V>) -> String { + switch keyPath { + \(cases) + default: fatalError("Unknown keypath for RealtimeTable \\(keyPath)") + } + } + } + """ + + let extensionDecl = try ExtensionDeclSyntax("\(raw: extensionSource)") + return [extensionDecl] + } + + // Extract (propertyName → columnName) pairs, honoring CodingKeys if present + private static func extractColumnNames(from decl: StructDeclSyntax) -> [(String, String)] { + // Find CodingKeys enum if it exists + var codingKeyMap: [String: String] = [:] + for member in decl.memberBlock.members { + if let enumDecl = member.decl.as(EnumDeclSyntax.self), + enumDecl.name.text == "CodingKeys" { + for enumMember in enumDecl.memberBlock.members { + if let caseDecl = enumMember.decl.as(EnumCaseDeclSyntax.self) { + for element in caseDecl.elements { + let propName = element.name.text + if let rawValue = element.rawValue?.value.as(StringLiteralExprSyntax.self)?.representedLiteralValue { + codingKeyMap[propName] = rawValue + } else { + codingKeyMap[propName] = propName + } + } + } + } + } + } + + var result: [(String, String)] = [] + for member in decl.memberBlock.members { + if let varDecl = member.decl.as(VariableDeclSyntax.self), + varDecl.bindingSpecifier.text == "var" { + for binding in varDecl.bindings { + if let name = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text { + let column: String + if let mapped = codingKeyMap[name] { + column = mapped + } else if codingKeyMap.isEmpty { + // No CodingKeys: camelCase → snake_case + column = camelToSnake(name) + } else { + column = name + } + result.append((name, column)) + } + } + } + } + return result + } + + private static func camelToSnake(_ s: String) -> String { + var result = "" + for (i, char) in s.enumerated() { + if char.isUppercase && i > 0 { + result.append("_") + result.append(char.lowercased()) + } else { + result.append(char) + } + } + return result + } +} + +struct MacroError: Error, CustomStringConvertible { + let message: String + var description: String { message } +} + +@main struct _RealtimeTableMacroPlugin: CompilerPlugin { + var providingMacros: [any Macro.Type] = [RealtimeTableMacro.self] +} +``` + +- [ ] **Step 6: Run macro tests — expect pass** + +```bash +cd Packages/_RealtimeTableMacros && swift test +``` + +Expected: 2 tests pass. + +- [ ] **Step 7: Add macro declaration to `_Realtime`** + +First, add the local macro package to `Packages/_Realtime/Package.swift`: + +```swift +// In dependencies: +.package(path: "../_RealtimeTableMacros"), + +// In _Realtime target dependencies: +.product(name: "_RealtimeTableMacros", package: "_RealtimeTableMacros"), +``` + +Then create `Sources/_Realtime/Macros/RealtimeTable+Macro.swift`: + +```swift +import _RealtimeTableMacros + +/// Synthesizes `RealtimeTable` conformance for a struct, enabling typed `Filter`. +/// +/// ```swift +/// @RealtimeTable(schema: "public", table: "messages") +/// struct Message: Codable, Sendable { +/// var id: UUID +/// var roomId: UUID +/// var text: String +/// } +/// +/// // Now usable with typed filters: +/// channel.changes(to: Message.self, where: .eq(\.roomId, roomId)) +/// ``` +/// +/// Column names follow `CodingKeys` if defined; otherwise camelCase is converted to snake_case. +@attached(extension, conformances: RealtimeTable, names: named(schema), named(tableName), named(columnName)) +public macro RealtimeTable(schema: String, table: String) = + #externalMacro(module: "_RealtimeTableMacroPlugin", type: "RealtimeTableMacro") +``` + +- [ ] **Step 8: Build `_Realtime` with macro** + +```bash +cd Packages/_Realtime && swift build +``` + +Expected: Build succeeded. + +- [ ] **Step 9: Commit** + +```bash +git add Packages/_RealtimeTableMacros Packages/_Realtime/Sources/_Realtime/Macros Packages/_Realtime/Package.swift +git commit -m "feat(_Realtime): Phase 8a — @RealtimeTable macro synthesis" +``` + +--- + +## Task 2: `SupabaseClient` integration + +**Files:** +- Modify: `Sources/Supabase/SupabaseClient.swift` +- Create: `Sources/Supabase/SupabaseClient+RealtimeV3.swift` +- Modify: `Package.swift` (root) + +- [ ] **Step 1: Add `_Realtime` to the `Supabase` target in root `Package.swift`** + +In the `Supabase` target's `dependencies` array, add: +```swift +.product(name: "_Realtime", package: "_Realtime"), +``` + +- [ ] **Step 2: Create `Sources/Supabase/SupabaseClient+RealtimeV3.swift`** + +```swift +import _Realtime +import Foundation + +extension SupabaseClient { + /// Realtime v3 client. Lazily created; shares the supabase URL and API key. + /// + /// The v3 client uses `_Realtime` — the new idiomatic Swift API targeting iOS 17+. + /// For iOS 13 support, use `realtimeV2` instead. + @available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) + public var realtimeV3: Realtime { + if let existing = _realtimeV3 { return existing } + var config = _Realtime.Configuration() + if let logger = options.global.logger { + config.logger = BridgedLogger(wrapped: logger) + } + let headers = options.global.headers.reduce(into: [String: String]()) { dict, pair in + dict[pair.name.rawValue] = pair.value + } + config.headers = headers + let client = Realtime( + url: realtimeURL, + apiKey: .dynamic { [weak self] in + guard let self else { return "" } + return try await self.auth.session.accessToken + }, + configuration: config + ) + _realtimeV3 = client + return client + } + + private var _realtimeV3: Realtime? { + get { objc_getAssociatedObject(self, &AssociatedKeys.realtimeV3) as? Realtime } + set { objc_setAssociatedObject(self, &AssociatedKeys.realtimeV3, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + private var realtimeURL: URL { + var comps = URLComponents(url: supabaseURL, resolvingAgainstBaseURL: false)! + comps.path = "/realtime/v1" + comps.scheme = comps.scheme == "https" ? "wss" : "ws" + return comps.url! + } + + private enum AssociatedKeys { + static var realtimeV3 = "realtimeV3" + } +} + +// Bridge the existing SupabaseLogger to RealtimeLogger +@available(iOS 17.0, macOS 14.0, tvOS 17.0, watchOS 10.0, visionOS 1.0, *) +private struct BridgedLogger: _Realtime.RealtimeLogger { + let wrapped: any SupabaseLogger + + func log(_ event: _Realtime.LogEvent) { + wrapped.log( + message: event.message, + level: { + switch event.level { + case .debug: return .debug + case .info: return .verbose + case .warn: return .warning + case .error: return .error + } + }() + ) + } +} +``` + +- [ ] **Step 3: Build root package** + +```bash +swift build -target Supabase +``` + +Expected: Build succeeded. + +- [ ] **Step 4: Commit** + +```bash +git add Sources/Supabase/SupabaseClient+RealtimeV3.swift Package.swift +git commit -m "feat(supabase): expose realtimeV3 on SupabaseClient using _Realtime" +``` + +--- + +## Task 3: Integration tests + +**Files:** +- Create: `Tests/IntegrationTests/RealtimeV3IntegrationTests.swift` + +Integration tests require `supabase start` from within `Tests/IntegrationTests/`. + +- [ ] **Step 1: Start local Supabase** + +```bash +cd Tests/IntegrationTests && supabase start +``` + +- [ ] **Step 2: Create integration tests** + +```swift +import Testing +import _Realtime + +// Integration tests require a running local Supabase instance. +// Run: cd Tests/IntegrationTests && supabase start + +@Suite(.disabled("Requires local Supabase — run with INTEGRATION=1")) +struct RealtimeV3IntegrationTests { + static let url = URL(string: ProcessInfo.processInfo.environment["SUPABASE_REALTIME_URL"] + ?? "ws://localhost:54321/realtime/v1")! + static let anonKey = ProcessInfo.processInfo.environment["SUPABASE_ANON_KEY"] + ?? "your-anon-key-here" + + func makeRealtime() -> Realtime { + Realtime(url: Self.url, apiKey: .literal(Self.anonKey)) + } + + @Test func connectAndDisconnect() async throws { + let realtime = makeRealtime() + try await realtime.connect() + let snapshot = await realtime.currentStatus + #expect(snapshot == .connected) + await realtime.disconnect() + } + + @Test func broadcastRoundTrip() async throws { + struct Msg: Codable, Sendable, Equatable { let text: String } + let r1 = makeRealtime() + let r2 = makeRealtime() + + try await r1.connect() + try await r2.connect() + + let sender = r1.channel("integration:broadcast") + let receiver = r2.channel("integration:broadcast") { $0.broadcast.receiveOwnBroadcasts = false } + + var received: [Msg] = [] + Task { + for try await msg in receiver.broadcasts(of: Msg.self, event: "test") { + received.append(msg) + } + } + try await Task.sleep(for: .milliseconds(500)) // wait for join + + try await sender.join() + try await sender.broadcast(Msg(text: "hello integration"), as: "test") + + try await Task.sleep(for: .seconds(1)) + #expect(received.contains(Msg(text: "hello integration"))) + + try await sender.leave() + try await receiver.leave() + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add Tests/IntegrationTests/RealtimeV3IntegrationTests.swift +git commit -m "test: add Realtime v3 integration test scaffold" +``` + +--- + +## Task 4: Migration guide + +**Files:** +- Create: `docs/migrations/RealtimeV3 Migration Guide.md` + +- [ ] **Step 1: Create the migration guide** + +```markdown +# Realtime V2 → V3 Migration Guide + +Realtime V3 (`_Realtime`) is a greenfield redesign targeting Swift 6.0+ and iOS 17+. +Import it with `import _Realtime` (renamed to `import Realtime` at final release). + +## Quick-reference mapping + +| V2 | V3 | +|----|-----| +| `import Realtime` | `import _Realtime` | +| `RealtimeClientV2(url:options:)` | `Realtime(url:apiKey:configuration:transport:)` | +| `client.channel("x", options: …)` | `realtime.channel("x") { $0.isPrivate = true }` | +| `await channel.subscribe()` | Implicit on first `broadcasts()` / `changes()` iteration | +| `await channel.unsubscribe()` | `try await channel.leave()` | +| `channel.broadcastStream(event:)` | `channel.broadcasts(of: T.self, event:)` | +| `await channel.broadcast(event:message:)` | `try await channel.broadcast(payload, as: event)` | +| `channel.postgresChange(.all, schema:table:filter:)` | `channel.changes(to: T.self, where: .eq(\.col, val))` | +| `channel.presenceChange()` | `channel.presence.diffs(T.self)` | +| `channel.track(state:)` | `try await channel.presence.track(state)` → `PresenceHandle` | +| `ObservationToken` / `subscription.cancel()` | Task cancellation ends `AsyncThrowingStream` iteration | +| `accessToken: () async -> String?` | `APIKeySource.dynamic { … }` | +| `any Error` at boundaries | `throws(RealtimeError)` everywhere | +| `RealtimeClientOptions.maxRetryAttempts` | `Configuration.reconnection: ReconnectionPolicy` | + +## Key behavioural differences + +### Explicit `leave()` — no auto-unsubscribe + +V2 unsubscribed on `ObservationToken` deallocation. V3 requires an explicit `try await channel.leave()`. +The channel is shared within a `Realtime` instance — `leave()` tears it down for **all** holders. + +### Channels shared by topic + +`realtime.channel("room:1")` always returns the same actor regardless of how many times it's called. +One server-side subscription per topic per `Realtime` instance. + +### `broadcast()` requires a joined channel + +`try await channel.broadcast(…)` throws `.channelNotJoined` if the channel hasn't joined. +For one-shot sends without joining, use `realtime.httpBroadcast(topic:event:payload:)`. + +### Stream lifecycle + +V2 callback-based: `channel.onBroadcast(event:) { … }` returning `ObservationToken`. +V3 `AsyncThrowingStream`: `for try await msg in channel.broadcasts(of: T.self, event: "chat") { … }`. +Cancel by cancelling the enclosing `Task`. + +### Typed errors + +Every throwing API uses `throws(RealtimeError)`. Call sites can switch exhaustively: +```swift +do { + try await channel.broadcast(msg, as: "event") +} catch let error as RealtimeError { + switch error { + case .channelNotJoined: … + case .disconnected: … + default: … + } +} +``` +``` + +- [ ] **Step 2: Commit** + +```bash +git add "docs/migrations/RealtimeV3 Migration Guide.md" +git commit -m "docs: add Realtime V2 → V3 migration guide" +``` + +--- + +## Task 5: Final validation + +- [ ] **Step 1: Run all `_Realtime` tests** + +```bash +cd Packages/_Realtime && swift test +``` + +Expected: All tests pass, 0 failures. + +- [ ] **Step 2: Run all `_RealtimeTableMacros` tests** + +```bash +cd Packages/_RealtimeTableMacros && swift test +``` + +Expected: All tests pass. + +- [ ] **Step 3: Build root package (all targets)** + +```bash +swift build +``` + +Expected: Build succeeded, no errors. + +- [ ] **Step 4: Run existing test suite — verify no regressions** + +```bash +swift test --filter RealtimeTests +swift test --filter AuthTests +swift test --filter SupabaseTests +``` + +Expected: All pass. Existing `Realtime` (V2) module untouched. + +- [ ] **Step 5: Final commit** + +```bash +git add . +git commit -m "feat: Realtime v3 — _Realtime package complete, all phases integrated" +``` diff --git a/docs/superpowers/plans/2026-04-24-realtime-v3-phases-1-2.md b/docs/superpowers/plans/2026-04-24-realtime-v3-phases-1-2.md new file mode 100644 index 000000000..2bef9d34a --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-realtime-v3-phases-1-2.md @@ -0,0 +1,653 @@ +# Realtime v3 — Phase 1 & 2 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Create the `Packages/_Realtime` Swift package with foundation types (Phase 1) and deterministic test infrastructure (Phase 2). + +**Architecture:** Standalone Swift 6.0 package at `Packages/_Realtime/`. Phase 1 defines pure value types — errors, transport protocol, configuration. Phase 2 adds `InMemoryTransport.pair()` that enables all future phases to test without real sockets. + +**Tech Stack:** Swift 6.0, swift-clocks, swift-concurrency-extras, xctest-dynamic-overlay, URLSessionWebSocketTask + +--- + +## Task 1: Package scaffold + +**Files:** +- Create: `Packages/_Realtime/Package.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/.gitkeep` +- Create: `Packages/_Realtime/Tests/_RealtimeTests/.gitkeep` + +- [ ] **Step 1: Create directory tree** + +```bash +mkdir -p Packages/_Realtime/Sources/_Realtime/{Error,Transport,Config,Testing,Internal,Client,Channel,Broadcast,Presence,Postgres,Macros} +mkdir -p Packages/_Realtime/Tests/_RealtimeTests +``` + +- [ ] **Step 2: Create `Packages/_Realtime/Package.swift`** + +```swift +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "_Realtime", + platforms: [ + .iOS(.v17), + .macOS(.v14), + .tvOS(.v17), + .watchOS(.v10), + .visionOS(.v1), + ], + products: [ + .library(name: "_Realtime", targets: ["_Realtime"]), + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.0"), + ], + targets: [ + .target( + name: "_Realtime", + dependencies: [ + .product(name: "Clocks", package: "swift-clocks"), + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), + ], + swiftSettings: [ + .swiftLanguageMode(.v6), + .enableUpcomingFeature("ExistentialAny"), + ] + ), + .testTarget( + name: "_RealtimeTests", + dependencies: [ + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + "_Realtime", + ] + ), + ] +) +``` + +- [ ] **Step 3: Verify package resolves** + +```bash +cd Packages/_Realtime && swift package resolve +``` + +Expected: Dependencies download without errors. + +--- + +## Task 2: Error types + +**Files:** +- Create: `Packages/_Realtime/Sources/_Realtime/Error/RealtimeError.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Error/RealtimeLogger.swift` + +- [ ] **Step 1: Create `Error/RealtimeError.swift`** + +```swift +import Foundation + +public enum RealtimeError: Error, Sendable { + // Connection + case disconnected + case transportFailure(underlying: any Error & Sendable) + case reconnectionGaveUp(lastError: any Error & Sendable) + + // Channel lifecycle + case channelNotJoined + case channelJoinTimeout + case channelJoinRejected(reason: String) + case channelClosed(CloseReason) + + // Auth + case authenticationFailed(reason: String, underlying: (any Error & Sendable)?) + case tokenExpired + + // Server + case rateLimited(retryAfter: Duration?) + case serverError(code: Int, message: String) + + // Broadcast + case broadcastFailed(reason: String) + case broadcastAckTimeout + + // Coding + case decoding(type: String, underlying: any Error & Sendable) + case encoding(underlying: any Error & Sendable) + + // Cancellation (Swift CancellationError folded here) + case cancelled +} + +public enum CloseReason: Sendable, Equatable { + case userRequested + case serverClosed(code: Int, message: String?) + case timeout + case unauthorized + case policyViolation(String) + case transportFailure +} +``` + +- [ ] **Step 2: Create `Error/RealtimeLogger.swift`** + +```swift +import Foundation + +public protocol RealtimeLogger: Sendable { + func log(_ event: LogEvent) +} + +public struct LogEvent: Sendable { + public let level: LogLevel + public let category: LogCategory + public let message: String + public let metadata: [String: String] + public let timestamp: Date + + public init( + level: LogLevel, + category: LogCategory, + message: String, + metadata: [String: String] = [:], + timestamp: Date = Date() + ) { + self.level = level + self.category = category + self.message = message + self.metadata = metadata + self.timestamp = timestamp + } +} + +public enum LogLevel: Sendable { case debug, info, warn, error } +public enum LogCategory: Sendable { case connection, channel, broadcast, presence, postgres } +``` + +--- + +## Task 3: Transport protocol + URLSessionTransport + +**Files:** +- Create: `Packages/_Realtime/Sources/_Realtime/Transport/RealtimeTransport.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Transport/URLSessionTransport.swift` + +- [ ] **Step 1: Create `Transport/RealtimeTransport.swift`** + +```swift +import Foundation + +public protocol RealtimeTransport: Sendable { + func connect(to url: URL, headers: [String: String]) async throws -> any RealtimeConnection +} + +public protocol RealtimeConnection: Sendable { + /// Incoming frames from the server. Finishes (possibly with error) when the connection closes. + var frames: AsyncThrowingStream { get } + func send(_ frame: TransportFrame) async throws + func close(code: Int, reason: String) async +} + +public enum TransportFrame: Sendable, Equatable { + case text(String) + case binary(Data) +} +``` + +- [ ] **Step 2: Create `Transport/URLSessionTransport.swift`** + +```swift +import Foundation + +public struct URLSessionTransport: RealtimeTransport { + private let session: URLSession + + public init(session: URLSession = .shared) { + self.session = session + } + + public func connect(to url: URL, headers: [String: String]) async throws -> any RealtimeConnection { + var request = URLRequest(url: url) + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + let task = session.webSocketTask(with: request) + task.resume() + return URLSessionConnection(task: task) + } +} + +private final class URLSessionConnection: RealtimeConnection, @unchecked Sendable { + private let task: URLSessionWebSocketTask + let frames: AsyncThrowingStream + private let continuation: AsyncThrowingStream.Continuation + + init(task: URLSessionWebSocketTask) { + self.task = task + let (stream, cont) = AsyncThrowingStream.makeStream() + self.frames = stream + self.continuation = cont + startReceiving() + } + + private func startReceiving() { + let task = self.task + let continuation = self.continuation + Task { + do { + while true { + 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 { + continuation.finish(throwing: error) + } + } + } + + func send(_ frame: TransportFrame) async throws { + 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 { + task.cancel(with: .normalClosure, reason: reason.data(using: .utf8)) + } +} +``` + +--- + +## Task 4: Configuration types + +**Files:** +- Create: `Packages/_Realtime/Sources/_Realtime/Config/APIKeySource.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Config/ReconnectionPolicy.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Config/Configuration.swift` + +- [ ] **Step 1: Create `Config/APIKeySource.swift`** + +```swift +public enum APIKeySource: Sendable { + case literal(String) + /// Called on connect and on `token_expired` server signal. + case dynamic(@Sendable () async throws -> String) +} +``` + +- [ ] **Step 2: Create `Config/ReconnectionPolicy.swift`** + +```swift +import Foundation + +public struct ReconnectionPolicy: Sendable { + /// Return `nil` to stop retrying. + public var nextDelay: @Sendable (_ attempt: Int, _ lastError: any Error & Sendable) -> Duration? + + public static let never = ReconnectionPolicy { _, _ in nil } + + public static func exponentialBackoff( + initial: Duration, + max: Duration, + jitter: Double = 0.2 + ) -> ReconnectionPolicy { + let initialSecs = Double(initial.components.seconds) + let maxSecs = Double(max.components.seconds) + return ReconnectionPolicy { attempt, _ in + let base = initialSecs * pow(2.0, Double(attempt - 1)) + let capped = Swift.min(base, maxSecs) + let noise = capped * Double.random(in: -jitter...jitter) + return .seconds(Swift.max(0, capped + noise)) + } + } + + public static func fixed(_ delay: Duration, maxAttempts: Int? = nil) -> ReconnectionPolicy { + ReconnectionPolicy { attempt, _ in + if let max = maxAttempts, attempt > max { return nil } + return delay + } + } +} +``` + +- [ ] **Step 3: Create `Config/Configuration.swift`** + +```swift +import Clocks +import Foundation + +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) + ) + /// Socket stays open this long after the last channel leaves. `.zero` = immediate close. + public var disconnectOnEmptyChannelsAfter: Duration = .seconds(50) + public var handleAppLifecycle: Bool = Configuration.defaultHandleAppLifecycle + public var protocolVersion: RealtimeProtocolVersion = .v2 + public var clock: any Clock = ContinuousClock() + public var headers: [String: String] = [:] + public var logger: (any RealtimeLogger)? = nil + public var decoder: JSONDecoder = { + let d = JSONDecoder() + d.dateDecodingStrategy = .iso8601 + return d + }() + public var encoder: JSONEncoder = { + let e = JSONEncoder() + e.dateEncodingStrategy = .iso8601 + return e + }() + + public static let `default` = Configuration() + public init() {} + + #if os(iOS) || os(macOS) || os(tvOS) || os(visionOS) + static let defaultHandleAppLifecycle = true + #else + static let defaultHandleAppLifecycle = false + #endif +} + +public enum RealtimeProtocolVersion: String, Sendable { + case v1 = "1.0.0" + case v2 = "2.0.0" +} +``` + +--- + +## Task 5: Phase 1 tests + build + +**Files:** +- Create: `Packages/_Realtime/Tests/_RealtimeTests/ReconnectionPolicyTests.swift` + +- [ ] **Step 1: Write failing test** + +```swift +import Testing +@testable import _Realtime + +@Suite struct ReconnectionPolicyTests { + @Test func neverPolicyReturnsNilImmediately() { + let policy = ReconnectionPolicy.never + let delay = policy.nextDelay(1, URLError(.notConnectedToInternet)) + #expect(delay == nil) + } + + @Test func fixedPolicyReturnsDelayUntilMax() { + let policy = ReconnectionPolicy.fixed(.seconds(2), maxAttempts: 3) + #expect(policy.nextDelay(1, URLError(.notConnectedToInternet)) == .seconds(2)) + #expect(policy.nextDelay(3, URLError(.notConnectedToInternet)) == .seconds(2)) + #expect(policy.nextDelay(4, URLError(.notConnectedToInternet)) == nil) + } + + @Test func exponentialBackoffGrowsWithAttempts() { + let policy = ReconnectionPolicy.exponentialBackoff( + initial: .seconds(1), max: .seconds(16), jitter: 0 + ) + let d1 = policy.nextDelay(1, URLError(.notConnectedToInternet))! + let d2 = policy.nextDelay(2, URLError(.notConnectedToInternet))! + let d3 = policy.nextDelay(3, URLError(.notConnectedToInternet))! + #expect(d1.components.seconds == 1) + #expect(d2.components.seconds == 2) + #expect(d3.components.seconds == 4) + } + + @Test func exponentialBackoffCapsAtMax() { + let policy = ReconnectionPolicy.exponentialBackoff( + initial: .seconds(1), max: .seconds(5), jitter: 0 + ) + let d10 = policy.nextDelay(10, URLError(.notConnectedToInternet))! + #expect(d10.components.seconds <= 5) + } +} +``` + +- [ ] **Step 2: Run test — expect compile success, tests pass** + +```bash +cd Packages/_Realtime && swift test --filter ReconnectionPolicyTests +``` + +Expected: All 4 tests pass. + +- [ ] **Step 3: Build the full target** + +```bash +cd Packages/_Realtime && swift build +``` + +Expected: Build succeeded, 0 errors. + +- [ ] **Step 4: Commit** + +```bash +git add Packages/_Realtime +git commit -m "feat(_Realtime): Phase 1 — foundation types (error, transport, config)" +``` + +--- + +## Task 6: InMemoryTransport (Phase 2) + +**Files:** +- Create: `Packages/_Realtime/Sources/_Realtime/Testing/InMemoryTransport.swift` + +- [ ] **Step 1: Write failing test first** + +Create `Packages/_Realtime/Tests/_RealtimeTests/TransportTests.swift`: + +```swift +import Testing +import ConcurrencyExtras +@testable import _Realtime + +@Suite struct TransportTests { + @Test func framesFlowClientToServer() async throws { + let (transport, server) = InMemoryTransport.pair() + let connection = try await transport.connect(to: URL(string: "ws://test")!, headers: [:]) + + try await connection.send(.text("hello")) + let received = await server.receive() + #expect(received == .text("hello")) + } + + @Test func framesFlowServerToClient() async throws { + let (transport, server) = InMemoryTransport.pair() + let connection = try await transport.connect(to: URL(string: "ws://test")!, headers: [:]) + + Task { await server.send(.text("from server")) } + + var iter = connection.frames.makeAsyncIterator() + let frame = try await iter.next() + #expect(frame == .text("from server")) + } + + @Test func serverCloseFinishesClientFrames() async throws { + let (transport, server) = InMemoryTransport.pair() + let connection = try await transport.connect(to: URL(string: "ws://test")!, headers: [:]) + + Task { await server.close() } + + var receivedFrames: [TransportFrame] = [] + do { + for try await frame in connection.frames { + receivedFrames.append(frame) + } + } catch { + // close with error is fine + } + // Stream ended — either normally or with error + #expect(receivedFrames.isEmpty) + } +} +``` + +- [ ] **Step 2: Run test — expect compile failure (InMemoryTransport not defined)** + +```bash +cd Packages/_Realtime && swift test --filter TransportTests 2>&1 | head -20 +``` + +Expected: error: cannot find type 'InMemoryTransport' + +- [ ] **Step 3: Create `Testing/InMemoryTransport.swift`** + +```swift +import Foundation + +/// A paired in-memory transport for deterministic tests. No real I/O. +/// +/// Usage: +/// ```swift +/// let (transport, server) = InMemoryTransport.pair() +/// let realtime = Realtime(url: testURL, apiKey: .literal("key"), transport: transport) +/// ``` +public final class InMemoryTransport: RealtimeTransport, @unchecked Sendable { + // server → client + private let (serverToClientStream, serverToClientCont) = + AsyncThrowingStream.makeStream() + // client → server + private let (clientToServerStream, clientToServerCont) = + AsyncThrowingStream.makeStream() + + private init() {} + + public static func pair() -> (client: InMemoryTransport, server: InMemoryServer) { + let t = InMemoryTransport() + let s = InMemoryServer( + receivedFrames: t.clientToServerStream, + sendContinuation: t.serverToClientCont + ) + return (t, s) + } + + public func connect(to url: URL, headers: [String: String]) async throws -> any RealtimeConnection { + InMemoryConnection( + inbound: serverToClientStream, + outbound: clientToServerCont + ) + } +} + +/// The server side of an `InMemoryTransport` pair. +public final class InMemoryServer: Sendable { + private let receivedFrames: AsyncThrowingStream + private let sendContinuation: AsyncThrowingStream.Continuation + + init( + receivedFrames: AsyncThrowingStream, + sendContinuation: AsyncThrowingStream.Continuation + ) { + self.receivedFrames = receivedFrames + self.sendContinuation = sendContinuation + } + + /// Awaits the next frame the client sent. + public func receive() async -> TransportFrame? { + try? await receivedFrames.first(where: { _ in true }) + } + + /// Push a frame to the client. + public func send(_ frame: TransportFrame) async { + sendContinuation.yield(frame) + } + + /// Simulate server-initiated close (with error). + public func close(code: Int = 1000, reason: String = "") { + sendContinuation.finish(throwing: URLError(.networkConnectionLost)) + } +} + +private struct InMemoryConnection: RealtimeConnection, Sendable { + let frames: AsyncThrowingStream + private let outbound: AsyncThrowingStream.Continuation + + init( + inbound: AsyncThrowingStream, + outbound: AsyncThrowingStream.Continuation + ) { + self.frames = inbound + self.outbound = outbound + } + + func send(_ frame: TransportFrame) async throws { + outbound.yield(frame) + } + + func close(code: Int, reason: String) async { + outbound.finish() + } +} +``` + +- [ ] **Step 4: Run tests — expect all pass** + +```bash +cd Packages/_Realtime && swift test --filter TransportTests +``` + +Expected: 3 tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add Packages/_Realtime/Sources/_Realtime/Testing \ + Packages/_Realtime/Tests/_RealtimeTests/TransportTests.swift +git commit -m "feat(_Realtime): Phase 2 — InMemoryTransport test infrastructure" +``` + +--- + +## Task 7: Wire `_Realtime` into the main package + +**Files:** +- Modify: `Package.swift` (root) + +- [ ] **Step 1: Add local package dependency and product to root `Package.swift`** + +Add to the `dependencies` array: +```swift +.package(path: "Packages/_Realtime"), +``` + +Add to the `products` array: +```swift +.library(name: "_Realtime", targets: ["_Realtime"]), +``` + +The root package doesn't need to depend on `_Realtime` in any existing target yet — that comes in Phase 8. This step just makes the package resolvable from the root. + +- [ ] **Step 2: Verify root package still builds** + +```bash +swift build +``` + +Expected: Build succeeded. Existing targets unaffected. + +- [ ] **Step 3: Commit** + +```bash +git add Package.swift +git commit -m "chore: wire Packages/_Realtime into root package as local dependency" +``` diff --git a/docs/superpowers/plans/2026-04-24-realtime-v3-phases-3-4.md b/docs/superpowers/plans/2026-04-24-realtime-v3-phases-3-4.md new file mode 100644 index 000000000..8b42db918 --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-realtime-v3-phases-3-4.md @@ -0,0 +1,1139 @@ +# Realtime v3 — Phase 3 & 4 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement the `Realtime` actor (WebSocket connection, reconnection, heartbeat) and the `Channel` actor (join/leave lifecycle, topic identity, options lock). + +**Architecture:** `Realtime` owns the WebSocket connection, channels registry, pending-reply tracking, and reconnect loop. `Channel` owns its join/leave state machine and per-feature continuation dictionaries. Cross-actor calls use `await`. Phoenix wire protocol is handled by an internal `PhoenixSerializer`. + +**Tech Stack:** Swift 6.0 actors, typed throws, `AsyncThrowingStream`, `swift-clocks` TestClock, `InMemoryTransport` from Phase 2. + +**Prerequisite:** Phases 1 & 2 complete and committed. + +--- + +## Task 1: Internal JSON + Phoenix wire types + +**Files:** +- Create: `Packages/_Realtime/Sources/_Realtime/Internal/JSONValue.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Internal/PhoenixMessage.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Internal/PhoenixSerializer.swift` + +- [ ] **Step 1: Write failing test for PhoenixSerializer** + +Create `Packages/_Realtime/Tests/_RealtimeTests/PhoenixSerializerTests.swift`: + +```swift +import Testing +import Foundation +@testable import _Realtime + +@Suite struct PhoenixSerializerTests { + @Test func roundTripTextFrame() throws { + let msg = PhoenixMessage( + joinRef: "1", ref: "2", topic: "room:1", + event: "phx_join", payload: ["status": .string("ok")] + ) + let frame = try PhoenixSerializer.encodeText(msg) + let decoded = try PhoenixSerializer.decodeText(frame) + #expect(decoded.joinRef == "1") + #expect(decoded.ref == "2") + #expect(decoded.topic == "room:1") + #expect(decoded.event == "phx_join") + #expect(decoded.payload["status"] == .string("ok")) + } + + @Test func decodeTextWithNullRefs() throws { + // [null, null, "phoenix", "heartbeat", {}] + let json = "[null,null,\"phoenix\",\"heartbeat\",{}]" + let decoded = try PhoenixSerializer.decodeText(json) + #expect(decoded.joinRef == nil) + #expect(decoded.ref == nil) + #expect(decoded.topic == "phoenix") + #expect(decoded.event == "heartbeat") + } + + @Test func decodeBinaryBroadcast() throws { + // Build a minimal type-0x04 binary frame + let topic = "room:1" + let event = "chat" + let payload = Data("{\"msg\":\"hi\"}".utf8) + + var data = Data() + data.append(0x04) // kind = server broadcast + data.append(UInt8(topic.utf8.count)) // topic_len + data.append(UInt8(event.utf8.count)) // event_len + data.append(0x00) // meta_len + data.append(0x01) // encoding = json + data.append(contentsOf: topic.utf8) + data.append(contentsOf: event.utf8) + data.append(payload) + + let broadcast = try PhoenixSerializer.decodeBinary(data) + #expect(broadcast.topic == "room:1") + #expect(broadcast.event == "chat") + } +} +``` + +- [ ] **Step 2: Run — expect compile failure** + +```bash +cd Packages/_Realtime && swift test --filter PhoenixSerializerTests 2>&1 | head -10 +``` + +Expected: error: cannot find type 'PhoenixMessage' + +- [ ] **Step 3: Create `Internal/JSONValue.swift`** + +```swift +import Foundation + +/// Codable JSON value without external dependencies. +public enum JSONValue: Codable, Sendable, Equatable { + case string(String) + case double(Double) + case int(Int) + case bool(Bool) + case null + indirect case array([JSONValue]) + indirect case object([String: JSONValue]) + + public init(from decoder: any Decoder) throws { + let c = try decoder.singleValueContainer() + if let v = try? c.decode(Bool.self) { self = .bool(v); return } + if let v = try? c.decode(Int.self) { self = .int(v); return } + if let v = try? c.decode(Double.self) { self = .double(v); return } + if let v = try? c.decode(String.self) { self = .string(v); return } + if c.decodeNil() { self = .null; return } + if let v = try? c.decode([JSONValue].self) { self = .array(v); return } + if let v = try? c.decode([String: JSONValue].self) { self = .object(v); return } + throw DecodingError.dataCorrupted( + .init(codingPath: decoder.codingPath, debugDescription: "Cannot decode JSONValue") + ) + } + + public func encode(to encoder: any Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .string(let v): try c.encode(v) + case .double(let v): try c.encode(v) + case .int(let v): try c.encode(v) + case .bool(let v): try c.encode(v) + case .null: try c.encodeNil() + case .array(let v): try c.encode(v) + case .object(let v): try c.encode(v) + } + } +} + +extension JSONValue: ExpressibleByStringLiteral { + public init(stringLiteral v: String) { self = .string(v) } +} +extension JSONValue: ExpressibleByIntegerLiteral { + public init(integerLiteral v: Int) { self = .int(v) } +} +extension JSONValue: ExpressibleByFloatLiteral { + public init(floatLiteral v: Double) { self = .double(v) } +} +extension JSONValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral v: Bool) { self = .bool(v) } +} +extension JSONValue: ExpressibleByNilLiteral { + public init(nilLiteral: ()) { self = .null } +} +extension JSONValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { self = .array(elements) } +} +extension JSONValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(Dictionary(uniqueKeysWithValues: elements)) + } +} +``` + +- [ ] **Step 4: Create `Internal/PhoenixMessage.swift`** + +```swift +import Foundation + +/// Decoded Phoenix protocol message (text array format or binary broadcast). +struct PhoenixMessage: Sendable { + var joinRef: String? + var ref: String? + var topic: String + var event: String + var payload: [String: JSONValue] +} + +/// Decoded server-to-client binary broadcast (type 0x04). +struct BinaryBroadcast: Sendable { + let topic: String + let event: String + enum Payload: Sendable { + case json([String: JSONValue]) + case binary(Data) + } + let payload: Payload +} +``` + +- [ ] **Step 5: Create `Internal/PhoenixSerializer.swift`** + +Vendor from `Sources/Realtime/RealtimeSerializer.swift`, adapted to use `JSONValue` instead of `AnyJSON`: + +```swift +import Foundation + +enum PhoenixSerializer { + enum BinaryKind: UInt8 { + case clientBroadcastPush = 3 + case serverBroadcast = 4 + } + enum PayloadEncoding: UInt8 { + case binary = 0 + case json = 1 + } + + // MARK: Text + + static func encodeText(_ msg: PhoenixMessage) throws -> String { + let array: [JSONValue] = [ + msg.joinRef.map { .string($0) } ?? .null, + msg.ref.map { .string($0) } ?? .null, + .string(msg.topic), + .string(msg.event), + .object(msg.payload), + ] + let data = try JSONEncoder().encode(array) + guard let text = String(data: data, encoding: .utf8) else { + throw RealtimeError.encoding(underlying: EncodingError.invalidValue(array, .init(codingPath: [], debugDescription: "UTF-8 failure"))) + } + return text + } + + static func decodeText(_ text: String) throws -> PhoenixMessage { + let data = Data(text.utf8) + let array = try JSONDecoder().decode([JSONValue].self, from: data) + guard array.count >= 5 else { + throw RealtimeError.decoding(type: "PhoenixMessage", underlying: DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Expected 5-element array, got \(array.count)"))) + } + let joinRef: String? = if case .string(let s) = array[0] { s } else { nil } + let ref: String? = if case .string(let s) = array[1] { s } else { nil } + guard case .string(let topic) = array[2], + case .string(let event) = array[3], + case .object(let payload) = array[4] + else { + throw RealtimeError.decoding(type: "PhoenixMessage", underlying: DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Unexpected array element types"))) + } + return PhoenixMessage(joinRef: joinRef, ref: ref, topic: topic, event: event, payload: payload) + } + + // MARK: Binary decode (server→client, type 0x04) + + static func decodeBinary(_ data: Data) throws -> BinaryBroadcast { + guard data.count >= 5 else { + throw RealtimeError.decoding(type: "BinaryBroadcast", underlying: DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Binary frame too short: \(data.count)"))) + } + let kind = data[data.startIndex] + guard kind == BinaryKind.serverBroadcast.rawValue else { + throw RealtimeError.decoding(type: "BinaryBroadcast", underlying: DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Unexpected kind byte: \(kind)"))) + } + let topicLen = Int(data[data.startIndex + 1]) + let eventLen = Int(data[data.startIndex + 2]) + let metaLen = Int(data[data.startIndex + 3]) + let encByte = data[data.startIndex + 4] + guard let encoding = PayloadEncoding(rawValue: encByte) else { + throw RealtimeError.decoding(type: "BinaryBroadcast", underlying: DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Unknown encoding: \(encByte)"))) + } + let headerSize = 5 + guard data.count >= headerSize + topicLen + eventLen + metaLen else { + throw RealtimeError.decoding(type: "BinaryBroadcast", underlying: DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Frame too short for field lengths"))) + } + var offset = data.startIndex + headerSize + let topicData = data[offset..<(offset + topicLen)]; offset += topicLen + let eventData = data[offset..<(offset + eventLen)]; offset += eventLen + offset += metaLen + let payloadData = data[offset...] + guard let topic = String(data: topicData, encoding: .utf8), + let event = String(data: eventData, encoding: .utf8) else { + throw RealtimeError.decoding(type: "BinaryBroadcast", underlying: DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "UTF-8 decode failure"))) + } + let payload: BinaryBroadcast.Payload + switch encoding { + case .json: + let obj = try JSONDecoder().decode([String: JSONValue].self, from: Data(payloadData)) + payload = .json(obj) + case .binary: + payload = .binary(Data(payloadData)) + } + return BinaryBroadcast(topic: topic, event: event, payload: payload) + } + + // MARK: Binary encode (client→server, type 0x03) + + static func encodeBroadcastPush( + joinRef: String?, ref: String?, + topic: String, event: String, + payload: [String: JSONValue] + ) throws -> Data { + let payloadData = try JSONEncoder().encode(payload) + return try _encodePush(joinRef: joinRef, ref: ref, topic: topic, event: event, encoding: .json, payload: payloadData) + } + + static func encodeBroadcastPush( + joinRef: String?, ref: String?, + topic: String, event: String, + binaryPayload: Data + ) throws -> Data { + try _encodePush(joinRef: joinRef, ref: ref, topic: topic, event: event, encoding: .binary, payload: binaryPayload) + } + + private static func _encodePush( + joinRef: String?, ref: String?, + topic: String, event: String, + encoding: PayloadEncoding, payload: Data + ) throws -> Data { + let jrBytes = Data((joinRef ?? "").utf8) + let rBytes = Data((ref ?? "").utf8) + let tBytes = Data(topic.utf8) + let eBytes = Data(event.utf8) + guard jrBytes.count <= 255, rBytes.count <= 255, tBytes.count <= 255, eBytes.count <= 255 else { + throw RealtimeError.encoding(underlying: EncodingError.invalidValue(topic, .init(codingPath: [], debugDescription: "Header field exceeds 255 bytes"))) + } + var out = Data() + out.append(BinaryKind.clientBroadcastPush.rawValue) + out.append(UInt8(jrBytes.count)) + out.append(UInt8(rBytes.count)) + out.append(UInt8(tBytes.count)) + out.append(UInt8(eBytes.count)) + out.append(0x00) // meta_len = 0 + out.append(encoding.rawValue) + out.append(jrBytes); out.append(rBytes); out.append(tBytes); out.append(eBytes) + out.append(payload) + return out + } +} +``` + +- [ ] **Step 6: Run serializer tests — expect all pass** + +```bash +cd Packages/_Realtime && swift test --filter PhoenixSerializerTests +``` + +Expected: 3 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add Packages/_Realtime/Sources/_Realtime/Internal \ + Packages/_Realtime/Tests/_RealtimeTests/PhoenixSerializerTests.swift +git commit -m "feat(_Realtime): Phase 3a — internal JSON + Phoenix wire serializer" +``` + +--- + +## Task 2: `ConnectionStatus` and `Realtime` actor + +**Files:** +- Create: `Packages/_Realtime/Sources/_Realtime/Client/ConnectionStatus.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Client/Realtime.swift` +- Create: `Packages/_Realtime/Tests/_RealtimeTests/RealtimeClientTests.swift` + +- [ ] **Step 1: Write failing client tests** + +```swift +import Testing +import Clocks +import ConcurrencyExtras +@testable import _Realtime + +@Suite struct RealtimeClientTests { + static let testURL = URL(string: "ws://localhost:4000/realtime/v1")! + + @Test func connectSendsAuthHeaders() async throws { + let (transport, server) = InMemoryTransport.pair() + let realtime = Realtime( + url: Self.testURL, + apiKey: .literal("anon-key"), + transport: transport + ) + + try await realtime.connect() + + // Verify the transport was asked to connect (implicit in no throw) + // and the status transitions to connected + let snapshot = await realtime.currentStatus + #expect(snapshot == .connected) + } + + @Test func disconnectStopsHeartbeatAndClosesSocket() async throws { + let clock = TestClock() + let (transport, _) = InMemoryTransport.pair() + let config = Configuration { $0.clock = clock } + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), configuration: config, transport: transport) + + try await realtime.connect() + await realtime.disconnect() + + let snapshot = await realtime.currentStatus + #expect(snapshot == .closed(.userRequested)) + } + + @Test func channelSameTopicReturnsSameActor() async throws { + let (transport, _) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + + let ch1 = realtime.channel("room:1") + let ch2 = realtime.channel("room:1") + #expect(ch1 === ch2) + } + + @Test func channelDifferentTopicsReturnDifferentActors() async throws { + let (transport, _) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + + let ch1 = realtime.channel("room:1") + let ch2 = realtime.channel("room:2") + #expect(ch1 !== ch2) + } +} +``` + +- [ ] **Step 2: Run — expect compile failure (Realtime not defined)** + +```bash +cd Packages/_Realtime && swift test --filter RealtimeClientTests 2>&1 | head -10 +``` + +- [ ] **Step 3: Create `Client/ConnectionStatus.swift`** + +```swift +import Foundation + +public struct ConnectionStatus: Sendable, Equatable { + public enum State: Sendable, Equatable { + case idle + case connecting(attempt: Int) + case connected + case reconnecting(attempt: Int) + case closed(CloseReason) + } + public let state: State + public let since: Date + public let latency: Duration? + + public static func == (lhs: Self, rhs: Self) -> Bool { lhs.state == rhs.state } +} + +// Convenience equatable for tests +extension ConnectionStatus.State { + public static func == (lhs: Self, rhs: Self) -> Bool { + switch (lhs, rhs) { + case (.idle, .idle), (.connected, .connected): true + case (.connecting(let a), .connecting(let b)): a == b + case (.reconnecting(let a), .reconnecting(let b)): a == b + case (.closed(let a), .closed(let b)): a == b + default: false + } + } +} +``` + +- [ ] **Step 4: Create `Client/Realtime.swift`** + +```swift +import Clocks +import Foundation +import IssueReporting + +public final actor Realtime: Sendable { + private let url: URL + private let apiKey: APIKeySource + let configuration: Configuration + private let transport: any RealtimeTransport + + private var connection: (any RealtimeConnection)? + private var receiveTask: Task? + private var heartbeatTask: Task? + private var channelRegistry: [String: Channel] = [:] + private var pendingReplies: [String: CheckedContinuation] = [:] + private var refCounter: Int = 0 + private var statusContinuations: [UUID: AsyncStream.Continuation] = [:] + private var _currentStatus: ConnectionStatus.State = .idle + + // Exposed for tests + var currentStatus: ConnectionStatus.State { _currentStatus } + + public init( + url: URL, + apiKey: APIKeySource, + configuration: Configuration = .default, + transport: any RealtimeTransport = URLSessionTransport() + ) { + self.url = url + self.apiKey = apiKey + self.configuration = configuration + self.transport = transport + } + + // MARK: - Public API + + public var status: AsyncStream { + AsyncStream { continuation in + let id = UUID() + statusContinuations[id] = continuation + continuation.onTermination = { [id] _ in + Task { await self.statusContinuations.removeValue(forKey: id) } + } + } + } + + public func connect() async throws(RealtimeError) { + guard _currentStatus == .idle || _currentStatus == .closed(.userRequested) else { return } + setStatus(.connecting(attempt: 1)) + + let token: String + do { + token = try await resolveToken() + } catch { + throw .authenticationFailed(reason: error.localizedDescription, underlying: error as? (any Error & Sendable)) + } + + var headers = configuration.headers + headers["apikey"] = token + headers["Authorization"] = "Bearer \(token)" + headers["vsn"] = configuration.protocolVersion.rawValue + + let wsURL = buildWebSocketURL() + let conn: any RealtimeConnection + do { + conn = try await transport.connect(to: wsURL, headers: headers) + } catch { + setStatus(.closed(.transportFailure)) + throw .transportFailure(underlying: error as! (any Error & Sendable)) + } + self.connection = conn + setStatus(.connected) + startReceiving(conn) + startHeartbeat() + } + + public func disconnect() async { + receiveTask?.cancel(); receiveTask = nil + heartbeatTask?.cancel(); heartbeatTask = nil + await connection?.close(code: 1000, reason: "user requested") + connection = nil + setStatus(.closed(.userRequested)) + failAllPendingReplies(with: RealtimeError.disconnected) + } + + public func updateToken(_ newToken: String) async throws(RealtimeError) { + let msg = PhoenixMessage( + joinRef: nil, ref: nextRef(), + topic: "phoenix", event: "access_token", + payload: ["access_token": .string(newToken)] + ) + _ = try await sendAndAwait(msg, timeout: configuration.joinTimeout) + } + + public func channel(_ topic: String, configure: (inout ChannelOptions) -> Void = { _ in }) -> Channel { + if let existing = channelRegistry[topic] { return existing } + var options = ChannelOptions() + configure(&options) + let ch = Channel(topic: topic, options: options, realtime: self) + channelRegistry[topic] = ch + return ch + } + + // MARK: - Internal send API (used by Channel) + + func send(_ message: PhoenixMessage) async throws(RealtimeError) { + guard let connection else { throw .disconnected } + do { + let text = try PhoenixSerializer.encodeText(message) + try await connection.send(.text(text)) + } catch let e as RealtimeError { + throw e + } catch { + throw .transportFailure(underlying: error as! (any Error & Sendable)) + } + } + + func sendAndAwait(_ message: PhoenixMessage, timeout: Duration) async throws(RealtimeError) -> PhoenixMessage { + guard connection != nil else { throw .disconnected } + let ref = nextRef() + var tagged = message + tagged.ref = ref + + return try await withTimeout(timeout, clock: configuration.clock) { + try await withCheckedThrowingContinuation { continuation in + pendingReplies[ref] = continuation + Task { + do { + let text = try PhoenixSerializer.encodeText(tagged) + try await self.connection?.send(.text(text)) + } catch { + if let cont = self.pendingReplies.removeValue(forKey: ref) { + cont.resume(throwing: error) + } + } + } + } + } onTimeout: { + if let cont = pendingReplies.removeValue(forKey: ref) { + cont.resume(throwing: RealtimeError.channelJoinTimeout) + } + } + } + + func removeChannel(_ topic: String) { + channelRegistry.removeValue(forKey: topic) + } + + // MARK: - Private + + private func resolveToken() async throws -> String { + switch apiKey { + case .literal(let key): return key + case .dynamic(let fn): return try await fn() + } + } + + private func buildWebSocketURL() -> URL { + guard var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return url } + var items = comps.queryItems ?? [] + items.append(URLQueryItem(name: "vsn", value: configuration.protocolVersion.rawValue)) + comps.queryItems = items + return comps.url ?? url + } + + private func nextRef() -> String { + refCounter += 1 + return String(refCounter) + } + + private func setStatus(_ state: ConnectionStatus.State) { + _currentStatus = state + let status = ConnectionStatus(state: state, since: Date(), latency: nil) + for cont in statusContinuations.values { cont.yield(status) } + } + + private func startReceiving(_ conn: any RealtimeConnection) { + receiveTask = Task { [weak self] in + do { + for try await frame in conn.frames { + await self?.handle(frame) + } + } catch { + await self?.handleConnectionLoss(error: error) + } + } + } + + private func handle(_ frame: TransportFrame) async { + switch frame { + case .text(let text): + guard let msg = try? PhoenixSerializer.decodeText(text) else { return } + await route(msg) + case .binary(let data): + guard let broadcast = try? PhoenixSerializer.decodeBinary(data) else { return } + // Binary broadcasts go directly to the channel + if let ch = channelRegistry[broadcast.topic] { + await ch.handleBinaryBroadcast(broadcast) + } + } + } + + private func route(_ msg: PhoenixMessage) async { + // Heartbeat reply + if msg.topic == "phoenix", msg.event == "phx_reply" { + if let ref = msg.ref, let cont = pendingReplies.removeValue(forKey: ref) { + cont.resume(returning: msg) + } + return + } + // Pending reply + if msg.event == "phx_reply", let ref = msg.ref, + let cont = pendingReplies.removeValue(forKey: ref) { + cont.resume(returning: msg) + return + } + // Route to channel + if let ch = channelRegistry[msg.topic] { + await ch.handle(msg) + } + } + + private func handleConnectionLoss(error: any Error) async { + setStatus(.closed(.transportFailure)) + failAllPendingReplies(with: RealtimeError.disconnected) + // Notify all channels + for ch in channelRegistry.values { + await ch.handleConnectionLoss() + } + // Attempt reconnect + await attemptReconnect(lastError: error) + } + + private func attemptReconnect(lastError: any Error) async { + var attempt = 1 + while !Task.isCancelled { + guard let delay = configuration.reconnection.nextDelay(attempt, lastError as! (any Error & Sendable)) else { + setStatus(.closed(.transportFailure)) + return + } + setStatus(.reconnecting(attempt: attempt)) + try? await configuration.clock.sleep(for: delay) + guard !Task.isCancelled else { return } + do { + try await connect() + // Re-join all channels + for ch in channelRegistry.values { + try? await ch.rejoin() + } + return + } catch { + attempt += 1 + } + } + } + + private func startHeartbeat() { + heartbeatTask = Task { [weak self] in + guard let self else { return } + while !Task.isCancelled { + do { + try await configuration.clock.sleep(for: configuration.heartbeat) + let msg = PhoenixMessage(joinRef: nil, ref: nil, topic: "phoenix", event: "heartbeat", payload: [:]) + _ = try await sendAndAwait(msg, timeout: configuration.heartbeat) + } catch is CancellationError { + return + } catch { + // heartbeat failure handled by connection loss + } + } + } + } + + private func failAllPendingReplies(with error: RealtimeError) { + let replies = pendingReplies + pendingReplies.removeAll() + for cont in replies.values { + cont.resume(throwing: error) + } + } +} + +// MARK: - Timeout helper + +private func withTimeout( + _ duration: Duration, + clock: any Clock, + operation: @Sendable () async throws -> T, + onTimeout: @Sendable () -> Void +) async throws(RealtimeError) -> T { + try await withThrowingTaskGroup(of: T.self) { group in + group.addTask { try await operation() } + group.addTask { + try await clock.sleep(for: duration) + throw RealtimeError.channelJoinTimeout + } + do { + let result = try await group.next()! + group.cancelAll() + return result + } catch let e as RealtimeError { + onTimeout() + group.cancelAll() + throw e + } catch { + group.cancelAll() + throw .transportFailure(underlying: error as! (any Error & Sendable)) + } + } +} +``` + +- [ ] **Step 5: Run client tests** + +```bash +cd Packages/_Realtime && swift test --filter RealtimeClientTests +``` + +Expected: All 4 tests pass. + +- [ ] **Step 6: Commit** + +```bash +git add Packages/_Realtime/Sources/_Realtime/Client \ + Packages/_Realtime/Tests/_RealtimeTests/RealtimeClientTests.swift +git commit -m "feat(_Realtime): Phase 3 — Realtime actor with connect/disconnect/channel registry" +``` + +--- + +## Task 3: `Channel` actor (Phase 4) + +**Files:** +- Create: `Packages/_Realtime/Sources/_Realtime/Channel/ChannelState.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Channel/ChannelOptions.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Channel/Channel.swift` +- Create: `Packages/_Realtime/Tests/_RealtimeTests/ChannelTests.swift` + +- [ ] **Step 1: Write failing channel tests** + +```swift +import Testing +import ConcurrencyExtras +@testable import _Realtime + +@Suite struct ChannelTests { + static let testURL = URL(string: "ws://localhost:4000/realtime/v1")! + + @Test func joinTransitionsToJoined() async throws { + let (transport, server) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + + let channel = realtime.channel("room:1") + + // Server auto-replies to phx_join with ok + Task { + if let frame = await server.receive() { + let msg = try! PhoenixSerializer.decodeText(frame.text!) + let reply = PhoenixMessage( + joinRef: msg.joinRef, ref: msg.ref, + topic: msg.topic, event: "phx_reply", + payload: ["status": "ok", "response": [:]] + ) + await server.send(.text(try! PhoenixSerializer.encodeText(reply))) + } + } + + try await channel.join() + let state = await channel.currentState + #expect(state == .joined) + } + + @Test func sameTopicReturnsSameChannel() async throws { + let (transport, _) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + + let ch1 = realtime.channel("room:42") + let ch2 = realtime.channel("room:42") + #expect(ch1 === ch2) + } + + @Test func firstCallWinsOnOptions() async throws { + let (transport, _) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + + let ch1 = realtime.channel("room:1") { $0.isPrivate = true } + let ch2 = realtime.channel("room:1") { $0.isPrivate = false } + let opts = await ch1.options + #expect(opts.isPrivate == true) // first call wins + #expect(ch1 === ch2) + } + + @Test func leaveClosesAllStreams() async throws { + let (transport, server) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + + // Auto-reply helper + Task { + for await frame in server.receivedFrames { + guard let text = frame.text, + let msg = try? PhoenixSerializer.decodeText(text) else { continue } + if msg.event == "phx_join" || msg.event == "phx_leave" { + let reply = PhoenixMessage( + joinRef: msg.joinRef, ref: msg.ref, topic: msg.topic, + event: "phx_reply", payload: ["status": "ok", "response": [:]] + ) + await server.send(.text(try! PhoenixSerializer.encodeText(reply))) + } + } + } + + let channel = realtime.channel("room:1") + try await channel.join() + + // Collect a broadcast stream — it should close when leave() is called + let broadcasts = await channel.broadcasts() + var caughtError: RealtimeError? + + Task { + do { + for try await _ in broadcasts {} + } catch let e as RealtimeError { + caughtError = e + } + } + + try await channel.leave() + + try await Task.sleep(for: .milliseconds(100)) + #expect(caughtError == .channelClosed(.userRequested)) + } +} + +extension TransportFrame { + var text: String? { if case .text(let t) = self { t } else { nil } } +} +``` + +- [ ] **Step 2: Run — expect compile failure** + +```bash +cd Packages/_Realtime && swift test --filter ChannelTests 2>&1 | head -10 +``` + +- [ ] **Step 3: Create `Channel/ChannelState.swift`** + +```swift +public enum ChannelState: Sendable, Equatable { + case unsubscribed + case joining + case joined + case leaving + case closed(CloseReason) +} +``` + +- [ ] **Step 4: Create `Channel/ChannelOptions.swift`** + +```swift +import Foundation + +public struct ChannelOptions: Sendable { + public var isPrivate: Bool = false + public var broadcast: BroadcastOptions = .init() + public var presenceKey: String? = nil +} + +public struct BroadcastOptions: Sendable { + public var acknowledge: Bool = false + public var receiveOwnBroadcasts: Bool = false + public var replay: ReplayOption? = nil +} + +public struct ReplayOption: Sendable { + public var since: Date + public var limit: Int? + public init(since: Date, limit: Int? = nil) { + self.since = since; self.limit = limit + } +} +``` + +- [ ] **Step 5: Create `Channel/Channel.swift`** + +```swift +import Foundation +import IssueReporting + +public final actor Channel: Sendable { + public let topic: String + public private(set) var options: ChannelOptions + private weak var realtime: Realtime? + + private var _state: ChannelState = .unsubscribed + var currentState: ChannelState { _state } + + // Fan-out continuations — populated by Broadcast/Presence/Postgres extensions + var broadcastContinuations: [UUID: AsyncThrowingStream<[String: JSONValue], RealtimeError>.Continuation] = [:] + var presenceContinuations: [UUID: AsyncStream<[String: JSONValue]>.Continuation] = [:] + var postgresContinuations: [UUID: AsyncThrowingStream<[String: JSONValue], RealtimeError>.Continuation] = [:] + var stateContinuations: [UUID: AsyncStream.Continuation] = [:] + + // Track re-join state + private var joinRef: String? + private var optionsLocked = false + + init(topic: String, options: ChannelOptions, realtime: Realtime) { + self.topic = topic + self.options = options + self.realtime = realtime + } + + // MARK: - Public API + + public var state: AsyncStream { + AsyncStream { continuation in + let id = UUID() + stateContinuations[id] = continuation + continuation.yield(_state) + continuation.onTermination = { [id] _ in + Task { await self.stateContinuations.removeValue(forKey: id) } + } + } + } + + public func join() async throws(RealtimeError) { + guard _state == .unsubscribed || _state == .closed(.userRequested) else { return } + optionsLocked = true + try await _join() + } + + public func leave() async throws(RealtimeError) { + guard _state == .joined || _state == .joining else { return } + setState(.leaving) + guard let realtime else { throw .disconnected } + let ref = await realtime.nextRef() + let msg = PhoenixMessage( + joinRef: joinRef, ref: ref, + topic: topic, event: "phx_leave", payload: [:] + ) + _ = try await realtime.sendAndAwait(msg, timeout: realtime.configuration.leaveTimeout) + setState(.closed(.userRequested)) + finishAllContinuations(throwing: .channelClosed(.userRequested)) + await realtime.removeChannel(topic) + } + + // MARK: - Internal routing (called by Realtime) + + func handle(_ msg: PhoenixMessage) async { + switch msg.event { + case "phx_close": + setState(.closed(.serverClosed(code: 0, message: nil))) + finishAllContinuations(throwing: .channelClosed(.serverClosed(code: 0, message: nil))) + case "phx_error": + let reason = msg.payload["reason"].flatMap { if case .string(let s) = $0 { s } else { nil } } ?? "unknown" + setState(.closed(.policyViolation(reason))) + finishAllContinuations(throwing: .channelClosed(.policyViolation(reason))) + case "broadcast": + for cont in broadcastContinuations.values { cont.yield(msg.payload) } + case "presence_diff": + for cont in presenceContinuations.values { cont.yield(msg.payload) } + case "presence_state": + for cont in presenceContinuations.values { cont.yield(msg.payload) } + case "postgres_changes": + for cont in postgresContinuations.values { cont.yield(msg.payload) } + default: + break + } + } + + func handleBinaryBroadcast(_ broadcast: BinaryBroadcast) async { + let payload: [String: JSONValue] + switch broadcast.payload { + case .json(let obj): payload = obj + case .binary: return // binary payloads not delivered to typed continuations + } + for cont in broadcastContinuations.values { cont.yield(payload) } + } + + func handleConnectionLoss() async { + // Streams pause silently during reconnection — no sentinel values + // state transitions to reflect loss + if _state == .joined { setState(.unsubscribed) } + } + + func rejoin() async throws(RealtimeError) { + guard _state == .unsubscribed else { return } + try await _join() + } + + // MARK: - Private + + private func _join() async throws(RealtimeError) { + guard let realtime else { throw .disconnected } + setState(.joining) + let ref = await realtime.nextRef() + joinRef = ref + + let joinPayload = buildJoinPayload() + let msg = PhoenixMessage( + joinRef: ref, ref: ref, + topic: topic, event: "phx_join", + payload: joinPayload + ) + let reply = try await realtime.sendAndAwait(msg, timeout: realtime.configuration.joinTimeout) + let status = reply.payload["status"].flatMap { if case .string(let s) = $0 { s } else { nil } } + if status == "ok" { + setState(.joined) + } else { + let reason = reply.payload["response"].flatMap { if case .object(let o) = $0 { o["reason"] } else { nil } }.flatMap { if case .string(let s) = $0 { s } else { nil } } ?? "unknown" + setState(.closed(.policyViolation(reason))) + throw .channelJoinRejected(reason: reason) + } + } + + private func buildJoinPayload() -> [String: JSONValue] { + var config: [String: JSONValue] = [:] + + if options.broadcast.acknowledge || options.broadcast.receiveOwnBroadcasts || options.broadcast.replay != nil { + var bc: [String: JSONValue] = [:] + if options.broadcast.acknowledge { bc["ack"] = true } + if options.broadcast.receiveOwnBroadcasts { bc["self"] = true } + if let replay = options.broadcast.replay { + bc["replay"] = .object([ + "since": .int(Int(replay.since.timeIntervalSince1970 * 1000)), + "limit": replay.limit.map { .int($0) } ?? .null, + ]) + } + config["broadcast"] = .object(bc) + } + + if let key = options.presenceKey { + config["presence"] = .object(["key": .string(key)]) + } + + if options.isPrivate { + config["private"] = true + } + + return ["config": .object(config)] + } + + private func setState(_ new: ChannelState) { + _state = new + for cont in stateContinuations.values { cont.yield(new) } + } + + private func finishAllContinuations(throwing error: RealtimeError) { + for cont in broadcastContinuations.values { cont.finish(throwing: error) } + for cont in postgresContinuations.values { cont.finish(throwing: error) } + for cont in presenceContinuations.values { cont.finish() } + for cont in stateContinuations.values { cont.finish() } + broadcastContinuations.removeAll() + postgresContinuations.removeAll() + presenceContinuations.removeAll() + stateContinuations.removeAll() + } +} + +// Expose nextRef from Realtime for Channel's use +extension Realtime { + func nextRef() -> String { + refCounter += 1 + return String(refCounter) + } +} +``` + +Note: `InMemoryServer.receivedFrames` needs to be exposed for the leave test. Add to `InMemoryServer` in `Testing/InMemoryTransport.swift`: + +```swift +public var receivedFrames: AsyncThrowingStream { _receivedFrames } +``` + +Rename the stored property to `_receivedFrames` and update the `receive()` helper accordingly. + +- [ ] **Step 6: Run channel tests** + +```bash +cd Packages/_Realtime && swift test --filter ChannelTests +``` + +Expected: All 4 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add Packages/_Realtime/Sources/_Realtime/Channel \ + Packages/_Realtime/Tests/_RealtimeTests/ChannelTests.swift +git commit -m "feat(_Realtime): Phase 4 — Channel actor with join/leave lifecycle" +``` diff --git a/docs/superpowers/plans/2026-04-24-realtime-v3-phases-5-7.md b/docs/superpowers/plans/2026-04-24-realtime-v3-phases-5-7.md new file mode 100644 index 000000000..9e692d1eb --- /dev/null +++ b/docs/superpowers/plans/2026-04-24-realtime-v3-phases-5-7.md @@ -0,0 +1,1156 @@ +# Realtime v3 — Phase 5, 6 & 7 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement broadcast (Phase 5), presence (Phase 6), and Postgres changes (Phase 7) on top of the Channel actor from Phase 4. + +**Architecture:** Each feature adds an `AsyncThrowingStream`-based fan-out API to `Channel` via extension files. The `Channel` actor owns the continuation dictionaries; extensions register/unregister continuations and deliver messages. All streams auto-join on first iteration. `Filter` uses `KeyPath`-based compile-time column name resolution; `RealtimeTable` is a protocol with manual conformance. + +**Prerequisite:** Phases 1–4 complete and committed. + +--- + +## Task 1: Broadcast (Phase 5) + +**Files:** +- Create: `Packages/_Realtime/Sources/_Realtime/Broadcast/BroadcastMessage.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Broadcast/Channel+Broadcast.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Broadcast/Realtime+HTTP.swift` +- Create: `Packages/_Realtime/Tests/_RealtimeTests/BroadcastTests.swift` + +- [ ] **Step 1: Write failing broadcast tests** + +```swift +import Testing +import ConcurrencyExtras +@testable import _Realtime + +@Suite struct BroadcastTests { + static let testURL = URL(string: "ws://localhost:4000/realtime/v1")! + + // Helper: connect + auto-reply to phx_join + func makeConnectedRealtime() async throws -> (Realtime, InMemoryServer) { + let (transport, server) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + + Task { + for await frame in server.receivedFrames { + guard let text = frame.text, + let msg = try? PhoenixSerializer.decodeText(text), + msg.event == "phx_join" else { continue } + let reply = PhoenixMessage( + joinRef: msg.joinRef, ref: msg.ref, topic: msg.topic, + event: "phx_reply", payload: ["status": "ok", "response": .object([:]) + ]) + await server.send(.text(try! PhoenixSerializer.encodeText(reply))) + } + } + return (realtime, server) + } + + @Test func broadcastDeliveredToTypedStream() async throws { + struct Msg: Decodable, Sendable { let text: String } + let (realtime, server) = try await makeConnectedRealtime() + let channel = realtime.channel("room:1") + + let stream = await channel.broadcasts(of: Msg.self, event: "chat") + var received: [Msg] = [] + + Task { + for try await msg in stream { received.append(msg) } + } + + // Wait for join + try await Task.sleep(for: .milliseconds(50)) + + // Server pushes a broadcast + let payload: [String: JSONValue] = ["event": "chat", "payload": .object(["text": "hello"]), "type": "broadcast"] + let push = PhoenixMessage(joinRef: nil, ref: nil, topic: "room:1", event: "broadcast", payload: payload) + await server.send(.text(try! PhoenixSerializer.encodeText(push))) + + try await Task.sleep(for: .milliseconds(50)) + #expect(received.count == 1) + #expect(received.first?.text == "hello") + } + + @Test func broadcastFanoutToMultipleSubscribers() async throws { + let (realtime, server) = try await makeConnectedRealtime() + let channel = realtime.channel("room:2") + + let s1 = await channel.broadcasts() + let s2 = await channel.broadcasts() + var count1 = 0; var count2 = 0 + + Task { for try await _ in s1 { count1 += 1 } } + Task { for try await _ in s2 { count2 += 1 } } + + try await Task.sleep(for: .milliseconds(50)) + + let payload: [String: JSONValue] = ["event": "e", "payload": .object([:]), "type": "broadcast"] + let push = PhoenixMessage(joinRef: nil, ref: nil, topic: "room:2", event: "broadcast", payload: payload) + await server.send(.text(try! PhoenixSerializer.encodeText(push))) + + try await Task.sleep(for: .milliseconds(50)) + #expect(count1 == 1) + #expect(count2 == 1) + } + + @Test func broadcastThrowsChannelNotJoinedIfNotJoined() async throws { + let (transport, _) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + let channel = realtime.channel("room:3") + + struct Msg: Encodable, Sendable { let x: Int } + do { + try await channel.broadcast(Msg(x: 1), as: "event") + Issue.record("Expected channelNotJoined") + } catch RealtimeError.channelNotJoined { + // expected + } + } +} +``` + +- [ ] **Step 2: Run — expect compile failure** + +```bash +cd Packages/_Realtime && swift test --filter BroadcastTests 2>&1 | head -10 +``` + +- [ ] **Step 3: Create `Broadcast/BroadcastMessage.swift`** + +```swift +import Foundation + +public struct BroadcastMessage: Sendable { + public let event: String + public let payload: JSONValue + public let receivedAt: Date +} +``` + +- [ ] **Step 4: Create `Broadcast/Channel+Broadcast.swift`** + +```swift +import Foundation + +extension Channel { + /// Typed event stream — decodes each broadcast payload to `T`. Auto-joins on first iteration. + public func broadcasts( + of type: T.Type = T.self, + event: String, + decoder: JSONDecoder? = nil + ) -> AsyncThrowingStream { + let raw = broadcasts() + let dec = decoder ?? JSONDecoder() + return AsyncThrowingStream { continuation in + Task { + do { + for try await msg in raw { + guard let eventVal = msg.payload["event"], + case .string(let evtName) = eventVal, + evtName == event else { continue } + guard let payloadVal = msg.payload["payload"], + case .object(let obj) = payloadVal else { continue } + do { + let data = try JSONEncoder().encode(obj) + let decoded = try dec.decode(T.self, from: data) + continuation.yield(decoded) + } catch { + continuation.finish(throwing: .decoding(type: String(describing: T.self), underlying: error as! (any Error & Sendable))) + return + } + } + continuation.finish() + } catch let e as RealtimeError { + continuation.finish(throwing: e) + } + } + } + } + + /// Untyped stream — raw payloads for all broadcast events. Auto-joins on first iteration. + public func broadcasts() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let id = UUID() + Task { + // Register continuation on the actor + await registerBroadcastContinuation(id: id, continuation: continuation) + continuation.onTermination = { [id] _ in + Task { await self.broadcastContinuations.removeValue(forKey: id) } + } + // Auto-join + do { try await joinIfNeeded() } + catch let e as RealtimeError { continuation.finish(throwing: e) } + } + } + } + + // Internal — called by Realtime when routing incoming broadcast frames + func deliverBroadcast(_ payload: [String: JSONValue]) { + let msg = BroadcastMessage( + event: (payload["event"].flatMap { if case .string(let s) = $0 { s } else { nil } }) ?? "", + payload: payload["payload"] ?? .null, + receivedAt: Date() + ) + for cont in broadcastContinuations.values { cont.yield(msg) } + } + + /// Send a typed broadcast. Throws `.channelNotJoined` if not joined. + public func broadcast(_ payload: T, as event: String) async throws(RealtimeError) { + guard currentState == .joined else { throw .channelNotJoined } + guard let realtime else { throw .disconnected } + let encoder = await realtime.configuration.encoder + let data: Data + do { data = try encoder.encode(payload) } + catch { throw .encoding(underlying: error as! (any Error & Sendable)) } + guard let obj = try? JSONDecoder().decode([String: JSONValue].self, from: data) else { + throw .encoding(underlying: EncodingError.invalidValue(payload, .init(codingPath: [], debugDescription: "Not a JSON object"))) + } + let msg = PhoenixMessage( + joinRef: joinRef, ref: nil, + topic: topic, event: "broadcast", + payload: ["event": .string(event), "payload": .object(obj)] + ) + if await realtime.configuration.broadcast.acknowledge { + _ = try await realtime.sendAndAwait(msg, timeout: await realtime.configuration.broadcastAckTimeout) + } else { + try await realtime.send(msg) + } + } + + /// Send raw `Data` as a binary broadcast frame. Throws `.channelNotJoined` if not joined. + public func broadcast(_ data: Data, as event: String) async throws(RealtimeError) { + guard currentState == .joined else { throw .channelNotJoined } + guard let realtime else { throw .disconnected } + let frame = try { + do { return try PhoenixSerializer.encodeBroadcastPush(joinRef: joinRef, ref: nil, topic: topic, event: event, binaryPayload: data) } + catch { throw RealtimeError.encoding(underlying: error as! (any Error & Sendable)) } + }() + try await realtime.sendBinary(frame) + } + + // MARK: - Private helpers + + private func registerBroadcastContinuation( + id: UUID, + continuation: AsyncThrowingStream.Continuation + ) { + broadcastContinuations[id] = continuation + } + + private func joinIfNeeded() async throws(RealtimeError) { + if currentState == .unsubscribed { try await join() } + } +} + +// Expose sendBinary on Realtime +extension Realtime { + func sendBinary(_ data: Data) async throws(RealtimeError) { + guard let connection else { throw .disconnected } + do { try await connection.send(.binary(data)) } + catch { throw .transportFailure(underlying: error as! (any Error & Sendable)) } + } +} +``` + +Update `Channel.handle(_:)` in `Channel.swift` to call `deliverBroadcast` instead of iterating directly: + +```swift +case "broadcast": + deliverBroadcast(msg.payload) +``` + +- [ ] **Step 5: Create `Broadcast/Realtime+HTTP.swift`** + +```swift +import Foundation + +extension Realtime { + /// One-shot broadcast via HTTP. Does not open the WebSocket. + public func httpBroadcast( + topic: String, + event: String, + payload: T, + isPrivate: Bool = false + ) async throws(RealtimeError) { + let msg = HttpBroadcastMessage(topic: topic, event: event, payload: payload, isPrivate: isPrivate) + try await httpBroadcast([msg]) + } + + /// Batch HTTP broadcast. + public func httpBroadcast(_ messages: [HttpBroadcastMessage]) async throws(RealtimeError) { + let token: String + do { token = try await resolveToken() } + catch { throw .authenticationFailed(reason: error.localizedDescription, underlying: nil) } + + var httpURL = url + // Replace ws(s):// with http(s):// + if var comps = URLComponents(url: url, resolvingAgainstBaseURL: false) { + comps.scheme = comps.scheme == "wss" ? "https" : "http" + comps.path = "/realtime/v1/api/broadcast" + comps.queryItems = nil + httpURL = comps.url ?? url + } + + let body: [[String: JSONValue]] = messages.map { m in + var entry: [String: JSONValue] = [ + "topic": .string(m.topic), + "event": .string(m.event), + ] + if m.isPrivate { entry["private"] = true } + // Encode payload to JSON object + if let data = try? configuration.encoder.encode(m.payload), + let obj = try? JSONDecoder().decode([String: JSONValue].self, from: data) { + entry["payload"] = .object(obj) + } + return entry + } + + guard let bodyData = try? JSONEncoder().encode(["messages": body]) else { + throw .encoding(underlying: EncodingError.invalidValue(messages, .init(codingPath: [], debugDescription: "Encoding failed"))) + } + + var request = URLRequest(url: httpURL) + request.httpMethod = "POST" + request.httpBody = bodyData + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue(token, forHTTPHeaderField: "apikey") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + + let (_, response): (Data, URLResponse) + do { + (_, response) = try await URLSession.shared.data(for: request) + } catch { + throw .transportFailure(underlying: error as! (any Error & Sendable)) + } + + if let http = response as? HTTPURLResponse, !(200..<300).contains(http.statusCode) { + if http.statusCode == 429 { + throw .rateLimited(retryAfter: nil) + } + throw .serverError(code: http.statusCode, message: HTTPURLResponse.localizedString(forStatusCode: http.statusCode)) + } + } +} + +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 + } +} +``` + +- [ ] **Step 6: Run broadcast tests** + +```bash +cd Packages/_Realtime && swift test --filter BroadcastTests +``` + +Expected: All 3 tests pass. + +- [ ] **Step 7: Commit** + +```bash +git add Packages/_Realtime/Sources/_Realtime/Broadcast \ + Packages/_Realtime/Tests/_RealtimeTests/BroadcastTests.swift +git commit -m "feat(_Realtime): Phase 5 — broadcast streams + HTTP one-shot send" +``` + +--- + +## Task 2: Presence (Phase 6) + +**Files:** +- Create: `Packages/_Realtime/Sources/_Realtime/Presence/PresenceHandle.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Presence/PresenceState.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Presence/Presence.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Presence/Channel+Presence.swift` +- Create: `Packages/_Realtime/Tests/_RealtimeTests/PresenceTests.swift` + +- [ ] **Step 1: Write failing presence tests** + +```swift +import Testing +import IssueReporting +@testable import _Realtime + +@Suite struct PresenceTests { + static let testURL = URL(string: "ws://localhost:4000/realtime/v1")! + + func makeConnectedChannel(topic: String = "room:1") async throws -> (Channel, InMemoryServer) { + let (transport, server) = InMemoryTransport.pair() + let realtime = Realtime(url: Self.testURL, apiKey: .literal("key"), transport: transport) + try await realtime.connect() + Task { + for await frame in server.receivedFrames { + guard let text = frame.text, let msg = try? PhoenixSerializer.decodeText(text) else { continue } + if msg.event == "phx_join" || msg.event == "presence" { + let reply = PhoenixMessage(joinRef: msg.joinRef, ref: msg.ref, topic: msg.topic, event: "phx_reply", payload: ["status": "ok", "response": .object([:])]) + await server.send(.text(try! PhoenixSerializer.encodeText(reply))) + } + } + } + return (realtime.channel(topic), server) + } + + @Test func trackSendsPresenceEvent() async throws { + struct State: Codable, Sendable { let userId: String } + let (channel, server) = try await makeConnectedChannel() + try await channel.join() + + let handle = try await channel.presence.track(State(userId: "u1")) + #expect(handle != nil) + + // Server receives a presence push + let frame = await server.receive() + #expect(frame?.text?.contains("presence") == true) + try await handle.cancel() + } + + @Test func observeDeliversPresenceSnapshot() async throws { + struct UserState: Decodable, Sendable { let name: String } + let (channel, server) = try await makeConnectedChannel() + try await channel.join() + + let states = await channel.presence.observe(UserState.self) + var snapshots: [PresenceState] = [] + Task { for await s in states { snapshots.append(s) } } + + // Server pushes presence_state + let presenceMsg = PhoenixMessage( + joinRef: nil, ref: nil, topic: "room:1", event: "presence_state", + payload: ["alice": .object(["metas": .array([.object(["name": "Alice"])])])] + ) + await server.send(.text(try! PhoenixSerializer.encodeText(presenceMsg))) + + try await Task.sleep(for: .milliseconds(50)) + #expect(!snapshots.isEmpty) + } +} +``` + +- [ ] **Step 2: Run — expect compile failure** + +```bash +cd Packages/_Realtime && swift test --filter PresenceTests 2>&1 | head -10 +``` + +- [ ] **Step 3: Create `Presence/PresenceState.swift`** + +```swift +import Foundation + +public typealias PresenceKey = String + +public struct PresenceState: Sendable { + /// All currently tracked presences keyed by presence key; each key may have multiple metas. + public let active: [PresenceKey: [T]] + /// The diff that produced this snapshot (nil on first emission). + public let lastDiff: PresenceDiff? +} + +public struct PresenceDiff: Sendable { + public let joined: [(PresenceKey, T)] + public let left: [(PresenceKey, T)] +} +``` + +- [ ] **Step 4: Create `Presence/PresenceHandle.swift`** + +```swift +import Foundation +import IssueReporting + +public final class PresenceHandle: Sendable { + private let _cancel: @Sendable () async throws(RealtimeError) -> Void + private let id: UUID = UUID() + private let isCancelled = _Atomic(false) + + init(cancel: @escaping @Sendable () async throws(RealtimeError) -> Void) { + self._cancel = cancel + } + + deinit { + if !isCancelled.value { + reportIssue("PresenceHandle deinited without cancel() — presence was not untracked. Call cancel() when done.") + } + } + + /// Idempotent. Untracks this presence meta. Awaits server ACK. + public func cancel() async throws(RealtimeError) { + guard !isCancelled.exchange(true) else { return } + try await _cancel() + } +} + +/// Thread-safe boolean for use in non-isolated contexts. +private final class _Atomic: @unchecked Sendable { + private var _value: T + private let lock = NSLock() + init(_ value: T) { _value = value } + var value: T { lock.withLock { _value } } + @discardableResult func exchange(_ newValue: T) -> T { + lock.withLock { let old = _value; _value = newValue; return old } + } +} +``` + +- [ ] **Step 5: Create `Presence/Presence.swift`** + +```swift +import Foundation + +public struct Presence: Sendable { + private let channel: Channel + + init(channel: Channel) { self.channel = channel } + + /// Begin tracking state for this client. Returns a handle; call `handle.cancel()` to untrack. + public func track(_ state: T) async throws(RealtimeError) -> PresenceHandle { + guard let realtime = await channel.realtime else { throw .disconnected } + let data: Data + do { data = try await realtime.configuration.encoder.encode(state) } + catch { throw .encoding(underlying: error as! (any Error & Sendable)) } + guard let obj = try? JSONDecoder().decode([String: JSONValue].self, from: data) else { + throw .encoding(underlying: EncodingError.invalidValue(state, .init(codingPath: [], debugDescription: "Not a JSON object"))) + } + let topic = await channel.topic + let joinRef = await channel.joinRef + let trackMsg = PhoenixMessage( + joinRef: joinRef, ref: nil, + topic: topic, event: "presence", + payload: ["event": "track", "payload": .object(obj)] + ) + _ = try await realtime.sendAndAwait(trackMsg, timeout: realtime.configuration.joinTimeout) + + // Store state for auto re-track on reconnect + let trackId = UUID() + await channel.registerTrack(id: trackId, state: obj) + + return PresenceHandle { + await channel.unregisterTrack(id: trackId) + let untrackMsg = PhoenixMessage( + joinRef: joinRef, ref: nil, + topic: topic, event: "presence", + payload: ["event": "untrack"] + ) + _ = try await realtime.sendAndAwait(untrackMsg, timeout: realtime.configuration.joinTimeout) + } + } + + /// Snapshot + diff stream of all presences in the channel. + public func observe(_ type: T.Type = T.self) -> AsyncStream> { + AsyncStream { continuation in + let id = UUID() + Task { + await channel.registerPresenceContinuation(id: id, onSnapshot: { raw in + let state = decodePresenceState(raw, as: T.self) + continuation.yield(state) + }) + continuation.onTermination = { [id] _ in + Task { await channel.unregisterPresenceContinuation(id: id) } + } + do { try await channel.joinIfNeeded() } + catch { continuation.finish() } + } + } + } + + /// Incremental diffs only. + public func diffs(_ type: T.Type = T.self) -> AsyncStream> { + AsyncStream { continuation in + let id = UUID() + Task { + await channel.registerPresenceDiffContinuation(id: id, onDiff: { raw in + let diff = decodePresenceDiff(raw, as: T.self) + continuation.yield(diff) + }) + continuation.onTermination = { [id] _ in + Task { await channel.unregisterPresenceDiffContinuation(id: id) } + } + do { try await channel.joinIfNeeded() } + catch { continuation.finish() } + } + } + } +} + +// MARK: - Decoding helpers + +private func decodePresenceState(_ raw: [String: JSONValue], as type: T.Type) -> PresenceState { + var active: [PresenceKey: [T]] = [:] + for (key, val) in raw { + guard case .object(let entry) = val, + case .array(let metas) = entry["metas"] else { continue } + active[key] = metas.compactMap { metaVal -> T? in + guard case .object(let metaObj) = metaVal, + let data = try? JSONEncoder().encode(metaObj), + let decoded = try? JSONDecoder().decode(T.self, from: data) else { return nil } + return decoded + } + } + return PresenceState(active: active, lastDiff: nil) +} + +private func decodePresenceDiff(_ raw: [String: JSONValue], as type: T.Type) -> PresenceDiff { + func extractEntries(_ val: JSONValue?) -> [(PresenceKey, T)] { + guard case .object(let dict) = val else { return [] } + return dict.flatMap { key, entry -> [(PresenceKey, T)] in + guard case .object(let entryObj) = entry, + case .array(let metas) = entryObj["metas"] else { return [] } + return metas.compactMap { metaVal -> (PresenceKey, T)? in + guard case .object(let metaObj) = metaVal, + let data = try? JSONEncoder().encode(metaObj), + let decoded = try? JSONDecoder().decode(T.self, from: data) else { return nil } + return (key, decoded) + } + } + } + return PresenceDiff(joined: extractEntries(raw["joins"]), left: extractEntries(raw["leaves"])) +} +``` + +- [ ] **Step 6: Create `Presence/Channel+Presence.swift`** + +```swift +import Foundation + +extension Channel { + public var presence: Presence { Presence(channel: self) } + + // Track registry for auto re-track on reconnect + private(set) var trackedStates: [UUID: [String: JSONValue]] = [:] + typealias PresenceSnapshotHandler = @Sendable ([String: JSONValue]) -> Void + typealias PresenceDiffHandler = @Sendable ([String: JSONValue]) -> Void + var presenceSnapshotHandlers: [UUID: PresenceSnapshotHandler] = [:] + var presenceDiffHandlers: [UUID: PresenceDiffHandler] = [:] + + func registerTrack(id: UUID, state: [String: JSONValue]) { + trackedStates[id] = state + } + + func unregisterTrack(id: UUID) { + trackedStates.removeValue(forKey: id) + } + + func registerPresenceContinuation(id: UUID, onSnapshot: @escaping PresenceSnapshotHandler) { + presenceSnapshotHandlers[id] = onSnapshot + } + + func unregisterPresenceContinuation(id: UUID) { + presenceSnapshotHandlers.removeValue(forKey: id) + } + + func registerPresenceDiffContinuation(id: UUID, onDiff: @escaping PresenceDiffHandler) { + presenceDiffHandlers[id] = onDiff + } + + func unregisterPresenceDiffContinuation(id: UUID) { + presenceDiffHandlers.removeValue(forKey: id) + } + + func joinIfNeeded() async throws(RealtimeError) { + if currentState == .unsubscribed { try await join() } + } +} +``` + +Update `Channel.handle(_:)` in `Channel.swift` to route presence events: + +```swift +case "presence_state": + for handler in presenceSnapshotHandlers.values { handler(msg.payload) } +case "presence_diff": + for handler in presenceDiffHandlers.values { handler(msg.payload) } +``` + +- [ ] **Step 7: Run presence tests** + +```bash +cd Packages/_Realtime && swift test --filter PresenceTests +``` + +Expected: All 2 tests pass. + +- [ ] **Step 8: Commit** + +```bash +git add Packages/_Realtime/Sources/_Realtime/Presence \ + Packages/_Realtime/Tests/_RealtimeTests/PresenceTests.swift +git commit -m "feat(_Realtime): Phase 6 — presence track/observe/diffs with auto re-track" +``` + +--- + +## Task 3: Postgres Changes (Phase 7) + +**Files:** +- Create: `Packages/_Realtime/Sources/_Realtime/Postgres/RealtimeTable.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Postgres/Filter.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Postgres/PostgresChange.swift` +- Create: `Packages/_Realtime/Sources/_Realtime/Postgres/Channel+Postgres.swift` +- Create: `Packages/_Realtime/Tests/_RealtimeTests/PostgresChangesTests.swift` + +- [ ] **Step 1: Write failing postgres tests** + +```swift +import Testing +@testable import _Realtime + +@Suite struct PostgresChangesTests { + + @Test func filterEqEncodesCorrectly() { + struct User: RealtimeTable { + static let schema = "public" + static let tableName = "users" + static func columnName(for kp: KeyPath) -> String { + switch kp { + case \Self.id: return "id" + case \Self.name: return "name" + default: return "" + } + } + var id: UUID + var name: String + } + let filter = Filter.eq(\User.id, UUID(uuidString: "00000000-0000-0000-0000-000000000001")!) + #expect(filter.wireValue == "id=eq.00000000-0000-0000-0000-000000000001") + } + + @Test func filterInEncodesCorrectly() { + struct Item: RealtimeTable { + static let schema = "public" + static let tableName = "items" + static func columnName(for kp: KeyPath) -> String { "status" } + var status: String + } + let filter = Filter.in(\Item.status, ["active", "pending"]) + #expect(filter.wireValue == "status=in.(active,pending)") + } + + @Test func untypedFilterEncodesCorrectly() { + let filter = UntypedFilter.eq("room_id", "abc-123") + #expect(filter.wireValue == "room_id=eq.abc-123") + } + + @Test func postgresChangeDecodesInsert() throws { + struct Message: Codable, Sendable { + let id: Int + let text: String + } + + let payload: [String: JSONValue] = [ + "data": .object([ + "type": "INSERT", + "record": .object(["id": .int(1), "text": .string("hello")]), + "old_record": .null, + "columns": .array([]), + "commit_timestamp": .string("2026-01-01T00:00:00Z"), + ]), + "ids": .array([.int(1)]) + ] + + let change = try PostgresChange.decode(from: payload) + if case .insert(let row) = change { + #expect(row.id == 1) + #expect(row.text == "hello") + } else { + Issue.record("Expected .insert, got \(change)") + } + } +} +``` + +- [ ] **Step 2: Run — expect compile failure** + +```bash +cd Packages/_Realtime && swift test --filter PostgresChangesTests 2>&1 | head -10 +``` + +- [ ] **Step 3: Create `Postgres/RealtimeTable.swift`** + +```swift +/// Conform your table model to `RealtimeTable` to use typed `Filter` in postgres change streams. +/// +/// When you own the type, use the `@RealtimeTable` macro (Phase 8) to synthesize this conformance. +/// For types you don't own, write the conformance manually: +/// ```swift +/// extension ExternalType: RealtimeTable { +/// static let schema = "public" +/// static let tableName = "widgets" +/// static func columnName(for kp: KeyPath) -> String { +/// switch kp { +/// case \Self.id: return "id" +/// default: fatalError("Unknown key path") +/// } +/// } +/// } +/// ``` +public protocol RealtimeTable { + static var schema: String { get } + static var tableName: String { get } + static func columnName(for keyPath: KeyPath) -> String +} + +/// Value types usable in filter predicates. +public protocol RealtimeFilterValue { + var filterString: String { get } +} + +extension String: RealtimeFilterValue { public var filterString: String { self } } +extension Int: RealtimeFilterValue { public var filterString: String { String(self) } } +extension Double: RealtimeFilterValue { public var filterString: String { String(self) } } +extension Bool: RealtimeFilterValue { public var filterString: String { String(self) } } +extension UUID: RealtimeFilterValue { public var filterString: String { uuidString.lowercased() } } +``` + +- [ ] **Step 4: Create `Postgres/Filter.swift`** + +```swift +/// Typed filter for postgres_changes subscriptions. One clause per subscription (wire constraint). +/// +/// Use static factories with `KeyPath` — the value type is compile-time checked: +/// ```swift +/// .eq(\Message.roomId, roomId) // roomId: UUID — correct +/// .eq(\Message.roomId, 42) // compile error — Int != UUID +/// ``` +public struct Filter: Sendable { + let wireValue: String + + public static func eq(_ kp: KeyPath, _ v: V) -> Filter { + Filter(wireValue: "\(T.columnName(for: kp))=eq.\(v.filterString)") + } + public static func neq(_ kp: KeyPath, _ v: V) -> Filter { + Filter(wireValue: "\(T.columnName(for: kp))=neq.\(v.filterString)") + } + public static func gt(_ kp: KeyPath, _ v: V) -> Filter { + Filter(wireValue: "\(T.columnName(for: kp))=gt.\(v.filterString)") + } + public static func gte(_ kp: KeyPath, _ v: V) -> Filter { + Filter(wireValue: "\(T.columnName(for: kp))=gte.\(v.filterString)") + } + public static func lt(_ kp: KeyPath, _ v: V) -> Filter { + Filter(wireValue: "\(T.columnName(for: kp))=lt.\(v.filterString)") + } + public static func lte(_ kp: KeyPath, _ v: V) -> Filter { + Filter(wireValue: "\(T.columnName(for: kp))=lte.\(v.filterString)") + } + public static func `in`(_ kp: KeyPath, _ values: [V]) -> Filter { + let list = values.map(\.filterString).joined(separator: ",") + return Filter(wireValue: "\(T.columnName(for: kp))=in.(\(list))") + } +} + +/// Untyped filter for types that don't conform to `RealtimeTable`. +public struct UntypedFilter: Sendable { + let wireValue: String + let column: String + + public static func eq(_ column: String, _ value: any RealtimeFilterValue) -> UntypedFilter { + UntypedFilter(wireValue: "\(column)=eq.\(value.filterString)", column: column) + } + public static func neq(_ column: String, _ value: any RealtimeFilterValue) -> UntypedFilter { + UntypedFilter(wireValue: "\(column)=neq.\(value.filterString)", column: column) + } + public static func gt(_ column: String, _ value: any RealtimeFilterValue) -> UntypedFilter { + UntypedFilter(wireValue: "\(column)=gt.\(value.filterString)", column: column) + } + public static func gte(_ column: String, _ value: any RealtimeFilterValue) -> UntypedFilter { + UntypedFilter(wireValue: "\(column)=gte.\(value.filterString)", column: column) + } + public static func lt(_ column: String, _ value: any RealtimeFilterValue) -> UntypedFilter { + UntypedFilter(wireValue: "\(column)=lt.\(value.filterString)", column: column) + } + public static func lte(_ column: String, _ value: any RealtimeFilterValue) -> UntypedFilter { + UntypedFilter(wireValue: "\(column)=lte.\(value.filterString)", column: column) + } + public static func `in`(_ column: String, _ values: [any RealtimeFilterValue]) -> UntypedFilter { + let list = values.map(\.filterString).joined(separator: ",") + return UntypedFilter(wireValue: "\(column)=in.(\(list))", column: column) + } +} +``` + +- [ ] **Step 5: Create `Postgres/PostgresChange.swift`** + +```swift +import Foundation + +/// A decoded postgres_changes event. +public enum PostgresChange: Sendable { + case insert(T) + case update(old: T, new: T) + case delete(old: T) + + static func decode(from payload: [String: JSONValue]) throws -> PostgresChange + where T: Decodable + { + guard case .object(let data) = payload["data"] else { + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing 'data' key")) + } + guard case .string(let type) = data["type"] else { + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Missing 'type'")) + } + + func decodeRecord(_ key: String) throws -> T { + guard case .object(let obj) = data[key] else { + throw DecodingError.keyNotFound( + CodingUserInfoKey(rawValue: key)!, + .init(codingPath: [], debugDescription: "Missing '\(key)'") + ) + } + let recordData = try JSONEncoder().encode(obj) + return try JSONDecoder().decode(T.self, from: recordData) + } + + switch type { + case "INSERT": + return .insert(try decodeRecord("record")) + case "UPDATE": + return .update(old: try decodeRecord("old_record"), new: try decodeRecord("record")) + case "DELETE": + return .delete(old: try decodeRecord("old_record")) + default: + throw DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Unknown type: \(type)")) + } + } +} +``` + +- [ ] **Step 6: Create `Postgres/Channel+Postgres.swift`** + +```swift +import Foundation + +extension Channel { + // MARK: Typed streams + + public func changes( + to type: T.Type = T.self, + where filter: Filter? = nil, + decoder: JSONDecoder? = nil + ) -> AsyncThrowingStream, RealtimeError> { + let subscriptionId = UUID() + let dec = decoder ?? JSONDecoder() + return AsyncThrowingStream { continuation in + let id = UUID() + Task { + await registerPostgresContinuation(id: id) { payload in + do { + let change = try PostgresChange.decode(from: payload) + continuation.yield(change) + } catch { + continuation.finish(throwing: .decoding(type: String(describing: T.self), underlying: error as! (any Error & Sendable))) + } + } + continuation.onTermination = { [id] _ in + Task { await self.unregisterPostgresContinuation(id: id) } + } + do { try await joinWithPostgresFilter( + schema: T.schema, table: T.tableName, + filter: filter?.wireValue, + subscriptionId: subscriptionId + ) } + catch let e as RealtimeError { continuation.finish(throwing: e) } + } + } + } + + public func inserts( + into type: T.Type = T.self, + where filter: Filter? = nil + ) -> AsyncThrowingStream { + let raw = changes(to: T.self, where: filter) + return AsyncThrowingStream { continuation in + Task { + do { + for try await change in raw { + if case .insert(let row) = change { continuation.yield(row) } + } + continuation.finish() + } catch let e as RealtimeError { continuation.finish(throwing: e) } + } + } + } + + public func updates( + of type: T.Type = T.self, + where filter: Filter? = nil + ) -> AsyncThrowingStream<(old: T, new: T), RealtimeError> { + let raw = changes(to: T.self, where: filter) + return AsyncThrowingStream { continuation in + Task { + do { + for try await change in raw { + if case .update(let old, let new) = change { continuation.yield((old, new)) } + } + continuation.finish() + } catch let e as RealtimeError { continuation.finish(throwing: e) } + } + } + } + + public func deletes( + from type: T.Type = T.self, + where filter: Filter? = nil + ) -> AsyncThrowingStream { + let raw = changes(to: T.self, where: filter) + return AsyncThrowingStream { continuation in + Task { + do { + for try await change in raw { + if case .delete(let old) = change { continuation.yield(old) } + } + continuation.finish() + } catch let e as RealtimeError { continuation.finish(throwing: e) } + } + } + } + + // MARK: Untyped escape hatch + + public func changes( + schema: String = "public", + table: String, + filter: UntypedFilter? = nil, + decoder: JSONDecoder? = nil + ) -> AsyncThrowingStream, RealtimeError> { + let subscriptionId = UUID() + return AsyncThrowingStream { continuation in + let id = UUID() + Task { + await registerPostgresContinuation(id: id) { payload in + do { + let change = try PostgresChange<[String: JSONValue]>.decode(from: payload) + continuation.yield(change) + } catch { + continuation.finish(throwing: .decoding(type: "JSONValue", underlying: error as! (any Error & Sendable))) + } + } + continuation.onTermination = { [id] _ in + Task { await self.unregisterPostgresContinuation(id: id) } + } + do { try await joinWithPostgresFilter( + schema: schema, table: table, + filter: filter?.wireValue, + subscriptionId: subscriptionId + ) } + catch let e as RealtimeError { continuation.finish(throwing: e) } + } + } + } + + // MARK: - Internal registration + + typealias PostgresHandler = @Sendable ([String: JSONValue]) -> Void + var postgresHandlers: [UUID: PostgresHandler] = [:] + + func registerPostgresContinuation(id: UUID, handler: @escaping PostgresHandler) { + postgresHandlers[id] = handler + } + + func unregisterPostgresContinuation(id: UUID) { + postgresHandlers.removeValue(forKey: id) + } + + // Delivers a postgres_changes payload to all handlers + func deliverPostgresChange(_ payload: [String: JSONValue]) { + for handler in postgresHandlers.values { handler(payload) } + } + + // Includes postgres_changes config in the join payload + private func joinWithPostgresFilter( + schema: String, table: String, + filter: String?, subscriptionId: UUID + ) async throws(RealtimeError) { + // Store subscription so it's re-sent on rejoin + await addPostgresSubscription(PostgresSubscription( + id: subscriptionId, schema: schema, table: table, filter: filter + )) + if currentState == .unsubscribed { try await join() } + } +} + +// MARK: - Postgres subscription registry (stored on Channel) +struct PostgresSubscription: Sendable { + let id: UUID + let schema: String + let table: String + let filter: String? +} + +extension Channel { + var postgresSubscriptions: [UUID: PostgresSubscription] { + get { _postgresSubscriptions } + set { _postgresSubscriptions = newValue } + } + // Backing storage — Swift actor stored properties can't use lazy/computed in extensions + // Store in a wrapper on the actor + func addPostgresSubscription(_ sub: PostgresSubscription) { + _postgresSubscriptions[sub.id] = sub + } +} +``` + +Note: `_postgresSubscriptions` needs to be a stored property on `Channel`. Add to `Channel.swift`: + +```swift +// Inside Channel actor body (add to stored properties): +var _postgresSubscriptions: [UUID: PostgresSubscription] = [:] +``` + +And update `buildJoinPayload()` in `Channel.swift` to include postgres_changes: + +```swift +let changes: [JSONValue] = _postgresSubscriptions.values.map { sub in + var entry: [String: JSONValue] = [ + "event": "*", + "schema": .string(sub.schema), + "table": .string(sub.table), + ] + if let f = sub.filter { entry["filter"] = .string(f) } + return .object(entry) +} +if !changes.isEmpty { + config["postgres_changes"] = .array(changes) +} +``` + +Update `Channel.handle(_:)` to deliver postgres changes: + +```swift +case "postgres_changes": + deliverPostgresChange(msg.payload) +``` + +- [ ] **Step 7: Run postgres tests** + +```bash +cd Packages/_Realtime && swift test --filter PostgresChangesTests +``` + +Expected: All 4 tests pass. + +- [ ] **Step 8: Run full test suite** + +```bash +cd Packages/_Realtime && swift test +``` + +Expected: All tests pass, 0 failures. + +- [ ] **Step 9: Commit** + +```bash +git add Packages/_Realtime/Sources/_Realtime/Postgres \ + Packages/_Realtime/Tests/_RealtimeTests/PostgresChangesTests.swift +git commit -m "feat(_Realtime): Phase 7 — Postgres changes with typed Filter and untyped escape hatch" +``` diff --git a/docs/superpowers/specs/2026-04-24-realtime-v3-design.md b/docs/superpowers/specs/2026-04-24-realtime-v3-design.md new file mode 100644 index 000000000..3c64fbe59 --- /dev/null +++ b/docs/superpowers/specs/2026-04-24-realtime-v3-design.md @@ -0,0 +1,353 @@ +# Realtime v3 — Implementation Design + +**Date:** 2026-04-24 +**RFC:** [Linear project](https://linear.app/supabase/project/realtime-v3-idiomatic-swift-api-rfc-044c5935314f/overview) +**Full API spec:** [Linear document](https://linear.app/supabase/document/realtime-v3-full-api-specification-a825f8ba2f42) +**Status:** Approved for implementation + +--- + +## Context + +Greenfield redesign of the Realtime module in `supabase-swift`. The RFC spec is the source of truth for the public API surface. This document covers the implementation strategy: module structure, phasing, actor architecture, and testing approach. Backend assumptions from the RFC are treated as confirmed. + +--- + +## Key Decisions + +| Decision | Choice | Rationale | +|----------|--------|-----------| +| Module delivery | Separate Swift package `Packages/_Realtime/` during dev; folded into main package at release | Lets the new package target Swift 6.0+ / iOS 17+ without bumping the main package floor prematurely | +| Temporary module name | `_Realtime` | Underscore signals unstable/transitional; renamed to `Realtime` at release | +| Platform floor | iOS 17+, macOS 14+, tvOS 17+, watchOS 10+, visionOS 1+ | Required for `@Observable` user integration; typed throws and actors are compiler-level | +| Swift language mode | Swift 6 (`swiftLanguageMode(.v6)`) | Enables typed throws (`throws(RealtimeError)`), strict concurrency | +| Phasing approach | 8 phases, linear, single coordinated release | Test infrastructure (Phase 2) lands early so all feature phases have deterministic tests | +| Release trigger | All 8 phases complete and fully functional | No partial releases | + +--- + +## Module Structure + +``` +supabase-swift/ +├── Sources/ ← existing, untouched during development +├── Tests/ ← existing, untouched during development +├── Packages/ +│ └── _Realtime/ ← new standalone Swift package +│ ├── Package.swift — swift-tools-version: 6.0, iOS 17+ +│ ├── Sources/ +│ │ └── _Realtime/ +│ │ ├── Error/ +│ │ ├── Transport/ +│ │ ├── Config/ +│ │ ├── Client/ +│ │ ├── Channel/ +│ │ ├── Broadcast/ +│ │ ├── Presence/ +│ │ ├── Postgres/ +│ │ ├── Macros/ +│ │ └── Internal/ — vendored wire protocol (Phoenix message decoding) +│ └── Tests/ +│ └── _RealtimeTests/ +``` + +The main `supabase-swift` `Package.swift` adds a local dependency on `Packages/_Realtime` so the `Supabase` target and integration tests can import `_Realtime` without publishing. + +At release: +- `Packages/_Realtime/Sources/_Realtime/` moves to `Sources/_Realtime/` (or `Sources/Realtime/` after old module is retired) +- `Packages/_Realtime/Tests/` moves to `Tests/_RealtimeTests/` +- `Packages/_Realtime/` is deleted +- Main `Package.swift` bumps to Swift 6.0+, iOS 17+, removes local package dependency, adds `_Realtime` target directly + +--- + +## Package.swift for `Packages/_Realtime/` + +```swift +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "_Realtime", + platforms: [ + .iOS(.v17), + .macOS(.v14), + .tvOS(.v17), + .watchOS(.v10), + .visionOS(.v1), + ], + products: [ + .library(name: "_Realtime", targets: ["_Realtime"]), + ], + dependencies: [ + .package(url: "https://github.com/pointfreeco/swift-clocks", from: "1.0.0"), + .package(url: "https://github.com/pointfreeco/swift-concurrency-extras", from: "1.1.0"), + .package(url: "https://github.com/pointfreeco/xctest-dynamic-overlay", from: "1.2.2"), + .package(url: "https://github.com/pointfreeco/swift-custom-dump", from: "1.3.2"), + .package(url: "https://github.com/pointfreeco/swift-snapshot-testing", from: "1.17.0"), + ], + targets: [ + .target( + name: "_Realtime", + dependencies: [ + .product(name: "Clocks", package: "swift-clocks"), + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), + ] + ), + .testTarget( + name: "_RealtimeTests", + dependencies: [ + .product(name: "CustomDump", package: "swift-custom-dump"), + .product(name: "InlineSnapshotTesting", package: "swift-snapshot-testing"), + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras"), + "_Realtime", + ] + ), + ] +) +``` + +--- + +## Phase Breakdown + +### Phase 1 — Foundation + +No actor, no connection. Pure types. + +``` +Sources/_Realtime/ + Error/ + RealtimeError.swift — flat RealtimeError enum (all cases from §7 of spec), CloseReason + RealtimeLogger.swift — RealtimeLogger protocol, LogEvent, LogLevel, Category + Transport/ + RealtimeTransport.swift — RealtimeTransport + RealtimeConnection protocols, TransportFrame enum + connect(to:headers:) uses plain URL + [String: String]; no HTTPTypes + URLSessionTransport.swift — production URLSession/URLSessionWebSocketTask-based implementation + Config/ + APIKeySource.swift — APIKeySource enum: .literal(String), .dynamic(@Sendable () async throws -> String) + ReconnectionPolicy.swift — ReconnectionPolicy struct + .never, .exponentialBackoff, .fixed factories + Configuration.swift — Configuration struct: heartbeat, joinTimeout, leaveTimeout, broadcastAckTimeout, + reconnection, disconnectOnEmptyChannelsAfter, handleAppLifecycle, + protocolVersion, clock, headers: [String: String], logger, decoder, encoder +``` + +### Phase 2 — Test Infrastructure + +Enables deterministic tests for all subsequent phases. + +``` +Sources/_Realtime/ + Testing/ + InMemoryTransport.swift — InMemoryTransport: RealtimeTransport; pair() -> (client, server) + InMemoryConnection.swift — InMemoryConnection: RealtimeConnection; server-side send + frame stream + +Tests/_RealtimeTests/ + TransportTests.swift — frames flow client→server and server→client correctly +``` + +`InMemoryTransport.pair()` is a public API in the main `_Realtime` target (not a separate test-helpers target) so callers can use it in their own tests — matching the RFC's intent. + +### Phase 3 — `Realtime` Actor + +``` +Sources/_Realtime/ + Client/ + ConnectionStatus.swift — ConnectionStatus struct + State enum (idle, connecting, connected, + reconnecting, closed) + Realtime.swift — Realtime actor: init, connect(), disconnect(), status stream, + updateToken(_:), channel(_:configure:), httpBroadcast + HeartbeatManager.swift — sends phx_heartbeat on interval, tracks RTT, fires on missed beats + ReconnectManager.swift — applies ReconnectionPolicy, drives reconnect loop with clock + +Tests/_RealtimeTests/ + RealtimeClientTests.swift — connect/disconnect, reconnect policy, heartbeat timeout, token rotation +``` + +### Phase 4 — `Channel` Actor + +``` +Sources/_Realtime/ + Channel/ + ChannelState.swift — ChannelState enum: unsubscribed, joining, joined, leaving, closed(CloseReason) + ChannelOptions.swift — ChannelOptions, BroadcastOptions, ReplayOption + Channel.swift — Channel actor: join(), leave(), state stream, options (first-call-wins lock) + ChannelRegistry.swift — [String: Channel] cache inside Realtime; topic identity guarantee + +Tests/_RealtimeTests/ + ChannelTests.swift — join/leave, duplicate-topic identity, first-call-wins options, + global leave tears down all streams with .channelClosed(.userRequested) +``` + +### Phase 5 — Broadcast + +``` +Sources/_Realtime/ + Broadcast/ + BroadcastMessage.swift — BroadcastMessage: event, payload: JSONValue, receivedAt: Date + Channel+Broadcast.swift — broadcasts(of:event:) -> AsyncThrowingStream + broadcasts() -> AsyncThrowingStream + broadcast(_:as:) async throws(RealtimeError) + broadcast(_data:as:) async throws(RealtimeError) + Broadcast/ + Realtime+HTTP.swift — httpBroadcast(topic:event:payload:isPrivate:) + httpBroadcast(_:[HttpBroadcastMessage]) batch form + HttpBroadcastMessage struct + +Tests/_RealtimeTests/ + BroadcastTests.swift — delivery, fan-out to N subscribers, typed decode, HTTP path, + .channelNotJoined when not joined, .disconnected during outage +``` + +### Phase 6 — Presence + +``` +Sources/_Realtime/ + Presence/ + PresenceHandle.swift — PresenceHandle class: cancel() async throws(RealtimeError) + debug IssueReporting warning on deinit without cancel + PresenceState.swift — PresenceState, PresenceDiff, PresenceKey typealias + Presence.swift — Presence struct: track(_:) -> PresenceHandle, observe(_:), diffs(_:) + auto re-track on reconnect; tracks last state per live handle + Channel+Presence.swift — channel.presence computed property + +Tests/_RealtimeTests/ + PresenceTests.swift — track/untrack, snapshot + diffs, auto re-track on rejoin, + multi-track (multiple metas per key), handle leak warning +``` + +### Phase 7 — Postgres Changes + +``` +Sources/_Realtime/ + Postgres/ + RealtimeTable.swift — RealtimeTable protocol: schema, tableName, columnName(for:) + Filter.swift — Filter struct with static factories: eq, neq, gt, gte, + lt, lte, in; UntypedFilter for escape hatch; wire encoding + PostgresChange.swift — PostgresChange enum: insert(T), update(old: T, new: T), delete(old: T) + internal decoding from Phoenix payload + Channel+Postgres.swift — changes(to:where:) -> AsyncThrowingStream, RealtimeError> + inserts(into:where:), updates(of:where:), deletes(from:where:) convenience + changes(schema:table:filter:) untyped overload -> PostgresChange + +Tests/_RealtimeTests/ + PostgresChangesTests.swift — filter wire encoding, typed changes stream, untyped escape hatch, + per-event convenience streams, insert/update/delete decoding +``` + +### Phase 8 — Macro + Integration + +``` +Packages/_RealtimeTableMacros/ — new standalone macro package + Package.swift — declares macro target, depends on swift-syntax + Sources/ + _RealtimeTableMacroPlugin/ + RealtimeTableMacro.swift — @RealtimeTable macro implementation: + synthesizes RealtimeTable conformance, + schema/tableName static properties, + columnName(for:) honoring CodingKeys + +Sources/_Realtime/ + Macros/ + RealtimeTable+Macro.swift — @attached(extension) macro declaration + +Sources/Supabase/ — main package (existing) + SupabaseClient+Realtime.swift — exposes realtime: Realtime property on SupabaseClient + +Tests/IntegrationTests/ — main package (existing) + RealtimeV3IntegrationTests.swift — real socket tests against local Supabase instance + +docs/migrations/RealtimeV3 Migration Guide.md +``` + +--- + +## Actor Architecture + +### Topology + +``` +Realtime (actor) + ├── WebSocket connection (via RealtimeTransport / RealtimeConnection) + ├── HeartbeatManager + ├── ReconnectManager + ├── ChannelRegistry: [topic: String → Channel actor] + └── Channel (actor) — one per unique topic + ├── Join/leave state machine + ├── broadcastContinuations: [UUID → Continuation] + ├── presenceContinuations: [UUID → Continuation] + └── postgresContinuations: [UUID → Continuation] +``` + +`Realtime` receives raw `TransportFrame`s from `RealtimeConnection.frames`, decodes them using vendored Phoenix message parsing (from `Sources/_Realtime/Internal/`), and routes to the correct `Channel` by topic. Each `Channel` fans out to its registered continuations. + +### Stream Fan-out Pattern + +All three feature streams (broadcast, presence, postgres) use the same pattern inside the `Channel` actor: + +```swift +// Registering a subscriber +func broadcasts() -> AsyncThrowingStream { + AsyncThrowingStream { continuation in + let id = UUID() + broadcastContinuations[id] = continuation + continuation.onTermination = { [id] _ in + Task { await self.broadcastContinuations.removeValue(forKey: id) } + } + Task { try await joinIfNeeded() } // auto-join on first subscriber + } +} + +// Routing an incoming message +func deliver(_ msg: BroadcastMessage) { + for cont in broadcastContinuations.values { cont.yield(msg) } +} +``` + +Typed streams (`broadcasts(of: T.self, event:)`) wrap the untyped stream, filtering by event name and decoding with `JSONDecoder`, surfacing decode failures as `RealtimeError.decoding(type:underlying:)`. + +### Leave Semantics + +`channel.leave()` after server ACK calls `.finish(throwing: RealtimeError.channelClosed(.userRequested))` on every live continuation (broadcast, presence, postgres), then removes the channel from `ChannelRegistry`. All active `for await` loops on that channel see the error immediately. + +### Phoenix Wire Protocol + +The existing Phoenix message encoding/decoding from `Sources/Realtime/RealtimeSerializer.swift` and `RealtimeMessageV2.swift` is vendored into `Sources/_Realtime/Internal/` (copied, not imported). This avoids a cross-target dependency on the old `Realtime` module. + +--- + +## Testing Strategy + +### Primary tool: `InMemoryTransport.pair()` + +```swift +let clock = TestClock() +let (transport, server) = InMemoryTransport.pair() +let realtime = Realtime( + url: testURL, apiKey: .literal("key"), + configuration: .init(clock: clock), + transport: transport +) +``` + +The `server` side exposes: +- `nextFrame() async -> TransportFrame` — awaits the next frame the client sends +- `send(_ frame: TransportFrame) async` — pushes a frame to the client's `.frames` stream +- `close(code:reason:)` — simulates server-initiated close + +### Coverage per Phase + +| Phase | Focus | +|-------|-------| +| 2 | Frames flow bidirectionally through InMemoryTransport | +| 3 | Connect, disconnect, reconnect with policy, heartbeat timeout via TestClock, token rotation retry | +| 4 | Join/leave ACK, duplicate-topic identity, first-call-wins options + debug warning, global leave | +| 5 | Broadcast delivery, N-subscriber fan-out, typed decode error, HTTP path, channelNotJoined | +| 6 | Track/untrack ACK, snapshot + incremental diffs, auto re-track on rejoin, multi-meta, leak warning | +| 7 | Filter wire strings, typed + untyped change streams, insert/update/delete payload decoding | +| 8 | @RealtimeTable macro expansion (compile-time), columnName(for:) with CodingKeys | + +Snapshot testing via `InlineSnapshotTesting` for Phoenix message encoding/decoding, matching the existing project convention. + +Integration tests (real socket, local Supabase) land in Phase 8 inside `Tests/IntegrationTests/` of the main package.