From 5fdb9e1c92283aae15577417404cee85ea202884 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 25 Jun 2025 16:43:32 -0300 Subject: [PATCH 1/8] Update signature of handleObjectSyncProtocolMessage The accompanying ably-cocoa change requires it. --- Sources/AblyLiveObjects/DefaultRealtimeObjects.swift | 2 +- Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift | 2 +- ably-cocoa | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index f96cb0e6..03ef9981 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -74,7 +74,7 @@ internal class DefaultRealtimeObjects: RealtimeObjects { receivedObjectSyncProtocolMessages } - internal func handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial _: String) { + internal func handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial _: String?) { receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) } diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 61b316a4..fcf975a9 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -109,7 +109,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte ) } - internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], protocolMessageChannelSerial: String, channel: ARTRealtimeChannel) { + internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], protocolMessageChannelSerial: String?, channel: ARTRealtimeChannel) { guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") } diff --git a/ably-cocoa b/ably-cocoa index 9b2bfe48..4a21e87f 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 9b2bfe48b4c8c070404957758869b8918e8eade3 +Subproject commit 4a21e87f988be4741f19d74f13cc4c040eb92121 From c53d880e23f87d3cbaffa8f3c49fafce70d1323d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 26 Jun 2025 11:38:02 -0300 Subject: [PATCH 2/8] Add a logger to use in tests --- .../Helpers/TestLogger.swift | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift b/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift new file mode 100644 index 00000000..59c51e17 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift @@ -0,0 +1,40 @@ +import AblyPlugin +import os + +/// An implementation of `AblyPlugin.Logger` to use when testing internal components of the LiveObjects plugin. +final class TestLogger: NSObject, AblyPlugin.Logger { + // By default, we don’t log in tests to keep the test logs easy to read. You can set this property to `true` to temporarily turn logging on if you want to debug a test. + static let loggingEnabled = false + + private let underlyingLogger = os.Logger() + + func log(_ message: String, with level: ARTLogLevel, file fileName: UnsafePointer, line: Int) { + guard Self.loggingEnabled else { + return + } + + underlyingLogger.log(level: level.toOSLogType, "(\(String(cString: fileName)):\(line)): \(message)") + } +} + +private extension ARTLogLevel { + var toOSLogType: OSLogType { + // Not much thought has gone into this conversion + switch self { + case .verbose: + .debug + case .debug: + .debug + case .info: + .info + case .warn: + .error + case .error: + .error + case .none: + .debug + @unknown default: + .debug + } + } +} From 7d7a5632a3e53abf3a49adcd4d1377fcb884d84a Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 27 Jun 2025 11:13:17 -0300 Subject: [PATCH 3/8] Add concurrency annotations to the public API Mark most of its types as Sendable, and callbacks as `sending`. I was going to defer this until later but when I started writing integration tests I realised that it's quite hard to actually use the SDK without any sort of concurrency annotations (many basic things that you try and do with it trigger a compiler error). I haven't added annotations to the BatchContext things yet (because they never leave the callback so may not need it); will address that once we need to test those APIs. Marking things as Sendable is consistent with the ably-cocoa public API, which I think is going to the approach that we take threading-wise (but will revisit in #3). --- .../DefaultRealtimeObjects.swift | 25 ++++++++--- .../AblyLiveObjects/Public/PublicTypes.swift | 44 +++++++++---------- Sources/AblyLiveObjects/Utility/WeakRef.swift | 8 ++++ 3 files changed, 48 insertions(+), 29 deletions(-) create mode 100644 Sources/AblyLiveObjects/Utility/WeakRef.swift diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 03ef9981..bcf1aacc 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -2,8 +2,11 @@ import Ably internal import AblyPlugin /// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. -internal class DefaultRealtimeObjects: RealtimeObjects { - private weak var channel: ARTRealtimeChannel? +internal final class DefaultRealtimeObjects: RealtimeObjects { + // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. + private let mutex = NSLock() + + private let channel: WeakRef private let logger: AblyPlugin.Logger private let pluginAPI: AblyPlugin.PluginAPIProtocol @@ -14,7 +17,7 @@ internal class DefaultRealtimeObjects: RealtimeObjects { private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation internal init(channel: ARTRealtimeChannel, logger: AblyPlugin.Logger, pluginAPI: AblyPlugin.PluginAPIProtocol) { - self.channel = channel + self.channel = .init(referenced: channel) self.logger = logger self.pluginAPI = pluginAPI (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() @@ -43,7 +46,7 @@ internal class DefaultRealtimeObjects: RealtimeObjects { notYetImplemented() } - internal func batch(callback _: (any BatchContext) -> Void) async throws { + internal func batch(callback _: sending (sending any BatchContext) -> Void) async throws { notYetImplemented() } @@ -57,9 +60,17 @@ internal class DefaultRealtimeObjects: RealtimeObjects { // MARK: Handling channel events - internal private(set) var testsOnly_onChannelAttachedHasObjects: Bool? + private nonisolated(unsafe) var onChannelAttachedHasObjects: Bool? + internal var testsOnly_onChannelAttachedHasObjects: Bool? { + mutex.withLock { + onChannelAttachedHasObjects + } + } + internal func onChannelAttached(hasObjects: Bool) { - testsOnly_onChannelAttachedHasObjects = hasObjects + mutex.withLock { + onChannelAttachedHasObjects = hasObjects + } } internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { @@ -82,7 +93,7 @@ internal class DefaultRealtimeObjects: RealtimeObjects { // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { - guard let channel else { + guard let channel = channel.referenced else { return } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 149536b2..15690e7d 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -3,7 +3,7 @@ import Ably /// A callback used in ``LiveObject`` to listen for updates to the object. /// /// - Parameter update: The update object describing the changes made to the object. -public typealias LiveObjectUpdateCallback = (_ update: T) -> Void +public typealias LiveObjectUpdateCallback = (_ update: sending T) -> Void /// The callback used for the events emitted by ``RealtimeObjects``. public typealias ObjectsEventCallback = () -> Void @@ -14,10 +14,10 @@ public typealias LiveObjectLifecycleEventCallback = () -> Void /// A function passed to ``RealtimeObjects/batch(callback:)`` to group multiple Objects operations into a single channel message. /// /// - Parameter batchContext: A ``BatchContext`` object that allows grouping Objects operations for this batch. -public typealias BatchCallback = (_ batchContext: BatchContext) -> Void +public typealias BatchCallback = (_ batchContext: sending BatchContext) -> Void /// Describes the events emitted by an ``RealtimeObjects`` object. -public enum ObjectsEvent { +public enum ObjectsEvent: Sendable { /// The local copy of Objects on a channel is currently being synchronized with the Ably service. case syncing /// The local copy of Objects on a channel has been synchronized with the Ably service. @@ -25,13 +25,13 @@ public enum ObjectsEvent { } /// Describes the events emitted by a ``LiveObject`` object. -public enum LiveObjectLifecycleEvent { +public enum LiveObjectLifecycleEvent: Sendable { /// Indicates that the object has been deleted from the Objects pool and should no longer be interacted with. case deleted } /// Enables the Objects to be read, modified and subscribed to for a channel. -public protocol RealtimeObjects { +public protocol RealtimeObjects: Sendable { /// Retrieves the root ``LiveMap`` object for Objects on a channel. func getRoot() async throws(ARTErrorInfo) -> any LiveMap @@ -61,7 +61,7 @@ public protocol RealtimeObjects { /// when the batched operations are applied by the Ably service and echoed back to the client. /// /// - Parameter callback: A batch callback function used to group operations together. - func batch(callback: BatchCallback) async throws + func batch(callback: sending BatchCallback) async throws /// Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. /// @@ -69,7 +69,7 @@ public protocol RealtimeObjects { /// - event: The named event to listen for. /// - callback: The event listener. /// - Returns: An ``OnObjectsEventResponse`` object that allows the provided listener to be deregistered from future updates. - func on(event: ObjectsEvent, callback: ObjectsEventCallback) -> OnObjectsEventResponse + func on(event: ObjectsEvent, callback: sending ObjectsEventCallback) -> OnObjectsEventResponse /// Deregisters all registrations, for all events and listeners. func offAll() @@ -77,20 +77,20 @@ public protocol RealtimeObjects { /// Represents the type of data stored for a given key in a ``LiveMap``. /// It may be a primitive value (``PrimitiveObjectValue``), or another ``LiveObject``. -public enum LiveMapValue { +public enum LiveMapValue: Sendable { case primitive(PrimitiveObjectValue) case liveMap(any LiveMap) case liveCounter(any LiveCounter) } /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. -public protocol OnObjectsEventResponse { +public protocol OnObjectsEventResponse: Sendable { /// Deregisters the listener passed to the `on` call. func off() } /// Enables grouping multiple Objects operations together by providing `BatchContext*` wrapper objects. -public protocol BatchContext { +public protocol BatchContext: Sendable { /// Mirrors the ``RealtimeObjects/getRoot()`` method and returns a ``BatchContextLiveMap`` wrapper for the root object on a channel. /// /// - Returns: A ``BatchContextLiveMap`` object. @@ -98,7 +98,7 @@ public protocol BatchContext { } /// A wrapper around the ``LiveMap`` object that enables batching operations inside a ``BatchCallback``. -public protocol BatchContextLiveMap: AnyObject { +public protocol BatchContextLiveMap: AnyObject, Sendable { /// Mirrors the ``LiveMap/get(key:)`` method and returns the value associated with a key in the map. /// /// - Parameter key: The key to retrieve the value for. @@ -130,7 +130,7 @@ public protocol BatchContextLiveMap: AnyObject { } /// A wrapper around the ``LiveCounter`` object that enables batching operations inside a ``BatchCallback``. -public protocol BatchContextLiveCounter: AnyObject { +public protocol BatchContextLiveCounter: AnyObject, Sendable { /// Returns the current value of the counter. var value: Int { get } @@ -197,7 +197,7 @@ public protocol LiveMap: LiveObject where Update == LiveMapUpdate { } /// Describes whether an entry in ``LiveMapUpdate/update`` represents an update or a removal. -public enum LiveMapUpdateAction { +public enum LiveMapUpdateAction: Sendable { /// The value of a key in the map was updated. case updated /// The value of a key in the map was removed. @@ -205,7 +205,7 @@ public enum LiveMapUpdateAction { } /// Represents an update to a ``LiveMap`` object, describing the keys that were updated or removed. -public protocol LiveMapUpdate { +public protocol LiveMapUpdate: Sendable { /// An object containing keys from a `LiveMap` that have changed, along with their change status: /// - ``LiveMapUpdateAction/updated`` - the value of a key in the map was updated. /// - ``LiveMapUpdateAction/removed`` - the key was removed from the map. @@ -213,7 +213,7 @@ public protocol LiveMapUpdate { } /// Represents a primitive value that can be stored in a ``LiveMap``. -public enum PrimitiveObjectValue { +public enum PrimitiveObjectValue: Sendable { case string(String) case number(Double) case bool(Bool) @@ -241,13 +241,13 @@ public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { } /// Represents an update to a ``LiveCounter`` object. -public protocol LiveCounterUpdate { +public protocol LiveCounterUpdate: Sendable { /// Holds the numerical change to the counter value. var amount: Int { get } } /// Describes the common interface for all conflict-free data structures supported by the Objects. -public protocol LiveObject: AnyObject { +public protocol LiveObject: AnyObject, Sendable { /// The type of update event that this object emits. associatedtype Update @@ -255,7 +255,7 @@ public protocol LiveObject: AnyObject { /// /// - Parameter listener: An event listener function that is called with an update object whenever this LiveObject is updated. /// - Returns: A ``SubscribeResponse`` object that allows the provided listener to be deregistered from future updates. - func subscribe(listener: LiveObjectUpdateCallback) -> SubscribeResponse + func subscribe(listener: sending LiveObjectUpdateCallback) -> SubscribeResponse /// Deregisters all listeners from updates for this LiveObject. func unsubscribeAll() @@ -266,27 +266,27 @@ public protocol LiveObject: AnyObject { /// - event: The named event to listen for. /// - callback: The event listener. /// - Returns: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to be deregistered from future updates. - func on(event: LiveObjectLifecycleEvent, callback: LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse + func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse /// Removes all registrations that match both the specified listener and the specified event. /// /// - Parameters: /// - event: The named event. /// - callback: The event listener. - func off(event: LiveObjectLifecycleEvent, callback: LiveObjectLifecycleEventCallback) + func off(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) /// Deregisters all registrations, for all events and listeners. func offAll() } /// Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. -public protocol SubscribeResponse { +public protocol SubscribeResponse: Sendable { /// Deregisters the listener passed to the `subscribe` call. func unsubscribe() } /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. -public protocol OnLiveObjectLifecycleEventResponse { +public protocol OnLiveObjectLifecycleEventResponse: Sendable { /// Deregisters the listener passed to the `on` call. func off() } diff --git a/Sources/AblyLiveObjects/Utility/WeakRef.swift b/Sources/AblyLiveObjects/Utility/WeakRef.swift new file mode 100644 index 00000000..c7175a7e --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/WeakRef.swift @@ -0,0 +1,8 @@ +/// A struct that holds a weak reference to an object. +/// +/// This allows us to store a weak reference inside a Sendable object. The pattern comes from the [`weak let` proposal](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0481-weak-let.md). (We can get rid of this type and use `weak let` once Swift 6.2 is out.) +internal struct WeakRef { + internal weak var referenced: Referenced? +} + +extension WeakRef: Sendable where Referenced: Sendable {} From 4db5deb266fcec0767b73655c08501674959ab76 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 1 Jul 2025 14:40:21 -0300 Subject: [PATCH 4/8] Remove LiveObject.off This was a mistake in ce8c022, in which I did not intend to add methods that unsubscribe a given callback (since you can't compare closures by reference in Swift). The equivalent functionality already exists via OnLiveObjectLifecycleEventResponse.off(). --- Sources/AblyLiveObjects/Public/PublicTypes.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 15690e7d..224fa09c 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -268,13 +268,6 @@ public protocol LiveObject: AnyObject, Sendable { /// - Returns: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to be deregistered from future updates. func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse - /// Removes all registrations that match both the specified listener and the specified event. - /// - /// - Parameters: - /// - event: The named event. - /// - callback: The event listener. - func off(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) - /// Deregisters all registrations, for all events and listeners. func offAll() } From 836f108b795a3947baf099461ae55897e7827c72 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 1 Jul 2025 14:43:00 -0300 Subject: [PATCH 5/8] Mark subscription methods as @discardableResult I _think_ that there are enough times that you want to subscribe to events without worrying about subsequently unsubscribing (in tests, but I think also in real life, especially when just playing around with the SDK) that we shouldn't make users think about the return value of these methods. --- Sources/AblyLiveObjects/Public/PublicTypes.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 224fa09c..edc3c3b2 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -69,6 +69,7 @@ public protocol RealtimeObjects: Sendable { /// - event: The named event to listen for. /// - callback: The event listener. /// - Returns: An ``OnObjectsEventResponse`` object that allows the provided listener to be deregistered from future updates. + @discardableResult func on(event: ObjectsEvent, callback: sending ObjectsEventCallback) -> OnObjectsEventResponse /// Deregisters all registrations, for all events and listeners. @@ -255,6 +256,7 @@ public protocol LiveObject: AnyObject, Sendable { /// /// - Parameter listener: An event listener function that is called with an update object whenever this LiveObject is updated. /// - Returns: A ``SubscribeResponse`` object that allows the provided listener to be deregistered from future updates. + @discardableResult func subscribe(listener: sending LiveObjectUpdateCallback) -> SubscribeResponse /// Deregisters all listeners from updates for this LiveObject. @@ -266,6 +268,7 @@ public protocol LiveObject: AnyObject, Sendable { /// - event: The named event to listen for. /// - callback: The event listener. /// - Returns: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to be deregistered from future updates. + @discardableResult func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse /// Deregisters all registrations, for all events and listeners. From acb0be8386bce42864da740b5e14587a9d2b6029 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 27 Jun 2025 13:45:02 -0300 Subject: [PATCH 6/8] Change type of `entries` accepted by RealtimeObjects.createMap I didn't copy the type across from JS correctly in ce8c022; their LiveMapType is really just a dictionary with typed values. --- Sources/AblyLiveObjects/DefaultRealtimeObjects.swift | 2 +- Sources/AblyLiveObjects/Public/PublicTypes.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index bcf1aacc..4581a1cc 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -30,7 +30,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { notYetImplemented() } - internal func createMap(entries _: any LiveMap) async throws(ARTErrorInfo) -> any LiveMap { + internal func createMap(entries _: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index edc3c3b2..0b1ea4bd 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -38,7 +38,7 @@ public protocol RealtimeObjects: Sendable { /// Creates a new ``LiveMap`` object instance with the provided entries. /// /// - Parameter entries: The initial entries for the new ``LiveMap`` object. - func createMap(entries: any LiveMap) async throws(ARTErrorInfo) -> any LiveMap + func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap /// Creates a new empty ``LiveMap`` object instance. func createMap() async throws(ARTErrorInfo) -> any LiveMap From 0541332b87cf4c792cd93d767af851007b2b7c18 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 24 Jun 2025 12:54:47 -0300 Subject: [PATCH 7/8] Change LiveCounter to use Double instead of Int When I copied the public API from JS in ce8c022 I didn't yet have enough context to know how to translate the use of JS's `number` type. Now from RTLc5 it's clear that counters are floating-point. --- .../AblyLiveObjects/DefaultRealtimeObjects.swift | 2 +- Sources/AblyLiveObjects/Public/PublicTypes.swift | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 4581a1cc..67ed5d94 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -38,7 +38,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { notYetImplemented() } - internal func createCounter(count _: Int) async throws(ARTErrorInfo) -> any LiveCounter { + internal func createCounter(count _: Double) async throws(ARTErrorInfo) -> any LiveCounter { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 0b1ea4bd..ed77e33e 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -46,7 +46,7 @@ public protocol RealtimeObjects: Sendable { /// Creates a new ``LiveCounter`` object instance with the provided `count` value. /// /// - Parameter count: The initial value for the new ``LiveCounter`` object. - func createCounter(count: Int) async throws(ARTErrorInfo) -> any LiveCounter + func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter /// Creates a new ``LiveCounter`` object instance with a value of zero. func createCounter() async throws(ARTErrorInfo) -> any LiveCounter @@ -133,7 +133,7 @@ public protocol BatchContextLiveMap: AnyObject, Sendable { /// A wrapper around the ``LiveCounter`` object that enables batching operations inside a ``BatchCallback``. public protocol BatchContextLiveCounter: AnyObject, Sendable { /// Returns the current value of the counter. - var value: Int { get } + var value: Double { get } /// Similar to the ``LiveCounter/increment(amount:)`` method, but instead, it adds an operation to increment the counter value to the current batch, to be sent in a single message to the Ably service. /// @@ -142,12 +142,12 @@ public protocol BatchContextLiveCounter: AnyObject, Sendable { /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. /// /// - Parameter amount: The amount by which to increase the counter value. - func increment(amount: Int) + func increment(amount: Double) /// An alias for calling [`increment(-amount)`](doc:BatchContextLiveCounter/increment(amount:)). /// /// - Parameter amount: The amount by which to decrease the counter value. - func decrement(amount: Int) + func decrement(amount: Double) } /// The `LiveMap` class represents a key-value map data structure, similar to a Swift `Dictionary`, where all changes are synchronized across clients in realtime. @@ -224,7 +224,7 @@ public enum PrimitiveObjectValue: Sendable { /// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { /// Returns the current value of the counter. - var value: Int { get } + var value: Double { get } /// Sends an operation to the Ably system to increment the value of this `LiveCounter` object. /// @@ -233,18 +233,18 @@ public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. /// /// - Parameter amount: The amount by which to increase the counter value. - func increment(amount: Int) async throws(ARTErrorInfo) + func increment(amount: Double) async throws(ARTErrorInfo) /// An alias for calling [`increment(-amount)`](doc:LiveCounter/increment(amount:)). /// /// - Parameter amount: The amount by which to decrease the counter value. - func decrement(amount: Int) async throws(ARTErrorInfo) + func decrement(amount: Double) async throws(ARTErrorInfo) } /// Represents an update to a ``LiveCounter`` object. public protocol LiveCounterUpdate: Sendable { /// Holds the numerical change to the counter value. - var amount: Int { get } + var amount: Double { get } } /// Describes the common interface for all conflict-free data structures supported by the Objects. From 51f82dc2e5f0abecf3115cb73aa220258f3b2474 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 3 Jul 2025 00:21:42 -0300 Subject: [PATCH 8/8] Put ably-cocoa behind a protocol For mocking in some upcoming tests. I've also decided to make the internals of the SDK not need to worry about our current memory management confusion, which will be revisited in #9. --- .../DefaultRealtimeObjects.swift | 18 ++------ .../AblyLiveObjects/Internal/CoreSDK.swift | 44 +++++++++++++++++++ .../Internal/DefaultInternalPlugin.swift | 3 +- 3 files changed, 50 insertions(+), 15 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/CoreSDK.swift diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 67ed5d94..68ded6a6 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -6,9 +6,8 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. private let mutex = NSLock() - private let channel: WeakRef + private let coreSDK: CoreSDK private let logger: AblyPlugin.Logger - private let pluginAPI: AblyPlugin.PluginAPIProtocol // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. private let receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> @@ -16,10 +15,9 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation - internal init(channel: ARTRealtimeChannel, logger: AblyPlugin.Logger, pluginAPI: AblyPlugin.PluginAPIProtocol) { - self.channel = .init(referenced: channel) + internal init(coreSDK: CoreSDK, logger: AblyPlugin.Logger) { + self.coreSDK = coreSDK self.logger = logger - self.pluginAPI = pluginAPI (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() } @@ -93,14 +91,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { - guard let channel = channel.referenced else { - return - } - - try await DefaultInternalPlugin.sendObject( - objectMessages: objectMessages, - channel: channel, - pluginAPI: pluginAPI, - ) + try await coreSDK.sendObject(objectMessages: objectMessages) } } diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift new file mode 100644 index 00000000..8a63d3c0 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -0,0 +1,44 @@ +import Ably +internal import AblyPlugin + +/// The API that the internal components of the SDK (that is, `DefaultLiveObjects` and down) use to interact with our core SDK (i.e. ably-cocoa). +/// +/// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to AblyPlugin's Objective-C API (i.e. boxing). +internal protocol CoreSDK: AnyObject, Sendable { + func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) +} + +internal final class DefaultCoreSDK: CoreSDK { + // We hold a weak reference to the channel so that `DefaultLiveObjects` can hold a strong reference to us without causing a strong reference cycle. We'll revisit this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9. + private let weakChannel: WeakRef + private let pluginAPI: PluginAPIProtocol + + internal init( + channel: ARTRealtimeChannel, + pluginAPI: PluginAPIProtocol + ) { + weakChannel = .init(referenced: channel) + self.pluginAPI = pluginAPI + } + + // MARK: - Fetching channel + + private var channel: ARTRealtimeChannel { + guard let channel = weakChannel.referenced else { + // It's currently completely possible that the channel _does_ become deallocated during the usage of the LiveObjects SDK; in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 we'll figure out how to prevent this. + preconditionFailure("Expected channel to not become deallocated during usage of LiveObjects SDK") + } + + return channel + } + + // MARK: - CoreSDK conformance + + internal func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + try await DefaultInternalPlugin.sendObject( + objectMessages: objectMessages, + channel: channel, + pluginAPI: pluginAPI, + ) + } +} diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index fcf975a9..5cfd4616 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -37,7 +37,8 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte let logger = pluginAPI.logger(for: channel) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) - let liveObjects = DefaultRealtimeObjects(channel: channel, logger: logger, pluginAPI: pluginAPI) + let coreSDK = DefaultCoreSDK(channel: channel, pluginAPI: pluginAPI) + let liveObjects = DefaultRealtimeObjects(coreSDK: coreSDK, logger: logger) pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) }