From 51e209ac8d8609e3409f838c543712e51c16e8ed Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 14 Jul 2025 10:50:03 -0300 Subject: [PATCH 1/9] Use callback typealiases in implementations It's a bit annoying that Xcode's autocomplete didn't do this when I created these implementations. --- CONTRIBUTING.md | 1 + .../AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift | 4 ++-- Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift | 4 ++-- .../Internal/InternalDefaultRealtimeObjects.swift | 4 ++-- .../Public Proxy Objects/PublicDefaultLiveCounter.swift | 4 ++-- .../Public/Public Proxy Objects/PublicDefaultLiveMap.swift | 4 ++-- .../Public Proxy Objects/PublicDefaultRealtimeObjects.swift | 4 ++-- 7 files changed, 13 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 462785bc..a1c78efd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,7 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with ` - Describe the SDK’s functionality via protocols (when doing so would still be sufficiently idiomatic to Swift). - When defining a `struct` that is emitted by the public API of the library, make sure to define a public memberwise initializer so that users can create one to be emitted by their mocks. (There is no way to make Swift’s autogenerated memberwise initializer public, so you will need to write one yourself. In Xcode, you can do this by clicking at the start of the type declaration and doing Editor → Refactor → Generate Memberwise Initializer.) - When writing code that implements behaviour specified by the LiveObjects features spec, add a comment that references the identifier of the relevant spec item. +- When writing methods that accept one of the public callback types (e.g. `LiveObjectUpdateCallback`), use the typealias name instead of the resolved type that Xcode fills in autocomplete; that is, write `LiveObjectUpdateCallback` instead of autocomplete's `(any LiveCounterUpdate) -> Void`. ### Throwing errors diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 7876aa0b..a23a0c55 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -90,7 +90,7 @@ internal final class InternalDefaultLiveCounter: Sendable { notYetImplemented() } - internal func subscribe(listener _: (sending any LiveCounterUpdate) -> Void) -> any SubscribeResponse { + internal func subscribe(listener _: LiveObjectUpdateCallback) -> any SubscribeResponse { notYetImplemented() } @@ -98,7 +98,7 @@ internal final class InternalDefaultLiveCounter: Sendable { notYetImplemented() } - internal func on(event _: LiveObjectLifecycleEvent, callback _: () -> Void) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event _: LiveObjectLifecycleEvent, callback _: LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 56be4a2f..101e646e 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -183,7 +183,7 @@ internal final class InternalDefaultLiveMap: Sendable { notYetImplemented() } - internal func subscribe(listener _: (sending any LiveMapUpdate) -> Void) -> any SubscribeResponse { + internal func subscribe(listener _: LiveObjectUpdateCallback) -> any SubscribeResponse { notYetImplemented() } @@ -191,7 +191,7 @@ internal final class InternalDefaultLiveMap: Sendable { notYetImplemented() } - internal func on(event _: LiveObjectLifecycleEvent, callback _: () -> Void) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event _: LiveObjectLifecycleEvent, callback _: LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index ec25db75..782eed34 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -132,11 +132,11 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool notYetImplemented() } - internal func batch(callback _: sending (sending any BatchContext) -> Void) async throws { + internal func batch(callback _: sending BatchCallback) async throws { notYetImplemented() } - internal func on(event _: ObjectsEvent, callback _: () -> Void) -> any OnObjectsEventResponse { + internal func on(event _: ObjectsEvent, callback _: ObjectsEventCallback) -> any OnObjectsEventResponse { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 1d1e5053..fdd616da 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -34,7 +34,7 @@ internal final class PublicDefaultLiveCounter: LiveCounter { try await proxied.decrement(amount: amount) } - internal func subscribe(listener: sending (sending any LiveCounterUpdate) -> Void) -> any SubscribeResponse { + internal func subscribe(listener: sending LiveObjectUpdateCallback) -> any SubscribeResponse { proxied.subscribe(listener: listener) } @@ -42,7 +42,7 @@ internal final class PublicDefaultLiveCounter: LiveCounter { proxied.unsubscribeAll() } - internal func on(event: LiveObjectLifecycleEvent, callback: sending () -> Void) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 52edb50c..84f4109c 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -80,7 +80,7 @@ internal final class PublicDefaultLiveMap: LiveMap { try await proxied.remove(key: key) } - internal func subscribe(listener: sending (sending any LiveMapUpdate) -> Void) -> any SubscribeResponse { + internal func subscribe(listener: sending LiveObjectUpdateCallback) -> any SubscribeResponse { proxied.subscribe(listener: listener) } @@ -88,7 +88,7 @@ internal final class PublicDefaultLiveMap: LiveMap { proxied.unsubscribeAll() } - internal func on(event: LiveObjectLifecycleEvent, callback: sending () -> Void) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index c0fd7dcf..b4a59e52 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -47,11 +47,11 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { try await proxied.createCounter() } - internal func batch(callback: sending (sending any BatchContext) -> Void) async throws { + internal func batch(callback: sending BatchCallback) async throws { try await proxied.batch(callback: callback) } - internal func on(event: ObjectsEvent, callback: sending () -> Void) -> any OnObjectsEventResponse { + internal func on(event: ObjectsEvent, callback: sending ObjectsEventCallback) -> any OnObjectsEventResponse { proxied.on(event: event, callback: callback) } From be7b19055d1bd129bd3eecbc27d21c7f0cbd92e3 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 11 Jul 2025 12:24:50 -0300 Subject: [PATCH 2/9] Make public API listeners @escaping Missed this in ce8c022. --- .../Internal/InternalDefaultLiveCounter.swift | 4 ++-- .../AblyLiveObjects/Internal/InternalDefaultLiveMap.swift | 4 ++-- .../Public Proxy Objects/PublicDefaultLiveCounter.swift | 4 ++-- .../Public/Public Proxy Objects/PublicDefaultLiveMap.swift | 4 ++-- .../Public Proxy Objects/PublicDefaultRealtimeObjects.swift | 2 +- Sources/AblyLiveObjects/Public/PublicTypes.swift | 6 +++--- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index a23a0c55..acce77f4 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -90,7 +90,7 @@ internal final class InternalDefaultLiveCounter: Sendable { notYetImplemented() } - internal func subscribe(listener _: LiveObjectUpdateCallback) -> any SubscribeResponse { + internal func subscribe(listener _: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { notYetImplemented() } @@ -98,7 +98,7 @@ internal final class InternalDefaultLiveCounter: Sendable { notYetImplemented() } - internal func on(event _: LiveObjectLifecycleEvent, callback _: LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event _: LiveObjectLifecycleEvent, callback _: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 101e646e..eb626005 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -183,7 +183,7 @@ internal final class InternalDefaultLiveMap: Sendable { notYetImplemented() } - internal func subscribe(listener _: LiveObjectUpdateCallback) -> any SubscribeResponse { + internal func subscribe(listener _: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { notYetImplemented() } @@ -191,7 +191,7 @@ internal final class InternalDefaultLiveMap: Sendable { notYetImplemented() } - internal func on(event _: LiveObjectLifecycleEvent, callback _: LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event _: LiveObjectLifecycleEvent, callback _: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index fdd616da..2f388be0 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -34,7 +34,7 @@ internal final class PublicDefaultLiveCounter: LiveCounter { try await proxied.decrement(amount: amount) } - internal func subscribe(listener: sending LiveObjectUpdateCallback) -> any SubscribeResponse { + internal func subscribe(listener: sending @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { proxied.subscribe(listener: listener) } @@ -42,7 +42,7 @@ internal final class PublicDefaultLiveCounter: LiveCounter { proxied.unsubscribeAll() } - internal func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event: LiveObjectLifecycleEvent, callback: sending @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 84f4109c..1e40024c 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -80,7 +80,7 @@ internal final class PublicDefaultLiveMap: LiveMap { try await proxied.remove(key: key) } - internal func subscribe(listener: sending LiveObjectUpdateCallback) -> any SubscribeResponse { + internal func subscribe(listener: sending @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { proxied.subscribe(listener: listener) } @@ -88,7 +88,7 @@ internal final class PublicDefaultLiveMap: LiveMap { proxied.unsubscribeAll() } - internal func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event: LiveObjectLifecycleEvent, callback: sending @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index b4a59e52..de1f0ab8 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -51,7 +51,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { try await proxied.batch(callback: callback) } - internal func on(event: ObjectsEvent, callback: sending ObjectsEventCallback) -> any OnObjectsEventResponse { + internal func on(event: ObjectsEvent, callback: sending @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 88d3bd1e..36b85250 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -70,7 +70,7 @@ public protocol RealtimeObjects: Sendable { /// - 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 + func on(event: ObjectsEvent, callback: sending @escaping ObjectsEventCallback) -> OnObjectsEventResponse /// Deregisters all registrations, for all events and listeners. func offAll() @@ -337,7 +337,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 + func subscribe(listener: sending @escaping LiveObjectUpdateCallback) -> SubscribeResponse /// Deregisters all listeners from updates for this LiveObject. func unsubscribeAll() @@ -349,7 +349,7 @@ public protocol LiveObject: AnyObject, Sendable { /// - 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 + func on(event: LiveObjectLifecycleEvent, callback: sending @escaping LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse /// Deregisters all registrations, for all events and listeners. func offAll() From 89394e816604f400f13826d486c14955485b011a Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 14 Jul 2025 10:40:56 -0300 Subject: [PATCH 3/9] Make callbacks Sendable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Didn't do this in ce8c022 because I didn't have a good idea of our threading approach. But for the initial approach that we'll be taking — namely, calling the callbacks on ably-cocoa's callback queue — I think we'll need it. Haven't done it for batch stuff yet because I don't yet know whether it'll be necessary (will get a better idea when we implement it). Now that the callback can be called on any thread, we can no longer easily use the "capture the return value of `subscribe` pattern so that we can unsubscribe later" pattern. So, I've decided to pass the subscripiton as a second argument to the callback. Might be there's a better pattern we can use (e.g. pass in an object to the `subscribe` call, like JS `fetch`'s AbortController), but this'll do for now. --- .../PublicDefaultLiveCounter.swift | 4 ++-- .../PublicDefaultLiveMap.swift | 4 ++-- .../PublicDefaultRealtimeObjects.swift | 2 +- .../AblyLiveObjects/Public/PublicTypes.swift | 19 ++++++++++++------- .../ObjectsIntegrationTests.swift | 8 +++----- 5 files changed, 20 insertions(+), 17 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 2f388be0..ec770d50 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -34,7 +34,7 @@ internal final class PublicDefaultLiveCounter: LiveCounter { try await proxied.decrement(amount: amount) } - internal func subscribe(listener: sending @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { + internal func subscribe(listener: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { proxied.subscribe(listener: listener) } @@ -42,7 +42,7 @@ internal final class PublicDefaultLiveCounter: LiveCounter { proxied.unsubscribeAll() } - internal func on(event: LiveObjectLifecycleEvent, callback: sending @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 1e40024c..1ef96b85 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -80,7 +80,7 @@ internal final class PublicDefaultLiveMap: LiveMap { try await proxied.remove(key: key) } - internal func subscribe(listener: sending @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { + internal func subscribe(listener: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { proxied.subscribe(listener: listener) } @@ -88,7 +88,7 @@ internal final class PublicDefaultLiveMap: LiveMap { proxied.unsubscribeAll() } - internal func on(event: LiveObjectLifecycleEvent, callback: sending @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index de1f0ab8..09828035 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -51,7 +51,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { try await proxied.batch(callback: callback) } - internal func on(event: ObjectsEvent, callback: sending @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { + internal func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 36b85250..d0da4314 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -2,14 +2,19 @@ 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: sending T) -> Void +/// - Parameters: +/// - update: The update object describing the changes made to the object. +/// - subscription: A ``SubscribeResponse`` object that allows the provided listener to deregister itself from future updates. +public typealias LiveObjectUpdateCallback = @Sendable (_ update: sending T, _ subscription: SubscribeResponse) -> Void /// The callback used for the events emitted by ``RealtimeObjects``. -public typealias ObjectsEventCallback = () -> Void +/// +/// - Parameter subscription: An ``OnObjectsEventResponse`` object that allows the provided listener to deregister itself from future updates. +public typealias ObjectsEventCallback = @Sendable (_ subscription: OnObjectsEventResponse) -> Void /// The callback used for the lifecycle events emitted by ``LiveObject``. -public typealias LiveObjectLifecycleEventCallback = () -> Void +/// - Parameter subscription: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to deregister itself from future updates. +public typealias LiveObjectLifecycleEventCallback = @Sendable (_ subscription: OnLiveObjectLifecycleEventResponse) -> Void /// A function passed to ``RealtimeObjects/batch(callback:)`` to group multiple Objects operations into a single channel message. /// @@ -70,7 +75,7 @@ public protocol RealtimeObjects: Sendable { /// - 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 @escaping ObjectsEventCallback) -> OnObjectsEventResponse + func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> OnObjectsEventResponse /// Deregisters all registrations, for all events and listeners. func offAll() @@ -337,7 +342,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 @escaping LiveObjectUpdateCallback) -> SubscribeResponse + func subscribe(listener: @escaping LiveObjectUpdateCallback) -> SubscribeResponse /// Deregisters all listeners from updates for this LiveObject. func unsubscribeAll() @@ -349,7 +354,7 @@ public protocol LiveObject: AnyObject, Sendable { /// - 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 @escaping LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse + func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse /// Deregisters all registrations, for all events and listeners. func offAll() diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index ec84e3f8..ad42e1a8 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -90,8 +90,7 @@ func waitFixtureChannelIsReady(_: ARTRealtime) async throws { func waitForMapKeyUpdate(_ map: any LiveMap, _ key: String) async { await withCheckedContinuation { (continuation: CheckedContinuation) in - var subscription: SubscribeResponse! - subscription = map.subscribe { update in + map.subscribe { update, subscription in if update.update[key] != nil { subscription.unsubscribe() continuation.resume() @@ -102,8 +101,7 @@ func waitForMapKeyUpdate(_ map: any LiveMap, _ key: String) async { func waitForCounterUpdate(_ counter: any LiveCounter) async { await withCheckedContinuation { (continuation: CheckedContinuation) in - var subscription: SubscribeResponse! - subscription = counter.subscribe { _ in + counter.subscribe { _, subscription in subscription.unsubscribe() continuation.resume() } @@ -642,7 +640,7 @@ private struct ObjectsIntegrationTests { async let counterSubPromise: Void = withCheckedThrowingContinuation { continuation in do { - try #require(root.get(key: "counter")?.liveCounterValue).subscribe { update in + try #require(root.get(key: "counter")?.liveCounterValue).subscribe { update, _ in #expect(update.amount == -1, "Check counter subscription callback is called with an expected update object after OBJECT_SYNC sequence with \"tombstone=true\"") continuation.resume() } From 91598b19aa18919faad01b62c67c4140bc604940 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 11 Jul 2025 15:17:09 -0300 Subject: [PATCH 4/9] Maintain same root object when resetting the ObjectsPool The correct behaviour wasn't clear from the spec when I wrote cb427d8, but new spec PR [1] makes it seem that this is the right thing to do (still needs clarifying though). [1] https://github.com/ably/specification/pull/346/ --- .../Internal/InternalDefaultLiveMap.swift | 13 +++++++++++++ .../Internal/InternalDefaultRealtimeObjects.swift | 3 +-- Sources/AblyLiveObjects/Internal/ObjectsPool.swift | 12 ++++++++++++ .../InternalDefaultRealtimeObjectsTests.swift | 11 +++++------ 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index eb626005..7a3c35f6 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -287,6 +287,13 @@ internal final class InternalDefaultLiveMap: Sendable { } } + /// Resets the map's data, per RTO4b2. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map. + internal func resetData() { + mutex.withLock { + mutableState.resetData() + } + } + // MARK: - Mutable state and the operations that affect it private struct MutableState { @@ -544,6 +551,12 @@ internal final class InternalDefaultLiveMap: Sendable { logger: logger, ) } + + /// Resets the map's data, per RTO4b2. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map. + internal mutating func resetData() { + // RTO4b2 + data = [:] + } } // MARK: - Helper Methods diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 782eed34..bd6697da 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -244,8 +244,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } // RTO4b1, RTO4b2: Reset the ObjectsPool to have a single empty root object - // TODO: this one is unclear (are we meant to replace the root or just clear its data?) https://github.com/ably/specification/pull/333/files#r2183493458 - objectsPool = .init(logger: logger) + objectsPool.reset() // I have, for now, not directly implemented the "perform the actions for object sync completion" of RTO4b4 since my implementation doesn't quite match the model given there; here you only have a SyncObjectsPool if you have an OBJECT_SYNC in progress, which you might not have upon receiving an ATTACHED. Instead I've just implemented what seem like the relevant side effects. Can revisit this if "the actions for object sync completion" get more complex. diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index c2e35855..a9928d36 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -209,4 +209,16 @@ internal struct ObjectsPool { logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) } + + /// Removes all entries except the root, and clears the root's data. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map, per RTO4b. + internal mutating func reset() { + let root = root + + // RTO4b1 + entries = [Self.rootKey: .map(root)] + + // RTO4b2 + // TODO: this one is unclear (are we meant to replace the root or just clear its data?) https://github.com/ably/specification/pull/333/files#r2183493458. I believe that the answer is that we should just clear its data but the spec point needs to be clearer, see https://github.com/ably/specification/pull/346/files#r2201434895. + root.resetData() + } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 017ec7b3..cc7abe18 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -383,10 +383,10 @@ struct InternalDefaultRealtimeObjectsTests { #expect(newPool.entries["map:existing@123"] == nil) // Should be removed #expect(newPool.entries["counter:existing@456"] == nil) // Should be removed - // Verify root is a new zero-valued map (RTO4b2) - // TODO: this one is unclear (are we meant to replace the root or just clear its data?) https://github.com/ably/specification/pull/333/files#r2183493458 + // Verify root is the same object, but with data cleared (RTO4b2) + // TODO: this one is unclear (are we meant to replace the root or just clear its data?) https://github.com/ably/specification/pull/333/files#r2183493458. I believe that the answer is that we should just clear its data but the spec point needs to be clearer, see https://github.com/ably/specification/pull/346/files#r2201434895. let newRoot = newPool.root - #expect(newRoot as AnyObject !== originalPool.root as AnyObject) // Should be a new instance + #expect(newRoot as AnyObject === originalPool.root as AnyObject) // Should be same instance #expect(newRoot.testsOnly_data.isEmpty) // Should be zero-valued (empty) // RTO4b3, RTO4b4, RTO4b5: SyncObjectsPool must be cleared, sync sequence cleared, BufferedObjectOperations cleared @@ -410,15 +410,14 @@ struct InternalDefaultRealtimeObjectsTests { realtimeObjects.onChannelAttached(hasObjects: false) #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == false) let newPool = realtimeObjects.testsOnly_objectsPool - #expect(newPool.root as AnyObject !== originalRoot as AnyObject) + #expect(newPool.root as AnyObject === originalRoot as AnyObject) #expect(newPool.entries.count == 1) // Third call with hasObjects = true again (should do nothing) - let secondResetRoot = newPool.root realtimeObjects.onChannelAttached(hasObjects: true) #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == true) let finalPool = realtimeObjects.testsOnly_objectsPool - #expect(finalPool.root as AnyObject === secondResetRoot as AnyObject) // Should be unchanged + #expect(finalPool.root as AnyObject === originalRoot as AnyObject) // Should be unchanged } /// Test that sync sequence is properly discarded even with complex sync state From a30f635d6853493fd19bf143fa64edc0c1bd4318 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 15 Jul 2025 15:04:06 -0300 Subject: [PATCH 5/9] Fix spec point reference Mistake in 6430358. --- .../AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index d4cd7b75..fb98727c 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -344,6 +344,6 @@ struct InternalDefaultLiveCounterTests { #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) } - // @specUntested RTLC7e3 - There is no way to check that it was a no-op since there are no side effects that this spec point tells us not to apply + // @specUntested RTLC7d3 - There is no way to check that it was a no-op since there are no side effects that this spec point tells us not to apply } } From cd325c19cd5e4859695d1e8918d3d1cbd152b711 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 14 Jul 2025 11:33:47 -0300 Subject: [PATCH 6/9] Inject callback queue into map and counter This is preparation for implementing subscriptions. Cursor updated the tests. --- .../Internal/DefaultInternalPlugin.swift | 5 +- .../Internal/InternalDefaultLiveCounter.swift | 12 +- .../Internal/InternalDefaultLiveMap.swift | 28 ++++- .../InternalDefaultRealtimeObjects.swift | 17 ++- .../Internal/ObjectsPool.swift | 17 ++- .../InternalDefaultLiveCounterTests.swift | 38 +++--- .../InternalDefaultLiveMapTests.swift | 111 ++++++++++-------- .../InternalDefaultRealtimeObjectsTests.swift | 2 +- .../ObjectsPoolTests.swift | 72 ++++++------ 9 files changed, 178 insertions(+), 124 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index b80a86aa..695e92cc 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -33,11 +33,12 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte // MARK: - LiveObjectsInternalPluginProtocol // Populates the channel's `objects` property. - internal func prepare(_ channel: AblyPlugin.RealtimeChannel, client _: AblyPlugin.RealtimeClient) { + internal func prepare(_ channel: AblyPlugin.RealtimeChannel, client: AblyPlugin.RealtimeClient) { let logger = pluginAPI.logger(for: channel) + let callbackQueue = pluginAPI.callbackQueue(for: client) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) - let liveObjects = InternalDefaultRealtimeObjects(logger: logger) + let liveObjects = InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: callbackQueue) pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index acce77f4..f1287b6b 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -28,24 +28,28 @@ internal final class InternalDefaultLiveCounter: Sendable { } private let logger: AblyPlugin.Logger + private let userCallbackQueue: DispatchQueue // MARK: - Initialization internal convenience init( testsOnly_data data: Double, objectID: String, - logger: AblyPlugin.Logger + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue ) { - self.init(data: data, objectID: objectID, logger: logger) + self.init(data: data, objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue) } private init( data: Double, objectID: String, - logger: AblyPlugin.Logger + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue ) { mutableState = .init(liveObject: .init(objectID: objectID), data: data) self.logger = logger + self.userCallbackQueue = userCallbackQueue } /// Creates a "zero-value LiveCounter", per RTLC4. @@ -55,11 +59,13 @@ internal final class InternalDefaultLiveCounter: Sendable { internal static func createZeroValued( objectID: String, logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) -> Self { .init( data: 0, objectID: objectID, logger: logger, + userCallbackQueue: userCallbackQueue, ) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 7a3c35f6..f49ec748 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -45,6 +45,7 @@ internal final class InternalDefaultLiveMap: Sendable { } private let logger: AblyPlugin.Logger + private let userCallbackQueue: DispatchQueue // MARK: - Initialization @@ -52,13 +53,15 @@ internal final class InternalDefaultLiveMap: Sendable { testsOnly_data data: [String: ObjectsMapEntry], objectID: String, testsOnly_semantics semantics: WireEnum? = nil, - logger: AblyPlugin.Logger + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { self.init( data: data, objectID: objectID, semantics: semantics, logger: logger, + userCallbackQueue: userCallbackQueue, ) } @@ -66,10 +69,12 @@ internal final class InternalDefaultLiveMap: Sendable { data: [String: ObjectsMapEntry], objectID: String, semantics: WireEnum?, - logger: AblyPlugin.Logger + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { mutableState = .init(liveObject: .init(objectID: objectID), data: data, semantics: semantics) self.logger = logger + self.userCallbackQueue = userCallbackQueue } /// Creates a "zero-value LiveMap", per RTLM4. @@ -81,12 +86,14 @@ internal final class InternalDefaultLiveMap: Sendable { objectID: String, semantics: WireEnum? = nil, logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) -> Self { .init( data: [:], objectID: objectID, semantics: semantics, logger: logger, + userCallbackQueue: userCallbackQueue, ) } @@ -211,6 +218,7 @@ internal final class InternalDefaultLiveMap: Sendable { using: state, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -222,6 +230,7 @@ internal final class InternalDefaultLiveMap: Sendable { from: operation, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -233,6 +242,7 @@ internal final class InternalDefaultLiveMap: Sendable { operation, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -251,6 +261,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectMessageSiteCode: objectMessageSiteCode, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -271,6 +282,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationData: operationData, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -314,6 +326,7 @@ internal final class InternalDefaultLiveMap: Sendable { using state: ObjectState, objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials liveObject.siteTimeserials = state.siteTimeserials @@ -330,6 +343,7 @@ internal final class InternalDefaultLiveMap: Sendable { from: createOp, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -339,6 +353,7 @@ internal final class InternalDefaultLiveMap: Sendable { from operation: ObjectOperation, objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { // RTLM17a: For each key–ObjectsMapEntry pair in ObjectOperation.map.entries if let entries = operation.map?.entries { @@ -359,6 +374,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationData: entry.data, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -374,6 +390,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectMessageSiteCode: String?, objectsPool: inout ObjectsPool, logger: Logger, + userCallbackQueue: DispatchQueue, ) { guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { // RTLM15b @@ -391,6 +408,7 @@ internal final class InternalDefaultLiveMap: Sendable { operation, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) case .known(.mapSet): guard let mapOp = operation.mapOp else { @@ -409,6 +427,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationData: data, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) case .known(.mapRemove): guard let mapOp = operation.mapOp else { @@ -433,6 +452,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationData: ObjectData, objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { // RTLM7a: If an entry exists in the private data for the specified key if let existingEntry = data[key] { @@ -459,7 +479,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute if let objectId = operationData.objectId, !objectId.isEmpty { // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 - _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger) + _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger, userCallbackQueue: userCallbackQueue) } } @@ -535,6 +555,7 @@ internal final class InternalDefaultLiveMap: Sendable { _ operation: ObjectOperation, objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { if liveObject.createOperationIsMerged { // RTLM16b @@ -549,6 +570,7 @@ internal final class InternalDefaultLiveMap: Sendable { from: operation, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index bd6697da..3d3e672c 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -9,6 +9,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool private nonisolated(unsafe) var mutableState: MutableState! private let logger: AblyPlugin.Logger + private let userCallbackQueue: DispatchQueue // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. private let receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> @@ -69,12 +70,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } } - internal init(logger: AblyPlugin.Logger) { + internal init(logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue) { self.logger = logger + self.userCallbackQueue = userCallbackQueue (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() - mutableState = .init(objectsPool: .init(logger: logger)) + mutableState = .init(objectsPool: .init(logger: logger, userCallbackQueue: userCallbackQueue)) } // MARK: - LiveMapObjectPoolDelegate @@ -171,6 +173,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool mutableState.handleObjectProtocolMessage( objectMessages: objectMessages, logger: logger, + userCallbackQueue: userCallbackQueue, receivedObjectProtocolMessagesContinuation: receivedObjectProtocolMessagesContinuation, ) } @@ -187,6 +190,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessages: objectMessages, protocolMessageChannelSerial: protocolMessageChannelSerial, logger: logger, + userCallbackQueue: userCallbackQueue, receivedObjectSyncProtocolMessagesContinuation: receivedObjectSyncProtocolMessagesContinuation, ) } @@ -197,7 +201,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool /// Intended as a way for tests to populate the object pool. internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String) -> ObjectsPool.Entry? { mutex.withLock { - mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, logger: logger) + mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue) } } @@ -258,6 +262,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessages: [InboundObjectMessage], protocolMessageChannelSerial: String?, logger: Logger, + userCallbackQueue: DispatchQueue, receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, ) { logger.log("handleObjectSyncProtocolMessage(objectMessages: \(objectMessages), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) @@ -314,6 +319,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectsPool.applySyncObjectsPool( completedSyncObjectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) // RTO5c6 @@ -323,6 +329,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool applyObjectProtocolMessageObjectMessage( objectMessage, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -338,6 +345,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool internal mutating func handleObjectProtocolMessage( objectMessages: [InboundObjectMessage], logger: Logger, + userCallbackQueue: DispatchQueue, receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, ) { receivedObjectProtocolMessagesContinuation.yield(objectMessages) @@ -356,6 +364,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool applyObjectProtocolMessageObjectMessage( objectMessage, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -365,6 +374,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool private mutating func applyObjectProtocolMessageObjectMessage( _ objectMessage: InboundObjectMessage, logger: Logger, + userCallbackQueue: DispatchQueue, ) { guard let operation = objectMessage.operation else { // RTO9a1 @@ -380,6 +390,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool guard let newEntry = objectsPool.createZeroValueObject( forObjectID: operation.objectId, logger: logger, + userCallbackQueue: userCallbackQueue, ) else { logger.log("Unable to create zero-value object for \(operation.objectId) when processing OBJECT message; dropping", level: .warn) return diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index a9928d36..1fdc406a 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -68,21 +68,24 @@ internal struct ObjectsPool { /// Creates an `ObjectsPool` whose root is a zero-value `LiveMap`. internal init( logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, testsOnly_otherEntries otherEntries: [String: Entry]? = nil, ) { self.init( logger: logger, + userCallbackQueue: userCallbackQueue, otherEntries: otherEntries, ) } private init( logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, otherEntries: [String: Entry]? ) { entries = otherEntries ?? [:] // TODO: What initial root entry to use? https://github.com/ably/specification/pull/333/files#r2152312933 - entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, logger: logger)) + entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, logger: logger, userCallbackQueue: userCallbackQueue)) } // MARK: - Typed root @@ -108,8 +111,9 @@ internal struct ObjectsPool { /// - Parameters: /// - objectID: The ID of the object to create /// - logger: The logger to use for any created LiveObject + /// - userCallbackQueue: The callback queue to use for any created LiveObject /// - Returns: The existing or newly created object - internal mutating func createZeroValueObject(forObjectID objectID: String, logger: AblyPlugin.Logger) -> Entry? { + internal mutating func createZeroValueObject(forObjectID objectID: String, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue) -> Entry? { // RTO6a: If an object with objectId exists in ObjectsPool, do not create a new object if let existingEntry = entries[objectID] { return existingEntry @@ -127,9 +131,9 @@ internal struct ObjectsPool { let entry: Entry switch typeString { case "map": - entry = .map(.createZeroValued(objectID: objectID, logger: logger)) + entry = .map(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue)) case "counter": - entry = .counter(.createZeroValued(objectID: objectID, logger: logger)) + entry = .counter(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue)) default: return nil } @@ -143,6 +147,7 @@ internal struct ObjectsPool { internal mutating func applySyncObjectsPool( _ syncObjectsPool: [ObjectState], logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { logger.log("applySyncObjectsPool called with \(syncObjectsPool.count) objects", level: .debug) @@ -174,14 +179,14 @@ internal struct ObjectsPool { if objectState.counter != nil { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger, userCallbackQueue: userCallbackQueue) counter.replaceData(using: objectState) newEntry = .counter(counter) } else if let objectsMap = objectState.map { // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, // set its private objectId equal to ObjectState.objectId, set its private semantics // equal to ObjectState.map.semantics and override its internal data per RTLM6 - let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue) map.replaceData(using: objectState, objectsPool: &self) newEntry = .map(map) } else { diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index fb98727c..c7dfb058 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -10,7 +10,7 @@ struct InternalDefaultLiveCounterTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func valueThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: channelState) #expect { @@ -28,7 +28,7 @@ struct InternalDefaultLiveCounterTests { @Test func valueReturnsCurrentDataWhenChannelIsValid() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attached) // Set some test data @@ -44,7 +44,7 @@ struct InternalDefaultLiveCounterTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) @@ -60,7 +60,7 @@ struct InternalDefaultLiveCounterTests { // Given: A counter whose createOperationIsMerged is true let logger = TestLogger() let counter = { - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // Test setup: Manipulate counter so that its createOperationIsMerged gets set to true (we need to do this since we want to later assert that it gets set to false, but the default is false). let state = TestFactories.counterObjectState( createOp: TestFactories.objectOperation( @@ -87,7 +87,7 @@ struct InternalDefaultLiveCounterTests { @Test func setsDataToCounterCount() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) let state = TestFactories.counterObjectState( count: 42, // Test value @@ -100,7 +100,7 @@ struct InternalDefaultLiveCounterTests { @Test func setsDataToZeroWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) counter.replaceData(using: TestFactories.counterObjectState( count: nil, // Test value - must be nil @@ -115,7 +115,7 @@ struct InternalDefaultLiveCounterTests { @Test func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) let state = TestFactories.counterObjectState( createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist @@ -134,7 +134,7 @@ struct InternalDefaultLiveCounterTests { @Test func addsCounterCountToData() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data @@ -152,7 +152,7 @@ struct InternalDefaultLiveCounterTests { @Test func doesNotModifyDataWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data @@ -173,7 +173,7 @@ struct InternalDefaultLiveCounterTests { @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist @@ -189,7 +189,7 @@ struct InternalDefaultLiveCounterTests { @Test func discardsOperationWhenCreateOperationIsMerged() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data and mark create operation as merged @@ -209,7 +209,7 @@ struct InternalDefaultLiveCounterTests { @Test func mergesInitialValue() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data but don't mark create operation as merged @@ -235,7 +235,7 @@ struct InternalDefaultLiveCounterTests { ] as [(operation: WireObjectsCounterOp?, expectedValue: Double)]) func addsAmountToData(operation: WireObjectsCounterOp?, expectedValue: Double) throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data @@ -256,7 +256,7 @@ struct InternalDefaultLiveCounterTests { @Test func discardsOperationWhenCannotBeApplied() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set up the counter with an existing site timeserial that will cause the operation to be discarded @@ -269,7 +269,7 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterInc), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Apply operation with serial "ts1" which is lexicographically less than existing "ts2" and thus will be applied per RTLO4a (this is a non-pathological case of RTOL4a, that spec point being fully tested elsewhere) counter.apply( @@ -291,11 +291,11 @@ struct InternalDefaultLiveCounterTests { @Test func appliesCounterCreateOperation() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) let operation = TestFactories.counterCreateOperation(count: 15) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Apply COUNTER_CREATE operation counter.apply( @@ -317,7 +317,7 @@ struct InternalDefaultLiveCounterTests { @Test func appliesCounterIncOperation() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data @@ -328,7 +328,7 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterInc), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Apply COUNTER_INC operation counter.apply( diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 3a2f1450..61d4ff36 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -10,7 +10,7 @@ struct InternalDefaultLiveMapTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func getThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) #expect { _ = try map.get(key: "test", coreSDK: MockCoreSDK(channelState: channelState), delegate: MockLiveMapObjectPoolDelegate()) @@ -30,7 +30,7 @@ struct InternalDefaultLiveMapTests { func returnsNilWhenNoEntryExists() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) #expect(try map.get(key: "nonexistent", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) } @@ -43,7 +43,7 @@ struct InternalDefaultLiveMapTests { data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) } @@ -53,7 +53,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(boolean: true)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.boolValue == true) } @@ -65,7 +65,7 @@ struct InternalDefaultLiveMapTests { let bytes = Data([0x01, 0x02, 0x03]) let entry = TestFactories.mapEntry(data: ObjectData(bytes: bytes)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.dataValue == bytes) } @@ -76,7 +76,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 123.456))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.numberValue == 123.456) } @@ -87,7 +87,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(string: .string("test"))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.stringValue == "test") } @@ -99,7 +99,7 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: "missing")) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) } @@ -111,10 +111,9 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) delegate.objects[objectId] = .map(referencedMap) - - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) let returnedMap = result?.liveMapValue #expect(returnedMap as AnyObject === referencedMap as AnyObject) @@ -128,9 +127,9 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) delegate.objects[objectId] = .counter(referencedCounter) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) let returnedCounter = result?.liveCounterValue #expect(returnedCounter as AnyObject === referencedCounter as AnyObject) @@ -143,8 +142,7 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData()) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) } } @@ -155,12 +153,12 @@ struct InternalDefaultLiveMapTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let state = TestFactories.objectState( objectId: "arbitrary-id", siteTimeserials: ["site1": "ts1", "site2": "ts2"], ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) } @@ -170,9 +168,9 @@ struct InternalDefaultLiveMapTests { func setsCreateOperationIsMergedToFalseWhenCreateOpAbsent() { // Given: let logger = TestLogger() - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let map = { - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // Test setup: Manipulate map so that its createOperationIsMerged gets set to true (we need to do this since we want to later assert that it gets set to false, but the default is false). let state = TestFactories.objectState( @@ -198,13 +196,13 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "test") let state = TestFactories.mapObjectState( objectId: "arbitrary-id", entries: [key: entry], ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) map.replaceData(using: state, objectsPool: &pool) let newData = map.testsOnly_data #expect(newData.count == 1) @@ -219,7 +217,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation( @@ -235,7 +233,7 @@ struct InternalDefaultLiveMapTests { ], ), ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) map.replaceData(using: state, objectsPool: &pool) // Note that we just check for some basic expected side effects of merging the initial value; RTLM17 is tested in more detail elsewhere // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d) @@ -256,7 +254,7 @@ struct InternalDefaultLiveMapTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: channelState) let delegate = MockLiveMapObjectPoolDelegate() @@ -304,6 +302,7 @@ struct InternalDefaultLiveMapTests { ], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) // Test size - should only count non-tombstoned entries @@ -346,6 +345,7 @@ struct InternalDefaultLiveMapTests { ], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) let size = try map.size(coreSDK: coreSDK) @@ -376,8 +376,8 @@ struct InternalDefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Create referenced objects for testing - let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) delegate.objects["map:ref@123"] = .map(referencedMap) delegate.objects["counter:ref@456"] = .counter(referencedCounter) @@ -392,6 +392,7 @@ struct InternalDefaultLiveMapTests { ], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) let size = try map.size(coreSDK: coreSDK) @@ -436,8 +437,9 @@ struct InternalDefaultLiveMapTests { testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Try to apply operation with lower timeserial (ts1 < ts2) map.testsOnly_applyMapSetOperation( @@ -471,8 +473,9 @@ struct InternalDefaultLiveMapTests { testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) map.testsOnly_applyMapSetOperation( key: "key1", @@ -534,8 +537,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) map.testsOnly_applyMapSetOperation( key: "newKey", @@ -581,7 +584,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // Create an existing object in the pool with some data let existingObjectId = "map:existing@123" @@ -589,9 +592,11 @@ struct InternalDefaultLiveMapTests { testsOnly_data: [:], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) var pool = ObjectsPool( logger: logger, + userCallbackQueue: .main, testsOnly_otherEntries: [existingObjectId: .map(existingObject)], ) // Populate the delegate so that when we "verify the MAP_SET operation was applied correctly" using map.get below it returns the referenced object @@ -630,6 +635,7 @@ struct InternalDefaultLiveMapTests { testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 @@ -651,6 +657,7 @@ struct InternalDefaultLiveMapTests { testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 @@ -682,7 +689,7 @@ struct InternalDefaultLiveMapTests { @Test func createsNewEntryWhenNoExistingEntry() throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -701,7 +708,7 @@ struct InternalDefaultLiveMapTests { @Test func setsNewEntryTombstoneToTrue() throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -767,8 +774,9 @@ struct InternalDefaultLiveMapTests { testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) map.testsOnly_applyMapSetOperation( key: "key1", @@ -797,8 +805,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Apply merge operation with MAP_SET entries let operation = TestFactories.mapCreateOperation( @@ -824,8 +832,9 @@ struct InternalDefaultLiveMapTests { testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Confirm that the initial data is there #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) != nil) @@ -850,8 +859,8 @@ struct InternalDefaultLiveMapTests { @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Apply merge operation let operation = TestFactories.mapCreateOperation(objectId: "arbitrary-id") @@ -869,8 +878,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Set initial data and mark create operation as merged map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) @@ -893,8 +902,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Set initial data but don't mark create operation as merged map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) @@ -919,10 +928,10 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // Set up the map with an existing site timeserial that will cause the operation to be discarded - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" @@ -956,12 +965,12 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let operation = TestFactories.mapCreateOperation( entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "value1").entry], ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Apply MAP_CREATE operation map.apply( @@ -985,10 +994,10 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // Set initial data - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], @@ -1022,10 +1031,10 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // Set initial data - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index cc7abe18..cf6e7f6f 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -10,7 +10,7 @@ struct InternalDefaultRealtimeObjectsTests { /// Creates a InternalDefaultRealtimeObjects instance for testing static func createDefaultRealtimeObjects() -> InternalDefaultRealtimeObjects { let logger = TestLogger() - return InternalDefaultRealtimeObjects(logger: logger) + return InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: .main) } /// Tests for `InternalDefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index dd3db831..c040a520 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -9,10 +9,10 @@ struct ObjectsPoolTests { @Test func returnsExistingObject() throws { let logger = TestLogger() - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, userCallbackQueue: .main) let map = try #require(result?.mapValue) #expect(map as AnyObject === existingMap as AnyObject) } @@ -21,9 +21,9 @@ struct ObjectsPoolTests { @Test func createsZeroValueMap() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, userCallbackQueue: .main) let map = try #require(result?.mapValue) // Verify it was added to the pool @@ -38,9 +38,9 @@ struct ObjectsPoolTests { func createsZeroValueCounter() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - let result = pool.createZeroValueObject(forObjectID: "counter:123@456", logger: logger) + let result = pool.createZeroValueObject(forObjectID: "counter:123@456", logger: logger, userCallbackQueue: .main) let counter = try #require(result?.counterValue) #expect(try counter.value(coreSDK: coreSDK) == 0) @@ -54,9 +54,9 @@ struct ObjectsPoolTests { @Test func returnsNilForInvalidObjectId() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - let result = pool.createZeroValueObject(forObjectID: "invalid", logger: logger) + let result = pool.createZeroValueObject(forObjectID: "invalid", logger: logger, userCallbackQueue: .main) #expect(result == nil) } @@ -64,9 +64,9 @@ struct ObjectsPoolTests { @Test func returnsNilForUnknownType() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", logger: logger) + let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", logger: logger, userCallbackQueue: .main) #expect(result == nil) #expect(pool.entries["unknown:123@456"] == nil) } @@ -82,8 +82,8 @@ struct ObjectsPoolTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "updated_value") let objectState = TestFactories.mapObjectState( @@ -92,7 +92,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) // Verify the existing map was updated by checking side effects of InternalDefaultLiveMap.replaceData(using:) let updatedMap = try #require(pool.entries["map:hash@123"]?.mapValue) @@ -108,8 +108,8 @@ struct ObjectsPoolTests { func updatesExistingCounterObject() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@123", @@ -117,7 +117,7 @@ struct ObjectsPoolTests { count: 42, ) - pool.applySyncObjectsPool([objectState], logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) // Verify the existing counter was updated by checking side effects of InternalDefaultLiveCounter.replaceData(using:) let updatedCounter = try #require(pool.entries["counter:hash@123"]?.counterValue) @@ -133,7 +133,7 @@ struct ObjectsPoolTests { func createsNewCounterObject() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -141,7 +141,7 @@ struct ObjectsPoolTests { count: 100, ) - pool.applySyncObjectsPool([objectState], logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) // Verify a new counter was created and data was set by checking side effects of InternalDefaultLiveCounter.replaceData(using:) let newCounter = try #require(pool.entries["counter:hash@456"]?.counterValue) @@ -160,7 +160,7 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key, entry) = TestFactories.stringMapEntry(key: "key2", value: "new_value") let objectState = TestFactories.mapObjectState( @@ -169,7 +169,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) // Verify a new map was created and data was set by checking side effects of InternalDefaultLiveMap.replaceData(using:) let newMap = try #require(pool.entries["map:hash@789"]?.mapValue) @@ -186,7 +186,7 @@ struct ObjectsPoolTests { @Test func ignoresNonMapOrCounterObject() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let validObjectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -196,7 +196,7 @@ struct ObjectsPoolTests { let invalidObjectState = TestFactories.objectState(objectId: "invalid") - pool.applySyncObjectsPool([invalidObjectState, validObjectState], logger: logger) + pool.applySyncObjectsPool([invalidObjectState, validObjectState], logger: logger, userCallbackQueue: .main) // Check that there's no entry for the key that we don't know how to handle, and that it didn't interfere with the insertion of the we one that we do know how to handle #expect(Set(pool.entries.keys) == ["root", "counter:hash@456"]) @@ -208,11 +208,11 @@ struct ObjectsPoolTests { @Test func removesObjectsNotInSync() throws { let logger = TestLogger() - let existingMap1 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - let existingMap2 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let existingMap1 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingMap2 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: [ + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: [ "map:hash@1": .map(existingMap1), "map:hash@2": .map(existingMap2), "counter:hash@1": .counter(existingCounter), @@ -221,7 +221,7 @@ struct ObjectsPoolTests { // Only sync one of the existing objects let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") - pool.applySyncObjectsPool([objectState], logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) // Verify only synced object and root remain #expect(pool.entries.count == 2) // root + map:hash@1 @@ -235,11 +235,11 @@ struct ObjectsPoolTests { @Test func doesNotRemoveRootObject() throws { let logger = TestLogger() - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) // Sync with empty list (no objects) - pool.applySyncObjectsPool([], logger: logger) + pool.applySyncObjectsPool([], logger: logger, userCallbackQueue: .main) // Verify root is preserved but other objects are removed #expect(pool.entries.count == 1) // Only root @@ -254,11 +254,11 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) - let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: [ + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: [ "map:existing@1": .map(existingMap), "counter:existing@1": .counter(existingCounter), "map:toremove@1": .map(toBeRemovedMap), @@ -292,7 +292,7 @@ struct ObjectsPoolTests { // Note: "map:toremove@1" is not in sync, so it should be removed ] - pool.applySyncObjectsPool(syncObjects, logger: logger) + pool.applySyncObjectsPool(syncObjects, logger: logger, userCallbackQueue: .main) // Verify final state #expect(pool.entries.count == 5) // root + 4 synced objects From 4822ac3ba2b2510e919d86897dad3a173d8aa9ed Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 15 Jul 2025 18:16:21 -0300 Subject: [PATCH 7/9] Mark subscribe methods as throwing Motivation as in 3f6de86; the new spec points in [1] tell us these can throw. [1] https://github.com/ably/specification/pull/346 --- .../AblyLiveObjects/Public/PublicTypes.swift | 2 +- .../ObjectsIntegrationTests.swift | 48 +++++++++++-------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index d0da4314..34e67f2b 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -342,7 +342,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: @escaping LiveObjectUpdateCallback) -> SubscribeResponse + func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> SubscribeResponse /// Deregisters all listeners from updates for this LiveObject. func unsubscribeAll() diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index ad42e1a8..f4e388a4 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -88,22 +88,30 @@ func waitFixtureChannelIsReady(_: ARTRealtime) async throws { try await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC) } -func waitForMapKeyUpdate(_ map: any LiveMap, _ key: String) async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - map.subscribe { update, subscription in - if update.update[key] != nil { - subscription.unsubscribe() - continuation.resume() +func waitForMapKeyUpdate(_ map: any LiveMap, _ key: String) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + do { + try map.subscribe { update, subscription in + if update.update[key] != nil { + subscription.unsubscribe() + continuation.resume() + } } + } catch { + continuation.resume(throwing: error) } } } -func waitForCounterUpdate(_ counter: any LiveCounter) async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - counter.subscribe { _, subscription in - subscription.unsubscribe() - continuation.resume() +func waitForCounterUpdate(_ counter: any LiveCounter) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + do { + try counter.subscribe { _, subscription in + subscription.unsubscribe() + continuation.resume() + } + } catch { + continuation.resume(throwing: error) } } } @@ -310,10 +318,10 @@ private struct ObjectsIntegrationTests { // Create the promise first, before the operations that will trigger it async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in group.addTask { - await waitForMapKeyUpdate(root, "counter") + try await waitForMapKeyUpdate(root, "counter") } group.addTask { - await waitForMapKeyUpdate(root, "map") + try await waitForMapKeyUpdate(root, "map") } while try await group.next() != nil {} } @@ -331,13 +339,13 @@ private struct ObjectsIntegrationTests { // Create the promise first, before the operations that will trigger it async let operationsAppliedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in group.addTask { - await waitForMapKeyUpdate(map, "anotherKey") + try await waitForMapKeyUpdate(map, "anotherKey") } group.addTask { - await waitForMapKeyUpdate(map, "shouldDelete") + try await waitForMapKeyUpdate(map, "shouldDelete") } group.addTask { - await waitForCounterUpdate(counter) + try await waitForCounterUpdate(counter) } while try await group.next() != nil {} } @@ -382,10 +390,10 @@ private struct ObjectsIntegrationTests { // Create the promise first, before the operations that will trigger it async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in group.addTask { - await waitForMapKeyUpdate(root, "counter") + try await waitForMapKeyUpdate(root, "counter") } group.addTask { - await waitForMapKeyUpdate(root, "map") + try await waitForMapKeyUpdate(root, "map") } while try await group.next() != nil {} } @@ -582,7 +590,7 @@ private struct ObjectsIntegrationTests { key: "counter", createOp: objectsHelper.counterCreateRestOp(number: 1), ) - _ = await counterCreatedPromise + _ = try await counterCreatedPromise #expect(try root.get(key: "counter") != nil, "Check counter exists on root before OBJECT_SYNC sequence with \"tombstone=true\"") @@ -636,7 +644,7 @@ private struct ObjectsIntegrationTests { key: "counter", createOp: objectsHelper.counterCreateRestOp(number: 1), ) - _ = await counterCreatedPromise + _ = try await counterCreatedPromise async let counterSubPromise: Void = withCheckedThrowingContinuation { continuation in do { From 776996a812dcfd9b140f6b20b5d4f6435c14442c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 16 Jul 2025 08:30:13 -0300 Subject: [PATCH 8/9] Fix some mistakes from cb427d8 --- Sources/AblyLiveObjects/Internal/ObjectsPool.swift | 2 +- .../InternalDefaultRealtimeObjectsTests.swift | 7 ++----- Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift | 6 +++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 1fdc406a..8d959660 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -143,7 +143,7 @@ internal struct ObjectsPool { return entry } - /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1. + /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1 and RTO5c2. internal mutating func applySyncObjectsPool( _ syncObjectsPool: [ObjectState], logger: AblyPlugin.Logger, diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index cf6e7f6f..67c65f69 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -155,9 +155,9 @@ struct InternalDefaultRealtimeObjectsTests { // MARK: - RTO5c: Post-Sync Behavior Tests - // @spec(RTO5c2, RTO5c2a) Objects not in sync are removed, except root + // A smoke test that the RTO5c post-sync behaviours get performed. They are tested in more detail in the ObjectsPool.applySyncObjectsPool tests. @Test - func removesObjectsNotInSyncButPreservesRoot() async throws { + func performsPostSyncSteps() async throws { let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Perform sync with only one object (RTO5a5 case) @@ -172,9 +172,6 @@ struct InternalDefaultRealtimeObjectsTests { let finalPool = realtimeObjects.testsOnly_objectsPool #expect(finalPool.entries["root"] != nil) // Root preserved #expect(finalPool.entries["map:synced@1"] != nil) // Synced object added - - // Note: We rely on applySyncObjectsPool being tested separately for RTO5c2 removal behavior - // as the side effect of removing pre-existing objects is tested in ObjectsPoolTests } // MARK: - Error Handling Tests diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index c040a520..6b1e0503 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -204,7 +204,7 @@ struct ObjectsPoolTests { // MARK: - RTO5c2 Tests - // @spec(RTO5c2) Remove objects not received during sync + // @spec RTO5c2 @Test func removesObjectsNotInSync() throws { let logger = TestLogger() @@ -231,7 +231,7 @@ struct ObjectsPoolTests { #expect(pool.entries["counter:hash@1"] == nil) // Should be removed } - // @spec(RTO5c2a) Root object must not be removed + // @spec RTO5c2a @Test func doesNotRemoveRootObject() throws { let logger = TestLogger() @@ -247,7 +247,7 @@ struct ObjectsPoolTests { #expect(pool.entries["map:hash@1"] == nil) // Should be removed } - // @spec(RTO5c1, RTO5c2) Complete sync scenario with mixed operations + // A more complete example of the behaviours described in RTO5c1 and RTO5c2. @Test func handlesComplexSyncScenario() throws { let logger = TestLogger() From 392fae35156e78a4097e531a1ca13268a2bec444 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 11 Jul 2025 10:59:17 -0300 Subject: [PATCH 9/9] Implement subscriptions spec Based on [1] at 2963300. Have not implemented RTL04b1's channel mode checking for same reason as mentioned in 8d881e2. Have not currently tested `replaceData`'s return value; will do once [2] clarified. [1] https://github.com/ably/specification/pull/346 [2] https://github.com/ably/specification/pull/346/files#r2201363446 --- CONTRIBUTING.md | 4 +- .../Internal/DefaultLiveCounterUpdate.swift | 3 + .../Internal/DefaultLiveMapUpdate.swift | 3 + .../Internal/InternalDefaultLiveCounter.swift | 90 ++++++-- .../Internal/InternalDefaultLiveMap.swift | 120 +++++++--- .../InternalDefaultRealtimeObjects.swift | 1 + .../Internal/LiveObjectMutableState.swift | 81 ++++++- .../Internal/LiveObjectUpdate.swift | 26 +++ .../Internal/ObjectsPool.swift | 49 +++- .../PublicDefaultLiveCounter.swift | 4 +- .../PublicDefaultLiveMap.swift | 4 +- .../Utility/NSLock+Extensions.swift | 10 + .../Helpers/Subscriber.swift | 51 +++++ .../InternalDefaultLiveCounterTests.swift | 129 ++++++++--- .../InternalDefaultLiveMapTests.swift | 173 ++++++++++++--- .../InternalDefaultRealtimeObjectsTests.swift | 16 +- .../LiveObjectMutableStateTests.swift | 210 +++++++++++++++++- .../ObjectsPoolTests.swift | 54 ++++- 18 files changed, 896 insertions(+), 132 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift create mode 100644 Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift create mode 100644 Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift create mode 100644 Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift create mode 100644 Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1c78efd..896d7bc2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,9 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with ` ### Throwing errors - The public API of the SDK should use typed throws, and the thrown errors should be of type `ARTErrorInfo`. -- `Dictionary.mapValues` does not support typed throws. We have our own extension `ablyLiveObjects_mapValuesWithTypedThrow` which does; use this. +- Some platform methods do not support typed throws. In these cases, we have our own extension which does; use this instead. They are: + - `Dictionary.mapValues`; use `ablyLiveObjects_mapValuesWithTypedThrow`. + - `NSLock.withLock`; use `ablyLiveObjects_withLockWithTypedThrow`. ### Memory management diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift new file mode 100644 index 00000000..b45a130d --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift @@ -0,0 +1,3 @@ +internal struct DefaultLiveCounterUpdate: LiveCounterUpdate, Equatable { + internal var amount: Double +} diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift new file mode 100644 index 00000000..3c145395 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift @@ -0,0 +1,3 @@ +internal struct DefaultLiveMapUpdate: LiveMapUpdate, Equatable { + internal var update: [String: LiveMapUpdateAction] +} diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index f1287b6b..de9e9696 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -96,14 +96,29 @@ internal final class InternalDefaultLiveCounter: Sendable { notYetImplemented() } - internal func subscribe(listener _: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { - notYetImplemented() + @discardableResult + internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { + try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in + // swiftlint:disable:next trailing_closure + try mutableState.liveObject.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + guard let self else { + return + } + + mutex.withLock { + action(&mutableState.liveObject) + } + }) + } } internal func unsubscribeAll() { - notYetImplemented() + mutex.withLock { + mutableState.liveObject.unsubscribeAll() + } } + @discardableResult internal func on(event _: LiveObjectLifecycleEvent, callback _: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { notYetImplemented() } @@ -112,31 +127,42 @@ internal final class InternalDefaultLiveCounter: Sendable { notYetImplemented() } + // MARK: - Emitting update from external sources + + /// Emit an event from this `LiveCounter`. + /// + /// This is used to instruct this counter to emit updates during an `OBJECT_SYNC`. + internal func emit(_ update: LiveObjectUpdate) { + mutex.withLock { + mutableState.liveObject.emit(update, on: userCallbackQueue) + } + } + // MARK: - Data manipulation /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. - internal func replaceData(using state: ObjectState) { + internal func replaceData(using state: ObjectState) -> LiveObjectUpdate { mutex.withLock { mutableState.replaceData(using: state) } } /// Test-only method to merge initial value from an ObjectOperation, per RTLC10. - internal func testsOnly_mergeInitialValue(from operation: ObjectOperation) { + internal func testsOnly_mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { mutex.withLock { mutableState.mergeInitialValue(from: operation) } } /// Test-only method to apply a COUNTER_CREATE operation, per RTLC8. - internal func testsOnly_applyCounterCreateOperation(_ operation: ObjectOperation) { + internal func testsOnly_applyCounterCreateOperation(_ operation: ObjectOperation) -> LiveObjectUpdate { mutex.withLock { mutableState.applyCounterCreateOperation(operation, logger: logger) } } /// Test-only method to apply a COUNTER_INC operation, per RTLC9. - internal func testsOnly_applyCounterIncOperation(_ operation: WireObjectsCounterOp?) { + internal func testsOnly_applyCounterIncOperation(_ operation: WireObjectsCounterOp?) -> LiveObjectUpdate { mutex.withLock { mutableState.applyCounterIncOperation(operation) } @@ -156,6 +182,7 @@ internal final class InternalDefaultLiveCounter: Sendable { objectMessageSiteCode: objectMessageSiteCode, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -164,13 +191,13 @@ internal final class InternalDefaultLiveCounter: Sendable { private struct MutableState { /// The mutable state common to all LiveObjects. - internal var liveObject: LiveObjectMutableState + internal var liveObject: LiveObjectMutableState /// The internal data that this map holds, per RTLC3. internal var data: Double /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. - internal mutating func replaceData(using state: ObjectState) { + internal mutating func replaceData(using state: ObjectState) -> LiveObjectUpdate { // RTLC6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials liveObject.siteTimeserials = state.siteTimeserials @@ -181,19 +208,32 @@ internal final class InternalDefaultLiveCounter: Sendable { data = state.counter?.count?.doubleValue ?? 0 // RTLC6d: If ObjectState.createOp is present, merge the initial value into the LiveCounter as described in RTLC10 - if let createOp = state.createOp { + return if let createOp = state.createOp { mergeInitialValue(from: createOp) + } else { + // TODO: I assume this is what to do, clarify in https://github.com/ably/specification/pull/346/files#r2201363446 + .noop } } /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC10. - internal mutating func mergeInitialValue(from operation: ObjectOperation) { + internal mutating func mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { + let update: LiveObjectUpdate + // RTLC10a: Add ObjectOperation.counter.count to data, if it exists if let operationCount = operation.counter?.count?.doubleValue { data += operationCount + // RTLC10c + update = .update(DefaultLiveCounterUpdate(amount: operationCount)) + } else { + // RTLC10d + update = .noop } + // RTLC10b: Set the private flag createOperationIsMerged to true liveObject.createOperationIsMerged = true + + return update } /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. @@ -203,6 +243,7 @@ internal final class InternalDefaultLiveCounter: Sendable { objectMessageSiteCode: String?, objectsPool: inout ObjectsPool, logger: Logger, + userCallbackQueue: DispatchQueue, ) { guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { // RTLC7b @@ -216,13 +257,17 @@ internal final class InternalDefaultLiveCounter: Sendable { switch operation.action { case .known(.counterCreate): // RTLC7d1 - applyCounterCreateOperation( + let update = applyCounterCreateOperation( operation, logger: logger, ) + // RTLC7d1a + liveObject.emit(update, on: userCallbackQueue) case .known(.counterInc): // RTLC7d2 - applyCounterIncOperation(operation.counterOp) + let update = applyCounterIncOperation(operation.counterOp) + // RTLC7d2a + liveObject.emit(update, on: userCallbackQueue) default: // RTLC7d3 logger.log("Operation \(operation) has unsupported action for LiveCounter; discarding", level: .warn) @@ -233,25 +278,28 @@ internal final class InternalDefaultLiveCounter: Sendable { internal mutating func applyCounterCreateOperation( _ operation: ObjectOperation, logger: Logger, - ) { + ) -> LiveObjectUpdate { if liveObject.createOperationIsMerged { // RTLC8b logger.log("Not applying COUNTER_CREATE because a COUNTER_CREATE has already been applied", level: .warn) - return + return .noop } - // RTLC8c - mergeInitialValue(from: operation) + // RTLC8c, RTLC8e + return mergeInitialValue(from: operation) } /// Applies a `COUNTER_INC` operation, per RTLC9. - internal mutating func applyCounterIncOperation(_ operation: WireObjectsCounterOp?) { + internal mutating func applyCounterIncOperation(_ operation: WireObjectsCounterOp?) -> LiveObjectUpdate { guard let operation else { - return + // RTL9e + return .noop } - // RTLC9b - data += operation.amount.doubleValue + // RTLC9b, RTLC9d + let amount = operation.amount.doubleValue + data += amount + return .update(DefaultLiveCounterUpdate(amount: amount)) } } } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index f49ec748..8146c139 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -190,14 +190,29 @@ internal final class InternalDefaultLiveMap: Sendable { notYetImplemented() } - internal func subscribe(listener _: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { - notYetImplemented() + @discardableResult + internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { + try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in + // swiftlint:disable:next trailing_closure + try mutableState.liveObject.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + guard let self else { + return + } + + mutex.withLock { + action(&mutableState.liveObject) + } + }) + } } internal func unsubscribeAll() { - notYetImplemented() + mutex.withLock { + mutableState.liveObject.unsubscribeAll() + } } + @discardableResult internal func on(event _: LiveObjectLifecycleEvent, callback _: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { notYetImplemented() } @@ -206,13 +221,24 @@ internal final class InternalDefaultLiveMap: Sendable { notYetImplemented() } + // MARK: - Emitting update from external sources + + /// Emit an event from this `LiveMap`. + /// + /// This is used to instruct this map to emit updates during an `OBJECT_SYNC`. + internal func emit(_ update: LiveObjectUpdate) { + mutex.withLock { + mutableState.liveObject.emit(update, on: userCallbackQueue) + } + } + // MARK: - Data manipulation /// Replaces the internal data of this map with the provided ObjectState, per RTLM6. /// /// - Parameters: /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. - internal func replaceData(using state: ObjectState, objectsPool: inout ObjectsPool) { + internal func replaceData(using state: ObjectState, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { mutex.withLock { mutableState.replaceData( using: state, @@ -224,7 +250,7 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Test-only method to merge initial value from an ObjectOperation, per RTLM17. - internal func testsOnly_mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) { + internal func testsOnly_mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { mutex.withLock { mutableState.mergeInitialValue( from: operation, @@ -236,7 +262,7 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Test-only method to apply a MAP_CREATE operation, per RTLM16. - internal func testsOnly_applyMapCreateOperation(_ operation: ObjectOperation, objectsPool: inout ObjectsPool) { + internal func testsOnly_applyMapCreateOperation(_ operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { mutex.withLock { mutableState.applyMapCreateOperation( operation, @@ -274,7 +300,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationTimeserial: String?, operationData: ObjectData, objectsPool: inout ObjectsPool, - ) { + ) -> LiveObjectUpdate { mutex.withLock { mutableState.applyMapSetOperation( key: key, @@ -290,7 +316,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// Applies a `MAP_REMOVE` operation to a key, per RTLM8. /// /// This is currently exposed just so that the tests can test RTLM8 without having to go through a convoluted replaceData(…) call, but I _think_ that it's going to be used in further contexts when we introduce the handling of incoming object operations in a future spec PR. - internal func testsOnly_applyMapRemoveOperation(key: String, operationTimeserial: String?) { + internal func testsOnly_applyMapRemoveOperation(key: String, operationTimeserial: String?) -> LiveObjectUpdate { mutex.withLock { mutableState.applyMapRemoveOperation( key: key, @@ -302,7 +328,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// Resets the map's data, per RTO4b2. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map. internal func resetData() { mutex.withLock { - mutableState.resetData() + mutableState.resetData(userCallbackQueue: userCallbackQueue) } } @@ -310,7 +336,7 @@ internal final class InternalDefaultLiveMap: Sendable { private struct MutableState { /// The mutable state common to all LiveObjects. - internal var liveObject: LiveObjectMutableState + internal var liveObject: LiveObjectMutableState /// The internal data that this map holds, per RTLM3. internal var data: [String: ObjectsMapEntry] @@ -327,7 +353,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, - ) { + ) -> LiveObjectUpdate { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials liveObject.siteTimeserials = state.siteTimeserials @@ -338,13 +364,16 @@ internal final class InternalDefaultLiveMap: Sendable { data = state.map?.entries ?? [:] // RTLM6d: If ObjectState.createOp is present, merge the initial value into the LiveMap as described in RTLM17 - if let createOp = state.createOp { + return if let createOp = state.createOp { mergeInitialValue( from: createOp, objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, ) + } else { + // TODO: I assume this is what to do, clarify in https://github.com/ably/specification/pull/346/files#r2201363446 + .noop } } @@ -354,10 +383,10 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, - ) { + ) -> LiveObjectUpdate { // RTLM17a: For each key–ObjectsMapEntry pair in ObjectOperation.map.entries - if let entries = operation.map?.entries { - for (key, entry) in entries { + let perKeyUpdates: [LiveObjectUpdate] = if let entries = operation.map?.entries { + entries.map { key, entry in if entry.tombstone == true { // RTLM17a2: If ObjectsMapEntry.tombstone is true, apply the MAP_REMOVE operation // as described in RTLM8, passing in the current key as ObjectsMapOp, and ObjectsMapEntry.timeserial as the operation's serial @@ -378,9 +407,28 @@ internal final class InternalDefaultLiveMap: Sendable { ) } } + } else { + [] } + // RTLM17b: Set the private flag createOperationIsMerged to true liveObject.createOperationIsMerged = true + + // RTLM17c: Merge the updates, skipping no-ops + // I don't love having to use uniqueKeysWithValues, when I shouldn't have to. I should be able to reason _statically_ that there are no overlapping keys. The problem that we're trying to use LiveMapUpdate throughout instead of something more communicative. But I don't know what's to come in the spec so I don't want to mess with this internal interface. + let filteredPerKeyUpdates = perKeyUpdates.compactMap { update -> LiveMapUpdate? in + switch update { + case .noop: + nil + case let .update(update): + update + } + } + let filteredPerKeyUpdateKeyValuePairs = filteredPerKeyUpdates.reduce(into: []) { result, element in + result.append(contentsOf: Array(element.update)) + } + let update = Dictionary(uniqueKeysWithValues: filteredPerKeyUpdateKeyValuePairs) + return .update(DefaultLiveMapUpdate(update: update)) } /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLM15. @@ -404,12 +452,14 @@ internal final class InternalDefaultLiveMap: Sendable { switch operation.action { case .known(.mapCreate): // RTLM15d1 - applyMapCreateOperation( + let update = applyMapCreateOperation( operation, objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, ) + // RTLM15d1a + liveObject.emit(update, on: userCallbackQueue) case .known(.mapSet): guard let mapOp = operation.mapOp else { logger.log("Could not apply MAP_SET since operation.mapOp is missing", level: .warn) @@ -421,7 +471,7 @@ internal final class InternalDefaultLiveMap: Sendable { } // RTLM15d2 - applyMapSetOperation( + let update = applyMapSetOperation( key: mapOp.key, operationTimeserial: applicableOperation.objectMessageSerial, operationData: data, @@ -429,16 +479,20 @@ internal final class InternalDefaultLiveMap: Sendable { logger: logger, userCallbackQueue: userCallbackQueue, ) + // RTLM15d2a + liveObject.emit(update, on: userCallbackQueue) case .known(.mapRemove): guard let mapOp = operation.mapOp else { return } // RTLM15d3 - applyMapRemoveOperation( + let update = applyMapRemoveOperation( key: mapOp.key, operationTimeserial: applicableOperation.objectMessageSerial, ) + // RTLM15d3a + liveObject.emit(update, on: userCallbackQueue) default: // RTLM15d4 logger.log("Operation \(operation) has unsupported action for LiveMap; discarding", level: .warn) @@ -453,12 +507,12 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, - ) { + ) -> LiveObjectUpdate { // RTLM7a: If an entry exists in the private data for the specified key if let existingEntry = data[key] { // RTLM7a1: If the operation cannot be applied as per RTLM9, discard the operation if !Self.canApplyMapOperation(entryTimeserial: existingEntry.timeserial, operationTimeserial: operationTimeserial) { - return + return .noop } // RTLM7a2: Otherwise, apply the operation // RTLM7a2a: Set ObjectsMapEntry.data to the ObjectData from the operation @@ -481,17 +535,20 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger, userCallbackQueue: userCallbackQueue) } + + // RTLM7f + return .update(DefaultLiveMapUpdate(update: [key: .updated])) } /// Applies a `MAP_REMOVE` operation to a key, per RTLM8. - internal mutating func applyMapRemoveOperation(key: String, operationTimeserial: String?) { + internal mutating func applyMapRemoveOperation(key: String, operationTimeserial: String?) -> LiveObjectUpdate { // (Note that, where the spec tells us to set ObjectsMapEntry.data to nil, we actually set it to an empty ObjectData, which is equivalent, since it contains no data) // RTLM8a: If an entry exists in the private data for the specified key if let existingEntry = data[key] { // RTLM8a1: If the operation cannot be applied as per RTLM9, discard the operation if !Self.canApplyMapOperation(entryTimeserial: existingEntry.timeserial, operationTimeserial: operationTimeserial) { - return + return .noop } // RTLM8a2: Otherwise, apply the operation // RTLM8a2a: Set ObjectsMapEntry.data to undefined/null @@ -508,6 +565,8 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM8b2: Set ObjectsMapEntry.tombstone for the new entry to true data[key] = ObjectsMapEntry(tombstone: true, timeserial: operationTimeserial, data: ObjectData()) } + + return .update(DefaultLiveMapUpdate(update: [key: .removed])) } /// Determines whether a map operation can be applied to a map entry, per RTLM9. @@ -556,17 +615,17 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, - ) { + ) -> LiveObjectUpdate { if liveObject.createOperationIsMerged { // RTLM16b logger.log("Not applying MAP_CREATE because a MAP_CREATE has already been applied", level: .warn) - return + return .noop } // TODO: RTLM16c `semantics` comparison; outstanding question in https://github.com/ably/specification/pull/343/files#r2192784482 - // RTLM16d - mergeInitialValue( + // RTLM16d, RTLM16f + return mergeInitialValue( from: operation, objectsPool: &objectsPool, logger: logger, @@ -574,10 +633,15 @@ internal final class InternalDefaultLiveMap: Sendable { ) } - /// Resets the map's data, per RTO4b2. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map. - internal mutating func resetData() { + /// Resets the map's data and emits a `removed` event for the existing keys, per RTO4b2 and RTO4b2a. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map. + internal mutating func resetData(userCallbackQueue: DispatchQueue) { // RTO4b2 + let previousData = data data = [:] + + // RTO4b2a + let mapUpdate = DefaultLiveMapUpdate(update: previousData.mapValues { _ in .removed }) + liveObject.emit(.update(mapUpdate), on: userCallbackQueue) } } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 3d3e672c..3bc22e6a 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -138,6 +138,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool notYetImplemented() } + @discardableResult internal func on(event _: ObjectsEvent, callback _: ObjectsEventCallback) -> any OnObjectsEventResponse { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index 3454909a..4c608fc5 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -3,7 +3,7 @@ internal import AblyPlugin /// This is the equivalent of the `LiveObject` abstract class described in RTLO. /// /// ``InternalDefaultLiveCounter`` and ``InternalDefaultLiveMap`` include it by composition. -internal struct LiveObjectMutableState { +internal struct LiveObjectMutableState { // RTLO3a internal var objectID: String // RTLO3b @@ -11,6 +11,17 @@ internal struct LiveObjectMutableState { // RTLO3c internal var createOperationIsMerged = false + /// Internal bookkeeping for subscriptions. + private var subscriptionsByID: [Subscription.ID: Subscription] = [:] + + internal init( + objectID: String, + testsOnly_siteTimeserials siteTimeserials: [String: String]? = nil, + ) { + self.objectID = objectID + self.siteTimeserials = siteTimeserials ?? [:] + } + /// Represents parameters of an operation that `canApplyOperation` has decided can be applied to a `LiveObject`. /// /// The key thing is that it offers a non-nil `serial` and `siteCode`, which will be needed when subsequently performing the operation. @@ -47,4 +58,72 @@ internal struct LiveObjectMutableState { return nil } + + // MARK: - Subscriptions + + private struct Subscription: Identifiable { + var id = UUID() + var listener: LiveObjectUpdateCallback + var updateLiveObject: UpdateLiveObject + } + + /// A function that allows a `LiveObjectMutableState` to later perform mutations to an externally-held copy of itself. This is used to allow a `SubscribeResponse` to unsubscribe. + /// + /// Accepts an action, which, if called, should be called with an `inout` reference to the externally-held copy. The function is not required to call this action (for example, if the function holds a weak reference which is now `nil`). + /// + /// Note that the `LiveObjectMutableState` will store a copy of this function and thus this function should be careful not to introduce a strong reference cycle. + internal typealias UpdateLiveObject = @Sendable (_ action: (inout LiveObjectMutableState) -> Void) -> Void + + private struct SubscribeResponse: AblyLiveObjects.SubscribeResponse { + var subscriptionID: Subscription.ID + var updateLiveObject: UpdateLiveObject + + func unsubscribe() { + updateLiveObject { liveObject in + liveObject.unsubscribe(subscriptionID: subscriptionID) + } + } + } + + @discardableResult + internal mutating func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK, updateSelfLater: @escaping UpdateLiveObject) throws(ARTErrorInfo) -> any AblyLiveObjects.SubscribeResponse { + // RTLO4b2 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "subscribe", + channelState: currentChannelState, + ) + .toARTErrorInfo() + } + + let subscription = Subscription(listener: listener, updateLiveObject: updateSelfLater) + subscriptionsByID[subscription.id] = subscription + return SubscribeResponse(subscriptionID: subscription.id, updateLiveObject: updateSelfLater) + } + + internal mutating func unsubscribeAll() { + subscriptionsByID.removeAll() + } + + private mutating func unsubscribe(subscriptionID: Subscription.ID) { + // RTLO4d + subscriptionsByID.removeValue(forKey: subscriptionID) + } + + internal func emit(_ update: LiveObjectUpdate, on queue: DispatchQueue) { + switch update { + case .noop: + // RTLO4b4c1 + return + case let .update(update): + // RTLO4b4c2 + for subscription in subscriptionsByID.values { + queue.async { + let response = SubscribeResponse(subscriptionID: subscription.id, updateLiveObject: subscription.updateLiveObject) + subscription.listener(update, response) + } + } + } + } } diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift b/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift new file mode 100644 index 00000000..e31a0639 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift @@ -0,0 +1,26 @@ +internal enum LiveObjectUpdate: Sendable { + case noop // RTLO4b4 + case update(Update) // RTLO4b4a + + // MARK: - Convenience getters + + /// Returns `true` if and only if this `LiveObjectUpdate` has case `noop`. + internal var isNoop: Bool { + if case .noop = self { + true + } else { + false + } + } + + /// If this `LiveObjectUpdate` has case `update`, returns the associated value. Else, returns `nil`. + internal var update: Update? { + if case let .update(update) = self { + update + } else { + nil + } + } +} + +extension LiveObjectUpdate: Equatable where Update: Equatable {} diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 8d959660..135ea792 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -53,6 +53,34 @@ internal struct ObjectsPool { ) } } + + /// A LiveObject plus an update that can be emitted on this LiveObject. Can be used to store pending events while applying the `SyncObjectsPool`. + fileprivate enum DeferredUpdate { + case map(InternalDefaultLiveMap, LiveObjectUpdate) + case counter(InternalDefaultLiveCounter, LiveObjectUpdate) + + /// Causes the referenced `LiveObject` to emit the stored event to its subscribers. + internal func emit() { + switch self { + case let .map(map, update): + map.emit(update) + case let .counter(counter, update): + counter.emit(update) + } + } + } + + /// Overrides the internal data for the object as per RTLC6, RTLM6. + /// + /// Returns a ``DeferredUpdate`` which contains the object plus an update that should be emitted on this object once the `SyncObjectsPool` has been applied. + fileprivate func replaceData(using state: ObjectState, objectsPool: inout ObjectsPool) -> DeferredUpdate { + switch self { + case let .map(map): + .map(map, map.replaceData(using: state, objectsPool: &objectsPool)) + case let .counter(counter): + .counter(counter, counter.replaceData(using: state)) + } + } } /// Keyed by `objectId`. @@ -154,6 +182,9 @@ internal struct ObjectsPool { // Keep track of object IDs that were received during sync for RTO5c2 var receivedObjectIds = Set() + // Keep track of updates to existing objects during sync for RTO5c1a2 + var updatesToExistingObjects: [ObjectsPool.Entry.DeferredUpdate] = [] + // RTO5c1: For each ObjectState member in the SyncObjectsPool list for objectState in syncObjectsPool { receivedObjectIds.insert(objectState.objectId) @@ -163,12 +194,9 @@ internal struct ObjectsPool { logger.log("Updating existing object with ID: \(objectState.objectId)", level: .debug) // RTO5c1a1: Override the internal data for the object as per RTLC6, RTLM6 - switch existingEntry { - case let .map(map): - map.replaceData(using: objectState, objectsPool: &self) - case let .counter(counter): - counter.replaceData(using: objectState) - } + let deferredUpdate = existingEntry.replaceData(using: objectState, objectsPool: &self) + // RTO5c1a2: Store this update to emit at end + updatesToExistingObjects.append(deferredUpdate) } else { // RTO5c1b: If an object with ObjectState.objectId does not exist in the internal ObjectsPool logger.log("Creating new object with ID: \(objectState.objectId)", level: .debug) @@ -180,14 +208,14 @@ internal struct ObjectsPool { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger, userCallbackQueue: userCallbackQueue) - counter.replaceData(using: objectState) + _ = counter.replaceData(using: objectState) newEntry = .counter(counter) } else if let objectsMap = objectState.map { // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, // set its private objectId equal to ObjectState.objectId, set its private semantics // equal to ObjectState.map.semantics and override its internal data per RTLM6 let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue) - map.replaceData(using: objectState, objectsPool: &self) + _ = map.replaceData(using: objectState, objectsPool: &self) newEntry = .map(map) } else { // RTO5c1b1c: Otherwise, log a warning that an unsupported object state message has been received, and discard the current ObjectState without taking any action @@ -212,6 +240,11 @@ internal struct ObjectsPool { } } + // RTO5c7: Emit the updates to existing objects + for deferredUpdate in updatesToExistingObjects { + deferredUpdate.emit() + } + logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index ec770d50..7b6c0795 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -34,8 +34,8 @@ internal final class PublicDefaultLiveCounter: LiveCounter { try await proxied.decrement(amount: amount) } - internal func subscribe(listener: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { - proxied.subscribe(listener: listener) + internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { + try proxied.subscribe(listener: listener, coreSDK: coreSDK) } internal func unsubscribeAll() { diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 1ef96b85..a49cbfd2 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -80,8 +80,8 @@ internal final class PublicDefaultLiveMap: LiveMap { try await proxied.remove(key: key) } - internal func subscribe(listener: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { - proxied.subscribe(listener: listener) + internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { + try proxied.subscribe(listener: listener, coreSDK: coreSDK) } internal func unsubscribeAll() { diff --git a/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift b/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift new file mode 100644 index 00000000..0a9540aa --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift @@ -0,0 +1,10 @@ +import Foundation + +internal extension NSLock { + /// Behaves like `NSLock.withLock`, but the thrown error has the same type as that thrown by the body. (`withLock` uses `rethrows`, which is always an untyped throw.) + func ablyLiveObjects_withLockWithTypedThrow(_ body: () throws(E) -> R) throws(E) -> R { + lock() + defer { unlock() } + return try body() + } +} diff --git a/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift b/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift new file mode 100644 index 00000000..e49ebafd --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift @@ -0,0 +1,51 @@ +import Foundation + +/// A class for testing LiveObjects subscriptions. +/// +/// Create a listener function using ``createListener``, and pass it to the `subscribe(listener:)` method of a LiveObject. Fetch details of the invocations of this listener function using ``getInvocations``. +@available(iOS 17.0.0, tvOS 17.0.0, *) // "Parameter packs in generic types are only available in tvOS 17.0.0 or newer". I wrote this class using this language feature and only after a while realised that this issue exists. So I've gone and marked all of the tests that use this as having the same availability. Might revisit this class at some point if this turns out to be a big nuisance (it's annoying that you can't mark whole suites as @available). +final class Subscriber: Sendable { + private let callbackQueue: DispatchQueue + // Used to synchronize access to the nonisolated(unsafe) mutable state. + private let mutex = NSLock() + private nonisolated(unsafe) var invocations: [(repeat each CallbackArg)] = [] + + /// Creates a `Subscriber`. + /// + /// - Parameters: + /// - callbackQueue: The queue on which this subscriber expects its listeners to be called. + init(callbackQueue: DispatchQueue) { + self.callbackQueue = callbackQueue + } + + /// Waits for the `callbackQueue` to perform all of its pending work, and then returns all of the invocations of a ``createListener`` listener that this subscriber has so far received. + func getInvocations() async -> [(repeat each CallbackArg)] { + await withCheckedContinuation { continuation in + callbackQueue.async { + continuation.resume() + } + } + + return mutex.withLock { + invocations + } + } + + /// Creates a listener function which, when invoked, records an invocation. The details of this invocation can subsequently be fetched using ``getInvocations``. + func createListener(_ action: (@Sendable (repeat each CallbackArg) -> Void)? = nil) -> (@Sendable (repeat each CallbackArg) -> Void) { + { [callbackQueue, weak self](arg: repeat each CallbackArg) in + dispatchPrecondition(condition: .onQueue(callbackQueue)) + + guard let self else { + return + } + mutex.withLock { + let invocation = (repeat each arg) + invocations.append(invocation) + } + if let action { + action(repeat each arg) + } + } + } +} diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index c7dfb058..83e85728 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -32,7 +32,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attached) // Set some test data - counter.replaceData(using: TestFactories.counterObjectState(count: 42)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 42)) #expect(try counter.value(coreSDK: coreSDK) == 42) } @@ -48,7 +48,7 @@ struct InternalDefaultLiveCounterTests { let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) - counter.replaceData(using: state) + _ = counter.replaceData(using: state) #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) } @@ -67,7 +67,7 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterCreate), ), ) - counter.replaceData(using: state) + _ = counter.replaceData(using: state) #expect(counter.testsOnly_createOperationIsMerged) return counter @@ -77,7 +77,7 @@ struct InternalDefaultLiveCounterTests { let state = TestFactories.counterObjectState( createOp: nil, // Test value - must be nil to test RTLC6b ) - counter.replaceData(using: state) + _ = counter.replaceData(using: state) // Then: #expect(!counter.testsOnly_createOperationIsMerged) @@ -92,7 +92,7 @@ struct InternalDefaultLiveCounterTests { let state = TestFactories.counterObjectState( count: 42, // Test value ) - counter.replaceData(using: state) + _ = counter.replaceData(using: state) #expect(try counter.value(coreSDK: coreSDK) == 42) } @@ -102,7 +102,7 @@ struct InternalDefaultLiveCounterTests { let logger = TestLogger() let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) - counter.replaceData(using: TestFactories.counterObjectState( + _ = counter.replaceData(using: TestFactories.counterObjectState( count: nil, // Test value - must be nil )) #expect(try counter.value(coreSDK: coreSDK) == 0) @@ -121,7 +121,7 @@ struct InternalDefaultLiveCounterTests { createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist count: 5, // Test value - must exist ) - counter.replaceData(using: state) + _ = counter.replaceData(using: state) #expect(try counter.value(coreSDK: coreSDK) == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC10a) #expect(counter.testsOnly_createOperationIsMerged) } @@ -131,6 +131,7 @@ struct InternalDefaultLiveCounterTests { /// Tests for the `testsOnly_mergeInitialValue` method, covering RTLC10 specification points struct MergeInitialValueTests { // @specOneOf(1/2) RTLC10a - with count + // @spec RTLC10c @Test func addsCounterCountToData() throws { let logger = TestLogger() @@ -138,17 +139,21 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist - counter.testsOnly_mergeInitialValue(from: operation) + let update = counter.testsOnly_mergeInitialValue(from: operation) #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 + + // Check return value + #expect(try #require(update.update).amount == 10) } // @specOneOf(2/2) RTLC10a - no count + // @spec RTLC10d @Test func doesNotModifyDataWhenCounterCountDoesNotExist() throws { let logger = TestLogger() @@ -156,7 +161,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation with no count @@ -164,9 +169,12 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterCreate), counter: nil, // Test value - must be nil ) - counter.testsOnly_mergeInitialValue(from: operation) + let update = counter.testsOnly_mergeInitialValue(from: operation) #expect(try counter.value(coreSDK: coreSDK) == 5) // Unchanged + + // Check return value + #expect(update.isNoop) } // @spec RTLC10b @@ -177,7 +185,7 @@ struct InternalDefaultLiveCounterTests { // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist - counter.testsOnly_mergeInitialValue(from: operation) + _ = counter.testsOnly_mergeInitialValue(from: operation) #expect(counter.testsOnly_createOperationIsMerged) } @@ -193,19 +201,23 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data and mark create operation as merged - counter.replaceData(using: TestFactories.counterObjectState(count: 5)) - counter.testsOnly_mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.testsOnly_mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) #expect(counter.testsOnly_createOperationIsMerged) // Try to apply another COUNTER_CREATE operation let operation = TestFactories.counterCreateOperation(count: 20) - counter.testsOnly_applyCounterCreateOperation(operation) + let update = counter.testsOnly_applyCounterCreateOperation(operation) // Verify the operation was discarded - data unchanged #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10, not 5 + 10 + 20 + + // Verify return value + #expect(update.isNoop) } // @spec RTLC8c + // @spec RTLC8e @Test func mergesInitialValue() throws { let logger = TestLogger() @@ -213,40 +225,58 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data but don't mark create operation as merged - counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) #expect(!counter.testsOnly_createOperationIsMerged) // Apply COUNTER_CREATE operation let operation = TestFactories.counterCreateOperation(count: 10) - counter.testsOnly_applyCounterCreateOperation(operation) + let update = counter.testsOnly_applyCounterCreateOperation(operation) // Verify the operation was applied - initial value merged. (The full logic of RTLC10 is tested elsewhere; we just check for some of its side effects here.) #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 #expect(counter.testsOnly_createOperationIsMerged) + + // Verify return value per RTLC8e + #expect(try #require(update.update).amount == 10) } } /// Tests for `COUNTER_INC` operations, covering RTLC9 specification points struct CounterIncOperationTests { // @spec RTLC9b - @Test(arguments: [ - (operation: TestFactories.counterOp(amount: 10), expectedValue: 15.0), // 5 + 10 - (operation: nil as WireObjectsCounterOp?, expectedValue: 5.0), // unchanged - ] as [(operation: WireObjectsCounterOp?, expectedValue: Double)]) - func addsAmountToData(operation: WireObjectsCounterOp?, expectedValue: Double) throws { + // @spec RTLC9d + // @spec RTLC9e + @Test( + arguments: [ + ( + operation: TestFactories.counterOp(amount: 10), + expectedValue: 15.0, // 5 + 10 + expectedUpdate: .update(.init(amount: 10)) // RTLC9d + ), + ( + operation: nil as WireObjectsCounterOp?, + expectedValue: 5.0, // unchanged + expectedUpdate: .noop // RTLC9e + ), + ] as [(operation: WireObjectsCounterOp?, expectedValue: Double, expectedUpdate: LiveObjectUpdate)], + ) + func addsAmountToData(operation: WireObjectsCounterOp?, expectedValue: Double, expectedUpdate: LiveObjectUpdate) throws { let logger = TestLogger() let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply COUNTER_INC operation - counter.testsOnly_applyCounterIncOperation(operation) + let update = counter.testsOnly_applyCounterIncOperation(operation) // Verify the operation was applied correctly #expect(try counter.value(coreSDK: coreSDK) == expectedValue) + + // Verify return value + #expect(update == expectedUpdate) } } @@ -260,7 +290,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set up the counter with an existing site timeserial that will cause the operation to be discarded - counter.replaceData(using: TestFactories.counterObjectState( + _ = counter.replaceData(using: TestFactories.counterObjectState( siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" count: 5, )) @@ -288,12 +318,17 @@ struct InternalDefaultLiveCounterTests { // @specOneOf(1/2) RTLC7c - We test this spec point for each possible operation // @spec RTLC7d1 - Tests COUNTER_CREATE operation application + // @spec RTLC7d1a + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func appliesCounterCreateOperation() throws { + func appliesCounterCreateOperation() async throws { let logger = TestLogger() let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) + let subscriber = Subscriber(callbackQueue: .main) + try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + let operation = TestFactories.counterCreateOperation(count: 15) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) @@ -310,18 +345,27 @@ struct InternalDefaultLiveCounterTests { #expect(counter.testsOnly_createOperationIsMerged) // Verify RTLC7c side-effect: site timeserial was updated #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Verify update was emitted per RTLC7d1a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(amount: 15)]) } // @specOneOf(2/2) RTLC7c - We test this spec point for each possible operation // @spec RTLC7d2 - Tests COUNTER_INC operation application + // @spec RTLC7d2a + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func appliesCounterIncOperation() throws { + func appliesCounterIncOperation() async throws { let logger = TestLogger() let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) + let subscriber = Subscriber(callbackQueue: .main) + try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + // Set initial data - counter.replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5)) #expect(try counter.value(coreSDK: coreSDK) == 5) let operation = TestFactories.objectOperation( @@ -342,8 +386,35 @@ struct InternalDefaultLiveCounterTests { #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 // Verify RTLC7c side-effect: site timeserial was updated #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Verify update was emitted per RTLC7d2a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(amount: 10)]) } - // @specUntested RTLC7d3 - There is no way to check that it was a no-op since there are no side effects that this spec point tells us not to apply + // @spec RTLC7d3 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func noOpForOtherOperation() async throws { + let logger = TestLogger() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let coreSDK = MockCoreSDK(channelState: .attaching) + + let subscriber = Subscriber(callbackQueue: .main) + try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + // Try to apply a MAP_CREATE to the counter (not supported) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + counter.apply( + TestFactories.mapCreateOperation(), + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // Check no update was emitted + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.isEmpty) + } } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 61d4ff36..2cdefbf8 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -159,7 +159,7 @@ struct InternalDefaultLiveMapTests { siteTimeserials: ["site1": "ts1", "site2": "ts2"], ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) } @@ -176,7 +176,7 @@ struct InternalDefaultLiveMapTests { let state = TestFactories.objectState( createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), ) - map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) return map @@ -184,7 +184,7 @@ struct InternalDefaultLiveMapTests { // When: let state = TestFactories.objectState(objectId: "arbitrary-id", createOp: nil) - map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectsPool: &pool) // Then: #expect(!map.testsOnly_createOperationIsMerged) @@ -203,7 +203,7 @@ struct InternalDefaultLiveMapTests { entries: [key: entry], ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectsPool: &pool) let newData = map.testsOnly_data #expect(newData.count == 1) #expect(Set(newData.keys) == ["key1"]) @@ -234,7 +234,7 @@ struct InternalDefaultLiveMapTests { ), ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectsPool: &pool) // Note that we just check for some basic expected side effects of merging the initial value; RTLM17 is tested in more detail elsewhere // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d) #expect(try map.get(key: "keyFromMapEntries", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromMapEntries") @@ -442,7 +442,7 @@ struct InternalDefaultLiveMapTests { var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Try to apply operation with lower timeserial (ts1 < ts2) - map.testsOnly_applyMapSetOperation( + let update = map.testsOnly_applyMapSetOperation( key: "key1", operationTimeserial: "ts1", operationData: ObjectData(objectId: "new"), @@ -453,10 +453,13 @@ struct InternalDefaultLiveMapTests { #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") // Verify that RTLM7c1 didn't happen (i.e. that we didn't create a zero-value object in the pool for object ID "new") #expect(Set(pool.entries.keys) == ["root"]) + // Verify return value + #expect(update.isNoop) } // @spec RTLM7a2 // @specOneOf(1/2) RTLM7c1 + // @specOneOf(1/2) RTLM7f @Test(arguments: [ // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectPool per RTLM7c) (operationData: ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), @@ -477,7 +480,7 @@ struct InternalDefaultLiveMapTests { ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - map.testsOnly_applyMapSetOperation( + let update = map.testsOnly_applyMapSetOperation( key: "key1", operationTimeserial: "ts2", operationData: operationData, @@ -516,6 +519,9 @@ struct InternalDefaultLiveMapTests { // For number values, no object should be created #expect(Set(pool.entries.keys) == ["root"]) } + + // RTLM7f: Check return value + #expect(try #require(update.update).update == ["key1": .updated]) } } @@ -525,6 +531,7 @@ struct InternalDefaultLiveMapTests { // @spec RTLM7b1 // @spec RTLM7b2 // @specOneOf(2/2) RTLM7c1 + // @specOneOf(2/2) RTLM7f @Test(arguments: [ // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectPool per RTLM7c) (operationData: ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), @@ -540,7 +547,7 @@ struct InternalDefaultLiveMapTests { let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - map.testsOnly_applyMapSetOperation( + let update = map.testsOnly_applyMapSetOperation( key: "newKey", operationTimeserial: "ts1", operationData: operationData, @@ -573,6 +580,9 @@ struct InternalDefaultLiveMapTests { // For number values, no object should be created #expect(Set(pool.entries.keys) == ["root"]) } + + // RTLM7f: Check return value + #expect(try #require(update.update).update == ["newKey": .updated]) } } @@ -603,7 +613,7 @@ struct InternalDefaultLiveMapTests { delegate.objects[existingObjectId] = pool.entries[existingObjectId] // Apply MAP_SET operation that references the existing object - map.testsOnly_applyMapSetOperation( + _ = map.testsOnly_applyMapSetOperation( key: "referenceKey", operationTimeserial: "ts1", operationData: ObjectData(objectId: existingObjectId), @@ -639,15 +649,18 @@ struct InternalDefaultLiveMapTests { ) // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 - map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts1") + let update = map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts1") // Verify the operation was discarded - existing data unchanged #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") + // Verify return value + #expect(update.isNoop) } // @spec RTLM8a2a // @spec RTLM8a2b // @spec RTLM8a2c + // @specOneOf(1/2) RTLM8e @Test func appliesOperationWhenCanBeApplied() throws { let logger = TestLogger() @@ -661,7 +674,7 @@ struct InternalDefaultLiveMapTests { ) // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 - map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts2") + let update = map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts2") // Verify the operation was applied #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) @@ -679,6 +692,9 @@ struct InternalDefaultLiveMapTests { // RTLM8a2c: Set ObjectsMapEntry.tombstone to true #expect(map.testsOnly_data["key1"]?.tombstone == true) + + // RTLM8e: Check return value + #expect(try #require(update.update).update == ["key1": .removed]) } } @@ -686,12 +702,13 @@ struct InternalDefaultLiveMapTests { struct NoExistingEntryTests { // @spec RTLM8b1 - Create new entry with ObjectsMapEntry.data set to undefined/null and operation's serial + // @specOneOf(1/2) RTLM8e @Test func createsNewEntryWhenNoExistingEntry() throws { let logger = TestLogger() let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") + let update = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") // Verify new entry was created let entry = map.testsOnly_data["newKey"] @@ -702,6 +719,9 @@ struct InternalDefaultLiveMapTests { #expect(entry?.data.boolean == nil) #expect(entry?.data.bytes == nil) #expect(entry?.data.objectId == nil) + + // RTLM8e: Check return value + #expect(try #require(update.update).update == ["newKey": .removed]) } // @spec RTLM8b2 - Set ObjectsMapEntry.tombstone for new entry to true @@ -710,7 +730,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") + _ = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") // Verify tombstone is true for new entry #expect(map.testsOnly_data["newKey"]?.tombstone == true) @@ -778,7 +798,7 @@ struct InternalDefaultLiveMapTests { ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - map.testsOnly_applyMapSetOperation( + _ = map.testsOnly_applyMapSetOperation( key: "key1", operationTimeserial: operationSerial, operationData: ObjectData(string: .string("new")), @@ -815,7 +835,7 @@ struct InternalDefaultLiveMapTests { "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, ], ) - map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + _ = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) // Note that we just check for some basic expected side effects of applying MAP_SET; RTLM7 is tested in more detail elsewhere // Check that it contains the data from the operation (per RTLM17a1) @@ -849,12 +869,50 @@ struct InternalDefaultLiveMapTests { objectId: "arbitrary-id", entries: ["key1": entry], ) - map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + _ = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) // Verify the MAP_REMOVE operation was applied #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) } + // @spec RTLM17c + @Test + func returnedUpdateMergesOperationUpdates() throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap( + testsOnly_data: [ + "keyThatWillBeRemoved": TestFactories.stringMapEntry(timeserial: "ts1").entry, + "keyThatWillNotBeRemoved": TestFactories.stringMapEntry(timeserial: "ts1").entry, + ], + objectID: "arbitrary", + logger: logger, + userCallbackQueue: .main, + ) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + + // Apply merge operation with MAP_CREATE and MAP_REMOVE entries (copied from RTLM17a1 and RTLM17a2 test cases) + let operation = TestFactories.mapCreateOperation( + objectId: "arbitrary-id", + entries: [ + "keyThatWillBeRemoved": TestFactories.mapEntry( + tombstone: true, + timeserial: "ts2", // Must be greater than existing entry's timeserial "ts1" + data: ObjectData(), + ), + "keyThatWillNotBeRemoved": TestFactories.mapEntry( + tombstone: true, + timeserial: "ts0", // Less than existing entry's timeserial "ts1" so MAP_REMOVE will be a no-op (this lets us test that no-ops are excluded from return value per RTLM17c) + data: ObjectData(), + ), + "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, + ], + ) + let update = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + + // Verify merged return value per RTLM17c + #expect(try #require(update.update).update == ["keyThatWillBeRemoved": .removed, "keyFromCreateOp": .updated]) + } + // @spec RTLM17b @Test func setsCreateOperationIsMergedToTrue() { @@ -864,7 +922,7 @@ struct InternalDefaultLiveMapTests { // Apply merge operation let operation = TestFactories.mapCreateOperation(objectId: "arbitrary-id") - map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + _ = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) } @@ -882,21 +940,25 @@ struct InternalDefaultLiveMapTests { var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Set initial data and mark create operation as merged - map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) - map.testsOnly_mergeInitialValue(from: TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]), objectsPool: &pool) + _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) + _ = map.testsOnly_mergeInitialValue(from: TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]), objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) // Try to apply another MAP_CREATE operation let operation = TestFactories.mapCreateOperation(entries: ["key3": TestFactories.stringMapEntry(key: "key3", value: "value3").entry]) - map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) + let update = map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) // Verify the operation was discarded - data unchanged #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "testValue") // Original data #expect(try map.get(key: "key2", coreSDK: coreSDK, delegate: delegate)?.stringValue == "value2") // From first merge #expect(try map.get(key: "key3", coreSDK: coreSDK, delegate: delegate) == nil) // Not added by second operation + + // Verify the return value + #expect(update.isNoop) } // @spec RTLM16d + // @spec RTLM16f @Test func mergesInitialValue() throws { let logger = TestLogger() @@ -906,17 +968,20 @@ struct InternalDefaultLiveMapTests { var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Set initial data but don't mark create operation as merged - map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) + _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) #expect(!map.testsOnly_createOperationIsMerged) // Apply MAP_CREATE operation let operation = TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]) - map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) + let update = map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) // Verify the operation was applied - initial value merged. (The full logic of RTLM17 is tested elsewhere; we just check for some of its side effects here.) #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "testValue") // Original data #expect(try map.get(key: "key2", coreSDK: coreSDK, delegate: delegate)?.stringValue == "value2") // From merge #expect(map.testsOnly_createOperationIsMerged) + + // Verify return value per RTLM16f + #expect(try #require(update.update).update == ["key2": .updated]) } } @@ -933,7 +998,7 @@ struct InternalDefaultLiveMapTests { // Set up the map with an existing site timeserial that will cause the operation to be discarded var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - map.replaceData(using: TestFactories.mapObjectState( + _ = map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" entries: [key1: entry1], ), objectsPool: &pool) @@ -960,13 +1025,18 @@ struct InternalDefaultLiveMapTests { // @specOneOf(1/3) RTLM15c - We test this spec point for each possible operation // @spec RTLM15d1 - Tests MAP_CREATE operation application + // @spec RTLM15d1a + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func appliesMapCreateOperation() throws { + func appliesMapCreateOperation() async throws { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + let operation = TestFactories.mapCreateOperation( entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "value1").entry], ) @@ -985,21 +1055,30 @@ struct InternalDefaultLiveMapTests { #expect(map.testsOnly_createOperationIsMerged) // Verify RTLM15c side-effect: site timeserial was updated #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Verify update was emitted per RTLM15d1a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .updated])]) } // @specOneOf(2/3) RTLM15c - We test this spec point for each possible operation // @spec RTLM15d2 - Tests MAP_SET operation application + // @spec RTLM15d2a + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func appliesMapSetOperation() throws { + func appliesMapSetOperation() async throws { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + // Set initial data var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - map.replaceData(using: TestFactories.mapObjectState( + _ = map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], entries: [key1: entry1], ), objectsPool: &pool) @@ -1022,21 +1101,30 @@ struct InternalDefaultLiveMapTests { #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") // Verify RTLM15c side-effect: site timeserial was updated #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Verify update was emitted per RTLM15d2a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .updated])]) } // @specOneOf(3/3) RTLM15c - We test this spec point for each possible operation // @spec RTLM15d3 - Tests MAP_REMOVE operation application + // @spec RTLM15d3a + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func appliesMapRemoveOperation() throws { + func appliesMapRemoveOperation() async throws { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + // Set initial data var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - map.replaceData(using: TestFactories.mapObjectState( + _ = map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], entries: [key1: entry1], ), objectsPool: &pool) @@ -1059,8 +1147,35 @@ struct InternalDefaultLiveMapTests { #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) // Verify RTLM15c side-effect: site timeserial was updated #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Verify update was emitted per RTLM15d3a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .removed])]) } - // @specUntested RTLM15d4 - There is no way to check that it was a no-op since there are no side effects that this spec point tells us not to apply + // @spec RTLM15d4 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func noOpForOtherOperation() async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let coreSDK = MockCoreSDK(channelState: .attaching) + + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + // Try to apply a COUNTER_CREATE to the map (not supported) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + map.apply( + TestFactories.counterCreateOperation(), + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // Check no update was emitted + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.isEmpty) + } } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 67c65f69..38e40444 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -338,16 +338,22 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO4b1 // @spec RTO4b2 + // @spec RTO4b2a // @spec RTO4b3 // @spec RTO4b4 // @spec RTO4b5 + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func handlesHasObjectsFalse() { + func handlesHasObjectsFalse() async throws { let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Set up initial state with additional objects in the pool using sync realtimeObjects.handleObjectSyncProtocolMessage( objectMessages: [ + TestFactories.mapObjectMessage(objectId: "root", entries: [ + "existingMap": TestFactories.objectReferenceMapEntry(key: "existingMap", objectId: "map:existing@123").entry, + "existingCounter": TestFactories.objectReferenceMapEntry(key: "existingCounter", objectId: "counter:existing@456").entry, + ]), TestFactories.mapObjectMessage(objectId: "map:existing@123"), TestFactories.counterObjectMessage(objectId: "counter:existing@456"), ], @@ -355,6 +361,11 @@ struct InternalDefaultRealtimeObjectsTests { ) let originalPool = realtimeObjects.testsOnly_objectsPool + #expect(Set(originalPool.root.testsOnly_data.keys) == ["existingMap", "existingCounter"]) + + let rootSubscriber = Subscriber(callbackQueue: .main) + let coreSDK = MockCoreSDK(channelState: .attached) + try originalPool.root.subscribe(listener: rootSubscriber.createListener(), coreSDK: coreSDK) // Set up an in-progress sync sequence realtimeObjects.handleObjectSyncProtocolMessage( @@ -379,6 +390,9 @@ struct InternalDefaultRealtimeObjectsTests { #expect(newPool.entries["root"] != nil) #expect(newPool.entries["map:existing@123"] == nil) // Should be removed #expect(newPool.entries["counter:existing@456"] == nil) // Should be removed + // Verify that `removed` was emitted for root's existing keys per RTO4b2a + let subscriberInvocations = await rootSubscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(update: ["existingMap": .removed, "existingCounter": .removed])]) // Verify root is the same object, but with data cleared (RTO4b2) // TODO: this one is unclear (are we meant to replace the root or just clear its data?) https://github.com/ably/specification/pull/333/files#r2183493458. I believe that the answer is that we should just clear its data but the spec point needs to be clearer, see https://github.com/ably/specification/pull/346/files#r2201434895. diff --git a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift index 47c03cbb..ff39d7d4 100644 --- a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift +++ b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift @@ -13,7 +13,7 @@ struct LiveObjectMutableStateTests { let objectMessageSerial: String? let objectMessageSiteCode: String? let siteTimeserials: [String: String] - let expectedResult: LiveObjectMutableState.ApplicableOperation? + let expectedResult: LiveObjectMutableState.ApplicableOperation? } // @spec RTLO4a3 @@ -98,9 +98,9 @@ struct LiveObjectMutableStateTests { ), ]) func canApplyOperation(testCase: TestCase) { - let state = LiveObjectMutableState( + let state = LiveObjectMutableState( objectID: "test:object@123", - siteTimeserials: testCase.siteTimeserials, + testsOnly_siteTimeserials: testCase.siteTimeserials, ) let logger = TestLogger() @@ -113,4 +113,208 @@ struct LiveObjectMutableStateTests { #expect(result == testCase.expectedResult, "Expected \(String(describing: testCase.expectedResult)) for case: \(testCase.description)") } } + + struct SubscriptionTests { + // swiftlint:disable trailing_closure + + // @spec RTLO4b2 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) + func subscribeThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + var mutableState = LiveObjectMutableState(objectID: "foo") + let queue = DispatchQueue.main + let subscriber = Subscriber(callbackQueue: queue) + let coreSDK = MockCoreSDK(channelState: channelState) + + #expect { + try mutableState.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK, updateSelfLater: { _ in fatalError("Not expected") }) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + struct EmitTests { + // @spec RTLO4b4c1 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func noop() async throws { + // Given + var mutableState = LiveObjectMutableState(objectID: "foo") + let queue = DispatchQueue.main + let subscriber = Subscriber(callbackQueue: queue) + let coreSDK = MockCoreSDK(channelState: .attached) + try mutableState.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK, updateSelfLater: { _ in fatalError("Not expected") }) + + // When + mutableState.emit(.noop, on: queue) + + // Then + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.isEmpty) + } + + // @spec RTLO4b4c2 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func update() async throws { + // Given + var mutableState = LiveObjectMutableState(objectID: "foo") + let queue = DispatchQueue.main + let subscriber = Subscriber(callbackQueue: queue) + let coreSDK = MockCoreSDK(channelState: .attached) + try mutableState.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK, updateSelfLater: { _ in fatalError("Not expected") }) + + // When + mutableState.emit(.update("bar"), on: queue) + + // Then + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == ["bar"]) + } + } + + struct UnsubscribeTests { + final class MutableStateStore: Sendable { + private let mutex = NSLock() + private nonisolated(unsafe) var stored: LiveObjectMutableState + + init(stored: LiveObjectMutableState) { + self.stored = stored + } + + @discardableResult + func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> SubscribeResponse { + try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in + try stored.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + guard let self else { + return + } + + mutex.withLock { + action(&stored) + } + }) + } + } + + func emit(_ update: LiveObjectUpdate, on queue: DispatchQueue) { + mutex.withLock { + stored.emit(update, on: queue) + } + } + + func unsubscribeAll() { + mutex.withLock { + stored.unsubscribeAll() + } + } + } + + // @specOneOf(1/3) RTLO4b5b - Check we can unsubscribe using the response that's returned from `subscribe` + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func unsubscribeFromReturnValue() async throws { + // Given + let store = MutableStateStore(stored: .init(objectID: "foo")) + let queue = DispatchQueue.main + let subscriber = Subscriber(callbackQueue: queue) + let coreSDK = MockCoreSDK(channelState: .attached) + let subscription = try store.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + // When + store.emit(.update("bar"), on: queue) + subscription.unsubscribe() + store.emit(.update("baz"), on: queue) + + // Then + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == ["bar"]) + } + + // @specOneOf(2/3) RTLO4b5b - Check we can unsubscribe using the `response` that's passed to the listener, and that when two updates are emitted back-to-back, the unsubscribe in the first listener causes us to not recieve the second update + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test(.disabled("This doesn't currently work and I don't think it's a priority, nor do I want to dwell on it right now or rush trying to fix it; see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/28")) + func unsubscribeInsideCallback_backToBackUpdates() async throws { + // Given + let store = MutableStateStore(stored: .init(objectID: "foo")) + let queue = DispatchQueue.main + let subscriber = Subscriber(callbackQueue: queue) + let coreSDK = MockCoreSDK(channelState: .attached) + // Create a listener that calls `unsubscribe` on the `response` that's passed to the listener + let listener = subscriber.createListener { _, response in + response.unsubscribe() + } + try store.subscribe(listener: listener, coreSDK: coreSDK) + + // When + store.emit(.update("bar"), on: queue) + store.emit(.update("baz"), on: queue) + + // Then + let subscriberInvocations = await subscriber.getInvocations() + // This is failing because it's still receiving "baz" too + #expect(subscriberInvocations.map(\.0) == ["bar"]) + } + + // @specOneOf(3/3) RTLO4b5b - Check we can unsubscribe using the `response` that's passed to the listener. This is a simpler version of the above test, in that there is an async pause between the unsubscribe-in-callback and the next `emit`. + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func unsubscribeInsideCallback_nonBackToBackUpdates() async throws { + // Given + let store = MutableStateStore(stored: .init(objectID: "foo")) + let queue = DispatchQueue.main + let subscriber = Subscriber(callbackQueue: queue) + let coreSDK = MockCoreSDK(channelState: .attached) + // Create a listener that calls `unsubscribe` on the `response` that's passed to the listener + let listener = subscriber.createListener { _, response in + response.unsubscribe() + } + try store.subscribe(listener: listener, coreSDK: coreSDK) + + // When + store.emit(.update("bar"), on: queue) + // This is what distinguishes us from the previous test; the updates aren't back to back + _ = await subscriber.getInvocations() + store.emit(.update("baz"), on: queue) + + // Then + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == ["bar"]) + } + + // @spec RTLO4d + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func unsubscribeAll() async throws { + // Given + let store = MutableStateStore(stored: .init(objectID: "foo")) + let queue = DispatchQueue.main + let coreSDK = MockCoreSDK(channelState: .attached) + let subscribers: [Subscriber] = [ + .init(callbackQueue: queue), + .init(callbackQueue: queue), + ] + for subscriber in subscribers { + try store.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + } + + // When + store.emit(.update("bar"), on: queue) + store.unsubscribeAll() + store.emit(.update("baz"), on: queue) + + // Then + for subscriber in subscribers { + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == ["bar"]) + } + } + } + + // swiftlint:enable trailing_closure + } } diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 6b1e0503..8b3d14d5 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -77,18 +77,26 @@ struct ObjectsPoolTests { // MARK: - RTO5c1 Tests // @specOneOf(1/2) RTO5c1a1 - Override the internal data for existing map objects + // @specOneOf(1/2) RTO5c1a2 - Check we store the update for existing map objects + // @specOneOf(1/2) RTO5c7 - Check we emit the update for existing map objects + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func updatesExistingMapObject() throws { + func updatesExistingMapObject() async throws { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingMapSubscriber = Subscriber(callbackQueue: .main) + try existingMap.subscribe(listener: existingMapSubscriber.createListener(), coreSDK: coreSDK) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "updated_value") let objectState = TestFactories.mapObjectState( objectId: "map:hash@123", siteTimeserials: ["site1": "ts1"], + createOp: TestFactories.mapCreateOperation(objectId: "map:hash@123", entries: [ + "createOpKey": TestFactories.stringMapEntry(value: "bar").entry, + ]), entries: [key: entry], ) @@ -101,20 +109,30 @@ struct ObjectsPoolTests { #expect(try updatedMap.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "updated_value") // Checking site timeserials to verify they were updated by replaceData #expect(updatedMap.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Check that the update was stored and emitted per RTO5c1a2 and RTO5c7 + let subscriberInvocations = await existingMapSubscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(update: ["createOpKey": .updated])]) } // @specOneOf(2/2) RTO5c1a1 - Override the internal data for existing counter objects + // @specOneOf(2/2) RTO5c1a2 - Check we store the update for existing counter objects + // @specOneOf(2/2) RTO5c7 - Check we emit the update for existing counter objects + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func updatesExistingCounterObject() throws { + func updatesExistingCounterObject() async throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingCounterSubscriber = Subscriber(callbackQueue: .main) + try existingCounter.subscribe(listener: existingCounterSubscriber.createListener(), coreSDK: coreSDK) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@123", siteTimeserials: ["site1": "ts1"], - count: 42, + createOp: TestFactories.counterCreateOperation(objectId: "counter:hash@123", count: 5), + count: 10, ) pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) @@ -123,9 +141,13 @@ struct ObjectsPoolTests { let updatedCounter = try #require(pool.entries["counter:hash@123"]?.counterValue) #expect(updatedCounter === existingCounter) // Checking counter value to verify replaceData was called successfully - #expect(try updatedCounter.value(coreSDK: coreSDK) == 42) + #expect(try updatedCounter.value(coreSDK: coreSDK) == 15) // 10 (state) + 5 (createOp) // Checking site timeserials to verify they were updated by replaceData #expect(updatedCounter.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Check that the update was stored and emitted per RTO5c1a2 and RTO5c7 + let subscriberInvocations = await existingCounterSubscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(amount: 5)]) // From createOp } // @spec RTO5c1b1a @@ -247,9 +269,10 @@ struct ObjectsPoolTests { #expect(pool.entries["map:hash@1"] == nil) // Should be removed } - // A more complete example of the behaviours described in RTO5c1 and RTO5c2. + // A more complete example of the behaviours described in RTO5c1, RTO5c2, and RTO5c7. + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func handlesComplexSyncScenario() throws { + func handlesComplexSyncScenario() async throws { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) @@ -258,6 +281,11 @@ struct ObjectsPoolTests { let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingMapSubscriber = Subscriber(callbackQueue: .main) + try existingMap.subscribe(listener: existingMapSubscriber.createListener(), coreSDK: coreSDK) + let existingCounterSubscriber = Subscriber(callbackQueue: .main) + try existingCounter.subscribe(listener: existingCounterSubscriber.createListener(), coreSDK: coreSDK) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: [ "map:existing@1": .map(existingMap), "counter:existing@1": .counter(existingCounter), @@ -269,12 +297,16 @@ struct ObjectsPoolTests { TestFactories.mapObjectState( objectId: "map:existing@1", siteTimeserials: ["site1": "ts1"], + createOp: TestFactories.mapCreateOperation(objectId: "map:existing@1", entries: [ + "createOpKey": TestFactories.stringMapEntry(value: "bar").entry, + ]), entries: ["updated": TestFactories.mapEntry(data: ObjectData(string: .string("updated")))], ), // Update existing counter TestFactories.counterObjectState( objectId: "counter:existing@1", siteTimeserials: ["site2": "ts2"], + createOp: TestFactories.counterCreateOperation(objectId: "counter:existing@1", count: 5), count: 100, ), // Create new map @@ -305,9 +337,17 @@ struct ObjectsPoolTests { // Checking map data to verify replaceData was called successfully #expect(try updatedMap.get(key: "updated", coreSDK: coreSDK, delegate: delegate)?.stringValue == "updated") + // Check update emitted by existing map per RTO5c7 + let existingMapSubscriberInvocations = await existingMapSubscriber.getInvocations() + #expect(existingMapSubscriberInvocations.map(\.0) == [.init(update: ["createOpKey": .updated])]) + let updatedCounter = try #require(pool.entries["counter:existing@1"]?.counterValue) // Checking counter value to verify replaceData was called successfully - #expect(try updatedCounter.value(coreSDK: coreSDK) == 100) + #expect(try updatedCounter.value(coreSDK: coreSDK) == 105) + + // Check update emitted by existing counter per RTO5c7 + let existingCounterInvocations = await existingCounterSubscriber.getInvocations() + #expect(existingCounterInvocations.map(\.0) == [.init(amount: 5)]) // New objects - verify by checking side effects of replaceData calls let newMap = try #require(pool.entries["map:new@1"]?.mapValue)