diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 8c4e9c4e..a9a38a0f 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -6,7 +6,7 @@ import Ably /// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to `_AblyPluginSupportPrivate`'s Objective-C API (i.e. boxing). internal protocol CoreSDK: AnyObject, Sendable { /// Implements the internal `#publish` method of RTO15. - func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) + func nosync_publish(objectMessages: [ProtocolTypes.OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) /// Implements the server time fetch of RTO16, including the storing and usage of the local clock offset. func nosync_fetchServerTime(callback: @escaping @Sendable (Result) -> Void) @@ -14,7 +14,7 @@ internal protocol CoreSDK: AnyObject, Sendable { /// Replaces the implementation of ``nosync_publish(objectMessages:callback:)``. /// /// Used by integration tests, for example to disable `ObjectMessage` publishing so that a test can verify that a behaviour is not a side effect of an `ObjectMessage` sent by the SDK. - func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) + func testsOnly_overridePublish(with newImplementation: @escaping ([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) /// Returns the current state of the Realtime channel that this wraps. var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { get } @@ -34,7 +34,7 @@ internal final class DefaultCoreSDK: CoreSDK { /// This enables the `testsOnly_overridePublish(with:)` test hook. /// /// - Note: This should be `throws(ARTErrorInfo)` but that causes a compilation error of "Runtime support for typed throws function types is only available in macOS 15.0.0 or newer". - private nonisolated(unsafe) var overriddenPublishImplementation: (([OutboundObjectMessage]) async throws -> PublishResult)? + private nonisolated(unsafe) var overriddenPublishImplementation: (([ProtocolTypes.OutboundObjectMessage]) async throws -> PublishResult)? internal init( channel: _AblyPluginSupportPrivate.RealtimeChannel, @@ -50,7 +50,7 @@ internal final class DefaultCoreSDK: CoreSDK { // MARK: - CoreSDK conformance - internal func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { + internal func nosync_publish(objectMessages: [ProtocolTypes.OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { logger.log("nosync_publish(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) // Use the overridden implementation if supplied @@ -83,7 +83,7 @@ internal final class DefaultCoreSDK: CoreSDK { ) } - internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { + internal func testsOnly_overridePublish(with newImplementation: @escaping ([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { mutex.withLock { overriddenPublishImplementation = newImplementation } diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index b15c7ab7..cf1dadb2 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -97,7 +97,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. wireObject: wireObject, decodingContext: context, ) - let objectMessage = try InboundObjectMessage( + let objectMessage = try ProtocolTypes.InboundObjectMessage( wireObjectMessage: wireObjectMessage, format: format, ) @@ -112,7 +112,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. _ publicObjectMessage: any _AblyPluginSupportPrivate.ObjectMessageProtocol, format: EncodingFormat, ) -> [String: Any] { - guard let outboundObjectMessageBox = publicObjectMessage as? ObjectMessageBox else { + guard let outboundObjectMessageBox = publicObjectMessage as? ObjectMessageBox else { preconditionFailure("Expected to receive the same OutboundObjectMessage type as we emit") } @@ -125,7 +125,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. } internal func nosync_handleObjectProtocolMessage(withObjectMessages publicObjectMessages: [any _AblyPluginSupportPrivate.ObjectMessageProtocol], channel: _AblyPluginSupportPrivate.RealtimeChannel) { - guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { + guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") } @@ -137,7 +137,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. } internal func nosync_handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any _AblyPluginSupportPrivate.ObjectMessageProtocol], protocolMessageChannelSerial: String?, channel: _AblyPluginSupportPrivate.RealtimeChannel) { - guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { + guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") } @@ -167,13 +167,13 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. // MARK: - Sending `OBJECT` ProtocolMessage internal static func nosync_sendObject( - objectMessages: [OutboundObjectMessage], + objectMessages: [ProtocolTypes.OutboundObjectMessage], channel: _AblyPluginSupportPrivate.RealtimeChannel, client: _AblyPluginSupportPrivate.RealtimeClient, pluginAPI: PluginAPIProtocol, callback: @escaping @Sendable (Result) -> Void, ) { - let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } + let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } let internalQueue = pluginAPI.internalQueue(for: client) pluginAPI.nosync_sendObject( diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index a94b7751..7186321f 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -118,7 +118,7 @@ internal final class InternalDefaultLiveCounter: Sendable { operationDescription: "LiveCounter.increment", ) - let objectMessage = OutboundObjectMessage( + let objectMessage = ProtocolTypes.OutboundObjectMessage( operation: .init( // RTLC12e2 action: .known(.counterInc), @@ -209,7 +209,7 @@ internal final class InternalDefaultLiveCounter: Sendable { /// - Parameters: /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this counter. internal func nosync_replaceData( - using state: ObjectState, + using state: ProtocolTypes.ObjectState, objectMessageSerialTimestamp: Date?, ) -> LiveObjectUpdate { mutableStateMutex.withoutSync { mutableState in @@ -224,14 +224,14 @@ internal final class InternalDefaultLiveCounter: Sendable { } /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC16. - internal func nosync_mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { + internal func nosync_mergeInitialValue(from operation: ProtocolTypes.ObjectOperation) -> LiveObjectUpdate { mutableStateMutex.withoutSync { mutableState in mutableState.mergeInitialValue(from: operation) } } /// Test-only method to apply a COUNTER_CREATE operation, per RTLC8. - internal func testsOnly_applyCounterCreateOperation(_ operation: ObjectOperation) -> LiveObjectUpdate { + internal func testsOnly_applyCounterCreateOperation(_ operation: ProtocolTypes.ObjectOperation) -> LiveObjectUpdate { mutableStateMutex.withSync { mutableState in mutableState.applyCounterCreateOperation(operation, logger: logger) } @@ -248,7 +248,7 @@ internal final class InternalDefaultLiveCounter: Sendable { /// /// - Returns: `true` if the operation was applied, `false` if it was skipped (RTLC7g). internal func nosync_apply( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, @@ -314,7 +314,7 @@ internal final class InternalDefaultLiveCounter: Sendable { /// - Parameters: /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this counter. internal mutating func replaceData( - using state: ObjectState, + using state: ProtocolTypes.ObjectState, objectMessageSerialTimestamp: Date?, logger: Logger, clock: SimpleClock, @@ -363,7 +363,7 @@ internal final class InternalDefaultLiveCounter: Sendable { } /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC16. - internal mutating func mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { + internal mutating func mergeInitialValue(from operation: ProtocolTypes.ObjectOperation) -> LiveObjectUpdate { let update: LiveObjectUpdate // RTLC16: Resolve counterCreate from either the direct property or the one @@ -390,7 +390,7 @@ internal final class InternalDefaultLiveCounter: Sendable { /// /// - Returns: `true` if the operation was applied, `false` if skipped (RTLC7g). internal mutating func apply( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, @@ -459,7 +459,7 @@ internal final class InternalDefaultLiveCounter: Sendable { /// Applies a `COUNTER_CREATE` operation, per RTLC8. internal mutating func applyCounterCreateOperation( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, logger: Logger, ) -> LiveObjectUpdate { if liveObjectMutableState.createOperationIsMerged { diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index e13ab40d..e3f22532 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -17,7 +17,7 @@ internal final class InternalDefaultLiveMap: Sendable { } } - internal var testsOnly_semantics: WireEnum? { + internal var testsOnly_semantics: WireEnum? { mutableStateMutex.withSync { mutableState in mutableState.semantics } @@ -50,7 +50,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal convenience init( testsOnly_data data: [String: InternalObjectsMapEntry], objectID: String, - testsOnly_semantics semantics: WireEnum? = nil, + testsOnly_semantics semantics: WireEnum? = nil, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, @@ -70,7 +70,7 @@ internal final class InternalDefaultLiveMap: Sendable { private init( data: [String: InternalObjectsMapEntry], objectID: String, - semantics: WireEnum?, + semantics: WireEnum?, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, @@ -92,7 +92,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// - semantics: The value to use for the "private `semantics` field" of RTO5c1b1b. internal static func createZeroValued( objectID: String, - semantics: WireEnum? = nil, + semantics: WireEnum? = nil, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, @@ -172,7 +172,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM20c try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.set") - let objectMessage = OutboundObjectMessage( + let objectMessage = ProtocolTypes.OutboundObjectMessage( operation: .init( // RTLM20e2 action: .known(.mapSet), @@ -205,7 +205,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM21c try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.remove") - let objectMessage = OutboundObjectMessage( + let objectMessage = ProtocolTypes.OutboundObjectMessage( operation: .init( // RTLM21e2 action: .known(.mapRemove), @@ -292,7 +292,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this map. internal func nosync_replaceData( - using state: ObjectState, + using state: ProtocolTypes.ObjectState, objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, ) -> LiveObjectUpdate { @@ -310,7 +310,7 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM23. - internal func nosync_mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { + internal func nosync_mergeInitialValue(from operation: ProtocolTypes.ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { mutableStateMutex.withoutSync { mutableState in mutableState.mergeInitialValue( from: operation, @@ -324,7 +324,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) -> LiveObjectUpdate { + internal func testsOnly_applyMapCreateOperation(_ operation: ProtocolTypes.ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { mutableStateMutex.withSync { mutableState in mutableState.applyMapCreateOperation( operation, @@ -341,7 +341,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// /// - Returns: `true` if the operation was applied, `false` if it was skipped (RTLM15g). internal func nosync_apply( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, @@ -370,7 +370,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal func testsOnly_applyMapSetOperation( key: String, operationTimeserial: String?, - operationData: ObjectData, + operationData: ProtocolTypes.ObjectData, objectsPool: inout ObjectsPool, ) -> LiveObjectUpdate { mutableStateMutex.withSync { mutableState in @@ -465,7 +465,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal var data: [String: InternalObjectsMapEntry] /// The "private `semantics` field" of RTO5c1b1b. - internal var semantics: WireEnum? + internal var semantics: WireEnum? /// RTLM25 internal var clearTimeserial: String? @@ -476,7 +476,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this map. internal mutating func replaceData( - using state: ObjectState, + using state: ProtocolTypes.ObjectState, objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, logger: Logger, @@ -556,7 +556,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM23. internal mutating func mergeInitialValue( - from operation: ObjectOperation, + from operation: ProtocolTypes.ObjectOperation, objectsPool: inout ObjectsPool, logger: Logger, internalQueue: DispatchQueue, @@ -623,7 +623,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// /// - Returns: `true` if the operation was applied, `false` if skipped (RTLM15g). internal mutating func apply( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, @@ -743,7 +743,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal mutating func applyMapSetOperation( key: String, operationTimeserial: String?, - operationData: ObjectData?, + operationData: ProtocolTypes.ObjectData?, objectsPool: inout ObjectsPool, logger: Logger, internalQueue: DispatchQueue, @@ -883,7 +883,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// Applies a `MAP_CREATE` operation, per RTLM16. internal mutating func applyMapCreateOperation( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, objectsPool: inout ObjectsPool, logger: Logger, internalQueue: DispatchQueue, diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index d50ca339..59f2c093 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -9,7 +9,7 @@ internal protocol InternalRealtimeObjectsProtocol: LiveMapObjectsPoolDelegate { /// call other methods on this object that read or mutate its state. /// https://github.com/ably/ably-liveobjects-swift-plugin/issues/120 tracks removing this restriction. func nosync_publishAndApply( - objectMessages: [OutboundObjectMessage], + objectMessages: [ProtocolTypes.OutboundObjectMessage], coreSDK: CoreSDK, callback: @escaping @Sendable (Result) -> Void, ) @@ -24,10 +24,10 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO private let clock: SimpleClock // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. - private let receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> - private let receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation - private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> - private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation + private let receivedObjectProtocolMessages: AsyncStream<[ProtocolTypes.InboundObjectMessage]> + private let receivedObjectProtocolMessagesContinuation: AsyncStream<[ProtocolTypes.InboundObjectMessage]>.Continuation + private let receivedObjectSyncProtocolMessages: AsyncStream<[ProtocolTypes.InboundObjectMessage]> + private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[ProtocolTypes.InboundObjectMessage]>.Continuation /// The RTO10a interval at which we will perform garbage collection. private let garbageCollectionInterval: TimeInterval @@ -387,12 +387,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } } - internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { + internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[ProtocolTypes.InboundObjectMessage]> { receivedObjectProtocolMessages } /// Implements the `OBJECT` handling of RTO8. - internal func nosync_handleObjectProtocolMessage(objectMessages: [InboundObjectMessage]) { + internal func nosync_handleObjectProtocolMessage(objectMessages: [ProtocolTypes.InboundObjectMessage]) { mutableStateMutex.withoutSync { mutableState in mutableState.nosync_handleObjectProtocolMessage( objectMessages: objectMessages, @@ -405,12 +405,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } } - internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { + internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[ProtocolTypes.InboundObjectMessage]> { receivedObjectSyncProtocolMessages } /// Implements the `OBJECT_SYNC` handling of RTO5. - internal func nosync_handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial: String?) { + internal func nosync_handleObjectSyncProtocolMessage(objectMessages: [ProtocolTypes.InboundObjectMessage], protocolMessageChannelSerial: String?) { mutableStateMutex.withoutSync { mutableState in mutableState.nosync_handleObjectSyncProtocolMessage( objectMessages: objectMessages, @@ -442,7 +442,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO // MARK: - Sending `OBJECT` ProtocolMessage // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. - internal func testsOnly_publish(objectMessages: [OutboundObjectMessage], coreSDK: CoreSDK) async throws(ARTErrorInfo) { + internal func testsOnly_publish(objectMessages: [ProtocolTypes.OutboundObjectMessage], coreSDK: CoreSDK) async throws(ARTErrorInfo) { try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in mutableStateMutex.withSync { _ in coreSDK.nosync_publish(objectMessages: objectMessages) { result in @@ -456,7 +456,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO /// /// Must be called from within `mutableStateMutex.withSync` (i.e. on the internal queue). internal func nosync_publishAndApply( - objectMessages: [OutboundObjectMessage], + objectMessages: [ProtocolTypes.OutboundObjectMessage], coreSDK: CoreSDK, callback: @escaping @Sendable (Result) -> Void, ) { @@ -475,7 +475,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO /// /// Must be called from within `mutableStateMutex.withSync` (i.e. on the internal queue). private func nosync_publishAndApply( - objectMessages: [OutboundObjectMessage], + objectMessages: [ProtocolTypes.OutboundObjectMessage], coreSDK: CoreSDK, mutableStateCallback: @escaping @Sendable (inout MutableState, Result) -> Void, ) { @@ -510,7 +510,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } // RTO20d: Create synthetic inbound ObjectMessages - let syntheticMessages = objectMessages.enumerated().compactMap { index, outboundMessage -> InboundObjectMessage? in + let syntheticMessages = objectMessages.enumerated().compactMap { index, outboundMessage -> ProtocolTypes.InboundObjectMessage? in // RTO20d1: Skip null serials (conflated) guard let serial = publishResult.serials[index] else { logger.log("nosync_publishAndApply: operation at index \(index) will not be applied locally: serial is null in PublishResult", level: .debug) @@ -688,14 +688,14 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO enum AssociatedData { class Syncing { /// `OBJECT` ProtocolMessages that were received whilst SYNCING, to be applied once the sync sequence is complete, per RTO7a. - var bufferedObjectOperations: [InboundObjectMessage] + var bufferedObjectOperations: [ProtocolTypes.InboundObjectMessage] /// Note that we only ever populate this during a multi-`ProtocolMessage` sync sequence. It is not used in the RTO4b or RTO5a5 cases where the sync data is entirely contained within a single ProtocolMessage, because an individual ProtocolMessage is processed atomically and so no other operations that might wish to query this property can occur concurrently with the handling of these cases. /// /// It is optional because there are times that we transition to SYNCING even when the sync data is contained in a single ProtocolMessage. var syncSequence: SyncSequence? - init(bufferedObjectOperations: [InboundObjectMessage], syncSequence: SyncSequence?) { + init(bufferedObjectOperations: [ProtocolTypes.InboundObjectMessage], syncSequence: SyncSequence?) { self.bufferedObjectOperations = bufferedObjectOperations self.syncSequence = syncSequence } @@ -770,13 +770,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO /// Implements the `OBJECT_SYNC` handling of RTO5. internal mutating func nosync_handleObjectSyncProtocolMessage( - objectMessages: [InboundObjectMessage], + objectMessages: [ProtocolTypes.InboundObjectMessage], protocolMessageChannelSerial: String?, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, - receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, + receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[ProtocolTypes.InboundObjectMessage]>.Continuation, ) { logger.log("handleObjectSyncProtocolMessage(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) @@ -882,12 +882,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO /// Implements the `OBJECT` handling of RTO8. internal mutating func nosync_handleObjectProtocolMessage( - objectMessages: [InboundObjectMessage], + objectMessages: [ProtocolTypes.InboundObjectMessage], logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, - receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, + receivedObjectProtocolMessagesContinuation: AsyncStream<[ProtocolTypes.InboundObjectMessage]>.Continuation, ) { receivedObjectProtocolMessagesContinuation.yield(objectMessages) @@ -915,7 +915,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO /// Implements the `OBJECT` application of RTO9. internal mutating func nosync_applyObjectProtocolMessageObjectMessage( - _ objectMessage: InboundObjectMessage, + _ objectMessage: ProtocolTypes.InboundObjectMessage, source: ObjectsOperationSource, logger: Logger, internalQueue: DispatchQueue, diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift index d8264ed8..38d8af43 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -1,6 +1,6 @@ import Foundation -/// Same as the public ``LiveMapValue`` type but with associated values of internal type. +/// The internal representation of a LiveMap value, with associated values of internal type. internal enum InternalLiveMapValue: Sendable, Equatable { case string(String) case number(Double) @@ -11,44 +11,10 @@ internal enum InternalLiveMapValue: Sendable, Equatable { case liveMap(InternalDefaultLiveMap) case liveCounter(InternalDefaultLiveCounter) - // MARK: - Creating from a public LiveMapValue - - /// Converts a public ``LiveMapValue`` into an ``InternalLiveMapValue``. - /// - /// Needed in order to access the internals of user-provided LiveObject-valued LiveMap entries to extract their object ID. - internal init(liveMapValue: LiveMapValue) { - switch liveMapValue { - case let .string(value): - self = .string(value) - case let .number(value): - self = .number(value) - case let .bool(value): - self = .bool(value) - case let .data(value): - self = .data(value) - case let .jsonArray(value): - self = .jsonArray(value) - case let .jsonObject(value): - self = .jsonObject(value) - case let .liveMap(publicLiveMap): - guard let publicDefaultLiveMap = publicLiveMap as? PublicDefaultLiveMap else { - // TODO: Try and remove this runtime check and know this type statically, see https://github.com/ably/ably-liveobjects-swift-plugin/issues/37 - preconditionFailure("Expected PublicDefaultLiveMap, got \(publicLiveMap)") - } - self = .liveMap(publicDefaultLiveMap.proxied) - case let .liveCounter(publicLiveCounter): - guard let publicDefaultLiveCounter = publicLiveCounter as? PublicDefaultLiveCounter else { - // TODO: Try and remove this runtime check and know this type statically, see https://github.com/ably/ably-liveobjects-swift-plugin/issues/37 - preconditionFailure("Expected PublicDefaultLiveCounter, got \(publicLiveCounter)") - } - self = .liveCounter(publicDefaultLiveCounter.proxied) - } - } - // MARK: - Representation in the Realtime protocol /// Converts an `InternalLiveMapValue` to the value that should be used when creating or updating a map entry in the Realtime protocol, per the rules of RTO11f14 and RTLM20e7. - internal var nosync_toObjectData: ObjectData { + internal var nosync_toObjectData: ProtocolTypes.ObjectData { // RTO11f14c1: Create an ObjectsMapEntry for the current value switch self { case let .bool(value): diff --git a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift index 4922ddfc..90d47c81 100644 --- a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift +++ b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift @@ -9,11 +9,11 @@ internal struct InternalObjectsMapEntry: Equatable { } internal var timeserial: String? // OME2b - internal var data: ObjectData? // OME2c + internal var data: ProtocolTypes.ObjectData? // OME2c } internal extension InternalObjectsMapEntry { - init(objectsMapEntry: ObjectsMapEntry, tombstonedAt: Date?) { + init(objectsMapEntry: ProtocolTypes.ObjectsMapEntry, tombstonedAt: Date?) { self.tombstonedAt = tombstonedAt timeserial = objectsMapEntry.timeserial data = objectsMapEntry.data diff --git a/Sources/AblyLiveObjects/Internal/InternalTypes.swift b/Sources/AblyLiveObjects/Internal/InternalTypes.swift new file mode 100644 index 00000000..1b9b5025 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/InternalTypes.swift @@ -0,0 +1,65 @@ +import Ably + +// This file contains supporting types for the internal live-object engine (the callbacks, update +// descriptors and subscription-handle protocols used by `InternalDefaultLiveMap` / +// `InternalDefaultLiveCounter` and `InternalDefaultRealtimeObjects`). None of these are exposed to +// users; the public surface is the path-object / instance API (see the `Path Based API` directory). + +/// A callback used by an internal live object to listen for updates to the object. +internal typealias LiveObjectUpdateCallback = @Sendable (_ update: sending T, _ subscription: SubscribeResponse) -> Void + +/// The callback used for the lifecycle events emitted by an internal live object. +internal typealias LiveObjectLifecycleEventCallback = @Sendable (_ subscription: OnLiveObjectLifecycleEventResponse) -> Void + +/// Describes the lifecycle events emitted by an internal live object. +internal enum LiveObjectLifecycleEvent: Sendable { + /// Indicates that the object has been deleted from the Objects pool and should no longer be interacted with. + case deleted +} + +// The `ObjectsEvent` enum that these types refer to now lives in `RealtimeObject.swift` as part of +// the new public API; its cases (`.syncing` / `.synced`) are unchanged, so the internal engine +// continues to use it. The following two supporting types were part of the old public API surface +// but are still needed internally by `InternalDefaultRealtimeObjects` (e.g. `getRoot()` waits for a +// sync via `onInternal(event:callback:)`), so they are retained as `internal`. + +/// The callback used for the events emitted by ``InternalDefaultRealtimeObjects``. +internal typealias ObjectsEventCallback = @Sendable (_ subscription: OnObjectsEventResponse) -> Void + +/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. +internal protocol OnObjectsEventResponse: Sendable { + /// Deregisters the listener passed to the `on` call. + func off() +} + +/// Describes whether an entry in ``LiveMapUpdate/update`` represents an update or a removal. +internal enum LiveMapUpdateAction: Sendable { + /// The value of a key in the map was updated. + case updated + /// The value of a key in the map was removed. + case removed +} + +/// Represents an update to an internal live map (``InternalDefaultLiveMap``). +internal protocol LiveMapUpdate: Sendable { + /// The keys that have changed, along with their change status. + var update: [String: LiveMapUpdateAction] { get } +} + +/// Represents an update to an internal live counter (``InternalDefaultLiveCounter``). +internal protocol LiveCounterUpdate: Sendable { + /// Holds the numerical change to the counter value. + var amount: Double { get } +} + +/// Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. +internal protocol SubscribeResponse: Sendable { + /// Deregisters the listener passed to the `subscribe` call. + func unsubscribe() +} + +/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. +internal protocol OnLiveObjectLifecycleEventResponse: Sendable { + /// Deregisters the listener passed to the `on` call. + func off() +} diff --git a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift index 66b17485..355a82cd 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift @@ -14,7 +14,7 @@ internal enum ObjectCreationHelpers { internal var objectID: String /// The ObjectMessage that must be sent in order for Realtime to create the object. - internal var objectMessage: OutboundObjectMessage + internal var objectMessage: ProtocolTypes.OutboundObjectMessage } /// The metadata that `createMap` needs in order to request that Realtime create a LiveMap and to populate the local objects pool. @@ -25,12 +25,12 @@ internal enum ObjectCreationHelpers { internal var objectID: String /// The ObjectMessage that must be sent in order for Realtime to create the object. - internal var objectMessage: OutboundObjectMessage + internal var objectMessage: ProtocolTypes.OutboundObjectMessage /// The semantics that should be used for the created LiveMap. /// /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. - internal var semantics: ObjectsMapSemantics + internal var semantics: ProtocolTypes.ObjectsMapSemantics } /// Creates a `COUNTER_CREATE` `ObjectMessage` for the `RealtimeObjects.createCounter` method per RTO12f. @@ -63,7 +63,7 @@ internal enum ObjectCreationHelpers { ) // RTO12f7-12: Set ObjectMessage.operation fields - let operation = ObjectOperation( + let operation = ProtocolTypes.ObjectOperation( action: .known(.counterCreate), objectId: objectId, counterCreateWithObjectId: .init( @@ -74,7 +74,7 @@ internal enum ObjectCreationHelpers { ) // Create the OutboundObjectMessage - let objectMessage = OutboundObjectMessage( + let objectMessage = ProtocolTypes.OutboundObjectMessage( operation: operation, ) @@ -94,12 +94,12 @@ internal enum ObjectCreationHelpers { timestamp: Date, ) -> MapCreationOperation { // RTO11f14: Create initial value for the new LiveMap - let mapEntries = entries.mapValues { liveMapValue -> ObjectsMapEntry in - ObjectsMapEntry(data: liveMapValue.nosync_toObjectData) + let mapEntries = entries.mapValues { liveMapValue -> ProtocolTypes.ObjectsMapEntry in + ProtocolTypes.ObjectsMapEntry(data: liveMapValue.nosync_toObjectData) } - let semantics = ObjectsMapSemantics.lww - let mapCreate = MapCreate( + let semantics = ProtocolTypes.ObjectsMapSemantics.lww + let mapCreate = ProtocolTypes.MapCreate( semantics: .known(semantics), entries: mapEntries, ) @@ -122,7 +122,7 @@ internal enum ObjectCreationHelpers { ) // RTO11f9-13: Set ObjectMessage.operation fields - let operation = ObjectOperation( + let operation = ProtocolTypes.ObjectOperation( action: .known(.mapCreate), objectId: objectId, mapCreateWithObjectId: .init( @@ -133,7 +133,7 @@ internal enum ObjectCreationHelpers { ) // Create the OutboundObjectMessage - let objectMessage = OutboundObjectMessage( + let objectMessage = ProtocolTypes.OutboundObjectMessage( operation: operation, ) diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 5d36f674..b7be883f 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -33,7 +33,7 @@ internal struct ObjectsPool { /// /// - Returns: `true` if the operation was applied, `false` if it was skipped. internal func nosync_apply( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, @@ -85,7 +85,7 @@ internal struct ObjectsPool { /// - Parameters: /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone the object. fileprivate func nosync_replaceData( - using state: ObjectState, + using state: ProtocolTypes.ObjectState, objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, userCallbackQueue: DispatchQueue, @@ -350,8 +350,8 @@ internal struct ObjectsPool { /// - Precondition: `state.objectId` must not be the root object ID, in order to preserve the RTO3b invariant that the root is always a map. /// - Precondition: `state` must have either `.counter` or `.map` populated. private mutating func nosync_createObjectFromSync( - state: ObjectState, - objectMessage: InboundObjectMessage, + state: ProtocolTypes.ObjectState, + objectMessage: ProtocolTypes.InboundObjectMessage, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, diff --git a/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift b/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift index 53f43e76..8c0ea8f2 100644 --- a/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift @@ -6,7 +6,7 @@ import Foundation internal struct SyncObjectsPool: Collection { /// Keyed by `objectId`. Every value has a non-nil `.object` with either `.map` or `.counter` populated; the /// `accumulate` method enforces this invariant. - private var objectMessages: [String: InboundObjectMessage] + private var objectMessages: [String: ProtocolTypes.InboundObjectMessage] /// Creates an empty pool. internal init() { @@ -15,7 +15,7 @@ internal struct SyncObjectsPool: Collection { /// Accumulates object messages into the pool per RTO5f. internal mutating func accumulate( - _ objectMessages: [InboundObjectMessage], + _ objectMessages: [ProtocolTypes.InboundObjectMessage], logger: Logger, ) { for objectMessage in objectMessages { @@ -25,7 +25,7 @@ internal struct SyncObjectsPool: Collection { /// Accumulates a single `ObjectMessage` into the pool per RTO5f. private mutating func accumulate( - _ objectMessage: InboundObjectMessage, + _ objectMessage: ProtocolTypes.InboundObjectMessage, logger: Logger, ) { // RTO5f3: Reject unsupported object types before pool lookup. Only messages whose `.object` has `.map` or `.counter` @@ -76,8 +76,8 @@ internal struct SyncObjectsPool: Collection { // MARK: - Collection conformance - internal typealias Index = Dictionary.Values.Index - internal typealias Element = InboundObjectMessage + internal typealias Index = Dictionary.Values.Index + internal typealias Element = ProtocolTypes.InboundObjectMessage internal var startIndex: Index { objectMessages.values.startIndex } internal var endIndex: Index { objectMessages.values.endIndex } diff --git a/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterInstance.swift b/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterInstance.swift new file mode 100644 index 00000000..01ad6c7b --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterInstance.swift @@ -0,0 +1,30 @@ +import Ably + +internal final class DefaultLiveCounterInstance: LiveCounterInstance { + internal var id: String { + notImplemented() + } + + internal var value: Double { + get throws(ARTErrorInfo) { + notImplemented() + } + } + + internal func increment(amount _: Double) async throws(ARTErrorInfo) { + notImplemented() + } + + internal func decrement(amount _: Double) async throws(ARTErrorInfo) { + notImplemented() + } + + @discardableResult + internal func subscribe(listener _: @escaping InstanceSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { + notImplemented() + } + + internal func compactJson() throws(ARTErrorInfo) -> JSONValue { + notImplemented() + } +} diff --git a/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterPathObject.swift b/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterPathObject.swift new file mode 100644 index 00000000..b9377fb6 --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterPathObject.swift @@ -0,0 +1,60 @@ +import Ably + +/// Skeleton implementation of ``LiveCounterPathObject``. Every member currently traps via +/// `notImplemented()`; this is a standalone `final class` (no shared base) so that we don't commit to +/// a particular implementation shape before the path-based API is actually built. `Sendable` is a +/// checked conformance: the class holds no state. +internal final class DefaultLiveCounterPathObject: LiveCounterPathObject, Sendable { + // MARK: - PathObject + + internal var path: String { + notImplemented() + } + + internal func instance() throws(ARTErrorInfo) -> Instance? { + notImplemented() + } + + internal func compactJson() throws(ARTErrorInfo) -> JSONValue? { + notImplemented() + } + + @discardableResult + internal func subscribe(options _: PathObjectSubscriptionOptions?, listener _: @escaping PathObjectSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { + notImplemented() + } + + internal func exists() throws(ARTErrorInfo) -> Bool { + notImplemented() + } + + internal func type() throws(ARTErrorInfo) -> ValueType? { + notImplemented() + } + + internal func asLiveMap() -> any LiveMapPathObject { + notImplemented() + } + + internal func asLiveCounter() -> any LiveCounterPathObject { + notImplemented() + } + + internal func asPrimitive() -> any PrimitivePathObject { + notImplemented() + } + + // MARK: - LiveCounterPathObject + + internal func value() throws(ARTErrorInfo) -> Double? { + notImplemented() + } + + internal func increment(amount _: Double) async throws(ARTErrorInfo) { + notImplemented() + } + + internal func decrement(amount _: Double) async throws(ARTErrorInfo) { + notImplemented() + } +} diff --git a/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapInstance.swift b/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapInstance.swift new file mode 100644 index 00000000..e7720df6 --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapInstance.swift @@ -0,0 +1,46 @@ +import Ably + +internal final class DefaultLiveMapInstance: LiveMapInstance { + internal var id: String { + notImplemented() + } + + internal func get(key _: String) throws(ARTErrorInfo) -> Instance? { + notImplemented() + } + + internal func entries() throws(ARTErrorInfo) -> [(key: String, value: Instance)] { + notImplemented() + } + + internal func keys() throws(ARTErrorInfo) -> [String] { + notImplemented() + } + + internal func values() throws(ARTErrorInfo) -> [Instance] { + notImplemented() + } + + internal var size: Int { + get throws(ARTErrorInfo) { + notImplemented() + } + } + + internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { + notImplemented() + } + + internal func remove(key _: String) async throws(ARTErrorInfo) { + notImplemented() + } + + @discardableResult + internal func subscribe(listener _: @escaping InstanceSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { + notImplemented() + } + + internal func compactJson() throws(ARTErrorInfo) -> JSONValue { + notImplemented() + } +} diff --git a/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapPathObject.swift b/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapPathObject.swift new file mode 100644 index 00000000..8e361751 --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapPathObject.swift @@ -0,0 +1,80 @@ +import Ably + +/// Skeleton implementation of ``LiveMapPathObject``. Every member currently traps via +/// `notImplemented()`; this is a standalone `final class` (no shared base) so that we don't commit to +/// a particular implementation shape before the path-based API is actually built. `Sendable` is a +/// checked conformance: the class holds no state. +internal final class DefaultLiveMapPathObject: LiveMapPathObject, Sendable { + // MARK: - PathObject + + internal var path: String { + notImplemented() + } + + internal func instance() throws(ARTErrorInfo) -> Instance? { + notImplemented() + } + + internal func compactJson() throws(ARTErrorInfo) -> JSONValue? { + notImplemented() + } + + @discardableResult + internal func subscribe(options _: PathObjectSubscriptionOptions?, listener _: @escaping PathObjectSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { + notImplemented() + } + + internal func exists() throws(ARTErrorInfo) -> Bool { + notImplemented() + } + + internal func type() throws(ARTErrorInfo) -> ValueType? { + notImplemented() + } + + internal func asLiveMap() -> any LiveMapPathObject { + notImplemented() + } + + internal func asLiveCounter() -> any LiveCounterPathObject { + notImplemented() + } + + internal func asPrimitive() -> any PrimitivePathObject { + notImplemented() + } + + // MARK: - LiveMapPathObject + + internal func get(key _: String) -> any PathObject { + notImplemented() + } + + internal func at(path _: String) -> any PathObject { + notImplemented() + } + + internal func entries() throws(ARTErrorInfo) -> [(key: String, value: any PathObject)] { + notImplemented() + } + + internal func keys() throws(ARTErrorInfo) -> [String] { + notImplemented() + } + + internal func values() throws(ARTErrorInfo) -> [any PathObject] { + notImplemented() + } + + internal func size() throws(ARTErrorInfo) -> Int? { + notImplemented() + } + + internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { + notImplemented() + } + + internal func remove(key _: String) async throws(ARTErrorInfo) { + notImplemented() + } +} diff --git a/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitiveInstance.swift b/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitiveInstance.swift new file mode 100644 index 00000000..91fa0538 --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitiveInstance.swift @@ -0,0 +1,17 @@ +import Ably + +internal final class DefaultPrimitiveInstance: PrimitiveInstance { + internal var value: Primitive { + get throws(ARTErrorInfo) { + notImplemented() + } + } + + internal var type: ValueType { + notImplemented() + } + + internal func compactJson() throws(ARTErrorInfo) -> JSONValue { + notImplemented() + } +} diff --git a/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitivePathObject.swift b/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitivePathObject.swift new file mode 100644 index 00000000..ce013761 --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitivePathObject.swift @@ -0,0 +1,52 @@ +import Ably + +/// Skeleton implementation of ``PrimitivePathObject``. Every member currently traps via +/// `notImplemented()`; this is a standalone `final class` (no shared base) so that we don't commit to +/// a particular implementation shape before the path-based API is actually built. `Sendable` is a +/// checked conformance: the class holds no state. +internal final class DefaultPrimitivePathObject: PrimitivePathObject, Sendable { + // MARK: - PathObject + + internal var path: String { + notImplemented() + } + + internal func instance() throws(ARTErrorInfo) -> Instance? { + notImplemented() + } + + internal func compactJson() throws(ARTErrorInfo) -> JSONValue? { + notImplemented() + } + + @discardableResult + internal func subscribe(options _: PathObjectSubscriptionOptions?, listener _: @escaping PathObjectSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { + notImplemented() + } + + internal func exists() throws(ARTErrorInfo) -> Bool { + notImplemented() + } + + internal func type() throws(ARTErrorInfo) -> ValueType? { + notImplemented() + } + + internal func asLiveMap() -> any LiveMapPathObject { + notImplemented() + } + + internal func asLiveCounter() -> any LiveCounterPathObject { + notImplemented() + } + + internal func asPrimitive() -> any PrimitivePathObject { + notImplemented() + } + + // MARK: - PrimitivePathObject + + internal func value() throws(ARTErrorInfo) -> Primitive? { + notImplemented() + } +} diff --git a/Sources/AblyLiveObjects/Path Based API/Default/DefaultStatusSubscription.swift b/Sources/AblyLiveObjects/Path Based API/Default/DefaultStatusSubscription.swift new file mode 100644 index 00000000..9a09f524 --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Default/DefaultStatusSubscription.swift @@ -0,0 +1,7 @@ +import Ably + +internal final class DefaultStatusSubscription: StatusSubscription, Sendable { + internal func off() { + notImplemented() + } +} diff --git a/Sources/AblyLiveObjects/Path Based API/Default/DefaultSubscription.swift b/Sources/AblyLiveObjects/Path Based API/Default/DefaultSubscription.swift new file mode 100644 index 00000000..fca03081 --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Default/DefaultSubscription.swift @@ -0,0 +1,7 @@ +import Ably + +internal final class DefaultSubscription: Subscription, Sendable { + internal func unsubscribe() { + notImplemented() + } +} diff --git a/Sources/AblyLiveObjects/Path Based API/NotImplemented.swift b/Sources/AblyLiveObjects/Path Based API/NotImplemented.swift new file mode 100644 index 00000000..37d1f0f2 --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/NotImplemented.swift @@ -0,0 +1,10 @@ +/// Marks an API surface point that has not yet been implemented in this experimental target. +/// +/// Every public type in this target is currently a skeleton: the API shape is defined, but the +/// behaviour is not. Calling into any of it traps. This mirrors the requested `fail("Not implemented")` +/// behaviour; we use `fatalError` (rather than Nimble's `fail`, which is test-only and returns `Void`) +/// because it returns `Never` and therefore satisfies any return type, including `throws`/`async` +/// contexts. +internal func notImplemented(_ function: StaticString = #function) -> Never { + fatalError("Not implemented: \(function)") +} diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Path Based API/Public/Channel+Object.swift similarity index 62% rename from Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift rename to Sources/AblyLiveObjects/Path Based API/Public/Channel+Object.swift index 0beb04b0..591b4329 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Path Based API/Public/Channel+Object.swift @@ -2,12 +2,20 @@ internal import _AblyPluginSupportPrivate import Ably public extension ARTRealtimeChannel { - /// A ``RealtimeObjects`` object. - var objects: RealtimeObjects { - nonTypeErasedObjects + /// The ``RealtimeObject`` for this channel — the entry point into the LiveObjects API. + /// + /// From here, ``RealtimeObject/get()`` returns a ``LiveMapPathObject`` rooted at the channel's + /// root map, from which the rest of the object graph is navigated. + /// + /// > Note: It is a programmer error to access this property without first providing the + /// > `LiveObjects` plugin in the client options. + /// + /// Spec: `RTL27`. + var object: any RealtimeObject { + nonTypeErasedObject } - private var nonTypeErasedObjects: PublicDefaultRealtimeObjects { + private var nonTypeErasedObject: PublicDefaultRealtimeObject { let pluginAPI = Plugin.defaultPluginAPI let underlyingObjects = pluginAPI.underlyingObjects(for: asPluginPublicRealtimeChannel) let internalQueue = pluginAPI.internalQueue(for: underlyingObjects.client) @@ -25,7 +33,7 @@ public extension ARTRealtimeChannel { logger: logger, ) - return PublicObjectsStore.shared.getOrCreateRealtimeObjects( + return PublicObjectsStore.shared.getOrCreateRealtimeObject( proxying: internalObjects, creationArgs: .init( coreSDK: coreSDK, @@ -34,8 +42,8 @@ public extension ARTRealtimeChannel { ) } - /// For tests to access the non-public API of `PublicDefaultRealtimeObjects`. - internal var testsOnly_nonTypeErasedObjects: PublicDefaultRealtimeObjects { - nonTypeErasedObjects + /// For tests to access the non-public API of `PublicDefaultRealtimeObject`. + internal var testsOnly_nonTypeErasedObject: PublicDefaultRealtimeObject { + nonTypeErasedObject } } diff --git a/Sources/AblyLiveObjects/Path Based API/Public/Instance.swift b/Sources/AblyLiveObjects/Path Based API/Public/Instance.swift new file mode 100644 index 00000000..510ccbf4 --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Public/Instance.swift @@ -0,0 +1,203 @@ +import Ably + +// MARK: - Instance (RTINS / RTTS9) + +/// A direct-reference view of a `LiveObject` or primitive value. +/// +/// Unlike ``PathObject``, which is path-addressed and re-resolves on each call, an `Instance` is +/// identity-addressed: it follows the specific object it was created with, regardless of where that +/// object sits in the graph. +/// +/// An `Instance` is obtained from ``PathObject/instance()``. It is modelled as an **enum** so that +/// callers can exhaustively `switch` over the three instance kinds and obtain the correctly-typed +/// payload directly: +/// +/// ```swift +/// switch instance { +/// case let .liveMap(map): … +/// case let .liveCounter(counter): … +/// case let .primitive(primitive): … +/// } +/// ``` +/// +/// > Note: This enum shape is the Swift-specific decision recorded on **AIT-1023** (chosen over the +/// > language-agnostic base-type + `as*`-cast model of spec `RTTS9`, so that discrimination is +/// > compile-time-exhaustive and there is no undefined mismatch path). Spec: `RTINS1`, `RTTS9`. +public enum Instance: Sendable { + case liveMap(any LiveMapInstance) + case liveCounter(any LiveCounterInstance) + case primitive(any PrimitiveInstance) + + /// The type of the wrapped value. Spec: `RTTS8`. O(1). + public var type: ValueType { + switch self { + case .liveMap: + .liveMap + case .liveCounter: + .liveCounter + case let .primitive(instance): + instance.type + } + } + + /// Returns a JSON-serializable, recursively-compacted representation of the wrapped value. + /// Spec: `RTINS11`, `RTINS11c` (never `nil`), `RTTS7a`. + /// + /// - Complexity: O(n) in the size of the wrapped value's subtree. + public func compactJson() throws(ARTErrorInfo) -> JSONValue { + switch self { + case let .liveMap(instance): + try instance.compactJson() + case let .liveCounter(instance): + try instance.compactJson() + case let .primitive(instance): + try instance.compactJson() + } + } +} + +// MARK: - LiveMapInstance (RTINS / RTTS10, map subset) + +/// An ``Instance`` payload exposing the members applicable when the wrapped value is a map. +/// Spec: `RTTS10`. +public protocol LiveMapInstance: Sendable { + /// The `objectId` of the wrapped map. Spec: `RTINS3`. + var id: String { get } + + /// Looks up `key` and returns an ``Instance`` wrapping the result, or `nil` if absent. + /// Spec: `RTINS5`, `RTINS5c`. + func get(key: String) throws(ARTErrorInfo) -> Instance? + + /// Returns an array of `[key, Instance]` pairs for the wrapped map. Spec: `RTINS6`. + /// + /// - Complexity: O(n) in the number of entries. + func entries() throws(ARTErrorInfo) -> [(key: String, value: Instance)] + + /// Returns the keys of the wrapped map. Spec: `RTINS7`. + /// + /// - Complexity: O(n) in the number of entries. + func keys() throws(ARTErrorInfo) -> [String] + + /// Returns an ``Instance`` for each value of the wrapped map. Spec: `RTINS8`. + /// + /// - Complexity: O(n) in the number of entries. + func values() throws(ARTErrorInfo) -> [Instance] + + /// Returns the number of entries in the wrapped map. Spec: `RTTS10a`, `RTINS9`. + /// + /// Non-optional: an `Instance` is bound to an already-resolved map, so this always yields a value + /// (`throws` only for the `RTO25` access-precondition check). + var size: Int { get throws(ARTErrorInfo) } + + /// Sends an operation to set `key` to `value` on the wrapped map. Spec: `RTINS12`. + func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) + + /// Sends an operation to remove `key` from the wrapped map. Spec: `RTINS13`. + func remove(key: String) async throws(ARTErrorInfo) + + /// Registers a listener that is called each time the wrapped map is updated. Spec: `RTINS16`. + @discardableResult + func subscribe(listener: @escaping InstanceSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription + + /// A JSON-serializable, recursively-compacted representation of the wrapped map. + /// Spec: `RTINS11`, `RTINS11c` (never `nil`). + /// + /// - Complexity: O(n) in the size of the map's subtree. + func compactJson() throws(ARTErrorInfo) -> JSONValue +} + +// MARK: - LiveCounterInstance (RTINS / RTTS10, counter subset) + +/// An ``Instance`` payload exposing the members applicable when the wrapped value is a counter. +/// Spec: `RTTS10`. +public protocol LiveCounterInstance: Sendable { + /// The `objectId` of the wrapped counter. Spec: `RTINS3`. + var id: String { get } + + /// The current value of the wrapped counter. Spec: `RTTS10b`, `RTINS4`. + /// + /// Non-optional: an `Instance` is bound to an already-resolved counter, so this always yields a + /// value (`throws` only for the `RTO25` access-precondition check). + var value: Double { get throws(ARTErrorInfo) } + + /// Sends an operation to increment the wrapped counter. Spec: `RTINS14`. + func increment(amount: Double) async throws(ARTErrorInfo) + + /// Sends an operation to decrement the wrapped counter. Spec: `RTINS15`. + func decrement(amount: Double) async throws(ARTErrorInfo) + + /// Registers a listener that is called each time the wrapped counter is updated. Spec: `RTINS16`. + @discardableResult + func subscribe(listener: @escaping InstanceSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription + + /// A JSON-serializable, recursively-compacted representation of the wrapped counter. + /// Spec: `RTINS11`, `RTINS11c` (never `nil`). + func compactJson() throws(ARTErrorInfo) -> JSONValue +} + +public extension LiveCounterInstance { + /// Sends an operation to increment the wrapped counter by 1. Spec: `RTINS14`. + func increment() async throws(ARTErrorInfo) { + try await increment(amount: 1) + } + + /// Sends an operation to decrement the wrapped counter by 1. Spec: `RTINS15`. + func decrement() async throws(ARTErrorInfo) { + try await decrement(amount: 1) + } +} + +// MARK: - PrimitiveInstance (RTINS / RTTS10, primitive subset) + +/// An ``Instance`` payload exposing the members applicable when the wrapped value is a primitive. +/// Spec: `RTTS10`. (See ``Primitive`` for the note on collapsing the six spec primitive sub-types.) +public protocol PrimitiveInstance: Sendable { + /// The wrapped primitive value. Spec: `RTTS10c`, `RTINS4`. + /// + /// Non-optional: an `Instance` is bound to an already-resolved primitive, so this always yields a + /// value (`throws` only for the `RTO25` access-precondition check). + var value: Primitive { get throws(ARTErrorInfo) } + + /// The specific primitive type of the wrapped value (e.g. ``ValueType/string``, ``ValueType/number``). + /// Spec: `RTTS8`. O(1). + var type: ValueType { get } + + /// A JSON-serializable representation of the wrapped primitive. Spec: `RTINS11`, `RTINS11c` (never `nil`). + func compactJson() throws(ARTErrorInfo) -> JSONValue +} + +// MARK: - AsyncSequence subscription variants + +/// `AsyncStream`-based subscription for map instances. +public extension LiveMapInstance { + /// Returns an `AsyncSequence` that emits an ``InstanceSubscriptionEvent`` each time the wrapped + /// map is updated. The underlying subscription is removed when the stream is terminated. + /// Spec: `RTINS16`. + func events() throws(ARTErrorInfo) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: InstanceSubscriptionEvent.self) + let subscription = try subscribe { event in + continuation.yield(event) + } + continuation.onTermination = { _ in + subscription.unsubscribe() + } + return stream + } +} + +/// `AsyncStream`-based subscription for counter instances. +public extension LiveCounterInstance { + /// Returns an `AsyncSequence` that emits an ``InstanceSubscriptionEvent`` each time the wrapped + /// counter is updated. The underlying subscription is removed when the stream is terminated. + /// Spec: `RTINS16`. + func events() throws(ARTErrorInfo) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: InstanceSubscriptionEvent.self) + let subscription = try subscribe { event in + continuation.yield(event) + } + continuation.onTermination = { _ in + subscription.unsubscribe() + } + return stream + } +} diff --git a/Sources/AblyLiveObjects/Path Based API/Public/PathObject.swift b/Sources/AblyLiveObjects/Path Based API/Public/PathObject.swift new file mode 100644 index 00000000..9fa7c9ba --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Public/PathObject.swift @@ -0,0 +1,177 @@ +import Ably + +// MARK: - PathObject (RTPO) + +/// A lazy, path-based reference into the LiveObjects graph. +/// +/// A `PathObject` stores a path (an ordered list of string segments) from the root map and resolves +/// it at the time each method is called. This means a `PathObject` survives object replacements: if +/// the object at a given path changes, the same `PathObject` will resolve to the new object on +/// subsequent calls. +/// +/// A `PathObject` is obtained from ``RealtimeObject/get()``, which returns a ``LiveMapPathObject`` +/// rooted at the channel's root map with an empty path. Further path objects are obtained by +/// navigating with ``LiveMapPathObject/get(key:)`` or ``LiveMapPathObject/at(path:)``. +/// +/// `PathObject` is loosely typed. To obtain a view with the methods applicable to a particular +/// expected type, use ``asLiveMap()``, ``asLiveCounter()`` or ``asPrimitive()``. These do not +/// guarantee that the value actually at the path has that type; that can only be determined when the +/// value is evaluated (e.g. via `value()`), at which point `nil` is returned if the actual type +/// differs from the one requested. To discriminate the type before casting, use ``type()`` or +/// ``exists()``. +/// +/// > Note: `PathObject`'s path-resolving accessors (``instance()``, ``compactJson()``, ``exists()`` +/// > etc.) are exposed as **methods** (rather than properties) because each resolves the path at call +/// > time and is therefore O(path length) — see the Swift API Design Guidelines note on documenting +/// > non-O(1) computed properties. ``path`` is a property, since it is constant for a given +/// > `PathObject` and can be trivially cached. +/// +/// Spec: `RTPO1`. +public protocol PathObject: Sendable { + /// A dot-delimited string representation of the stored path segments. Dot characters + /// occurring within individual segments are escaped with a backslash. An empty path (the root) + /// is the empty string. Spec: `RTPO4`. + var path: String { get } + + /// Resolves the path and, if it resolves to a `LiveObject`, returns an ``Instance`` wrapping it. + /// Returns `nil` if the resolved value is a primitive or if resolution fails. Spec: `RTPO8`. + func instance() throws(ARTErrorInfo) -> Instance? + + /// Resolves the path and returns a JSON-serializable, recursively-compacted representation of the + /// resolved value, or `nil` if resolution fails. Spec: `RTPO14`. + func compactJson() throws(ARTErrorInfo) -> JSONValue? + + /// Registers a listener that is called when the object at this path is updated. + /// + /// - Parameters: + /// - options: Subscription options, such as the nesting depth to observe. + /// - listener: The listener to call with a ``PathObjectSubscriptionEvent``. + /// - Returns: A ``Subscription`` that allows the listener to be deregistered. + /// Spec: `RTPO19`. + @discardableResult + func subscribe(options: PathObjectSubscriptionOptions?, listener: @escaping PathObjectSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription + + /// Resolves the path and reports whether a value exists there. Spec: `RTTS4a`. + func exists() throws(ARTErrorInfo) -> Bool + + /// Resolves the path and returns the ``ValueType`` of the value there, or `nil` if nothing + /// resolves at the path. Spec: `RTTS4b`. + func type() throws(ARTErrorInfo) -> ValueType? + + /// Returns a view of this path object typed as a ``LiveMapPathObject``. Purely a type refinement; + /// it does not resolve the path and never throws on a type mismatch. Spec: `RTTS5a`. + func asLiveMap() -> any LiveMapPathObject + + /// Returns a view of this path object typed as a ``LiveCounterPathObject``. Purely a type + /// refinement; it does not resolve the path and never throws on a type mismatch. Spec: `RTTS5b`. + func asLiveCounter() -> any LiveCounterPathObject + + /// Returns a view of this path object typed as a ``PrimitivePathObject``. Purely a type + /// refinement; it does not resolve the path and never throws on a type mismatch. Spec: `RTTS5c`. + func asPrimitive() -> any PrimitivePathObject +} + +public extension PathObject { + /// Registers a listener that is called when the object at this path is updated, using default + /// options. Spec: `RTPO19`. + @discardableResult + func subscribe(listener: @escaping PathObjectSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { + try subscribe(options: nil, listener: listener) + } +} + +/// `AsyncStream`-based subscription. +public extension PathObject { + /// Returns an `AsyncSequence` that emits a ``PathObjectSubscriptionEvent`` each time the object at + /// this path is updated. The underlying subscription is removed when the stream is terminated. + /// Spec: `RTPO19`. + func events(options: PathObjectSubscriptionOptions? = nil) throws(ARTErrorInfo) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: PathObjectSubscriptionEvent.self) + let subscription = try subscribe(options: options) { event in + continuation.yield(event) + } + continuation.onTermination = { _ in + subscription.unsubscribe() + } + return stream + } +} + +// MARK: - LiveMapPathObject (RTPO / RTTS6, map subset) + +/// A ``PathObject`` view exposing the methods applicable when the value at the path is expected to be +/// a map. Spec: `RTTS6`. +public protocol LiveMapPathObject: PathObject { + /// Returns a new ``PathObject`` with `key` appended to this object's path. Purely navigational; + /// does not resolve the path. Spec: `RTPO5`. + func get(key: String) -> any PathObject + + /// Returns a new ``PathObject`` with the parsed segments of the dot-delimited `path` appended to + /// this object's path. Purely navigational; does not resolve the path. Spec: `RTPO6`. + func at(path: String) -> any PathObject + + /// Resolves the path and, if it resolves to a map, returns an array of `[key, PathObject]` pairs. + /// Returns an empty array if the resolved value is not a map or resolution fails. Spec: `RTPO9`. + func entries() throws(ARTErrorInfo) -> [(key: String, value: any PathObject)] + + /// Resolves the path and, if it resolves to a map, returns its keys. Returns an empty array if + /// the resolved value is not a map or resolution fails. Spec: `RTPO10`. + func keys() throws(ARTErrorInfo) -> [String] + + /// Resolves the path and, if it resolves to a map, returns a ``PathObject`` for each value. + /// Returns an empty array if the resolved value is not a map or resolution fails. Spec: `RTPO11`. + func values() throws(ARTErrorInfo) -> [any PathObject] + + /// Resolves the path and, if it resolves to a map, returns the number of entries. Returns `nil` + /// if the resolved value is not a map or resolution fails. Spec: `RTPO12`. + func size() throws(ARTErrorInfo) -> Int? + + /// Sends an operation to set `key` to `value` on the map at this path. Spec: `RTPO15`. + func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) + + /// Sends an operation to remove `key` from the map at this path. Spec: `RTPO16`. + func remove(key: String) async throws(ARTErrorInfo) +} + +// MARK: - LiveCounterPathObject (RTPO / RTTS6, counter subset) + +/// A ``PathObject`` view exposing the methods applicable when the value at the path is expected to be +/// a counter. Spec: `RTTS6`. +public protocol LiveCounterPathObject: PathObject { + /// Resolves the path and, if it resolves to a counter, returns its current value. Returns `nil` + /// if the resolved value is not a counter or resolution fails. Spec: `RTTS6b`. + func value() throws(ARTErrorInfo) -> Double? + + /// Sends an operation to increment the counter at this path. Spec: `RTPO17`. + /// + /// - Parameter amount: The amount by which to increment. + func increment(amount: Double) async throws(ARTErrorInfo) + + /// Sends an operation to decrement the counter at this path. Spec: `RTPO18`. + /// + /// - Parameter amount: The amount by which to decrement. + func decrement(amount: Double) async throws(ARTErrorInfo) +} + +public extension LiveCounterPathObject { + /// Sends an operation to increment the counter at this path by 1. Spec: `RTPO17`. + func increment() async throws(ARTErrorInfo) { + try await increment(amount: 1) + } + + /// Sends an operation to decrement the counter at this path by 1. Spec: `RTPO18`. + func decrement() async throws(ARTErrorInfo) { + try await decrement(amount: 1) + } +} + +// MARK: - PrimitivePathObject (RTPO / RTTS6, primitive subset) + +/// A ``PathObject`` view exposing the methods applicable when the value at the path is expected to be +/// a primitive. Spec: `RTTS6`. (See ``Primitive`` for the note on collapsing the six spec primitive +/// sub-types.) +public protocol PrimitivePathObject: PathObject { + /// Resolves the path and, if it resolves to a primitive, returns it. Returns `nil` if the + /// resolved value is not a primitive or resolution fails. Spec: `RTTS6b`. + func value() throws(ARTErrorInfo) -> Primitive? +} diff --git a/Sources/AblyLiveObjects/Path Based API/Public/PublicObjectMessage.swift b/Sources/AblyLiveObjects/Path Based API/Public/PublicObjectMessage.swift new file mode 100644 index 00000000..4cdb9f8d --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Public/PublicObjectMessage.swift @@ -0,0 +1,263 @@ +import Ably +import Foundation + +// The user-facing value types representing an inbound object message that carried an +// operation. These correspond to the spec's `PublicAPI::ObjectMessage` (PAOM), +// `PublicAPI::ObjectOperation` (PAOOP) and friends. +// +// They are delivered to subscription listeners (see ``PathObjectSubscriptionEvent`` / +// ``InstanceSubscriptionEvent``) so user code can inspect the metadata of the message that +// triggered an object change. They are modelled as plain `Sendable` value types rather than +// protocols: they carry no behaviour, only data. Their internal wire counterparts of the same +// name live under the ``ProtocolTypes`` namespace. + +// MARK: - ObjectMessage (PAOM) + +/// The user-facing representation of an inbound object message that carried an operation. +/// Spec: `PAOM`. +public struct ObjectMessage: Sendable, Equatable { + /// The `id` of the source object message. Spec: `PAOM2a`. + public var id: String? + /// The `clientId` of the source object message. Spec: `PAOM2b`. + public var clientId: String? + /// The `connectionId` of the source object message. Spec: `PAOM2c`. + public var connectionId: String? + /// The `timestamp` of the source object message. Spec: `PAOM2d`. + public var timestamp: Date? + /// The name of the channel on which the source object message was received. Spec: `PAOM2e`. + public var channel: String + /// The operation carried by the source object message. Spec: `PAOM2f`. + public var operation: ObjectOperation + /// The `serial` of the source object message. Spec: `PAOM2g`. + public var serial: String? + /// The `serialTimestamp` of the source object message. Spec: `PAOM2h`. + public var serialTimestamp: Date? + /// The `siteCode` of the source object message. Spec: `PAOM2i`. + public var siteCode: String? + /// The `extras` of the source object message. Spec: `PAOM2j`. + public var extras: [String: JSONValue]? + + public init( + id: String? = nil, + clientId: String? = nil, + connectionId: String? = nil, + timestamp: Date? = nil, + channel: String, + operation: ObjectOperation, + serial: String? = nil, + serialTimestamp: Date? = nil, + siteCode: String? = nil, + extras: [String: JSONValue]? = nil + ) { + self.id = id + self.clientId = clientId + self.connectionId = connectionId + self.timestamp = timestamp + self.channel = channel + self.operation = operation + self.serial = serial + self.serialTimestamp = serialTimestamp + self.siteCode = siteCode + self.extras = extras + } +} + +// MARK: - ObjectOperation (PAOOP) + +/// The user-facing representation of an object operation. It is the type of +/// ``ObjectMessage/operation``. +/// +/// Unlike the wire `ObjectOperation`, it does not carry the `mapCreateWithObjectId` / +/// `counterCreateWithObjectId` variants; those outbound-only forms are resolved back to their +/// derived ``MapCreate`` / ``CounterCreate`` forms. Spec: `PAOOP`. +public struct ObjectOperation: Sendable, Equatable { + /// The action of the operation. Spec: `PAOOP2a`. + public var action: ObjectOperationAction + /// The object ID the operation applies to. Spec: `PAOOP2b`. + public var objectId: String + /// The map-create payload, if applicable. Spec: `PAOOP2c`. + public var mapCreate: MapCreate? + /// The map-set payload, if applicable. Spec: `PAOOP2d`. + public var mapSet: MapSet? + /// The map-remove payload, if applicable. Spec: `PAOOP2e`. + public var mapRemove: MapRemove? + /// The counter-create payload, if applicable. Spec: `PAOOP2f`. + public var counterCreate: CounterCreate? + /// The counter-increment payload, if applicable. Spec: `PAOOP2g`. + public var counterInc: CounterInc? + /// The object-delete payload, if applicable. Spec: `PAOOP2h`. + public var objectDelete: ObjectDelete? + /// The map-clear payload, if applicable. Spec: `PAOOP2i`. + public var mapClear: MapClear? + + public init( + action: ObjectOperationAction, + objectId: String, + mapCreate: MapCreate? = nil, + mapSet: MapSet? = nil, + mapRemove: MapRemove? = nil, + counterCreate: CounterCreate? = nil, + counterInc: CounterInc? = nil, + objectDelete: ObjectDelete? = nil, + mapClear: MapClear? = nil + ) { + self.action = action + self.objectId = objectId + self.mapCreate = mapCreate + self.mapSet = mapSet + self.mapRemove = mapRemove + self.counterCreate = counterCreate + self.counterInc = counterInc + self.objectDelete = objectDelete + self.mapClear = mapClear + } +} + +// MARK: - ObjectOperationAction (OOP2) + +/// The set of actions that an ``ObjectOperation`` can represent. Spec: `OOP2`. +public enum ObjectOperationAction: Sendable, Equatable { + case mapCreate + case mapSet + case mapRemove + case counterCreate + case counterInc + case objectDelete + case mapClear +} + +// MARK: - Operation payloads + +/// The map-create operation payload. Spec: `MCR`. +public struct MapCreate: Sendable, Equatable { + /// The conflict-resolution semantics for the map. Spec: `MCR2a`. + public var semantics: ObjectsMapSemantics + /// The initial entries for the map. Spec: `MCR2b`. + public var entries: [String: ObjectsMapEntry] + + public init(semantics: ObjectsMapSemantics, entries: [String: ObjectsMapEntry]) { + self.semantics = semantics + self.entries = entries + } +} + +/// The map-set operation payload. Spec: `MST`. +public struct MapSet: Sendable, Equatable { + /// The key being set. Spec: `MST2a`. + public var key: String + /// The value being set. Spec: `MST2b`. + public var value: ObjectData + + public init(key: String, value: ObjectData) { + self.key = key + self.value = value + } +} + +/// The map-remove operation payload. Spec: `MRM`. +public struct MapRemove: Sendable, Equatable { + /// The key being removed. Spec: `MRM2a`. + public var key: String + + public init(key: String) { + self.key = key + } +} + +/// The counter-create operation payload. Spec: `CCR`. +public struct CounterCreate: Sendable, Equatable { + /// The initial count. Spec: `CCR2a`. + public var count: Double + + public init(count: Double) { + self.count = count + } +} + +/// The counter-increment operation payload. Spec: `CIN`. +public struct CounterInc: Sendable, Equatable { + /// The amount to increment by. Spec: `CIN2a`. + public var number: Double + + public init(number: Double) { + self.number = number + } +} + +/// The object-delete operation payload. Spec: `ODE`. +public struct ObjectDelete: Sendable, Equatable { + public init() {} +} + +/// The map-clear operation payload. Spec: `MCL`. +public struct MapClear: Sendable, Equatable { + public init() {} +} + +// MARK: - Supporting wire types + +/// The conflict-resolution semantics for a map. Spec: `OMP2`. +public enum ObjectsMapSemantics: Sendable, Equatable { + /// Last-write-wins. Spec: `OMP2`. + case lww +} + +/// A single entry within a ``MapCreate`` payload. Spec: `OME`. +public struct ObjectsMapEntry: Sendable, Equatable { + /// Whether this entry is tombstoned (removed). Spec: `OME2a`. + public var tombstone: Bool? + /// The timeserial at which this entry was last updated. Spec: `OME2b`. + public var timeserial: String? + /// The serial timestamp at which this entry was last updated. Spec: `OME2d`. + public var serialTimestamp: Date? + /// The entry's data. Spec: `OME2c`. + public var data: ObjectData? + + public init( + tombstone: Bool? = nil, + timeserial: String? = nil, + serialTimestamp: Date? = nil, + data: ObjectData? = nil + ) { + self.tombstone = tombstone + self.timeserial = timeserial + self.serialTimestamp = serialTimestamp + self.data = data + } +} + +/// The data value carried by a map entry or map-set operation. Spec: `OD`. +public struct ObjectData: Sendable, Equatable { + /// The object ID, if this data references a `LiveObject`. Spec: `OD2a`. + public var objectId: String? + /// The encoding applied to the data. Spec: `OD2b`. + public var encoding: String? + /// A boolean value. Spec: `OD2c`. + public var boolean: Bool? + /// A binary value. Spec: `OD2d`. + public var bytes: Data? + /// A numeric value. Spec: `OD2e`. + public var number: Double? + /// A string value. Spec: `OD2f`. + public var string: String? + /// A JSON-encoded value. Spec: `OD2g`. + public var json: String? + + public init( + objectId: String? = nil, + encoding: String? = nil, + boolean: Bool? = nil, + bytes: Data? = nil, + number: Double? = nil, + string: String? = nil, + json: String? = nil + ) { + self.objectId = objectId + self.encoding = encoding + self.boolean = boolean + self.bytes = bytes + self.number = number + self.string = string + self.json = json + } +} diff --git a/Sources/AblyLiveObjects/Path Based API/Public/RealtimeObject.swift b/Sources/AblyLiveObjects/Path Based API/Public/RealtimeObject.swift new file mode 100644 index 00000000..5773cc8a --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Public/RealtimeObject.swift @@ -0,0 +1,32 @@ +import Ably + +/// Describes the events emitted by a ``RealtimeObject``. Spec: `RTO18b`. +public enum ObjectsEvent: Sendable { + /// The local copy of Objects on a channel is currently being synchronized with the Ably service. + case syncing + /// The local copy of Objects on a channel has been synchronized with the Ably service. + case synced +} + +/// Enables the Objects on a channel to be read, modified and subscribed to, via path objects. +/// +/// This is the entry point into the public LiveObjects API. ``get()`` returns a +/// ``LiveMapPathObject`` rooted at the channel's root map, from which the rest of the graph is +/// navigated. Spec: `RTO`. +public protocol RealtimeObject: Sendable { + /// Returns a ``LiveMapPathObject`` rooted at the channel's root map with an empty path, once the + /// objects are synchronized with the Ably service. Spec: `RTO23`. + func get() async throws(ARTErrorInfo) -> any LiveMapPathObject + + /// Registers the provided listener for the specified event. + /// + /// To deregister the listener, call ``StatusSubscription/off()`` on the returned subscription. + /// + /// - Parameters: + /// - event: The event to listen for. + /// - callback: The listener to call when the event is emitted. + /// - Returns: A ``StatusSubscription`` that allows the listener to be deregistered. + /// Spec: `RTO18`. + @discardableResult + func on(event: ObjectsEvent, callback: @escaping @Sendable () -> Void) -> any StatusSubscription +} diff --git a/Sources/AblyLiveObjects/Path Based API/Public/Subscriptions.swift b/Sources/AblyLiveObjects/Path Based API/Public/Subscriptions.swift new file mode 100644 index 00000000..cfac146a --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Public/Subscriptions.swift @@ -0,0 +1,67 @@ +import Ably + +// MARK: - Subscription (SUB) + +/// A registration for receiving events from a subscribe operation. Spec: `SUB`. +public protocol Subscription: Sendable { + /// Deregisters the listener registered by the corresponding `subscribe` call. Once called, the + /// listener must not be called for any subsequent events. Calling more than once is a no-op. + /// Spec: `SUB2a`, `SUB2b`. + func unsubscribe() +} + +// MARK: - StatusSubscription (RTO18f) + +/// Object returned from ``RealtimeObject/on(event:callback:)``, allowing the listener provided in +/// that call to be deregistered. Spec: `RTO18f`. +public protocol StatusSubscription: Sendable { + /// Deregisters the listener passed to the `on` call. Spec: `RTO18f1`. + func off() +} + +// MARK: - PathObject subscription + +/// The event delivered to a ``PathObject/subscribe(options:listener:)`` listener. Spec: `RTPO19e`. +public struct PathObjectSubscriptionEvent: Sendable { + /// A ``PathObject`` pointing to the path where the change occurred. Spec: `RTPO19e1`. + public let object: any PathObject + /// The object message that triggered this event, if available. Spec: `RTPO19e2`. + public let message: ObjectMessage? + + public init(object: any PathObject, message: ObjectMessage? = nil) { + self.object = object + self.message = message + } +} + +/// Options for ``PathObject/subscribe(options:listener:)``. Spec: `RTPO19c`. +public struct PathObjectSubscriptionOptions: Sendable { + /// Controls how many levels of path nesting below the subscription path trigger the listener. + /// Defaults to `nil`. If provided, must be a positive integer. Spec: `RTPO19c1`. + public let depth: Int? + + public init(depth: Int? = nil) { + self.depth = depth + } +} + +/// The callback used by ``PathObject/subscribe(options:listener:)``. Spec: `RTPO19a1`. +public typealias PathObjectSubscriptionCallback = @Sendable (_ event: PathObjectSubscriptionEvent) -> Void + +// MARK: - Instance subscription + +/// The event delivered to an ``Instance`` subscribe listener. Spec: `RTINS16e`. +public struct InstanceSubscriptionEvent: Sendable { + /// An ``Instance`` wrapping the underlying object. Spec: `RTINS16e1`. + public let object: Instance + /// The object message that triggered this event, if available. Spec: `RTINS16e2`. + public let message: ObjectMessage? + + public init(object: Instance, message: ObjectMessage? = nil) { + self.object = object + self.message = message + } +} + +/// The callback used by an ``Instance`` subscribe. Spec: `RTINS16a1`. +public typealias InstanceSubscriptionCallback = @Sendable (_ event: InstanceSubscriptionEvent) -> Void diff --git a/Sources/AblyLiveObjects/Path Based API/Public/ValueTypes.swift b/Sources/AblyLiveObjects/Path Based API/Public/ValueTypes.swift new file mode 100644 index 00000000..de713cb4 --- /dev/null +++ b/Sources/AblyLiveObjects/Path Based API/Public/ValueTypes.swift @@ -0,0 +1,268 @@ +import Ably +import Foundation + +// MARK: - ValueType + +/// Identifies the type of the value at a path or wrapped by an ``Instance``. Spec: `RTTS2`. +/// +/// Used by ``PathObject/type()`` to discriminate a path's resolved type before casting, and by +/// ``PrimitiveInstance/type`` / ``Instance/type`` to identify the wrapped value's type. +/// +/// > Note: the spec name for this type is `ValueType` (`RTTS2`). A rename to `TypeOfValue` has been +/// > suggested on specification #491 but is not yet decided; this SDK follows the current spec name. +public enum ValueType: Sendable, Equatable { + case string + case number + case boolean + case binary + case jsonObject + case jsonArray + case liveMap + case liveCounter + /// The resolved value has an unrecognised type. Spec: `RTTS2`. + case unknown +} + +// MARK: - Primitive + +/// Represents a primitive value that can be stored at a path in the LiveObjects graph. +/// +/// A ``PrimitivePathObject`` or ``PrimitiveInstance`` resolves to a `Primitive` when its +/// ``PrimitivePathObject/value()`` (resp. ``PrimitiveInstance/value``) is read and the underlying +/// value is in fact a primitive. +/// +/// > Note: **Deliberate divergence from `RTTS6c`/`RTTS10c`.** The spec defines six separate primitive +/// > path-object and instance sub-types (`StringPathObject`, `NumberPathObject`, … / `StringInstance`, +/// > `NumberInstance`, …), each with a type-filtered `value`. This SDK instead collapses them into a +/// > single ``PrimitivePathObject``/``PrimitiveInstance`` that resolves to this `Primitive` enum, +/// > which callers pattern-match. This is a Swift-idiomatic consolidation agreed for this SDK; it +/// > means `value` returns whatever primitive resolved rather than being pre-filtered to one type. +public enum Primitive: Sendable, Equatable { + case string(String) + case number(Double) + case bool(Bool) + case data(Data) + case jsonArray([JSONValue]) + case jsonObject([String: JSONValue]) + + // MARK: - Convenience getters for associated values + + /// If this `Primitive` has case `string`, this returns the associated value. Else, it returns `nil`. + public var stringValue: String? { + if case let .string(value) = self { + return value + } + return nil + } + + /// If this `Primitive` has case `number`, this returns the associated value. Else, it returns `nil`. + public var numberValue: Double? { + if case let .number(value) = self { + return value + } + return nil + } + + /// If this `Primitive` has case `bool`, this returns the associated value. Else, it returns `nil`. + public var boolValue: Bool? { + if case let .bool(value) = self { + return value + } + return nil + } + + /// If this `Primitive` has case `data`, this returns the associated value. Else, it returns `nil`. + public var dataValue: Data? { + if case let .data(value) = self { + return value + } + return nil + } + + /// If this `Primitive` has case `jsonArray`, this returns the associated value. Else, it returns `nil`. + public var jsonArrayValue: [JSONValue]? { + if case let .jsonArray(value) = self { + return value + } + return nil + } + + /// If this `Primitive` has case `jsonObject`, this returns the associated value. Else, it returns `nil`. + public var jsonObjectValue: [String: JSONValue]? { + if case let .jsonObject(value) = self { + return value + } + return nil + } +} + +// MARK: - LiveCounter (value type, RTLCV) + +/// A value type describing a new `LiveCounter` to be created, for use as a value passed to +/// ``LiveMapPathObject/set(key:value:)`` (or the equivalent on ``LiveMapInstance``). +/// +/// This is **not** a live, synchronized counter; it is a lightweight, local description of the +/// counter to create. The live counter is created on the Ably system when this value is set into the +/// graph. Spec: `RTLCV`. +public struct LiveCounter: Sendable, Equatable { + /// The initial count for the counter. Spec: `RTLCV2a`. + internal let count: Double + + private init(count: Double) { + self.count = count + } + + /// Creates a new `LiveCounter` value type with the provided initial count. + /// + /// - Parameter initialCount: The initial value for the new counter. + /// Spec: `RTLCV3`. + public static func create(initialCount: Double) -> LiveCounter { + .init(count: initialCount) + } + + /// Creates a new `LiveCounter` value type with an initial count of zero. + /// Spec: `RTLCV3`. + public static func create() -> LiveCounter { + .init(count: 0) + } +} + +// MARK: - LiveMap (value type, RTLMV) + +/// A value type describing a new `LiveMap` to be created, for use as a value passed to +/// ``LiveMapPathObject/set(key:value:)`` (or the equivalent on ``LiveMapInstance``). +/// +/// This is **not** a live, synchronized map; it is a lightweight, local description of the map to +/// create. The live map is created on the Ably system when this value is set into the graph. +/// Spec: `RTLMV`. +public struct LiveMap: Sendable, Equatable { + /// The initial entries for the map. Spec: `RTLMV2a`. + internal let entries: [String: LiveMapValue]? + + private init(entries: [String: LiveMapValue]?) { + self.entries = entries + } + + /// Creates a new `LiveMap` value type with the provided initial entries. + /// + /// - Parameter entries: The initial entries for the new map. + /// Spec: `RTLMV3`. + public static func create(entries: [String: LiveMapValue]) -> LiveMap { + .init(entries: entries) + } + + /// Creates a new empty `LiveMap` value type. + /// Spec: `RTLMV3`. + public static func create() -> LiveMap { + .init(entries: nil) + } +} + +// MARK: - LiveMapValue + +/// Represents the type of data that can be stored for a given key in a map, when *writing* to the +/// graph via ``LiveMapPathObject/set(key:value:)`` or ``LiveMapInstance/set(key:value:)``. +/// +/// It may be a primitive value (string, number, boolean, binary data, JSON array, or JSON object), +/// or a new ``LiveMap``/``LiveCounter`` value type to be created. +/// +/// `LiveMapValue` implements Swift's `ExpressibleBy*Literal` protocols. This, in combination with +/// `JSONValue`'s conformance to these protocols, allows you to write type-safe map values using +/// familiar syntax. For example: +/// +/// ```swift +/// try await root.asLiveMap().set(key: "someStringKey", value: "someString") +/// try await root.asLiveMap().set(key: "someJSONObjectKey", value: [ +/// "someNestedJSONObjectKey": [ +/// "someOtherKey": "someOtherValue", +/// ], +/// ]) +/// ``` +public enum LiveMapValue: Sendable, Equatable { + /// A primitive value (string, number, boolean, binary data, JSON array, or JSON object). + case primitive(Primitive) + case liveMap(LiveMap) + case liveCounter(LiveCounter) + + // MARK: - Convenience getters for associated values + + /// If this `LiveMapValue` has case `primitive`, this returns the associated value. Else, it returns `nil`. + public var primitiveValue: Primitive? { + if case let .primitive(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `liveMap`, this returns the associated value. Else, it returns `nil`. + public var liveMapValue: LiveMap? { + if case let .liveMap(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `liveCounter`, this returns the associated value. Else, it returns `nil`. + public var liveCounterValue: LiveCounter? { + if case let .liveCounter(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` wraps a `string` primitive, this returns the associated value. Else, it returns `nil`. + public var stringValue: String? { primitiveValue?.stringValue } + + /// If this `LiveMapValue` wraps a `number` primitive, this returns the associated value. Else, it returns `nil`. + public var numberValue: Double? { primitiveValue?.numberValue } + + /// If this `LiveMapValue` wraps a `bool` primitive, this returns the associated value. Else, it returns `nil`. + public var boolValue: Bool? { primitiveValue?.boolValue } + + /// If this `LiveMapValue` wraps a `data` primitive, this returns the associated value. Else, it returns `nil`. + public var dataValue: Data? { primitiveValue?.dataValue } + + /// If this `LiveMapValue` wraps a `jsonArray` primitive, this returns the associated value. Else, it returns `nil`. + public var jsonArrayValue: [JSONValue]? { primitiveValue?.jsonArrayValue } + + /// If this `LiveMapValue` wraps a `jsonObject` primitive, this returns the associated value. Else, it returns `nil`. + public var jsonObjectValue: [String: JSONValue]? { primitiveValue?.jsonObjectValue } +} + +// MARK: - ExpressibleBy*Literal conformances + +extension LiveMapValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .primitive(.jsonObject(.init(uniqueKeysWithValues: elements))) + } +} + +extension LiveMapValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .primitive(.jsonArray(elements)) + } +} + +extension LiveMapValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .primitive(.string(value)) + } +} + +extension LiveMapValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .primitive(.number(Double(value))) + } +} + +extension LiveMapValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .primitive(.number(value)) + } +} + +extension LiveMapValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .primitive(.bool(value)) + } +} diff --git a/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift b/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift index 48cf75a5..556b05f1 100644 --- a/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift +++ b/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift @@ -1,9 +1,9 @@ -internal extension InboundObjectMessage { +internal extension ProtocolTypes.InboundObjectMessage { /// Creates a synthetic inbound message from an outbound message, per RTO20d2 and RTO20d3. /// /// Used to apply a locally-published operation upon receipt of the ACK from Realtime. - static func createSynthetic(from outboundMessage: OutboundObjectMessage, serial: String, siteCode: String) -> InboundObjectMessage { - InboundObjectMessage( + static func createSynthetic(from outboundMessage: ProtocolTypes.OutboundObjectMessage, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage { + ProtocolTypes.InboundObjectMessage( id: outboundMessage.id, clientId: outboundMessage.clientId, connectionId: outboundMessage.connectionId, diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 9ca106f5..fcbe3270 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -4,112 +4,125 @@ import Foundation // This file contains the ObjectMessage types that we use within the codebase. We convert them to and from the corresponding wire types (e.g. `InboundWireObjectMessage`) for sending and receiving over the wire. -/// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. -internal struct InboundObjectMessage: Equatable { - internal var id: String? // OM2a - internal var clientId: String? // OM2b - internal var connectionId: String? // OM2c - internal var extras: [String: JSONValue]? // OM2d - internal var timestamp: Date? // OM2e - internal var operation: ObjectOperation? // OM2f - internal var object: ObjectState? // OM2g - internal var serial: String? // OM2h - internal var siteCode: String? // OM2i - internal var serialTimestamp: Date? // OM2j -} - -/// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. +/// Namespace for the internal "protocol" representations of an object message and its constituent +/// operations, states and data. /// -/// - Important: When adding new fields, also update ``InboundObjectMessage/createSynthetic(from:serial:siteCode:)``. -internal struct OutboundObjectMessage: Equatable { - internal var id: String? // OM2a - internal var clientId: String? // OM2b - internal var connectionId: String? - internal var extras: [String: JSONValue]? // OM2d - internal var timestamp: Date? // OM2e - internal var operation: ObjectOperation? // OM2f - internal var object: ObjectState? // OM2g - internal var serial: String? // OM2h - internal var siteCode: String? // OM2i - internal var serialTimestamp: Date? // OM2j -} +/// These types are scoped under `ProtocolTypes` to disambiguate them from the identically-named +/// public value types (e.g. `ObjectOperation` / `ObjectData`) that the SDK exposes to users; see the +/// `Path Based API` directory. They mirror the wire types (e.g. ``InboundWireObjectMessage``) but +/// with decoded, strongly-typed payloads. +/// +/// > Note: The spec-suggested name for this namespace was `Protocol`, but that clashes with the +/// > Objective-C `Protocol` type imported via Foundation (ambiguous for importers such as the test +/// > target), so `ProtocolTypes` is used instead. +internal enum ProtocolTypes { + /// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. + internal struct InboundObjectMessage: Equatable { + internal var id: String? // OM2a + internal var clientId: String? // OM2b + internal var connectionId: String? // OM2c + internal var extras: [String: JSONValue]? // OM2d + internal var timestamp: Date? // OM2e + internal var operation: ObjectOperation? // OM2f + internal var object: ObjectState? // OM2g + internal var serial: String? // OM2h + internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j + } -internal struct ObjectOperation: Equatable { - internal var action: WireEnum // OOP3a - internal var objectId: String // OOP3b - internal var mapCreate: MapCreate? // OOP3j - internal var mapSet: MapSet? // OOP3k - internal var mapRemove: WireMapRemove? // OOP3l - internal var counterCreate: WireCounterCreate? // OOP3m - internal var counterInc: WireCounterInc? // OOP3n - internal var objectDelete: WireObjectDelete? // OOP3o - internal var mapCreateWithObjectId: MapCreateWithObjectId? // OOP3p - internal var counterCreateWithObjectId: CounterCreateWithObjectId? // OOP3q - internal var mapClear: WireMapClear? // OOP3r -} + /// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. + /// + /// - Important: When adding new fields, also update ``InboundObjectMessage/createSynthetic(from:serial:siteCode:)``. + internal struct OutboundObjectMessage: Equatable { + internal var id: String? // OM2a + internal var clientId: String? // OM2b + internal var connectionId: String? + internal var extras: [String: JSONValue]? // OM2d + internal var timestamp: Date? // OM2e + internal var operation: ObjectOperation? // OM2f + internal var object: ObjectState? // OM2g + internal var serial: String? // OM2h + internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j + } -internal struct ObjectData: Equatable { - internal var objectId: String? // OD2a - internal var boolean: Bool? // OD2c - internal var bytes: Data? // OD2d - internal var number: NSNumber? // OD2e - internal var string: String? // OD2f - internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) -} + internal struct ObjectOperation: Equatable { + internal var action: WireEnum // OOP3a + internal var objectId: String // OOP3b + internal var mapCreate: MapCreate? // OOP3j + internal var mapSet: MapSet? // OOP3k + internal var mapRemove: WireMapRemove? // OOP3l + internal var counterCreate: WireCounterCreate? // OOP3m + internal var counterInc: WireCounterInc? // OOP3n + internal var objectDelete: WireObjectDelete? // OOP3o + internal var mapCreateWithObjectId: MapCreateWithObjectId? // OOP3p + internal var counterCreateWithObjectId: CounterCreateWithObjectId? // OOP3q + internal var mapClear: WireMapClear? // OOP3r + } -internal struct MapSet: Equatable { - internal var key: String // MST2a - internal var value: ObjectData? // MST2b -} + internal struct ObjectData: Equatable { + internal var objectId: String? // OD2a + internal var boolean: Bool? // OD2c + internal var bytes: Data? // OD2d + internal var number: NSNumber? // OD2e + internal var string: String? // OD2f + internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + } -internal struct MapCreate: Equatable { - internal var semantics: WireEnum // MCR2a - internal var entries: [String: ObjectsMapEntry]? // MCR2b -} + internal struct MapSet: Equatable { + internal var key: String // MST2a + internal var value: ObjectData? // MST2b + } -internal struct MapCreateWithObjectId: Equatable { - internal var initialValue: String // MCRO2a - internal var nonce: String // MCRO2b + internal struct MapCreate: Equatable { + internal var semantics: WireEnum // MCR2a + internal var entries: [String: ObjectsMapEntry]? // MCR2b + } - /// The source `MapCreate` from which this `MapCreateWithObjectId` was derived. - /// For local use only (apply-on-ACK per RTLM23); must not be sent over the wire. - /// - SeeAlso: RTO11f18 - internal var derivedFrom: MapCreate? -} + internal struct MapCreateWithObjectId: Equatable { + internal var initialValue: String // MCRO2a + internal var nonce: String // MCRO2b -internal struct CounterCreateWithObjectId: Equatable { - internal var initialValue: String // CCRO2a - internal var nonce: String // CCRO2b + /// The source `MapCreate` from which this `MapCreateWithObjectId` was derived. + /// For local use only (apply-on-ACK per RTLM23); must not be sent over the wire. + /// - SeeAlso: RTO11f18 + internal var derivedFrom: MapCreate? + } - /// The source `WireCounterCreate` from which this `CounterCreateWithObjectId` was derived. - /// For local use only (apply-on-ACK per RTLC16); must not be sent over the wire. - /// - SeeAlso: RTO12f16 - internal var derivedFrom: WireCounterCreate? -} + internal struct CounterCreateWithObjectId: Equatable { + internal var initialValue: String // CCRO2a + internal var nonce: String // CCRO2b -internal struct ObjectsMapEntry: Equatable { - internal var tombstone: Bool? // OME2a - internal var timeserial: String? // OME2b - internal var data: ObjectData? // OME2c - internal var serialTimestamp: Date? // OME2d -} + /// The source `WireCounterCreate` from which this `CounterCreateWithObjectId` was derived. + /// For local use only (apply-on-ACK per RTLC16); must not be sent over the wire. + /// - SeeAlso: RTO12f16 + internal var derivedFrom: WireCounterCreate? + } -internal struct ObjectsMap: Equatable { - internal var semantics: WireEnum // OMP3a - internal var entries: [String: ObjectsMapEntry]? // OMP3b - internal var clearTimeserial: String? // OMP3c -} + internal struct ObjectsMapEntry: Equatable { + internal var tombstone: Bool? // OME2a + internal var timeserial: String? // OME2b + internal var data: ObjectData? // OME2c + internal var serialTimestamp: Date? // OME2d + } -internal struct ObjectState: Equatable { - internal var objectId: String // OST2a - internal var siteTimeserials: [String: String] // OST2b - internal var tombstone: Bool // OST2c - internal var createOp: ObjectOperation? // OST2d - internal var map: ObjectsMap? // OST2e - internal var counter: WireObjectsCounter? // OST2f + internal struct ObjectsMap: Equatable { + internal var semantics: WireEnum // OMP3a + internal var entries: [String: ObjectsMapEntry]? // OMP3b + internal var clearTimeserial: String? // OMP3c + } + + internal struct ObjectState: Equatable { + internal var objectId: String // OST2a + internal var siteTimeserials: [String: String] // OST2b + internal var tombstone: Bool // OST2c + internal var createOp: ObjectOperation? // OST2d + internal var map: ObjectsMap? // OST2e + internal var counter: WireObjectsCounter? // OST2f + } } -internal extension InboundObjectMessage { +internal extension ProtocolTypes.InboundObjectMessage { /// Initializes an `InboundObjectMessage` from an `InboundWireObjectMessage`, applying the data decoding rules of OD5. /// /// - Parameters: @@ -136,7 +149,7 @@ internal extension InboundObjectMessage { } } -internal extension OutboundObjectMessage { +internal extension ProtocolTypes.OutboundObjectMessage { /// Converts this `OutboundObjectMessage` to an `OutboundWireObjectMessage`, applying the data encoding rules of OD4. /// /// - Parameters: @@ -157,7 +170,7 @@ internal extension OutboundObjectMessage { } } -internal extension ObjectOperation { +internal extension ProtocolTypes.ObjectOperation { /// Initializes an `ObjectOperation` from a `WireObjectOperation`, applying the data decoding rules of OD5. /// /// - Parameters: @@ -207,7 +220,7 @@ internal extension ObjectOperation { } } -internal extension ObjectData { +internal extension ProtocolTypes.ObjectData { /// Initializes an `ObjectData` from a `WireObjectData`, applying the data decoding rules of OD5. /// /// - Parameters: @@ -311,7 +324,7 @@ internal extension ObjectData { } } -internal extension MapSet { +internal extension ProtocolTypes.MapSet { init( wireMapSet: WireMapSet, format: _AblyPluginSupportPrivate.EncodingFormat @@ -330,7 +343,7 @@ internal extension MapSet { } } -internal extension MapCreate { +internal extension ProtocolTypes.MapCreate { init( wireMapCreate: WireMapCreate, format: _AblyPluginSupportPrivate.EncodingFormat @@ -349,7 +362,7 @@ internal extension MapCreate { } } -internal extension MapCreateWithObjectId { +internal extension ProtocolTypes.MapCreateWithObjectId { init(wireMapCreateWithObjectId: WireMapCreateWithObjectId) { nonce = wireMapCreateWithObjectId.nonce initialValue = wireMapCreateWithObjectId.initialValue @@ -360,7 +373,7 @@ internal extension MapCreateWithObjectId { } } -internal extension CounterCreateWithObjectId { +internal extension ProtocolTypes.CounterCreateWithObjectId { init(wireCounterCreateWithObjectId: WireCounterCreateWithObjectId) { nonce = wireCounterCreateWithObjectId.nonce initialValue = wireCounterCreateWithObjectId.initialValue @@ -371,7 +384,7 @@ internal extension CounterCreateWithObjectId { } } -internal extension ObjectsMapEntry { +internal extension ProtocolTypes.ObjectsMapEntry { /// Initializes an `ObjectsMapEntry` from a `WireObjectsMapEntry`, applying the data decoding rules of OD5. /// /// - Parameters: @@ -404,7 +417,7 @@ internal extension ObjectsMapEntry { } } -internal extension ObjectsMap { +internal extension ProtocolTypes.ObjectsMap { /// Initializes an `ObjectsMap` from a `WireObjectsMap`, applying the data decoding rules of OD5. /// /// - Parameters: @@ -434,7 +447,7 @@ internal extension ObjectsMap { } } -internal extension ObjectState { +internal extension ProtocolTypes.ObjectState { /// Initializes an `ObjectState` from a `WireObjectState`, applying the data decoding rules of OD5. /// /// - Parameters: @@ -474,7 +487,7 @@ internal extension ObjectState { // MARK: - CustomDebugStringConvertible -extension InboundObjectMessage: CustomDebugStringConvertible { +extension ProtocolTypes.InboundObjectMessage: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -493,7 +506,7 @@ extension InboundObjectMessage: CustomDebugStringConvertible { } } -extension OutboundObjectMessage: CustomDebugStringConvertible { +extension ProtocolTypes.OutboundObjectMessage: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -512,7 +525,7 @@ extension OutboundObjectMessage: CustomDebugStringConvertible { } } -extension ObjectOperation: CustomDebugStringConvertible { +extension ProtocolTypes.ObjectOperation: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -532,7 +545,7 @@ extension ObjectOperation: CustomDebugStringConvertible { } } -extension ObjectState: CustomDebugStringConvertible { +extension ProtocolTypes.ObjectState: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -547,7 +560,7 @@ extension ObjectState: CustomDebugStringConvertible { } } -extension ObjectsMap: CustomDebugStringConvertible { +extension ProtocolTypes.ObjectsMap: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -566,7 +579,7 @@ extension ObjectsMap: CustomDebugStringConvertible { } } -extension ObjectsMapEntry: CustomDebugStringConvertible { +extension ProtocolTypes.ObjectsMapEntry: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -579,7 +592,7 @@ extension ObjectsMapEntry: CustomDebugStringConvertible { } } -extension ObjectData: CustomDebugStringConvertible { +extension ProtocolTypes.ObjectData: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -594,7 +607,7 @@ extension ObjectData: CustomDebugStringConvertible { } } -extension MapSet: CustomDebugStringConvertible { +extension ProtocolTypes.MapSet: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -605,7 +618,7 @@ extension MapSet: CustomDebugStringConvertible { } } -extension MapCreate: CustomDebugStringConvertible { +extension ProtocolTypes.MapCreate: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -623,7 +636,7 @@ extension MapCreate: CustomDebugStringConvertible { } } -extension MapCreateWithObjectId: CustomDebugStringConvertible { +extension ProtocolTypes.MapCreateWithObjectId: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -635,7 +648,7 @@ extension MapCreateWithObjectId: CustomDebugStringConvertible { } } -extension CounterCreateWithObjectId: CustomDebugStringConvertible { +extension ProtocolTypes.CounterCreateWithObjectId: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 8647e733..e4009da6 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -143,24 +143,26 @@ extension OutboundWireObjectMessage: WireObjectEncodable { } } -// OOP2 -internal enum ObjectOperationAction: Int { - case mapCreate = 0 - case mapSet = 1 - case mapRemove = 2 - case counterCreate = 3 - case counterInc = 4 - case objectDelete = 5 - case mapClear = 6 -} +internal extension ProtocolTypes { + // OOP2 + enum ObjectOperationAction: Int { + case mapCreate = 0 + case mapSet = 1 + case mapRemove = 2 + case counterCreate = 3 + case counterInc = 4 + case objectDelete = 5 + case mapClear = 6 + } -// OMP2 -internal enum ObjectsMapSemantics: Int { - case lww = 0 + // OMP2 + enum ObjectsMapSemantics: Int { + case lww = 0 + } } internal struct WireObjectOperation { - internal var action: WireEnum // OOP3a + internal var action: WireEnum // OOP3a internal var objectId: String // OOP3b internal var mapCreate: WireMapCreate? // OOP3j internal var mapSet: WireMapSet? // OOP3k @@ -297,7 +299,7 @@ extension WireObjectState: WireObjectCodable { } internal struct WireObjectsMap { - internal var semantics: WireEnum // OMP3a + internal var semantics: WireEnum // OMP3a internal var entries: [String: WireObjectsMapEntry]? // OMP3b internal var clearTimeserial: String? // OMP3c } @@ -408,7 +410,7 @@ extension WireMapRemove: WireObjectCodable { } internal struct WireMapCreate { - internal var semantics: WireEnum // MCR2a + internal var semantics: WireEnum // MCR2a internal var entries: [String: WireObjectsMapEntry]? // MCR2b } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift deleted file mode 100644 index 6e0419a7..00000000 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift +++ /dev/null @@ -1,51 +0,0 @@ -internal import _AblyPluginSupportPrivate - -internal extension InternalLiveMapValue { - // MARK: - Mapping to public types - - struct PublicValueCreationArgs { - internal var coreSDK: CoreSDK - internal var realtimeObjects: any InternalRealtimeObjectsProtocol - internal var logger: Logger - - internal var toCounterCreationArgs: PublicObjectsStore.CounterCreationArgs { - .init(coreSDK: coreSDK, realtimeObjects: realtimeObjects, logger: logger) - } - - internal var toMapCreationArgs: PublicObjectsStore.MapCreationArgs { - .init(coreSDK: coreSDK, realtimeObjects: realtimeObjects, logger: logger) - } - } - - /// Fetches the cached public object that wraps this `InternalLiveMapValue`'s associated value, creating a new public object if there isn't already one. - func toPublic(creationArgs: PublicValueCreationArgs) -> LiveMapValue { - switch self { - case let .string(value): - .string(value) - case let .number(value): - .number(value) - case let .bool(value): - .bool(value) - case let .data(value): - .data(value) - case let .jsonArray(value): - .jsonArray(value) - case let .jsonObject(value): - .jsonObject(value) - case let .liveMap(internalLiveMap): - .liveMap( - PublicObjectsStore.shared.getOrCreateMap( - proxying: internalLiveMap, - creationArgs: creationArgs.toMapCreationArgs, - ), - ) - case let .liveCounter(internalLiveCounter): - .liveCounter( - PublicObjectsStore.shared.getOrCreateCounter( - proxying: internalLiveCounter, - creationArgs: creationArgs.toCounterCreationArgs, - ), - ) - } - } -} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift deleted file mode 100644 index 53995a79..00000000 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ /dev/null @@ -1,54 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// Our default implementation of ``LiveCounter``. -/// -/// This is largely a wrapper around ``InternalDefaultLiveCounter``. -internal final class PublicDefaultLiveCounter: LiveCounter { - internal let proxied: InternalDefaultLiveCounter - - // MARK: - Dependencies that hold a strong reference to `proxied` - - private let coreSDK: CoreSDK - private let realtimeObjects: any InternalRealtimeObjectsProtocol - private let logger: Logger - - internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK, realtimeObjects: any InternalRealtimeObjectsProtocol, logger: Logger) { - self.proxied = proxied - self.coreSDK = coreSDK - self.realtimeObjects = realtimeObjects - self.logger = logger - } - - // MARK: - `LiveCounter` protocol - - internal var value: Double { - get throws(ARTErrorInfo) { - try proxied.value(coreSDK: coreSDK) - } - } - - internal func increment(amount: Double) async throws(ARTErrorInfo) { - try await proxied.increment(amount: amount, coreSDK: coreSDK, realtimeObjects: realtimeObjects) - } - - internal func decrement(amount: Double) async throws(ARTErrorInfo) { - try await proxied.decrement(amount: amount, coreSDK: coreSDK, realtimeObjects: realtimeObjects) - } - - internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { - try proxied.subscribe(listener: listener, coreSDK: coreSDK) - } - - internal func unsubscribeAll() { - proxied.unsubscribeAll() - } - - internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { - proxied.on(event: event, callback: callback) - } - - internal func offAll() { - proxied.offAll() - } -} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift deleted file mode 100644 index d3b003cf..00000000 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ /dev/null @@ -1,103 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// Our default implementation of ``LiveMap``. -/// -/// This is largely a wrapper around ``InternalDefaultLiveMap``. -internal final class PublicDefaultLiveMap: LiveMap { - internal let proxied: InternalDefaultLiveMap - - // MARK: - Dependencies that hold a strong reference to `proxied` - - private let coreSDK: CoreSDK - private let realtimeObjects: any InternalRealtimeObjectsProtocol - private let logger: Logger - - internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, realtimeObjects: any InternalRealtimeObjectsProtocol, logger: Logger) { - self.proxied = proxied - self.coreSDK = coreSDK - self.realtimeObjects = realtimeObjects - self.logger = logger - } - - // MARK: - `LiveMap` protocol - - internal func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? { - try proxied.get(key: key, coreSDK: coreSDK, delegate: realtimeObjects)?.toPublic( - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: realtimeObjects, - logger: logger, - ), - ) - } - - internal var size: Int { - get throws(ARTErrorInfo) { - try proxied.size(coreSDK: coreSDK, delegate: realtimeObjects) - } - } - - internal var entries: [(key: String, value: LiveMapValue)] { - get throws(ARTErrorInfo) { - try proxied.entries(coreSDK: coreSDK, delegate: realtimeObjects).map { entry in - ( - entry.key, - entry.value.toPublic( - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: realtimeObjects, - logger: logger, - ), - ) - ) - } - } - } - - internal var keys: [String] { - get throws(ARTErrorInfo) { - try proxied.keys(coreSDK: coreSDK, delegate: realtimeObjects) - } - } - - internal var values: [LiveMapValue] { - get throws(ARTErrorInfo) { - try proxied.values(coreSDK: coreSDK, delegate: realtimeObjects).map { value in - value.toPublic( - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: realtimeObjects, - logger: logger, - ), - ) - } - } - } - - internal func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) { - let internalValue = InternalLiveMapValue(liveMapValue: value) - - try await proxied.set(key: key, value: internalValue, coreSDK: coreSDK, realtimeObjects: realtimeObjects) - } - - internal func remove(key: String) async throws(ARTErrorInfo) { - try await proxied.remove(key: key, coreSDK: coreSDK, realtimeObjects: realtimeObjects) - } - - internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { - try proxied.subscribe(listener: listener, coreSDK: coreSDK) - } - - internal func unsubscribeAll() { - proxied.unsubscribeAll() - } - - internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { - proxied.on(event: event, callback: callback) - } - - internal func offAll() { - proxied.offAll() - } -} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift new file mode 100644 index 00000000..cfcd0684 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift @@ -0,0 +1,42 @@ +import Ably + +/// The default implementation of the public ``RealtimeObject`` entry point, backing +/// ``ARTRealtimeChannel/object``. +/// +/// This is largely a wrapper around ``InternalDefaultRealtimeObjects``. The `Public` prefix +/// expresses the contrast with that internal type, per the documented memory-management policy (the +/// public proxy holds a strong reference to the internal object, not vice versa); hence it lives +/// alongside the other proxy objects in `Public/Public Proxy Objects`. +internal final class PublicDefaultRealtimeObject: RealtimeObject { + private let proxied: InternalDefaultRealtimeObjects + + // MARK: - Dependencies that hold a strong reference to `proxied` + + private let coreSDK: CoreSDK + private let logger: Logger + + internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK, logger: Logger) { + self.proxied = proxied + self.coreSDK = coreSDK + self.logger = logger + } + + internal var testsOnly_proxied: InternalDefaultRealtimeObjects { + proxied + } + + internal var testsOnly_coreSDK: CoreSDK { + coreSDK + } + + // MARK: - `RealtimeObject` protocol + + internal func get() async throws(ARTErrorInfo) -> any LiveMapPathObject { + notImplemented() + } + + @discardableResult + internal func on(event _: ObjectsEvent, callback _: @escaping @Sendable () -> Void) -> any StatusSubscription { + notImplemented() + } +} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift deleted file mode 100644 index 1dd1fb3a..00000000 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ /dev/null @@ -1,131 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. -/// -/// This is largely a wrapper around ``InternalDefaultRealtimeObjects``. -internal final class PublicDefaultRealtimeObjects: RealtimeObjects { - private let proxied: InternalDefaultRealtimeObjects - internal var testsOnly_proxied: InternalDefaultRealtimeObjects { - proxied - } - - // MARK: - Dependencies that hold a strong reference to `proxied` - - private let coreSDK: CoreSDK - private let logger: Logger - - internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK, logger: Logger) { - self.proxied = proxied - self.coreSDK = coreSDK - self.logger = logger - } - - // MARK: - `RealtimeObjects` protocol - - internal func getRoot() async throws(ARTErrorInfo) -> any LiveMap { - let internalMap = try await proxied.getRoot(coreSDK: coreSDK) - return PublicObjectsStore.shared.getOrCreateMap( - proxying: internalMap, - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: proxied, - logger: logger, - ), - ) - } - - internal func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { - let internalEntries: [String: InternalLiveMapValue] = entries.mapValues { .init(liveMapValue: $0) } - let internalMap = try await proxied.createMap(entries: internalEntries, coreSDK: coreSDK) - - return PublicObjectsStore.shared.getOrCreateMap( - proxying: internalMap, - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: proxied, - logger: logger, - ), - ) - } - - internal func createMap() async throws(ARTErrorInfo) -> any LiveMap { - let internalMap = try await proxied.createMap(coreSDK: coreSDK) - - return PublicObjectsStore.shared.getOrCreateMap( - proxying: internalMap, - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: proxied, - logger: logger, - ), - ) - } - - internal func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter { - let internalCounter = try await proxied.createCounter(count: count, coreSDK: coreSDK) - - return PublicObjectsStore.shared.getOrCreateCounter( - proxying: internalCounter, - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: proxied, - logger: logger, - ), - ) - } - - internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter { - let internalCounter = try await proxied.createCounter(coreSDK: coreSDK) - - return PublicObjectsStore.shared.getOrCreateCounter( - proxying: internalCounter, - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: proxied, - logger: logger, - ), - ) - } - - internal func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { - proxied.on(event: event, callback: callback) - } - - internal func offAll() { - proxied.offAll() - } - - // MARK: - Test-only APIs - - // These are only used by our plumbingSmokeTest (the rest of our unit tests test the internal classes, not the public ones). - - internal var testsOnly_onChannelAttachedHasObjects: Bool? { - proxied.testsOnly_onChannelAttachedHasObjects - } - - internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { - proxied.testsOnly_receivedObjectProtocolMessages - } - - internal func testsOnly_publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) { - try await proxied.testsOnly_publish(objectMessages: objectMessages, coreSDK: coreSDK) - } - - internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { - proxied.testsOnly_receivedObjectSyncProtocolMessages - } - - // These are used by the integration tests. - - /// Replaces the method that this `RealtimeObjects` uses to send any outbound `ObjectMessage`s. - /// - /// Used by integration tests, for example to disable `ObjectMessage` publishing so that a test can verify that a behaviour is not a side effect of an `ObjectMessage` sent by the SDK. - internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { - coreSDK.testsOnly_overridePublish(with: newImplementation) - } - - internal var testsOnly_gcGracePeriod: TimeInterval { - proxied.testsOnly_gcGracePeriod - } -} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift index 86521085..01ab44d5 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift @@ -1,17 +1,17 @@ -internal import _AblyPluginSupportPrivate import Foundation -/// Stores the public objects that wrap the SDK's internal components. +/// Stores the public path-based objects that wrap the SDK's internal components. /// -/// This allows us to provide stable object identity for our public `RealtimeObjects`, `LiveMap`, and `LiveCounter` objects. Concretely, this means that it allows us to, for example, consistently return: +/// This allows us to provide stable object identity for our public objects. Concretely, it allows us +/// to consistently return the same `PublicDefaultRealtimeObject` instance across multiple calls to +/// `ARTRealtimeChannel.object`. It mirrors the mechanism previously used for the (now-removed) +/// `objects` API. /// -/// - the same `PublicDefaultRealtimeObjects` instance across multiple calls to `ARTRealtimeChannel.objects` -/// - the same `PublicDefaultLiveMap` instance across multiple calls to `PublicDefaultRealtimeObjects.getRoot()` -/// - the same `PublicDefaultLiveMap` and `PublicDefaultLiveCounter` instance across multiple calls to `PublicDefaultLiveMap.get(…)` with the same key (similarly for other `LiveMap` getters) -/// -/// This differs from the approach that we take in ably-cocoa, in which we create a new public object each time we need to return one. Given that the LiveObjects SDK revolves around the concept of various live-updating objects, it seemed like it might be quite a confusing user experience if the pointer identity of, say, a `LiveMap` changed each time it was fetched. -/// -/// - Note: We can only make a best-effort attempt to maintain the pointer identity of the public objects. Since the SDK cannot maintain a strong reference to the public objects (given that the whole reason that these objects exist is for us to know whether the user holds a strong reference to them), if the user releases all of their strong references to a public object then the next time they fetch the public object they will receive a new object. +/// - Note: We can only make a best-effort attempt to maintain the pointer identity of the public +/// objects. Since the SDK cannot maintain a strong reference to the public objects (given that the +/// whole reason that these objects exist is for us to know whether the user holds a strong reference +/// to them), if the user releases all of their strong references to a public object then the next +/// time they fetch the public object they will receive a new object. internal final class PublicObjectsStore: Sendable { // Used to synchronize access to mutable state private let mutex = NSLock() @@ -19,48 +19,20 @@ internal final class PublicObjectsStore: Sendable { internal static let shared = PublicObjectsStore() - internal struct RealtimeObjectsCreationArgs { - internal var coreSDK: CoreSDK - internal var logger: Logger - } - - /// Fetches the cached `PublicDefaultRealtimeObjects` that wraps a given `InternalDefaultRealtimeObjects`, creating a new public object if there isn't already one. - internal func getOrCreateRealtimeObjects(proxying proxied: InternalDefaultRealtimeObjects, creationArgs: RealtimeObjectsCreationArgs) -> PublicDefaultRealtimeObjects { - mutex.withLock { - mutableState.getOrCreateRealtimeObjects(proxying: proxied, creationArgs: creationArgs) - } - } - - internal struct CounterCreationArgs { + internal struct RealtimeObjectCreationArgs { internal var coreSDK: CoreSDK - internal var realtimeObjects: any InternalRealtimeObjectsProtocol internal var logger: Logger } - /// Fetches the cached `PublicDefaultLiveCounter` that wraps a given `InternalDefaultLiveCounter`, creating a new public object if there isn't already one. - internal func getOrCreateCounter(proxying proxied: InternalDefaultLiveCounter, creationArgs: CounterCreationArgs) -> PublicDefaultLiveCounter { + /// Fetches the cached `PublicDefaultRealtimeObject` that wraps a given `InternalDefaultRealtimeObjects`, creating a new public object if there isn't already one. + internal func getOrCreateRealtimeObject(proxying proxied: InternalDefaultRealtimeObjects, creationArgs: RealtimeObjectCreationArgs) -> PublicDefaultRealtimeObject { mutex.withLock { - mutableState.getOrCreateCounter(proxying: proxied, creationArgs: creationArgs) - } - } - - internal struct MapCreationArgs { - internal var coreSDK: CoreSDK - internal var realtimeObjects: any InternalRealtimeObjectsProtocol - internal var logger: Logger - } - - /// Fetches the cached `PublicDefaultLiveMap` that wraps a given `InternalDefaultLiveMap`, creating a new public object if there isn't already one. - internal func getOrCreateMap(proxying proxied: InternalDefaultLiveMap, creationArgs: MapCreationArgs) -> PublicDefaultLiveMap { - mutex.withLock { - mutableState.getOrCreateMap(proxying: proxied, creationArgs: creationArgs) + mutableState.getOrCreateRealtimeObject(proxying: proxied, creationArgs: creationArgs) } } private struct MutableState { - private var realtimeObjectsProxies = Proxies() - private var counterProxies = Proxies() - private var mapProxies = Proxies() + private var realtimeObjectProxies = Proxies() /// Stores weak references to proxy objects. private struct Proxies { @@ -104,54 +76,18 @@ internal final class PublicObjectsStore: Sendable { } } - internal mutating func getOrCreateRealtimeObjects( + internal mutating func getOrCreateRealtimeObject( proxying proxied: InternalDefaultRealtimeObjects, - creationArgs: RealtimeObjectsCreationArgs, - ) -> PublicDefaultRealtimeObjects { - realtimeObjectsProxies.getOrCreate( - proxying: proxied, - logger: creationArgs.logger, - logObjectType: "RealtimeObjects", - ) { - .init( - proxied: proxied, - coreSDK: creationArgs.coreSDK, - logger: creationArgs.logger, - ) - } - } - - internal mutating func getOrCreateCounter( - proxying proxied: InternalDefaultLiveCounter, - creationArgs: CounterCreationArgs, - ) -> PublicDefaultLiveCounter { - counterProxies.getOrCreate( - proxying: proxied, - logger: creationArgs.logger, - logObjectType: "LiveCounter", - ) { - .init( - proxied: proxied, - coreSDK: creationArgs.coreSDK, - realtimeObjects: creationArgs.realtimeObjects, - logger: creationArgs.logger, - ) - } - } - - internal mutating func getOrCreateMap( - proxying proxied: InternalDefaultLiveMap, - creationArgs: MapCreationArgs, - ) -> PublicDefaultLiveMap { - mapProxies.getOrCreate( + creationArgs: RealtimeObjectCreationArgs, + ) -> PublicDefaultRealtimeObject { + realtimeObjectProxies.getOrCreate( proxying: proxied, logger: creationArgs.logger, - logObjectType: "LiveMap", + logObjectType: "RealtimeObject", ) { .init( proxied: proxied, coreSDK: creationArgs.coreSDK, - realtimeObjects: creationArgs.realtimeObjects, logger: creationArgs.logger, ) } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift deleted file mode 100644 index 086ee986..00000000 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ /dev/null @@ -1,389 +0,0 @@ -import Ably - -/// A callback used in ``LiveObject`` to listen for updates to the object. -/// -/// - 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``. -/// -/// - 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``. -/// - Parameter subscription: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to deregister itself from future updates. -public typealias LiveObjectLifecycleEventCallback = @Sendable (_ subscription: OnLiveObjectLifecycleEventResponse) -> Void - -/// Describes the events emitted by an ``RealtimeObjects`` object. -public enum ObjectsEvent: Sendable { - /// The local copy of Objects on a channel is currently being synchronized with the Ably service. - case syncing - /// The local copy of Objects on a channel has been synchronized with the Ably service. - case synced -} - -/// Describes the events emitted by a ``LiveObject`` object. -public enum LiveObjectLifecycleEvent: Sendable { - /// Indicates that the object has been deleted from the Objects pool and should no longer be interacted with. - case deleted -} - -/// Enables the Objects to be read, modified and subscribed to for a channel. -public protocol RealtimeObjects: Sendable { - /// Retrieves the root ``LiveMap`` object for Objects on a channel. - func getRoot() async throws(ARTErrorInfo) -> any LiveMap - - /// Creates a new ``LiveMap`` object instance with the provided entries. - /// - /// - Parameter entries: The initial entries for the new ``LiveMap`` object. - func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap - - /// Creates a new empty ``LiveMap`` object instance. - func createMap() async throws(ARTErrorInfo) -> any LiveMap - - /// Creates a new ``LiveCounter`` object instance with the provided `count` value. - /// - /// - Parameter count: The initial value for the new ``LiveCounter`` object. - func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter - - /// Creates a new ``LiveCounter`` object instance with a value of zero. - func createCounter() async throws(ARTErrorInfo) -> any LiveCounter - - /// Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. - /// - /// - Parameters: - /// - event: The named event to listen for. - /// - callback: The event listener. - /// - Returns: An ``OnObjectsEventResponse`` object that allows the provided listener to be deregistered from future updates. - @discardableResult - func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> OnObjectsEventResponse - - /// Deregisters all registrations, for all events and listeners. - func offAll() -} - -/// Represents the type of data stored for a given key in a ``LiveMap``. -/// It may be a primitive value (string, number, boolean, binary data, JSON array, or JSON object), or another ``LiveObject``. -/// -/// `LiveMapValue` implements Swift's `ExpressibleBy*Literal` protocols. This, in combination with `JSONValue`'s conformance to these protocols, allows you to write type-safe map values using familiar syntax. For example: -/// -/// ```swift -/// let map = try await channel.objects.createMap(entries: [ -/// "someStringKey": "someString", -/// "someIntegerKey": 123, -/// "someFloatKey": 123.456, -/// "someTrueKey": true, -/// "someFalseKey": false, -/// "someJSONObjectKey": [ -/// "someNestedJSONObjectKey": [ -/// "someOtherKey": "someOtherValue", -/// ], -/// ], -/// "someJSONArrayKey": [ -/// "foo", -/// 42, -/// ], -/// ]) -/// ``` -public enum LiveMapValue: Sendable, Equatable { - case string(String) - case number(Double) - case bool(Bool) - case data(Data) - case jsonArray([JSONValue]) - case jsonObject([String: JSONValue]) - case liveMap(any LiveMap) - case liveCounter(any LiveCounter) - - // MARK: - Convenience getters for associated values - - /// If this `LiveMapValue` has case `liveMap`, this returns the associated value. Else, it returns `nil`. - public var liveMapValue: (any LiveMap)? { - if case let .liveMap(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `liveCounter`, this returns the associated value. Else, it returns `nil`. - public var liveCounterValue: (any LiveCounter)? { - if case let .liveCounter(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `string`, this returns the associated value. Else, it returns `nil`. - public var stringValue: String? { - if case let .string(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `number`, this returns the associated value. Else, it returns `nil`. - public var numberValue: Double? { - if case let .number(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `bool`, this returns the associated value. Else, it returns `nil`. - public var boolValue: Bool? { - if case let .bool(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `data`, this returns the associated value. Else, it returns `nil`. - public var dataValue: Data? { - if case let .data(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `jsonArray`, this returns the associated value. Else, it returns `nil`. - public var jsonArrayValue: [JSONValue]? { - if case let .jsonArray(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `jsonObject`, this returns the associated value. Else, it returns `nil`. - public var jsonObjectValue: [String: JSONValue]? { - if case let .jsonObject(value) = self { - return value - } - return nil - } - - // MARK: - Equatable Implementation - - public static func == (lhs: LiveMapValue, rhs: LiveMapValue) -> Bool { - switch (lhs, rhs) { - case let (.string(lhsValue), .string(rhsValue)): - lhsValue == rhsValue - case let (.number(lhsValue), .number(rhsValue)): - lhsValue == rhsValue - case let (.bool(lhsValue), .bool(rhsValue)): - lhsValue == rhsValue - case let (.data(lhsValue), .data(rhsValue)): - lhsValue == rhsValue - case let (.jsonArray(lhsValue), .jsonArray(rhsValue)): - lhsValue == rhsValue - case let (.jsonObject(lhsValue), .jsonObject(rhsValue)): - lhsValue == rhsValue - case let (.liveMap(lhsMap), .liveMap(rhsMap)): - lhsMap === rhsMap - case let (.liveCounter(lhsCounter), .liveCounter(rhsCounter)): - lhsCounter === rhsCounter - default: - false - } - } -} - -// MARK: - ExpressibleBy*Literal conformances - -extension LiveMapValue: ExpressibleByDictionaryLiteral { - public init(dictionaryLiteral elements: (String, JSONValue)...) { - self = .jsonObject(.init(uniqueKeysWithValues: elements)) - } -} - -extension LiveMapValue: ExpressibleByArrayLiteral { - public init(arrayLiteral elements: JSONValue...) { - self = .jsonArray(elements) - } -} - -extension LiveMapValue: ExpressibleByStringLiteral { - public init(stringLiteral value: String) { - self = .string(value) - } -} - -extension LiveMapValue: ExpressibleByIntegerLiteral { - public init(integerLiteral value: Int) { - self = .number(Double(value)) - } -} - -extension LiveMapValue: ExpressibleByFloatLiteral { - public init(floatLiteral value: Double) { - self = .number(value) - } -} - -extension LiveMapValue: ExpressibleByBooleanLiteral { - public init(booleanLiteral value: Bool) { - self = .bool(value) - } -} - -/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. -public protocol OnObjectsEventResponse: Sendable { - /// Deregisters the listener passed to the `on` call. - func off() -} - -/// The `LiveMap` class represents a key-value map data structure, similar to a Swift `Dictionary`, where all changes are synchronized across clients in realtime. -/// Conflicts in a LiveMap are automatically resolved with last-write-wins (LWW) semantics, -/// meaning that if two clients update the same key in the map, the update with the most recent timestamp wins. -/// -/// Keys must be strings. Values can be another ``LiveObject``, or a primitive type, such as a string, number, boolean, JSON-serializable object or array, or binary data. -public protocol LiveMap: LiveObject where Update == LiveMapUpdate { - /// Returns the value associated with a given key. Returns `nil` if the key doesn't exist in a map or if the associated ``LiveObject`` has been deleted. - /// - /// Always returns `nil` if this map object is deleted. - /// - /// - Parameter key: The key to retrieve the value for. - /// - Returns: A ``LiveObject``, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `nil` if the key doesn't exist in a map or the associated ``LiveObject`` has been deleted. Always `nil` if this map object is deleted. - func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? - - /// Returns the number of key-value pairs in the map. - var size: Int { get throws(ARTErrorInfo) } - - /// Returns an array of key-value pairs for every entry in the map. - var entries: [(key: String, value: LiveMapValue)] { get throws(ARTErrorInfo) } - - /// Returns an array of keys in the map. - var keys: [String] { get throws(ARTErrorInfo) } - - /// Returns an iterable of values in the map. - var values: [LiveMapValue] { get throws(ARTErrorInfo) } - - /// Sends an operation to the Ably system to set a key on this `LiveMap` object to a specified value. - /// - /// This does not modify the underlying data of this object. Instead, the change is applied when - /// the published operation is echoed back to the client and applied to the object. - /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. - /// - /// - Parameters: - /// - key: The key to set the value for. - /// - value: The value to assign to the key. - func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) - - /// Sends an operation to the Ably system to remove a key from this `LiveMap` object. - /// - /// This does not modify the underlying data of this object. Instead, the change is applied when - /// the published operation is echoed back to the client and applied to the object. - /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. - /// - /// - Parameter key: The key to remove. - func remove(key: String) async throws(ARTErrorInfo) -} - -/// Describes whether an entry in ``LiveMapUpdate/update`` represents an update or a removal. -public enum LiveMapUpdateAction: Sendable { - /// The value of a key in the map was updated. - case updated - /// The value of a key in the map was removed. - case removed -} - -/// Represents an update to a ``LiveMap`` object, describing the keys that were updated or removed. -public protocol LiveMapUpdate: Sendable { - /// An object containing keys from a `LiveMap` that have changed, along with their change status: - /// - ``LiveMapUpdateAction/updated`` - the value of a key in the map was updated. - /// - ``LiveMapUpdateAction/removed`` - the key was removed from the map. - var update: [String: LiveMapUpdateAction] { get } -} - -/// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. -public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { - /// Returns the current value of the counter. - var value: Double { get throws(ARTErrorInfo) } - - /// Sends an operation to the Ably system to increment the value of this `LiveCounter` object. - /// - /// This does not modify the underlying data of this object. Instead, the change is applied when - /// the published operation is echoed back to the client and applied to the object. - /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. - /// - /// - Parameter amount: The amount by which to increase the counter value. - func increment(amount: Double) async throws(ARTErrorInfo) - - /// An alias for calling [`increment(-amount)`](doc:LiveCounter/increment(amount:)). - /// - /// - Parameter amount: The amount by which to decrease the counter value. - func decrement(amount: Double) async throws(ARTErrorInfo) -} - -/// Represents an update to a ``LiveCounter`` object. -public protocol LiveCounterUpdate: Sendable { - /// Holds the numerical change to the counter value. - var amount: Double { get } -} - -/// Describes the common interface for all conflict-free data structures supported by the Objects. -public protocol LiveObject: AnyObject, Sendable { - /// The type of update event that this object emits. - associatedtype Update - - /// Registers a listener that is called each time this LiveObject is updated. - /// - /// - 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) throws(ARTErrorInfo) -> SubscribeResponse - - /// Deregisters all listeners from updates for this LiveObject. - func unsubscribeAll() - - /// Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. - /// - /// - Parameters: - /// - event: The named event to listen for. - /// - callback: The event listener. - /// - Returns: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to be deregistered from future updates. - @discardableResult - func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse - - /// Deregisters all registrations, for all events and listeners. - func offAll() -} - -/// Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. -public protocol SubscribeResponse: Sendable { - /// Deregisters the listener passed to the `subscribe` call. - func unsubscribe() -} - -/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. -public protocol OnLiveObjectLifecycleEventResponse: Sendable { - /// Deregisters the listener passed to the `on` call. - func off() -} - -// MARK: - AsyncSequence Extensions - -/// Extension to provide AsyncSequence-based subscription for `LiveObject` updates. -public extension LiveObject { - /// Returns an `AsyncSequence` that emits updates to this `LiveObject`. - /// - /// This provides an alternative to the callback-based ``subscribe(listener:)`` method, - /// allowing you to use Swift's structured concurrency features like `for await` loops. - /// - /// - Returns: An AsyncSequence that emits ``Update`` values when the object is updated. - /// - Throws: An ``ARTErrorInfo`` if the subscription fails. - func updates() throws(ARTErrorInfo) -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream(of: Update.self) - - let subscription = try subscribe { update, _ in - continuation.yield(update) - } - - continuation.onTermination = { _ in - subscription.unsubscribe() - } - - return stream - } -} diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index fbb9ef89..f16d2432 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -20,8 +20,8 @@ struct AblyLiveObjectsTests { // Then - // Check that the `channel.objects` property works and gives the internal type we expect - #expect(channel.objects is PublicDefaultRealtimeObjects) + // Check that the `channel.object` property works and gives the internal type we expect + #expect(channel.object is PublicDefaultRealtimeObject) } /// A basic test of the core interactions between this plugin and ably-cocoa. @@ -79,11 +79,17 @@ struct AblyLiveObjectsTests { let channel = realtime.channels.get(channelName, options: channelOptions) try await channel.attachAsync() + // Go through the real public proxy plumbing (rather than a test-only reimplementation of it) + // to reach the internal objects and a `CoreSDK` for publishing. + let object = channel.testsOnly_nonTypeErasedObject + let objects = object.testsOnly_proxied + let coreSDK = object.testsOnly_coreSDK + // 3. Check that ably-cocoa called our onChannelAttached and passed the HAS_OBJECTS flag. - #expect(channel.testsOnly_nonTypeErasedObjects.testsOnly_onChannelAttachedHasObjects == true) + #expect(objects.testsOnly_onChannelAttachedHasObjects == true) // 4. Check that ably-cocoa used us to decode the ObjectMessages in the OBJECT_SYNC, and then called our handleObjectSyncProtocolMessage with these ObjectMessages; we expect the OBJECT_SYNC to contain the root object and the map that we created in the REST call above. - let objectSyncObjectMessages = try #require(await channel.testsOnly_nonTypeErasedObjects.testsOnly_receivedObjectSyncProtocolMessages.first { _ in true }) + let objectSyncObjectMessages = try #require(await objects.testsOnly_receivedObjectSyncProtocolMessages.first { _ in true }) #expect(Set(objectSyncObjectMessages.map(\.object?.objectId)) == ["root", restCreatedMapObjectID]) // 5. Now, send an OBJECT ProtocolMessage that creates a new Map. This confirms that Ably is using us to encode this ProtocolMessage's contained ObjectMessages. @@ -96,8 +102,8 @@ struct AblyLiveObjectsTests { timestamp: Date(timeIntervalSince1970: Double(currentAblyTimestamp) / 1000), ) - try await channel.testsOnly_nonTypeErasedObjects.testsOnly_publish(objectMessages: [ - OutboundObjectMessage( + try await objects.testsOnly_publish(objectMessages: [ + ProtocolTypes.OutboundObjectMessage( operation: .init( action: .known(.mapCreate), objectId: realtimeCreatedMapObjectID, @@ -105,10 +111,10 @@ struct AblyLiveObjectsTests { mapCreateWithObjectId: .init(initialValue: initialValueJSON, nonce: "1"), ), ), - ]) + ], coreSDK: coreSDK) // 6. Check that ably-cocoa used us to decode the ObjectMessages in the OBJECT triggered by this map creation, and then called our handleObjectProtocolMessage with these ObjectMessages; we expect the OBJECT to contain the map create operation that we just performed. - let objectObjectMessages = try #require(await channel.testsOnly_nonTypeErasedObjects.testsOnly_receivedObjectProtocolMessages.first { _ in true }) + let objectObjectMessages = try #require(await objects.testsOnly_receivedObjectProtocolMessages.first { _ in true }) try #require(objectObjectMessages.count == 1) let receivedMapCreateObjectMessage = objectObjectMessages[0] #expect(receivedMapCreateObjectMessage.operation?.objectId == realtimeCreatedMapObjectID) @@ -116,76 +122,13 @@ struct AblyLiveObjectsTests { // 7. Now, send an invalid OBJECT ProtocolMessage to check that ably-cocoa correctly reports on its NACK. let invalidObjectThrownError = try await #require(throws: ARTErrorInfo.self) { - try await channel.testsOnly_nonTypeErasedObjects.testsOnly_publish(objectMessages: [ + try await objects.testsOnly_publish(objectMessages: [ .init(), - ]) + ], coreSDK: coreSDK) } // (These are just based on what I observed in the NACK) #expect(invalidObjectThrownError.code == 92000) #expect(invalidObjectThrownError.message == "invalid object message: object operation required") } - - /// A basic test of the public API of the LiveObjects plugin. - @Test(arguments: [true, false]) - func smokeTest(useBinaryProtocol: Bool) async throws { - let client = try await ClientHelper.realtimeWithObjects(options: .init(useBinaryProtocol: useBinaryProtocol)) - let channel = client.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) - try await channel.attachAsync() - - let root = try await channel.objects.getRoot() - let rootSubscription = try root.updates() - - // Create a counter - let counter = try await channel.objects.createCounter(count: 52) - let counterSubscription = try counter.updates() - - // Create a map and check its initial entries - let map = try await channel.objects.createMap(entries: [ - "boolKey": true, - "numberKey": 10, - ]) - #expect( - try Dictionary(uniqueKeysWithValues: map.entries) == [ - "boolKey": true, - "numberKey": 10, - ], - ) - let mapSubscription = try map.updates() - - // Perform a `set` on the root and check it comes through on subscription - try await root.set(key: "mapKey", value: .liveMap(map)) - let rootUpdate = try #require(await rootSubscription.first { _ in true }) - #expect(rootUpdate.update == ["mapKey": .updated]) - #expect(try Dictionary(uniqueKeysWithValues: root.entries) == ["mapKey": .liveMap(map)]) - - // Perform a `set` on the map and check it comes through on subscription and that the map is updated - try await map.set(key: "counterKey", value: .liveCounter(counter)) - let mapUpdate = try #require(await mapSubscription.first { _ in true }) - #expect(mapUpdate.update == ["counterKey": .updated]) - #expect( - try Dictionary(uniqueKeysWithValues: map.entries) == [ - "boolKey": true, - "numberKey": 10, - "counterKey": .liveCounter(counter), - ], - ) - - // Perform an `increment` on the counter and check it comes through on subscription and that the counter is updated - try await counter.increment(amount: 30) - let counterUpdate = try #require(await counterSubscription.first { _ in true }) - #expect(counterUpdate.amount == 30) - #expect(try counter.value == 82) - - // Perform a `remove` on the map and check it comes through on subscription and that the map is updated - try await map.remove(key: "boolKey") - let mapRemoveUpdate = try #require(await mapSubscription.first { _ in true }) - #expect(mapRemoveUpdate.update == ["boolKey": .removed]) - #expect( - try Dictionary(uniqueKeysWithValues: map.entries) == [ - "numberKey": 10, - "counterKey": .liveCounter(counter), - ], - ) - } } diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index 55b63a00..49ff608f 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -190,11 +190,11 @@ struct TestFactories { objectId: String = "test:object@123", siteTimeserials: [String: String] = ["site1": "ts1"], tombstone: Bool = false, - createOp: ObjectOperation? = nil, - map: ObjectsMap? = nil, + createOp: ProtocolTypes.ObjectOperation? = nil, + map: ProtocolTypes.ObjectsMap? = nil, counter: WireObjectsCounter? = nil, - ) -> ObjectState { - ObjectState( + ) -> ProtocolTypes.ObjectState { + ProtocolTypes.ObjectState( objectId: objectId, siteTimeserials: siteTimeserials, tombstone: tombstone, @@ -209,15 +209,15 @@ struct TestFactories { objectId: String = "map:test@123", siteTimeserials: [String: String] = ["site1": "ts1"], tombstone: Bool = false, - createOp: ObjectOperation? = nil, - entries: [String: ObjectsMapEntry]? = nil, - ) -> ObjectState { + createOp: ProtocolTypes.ObjectOperation? = nil, + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, + ) -> ProtocolTypes.ObjectState { objectState( objectId: objectId, siteTimeserials: siteTimeserials, tombstone: tombstone, createOp: createOp, - map: ObjectsMap( + map: ProtocolTypes.ObjectsMap( semantics: .known(.lww), entries: entries, ), @@ -230,9 +230,9 @@ struct TestFactories { objectId: String = "counter:test@123", siteTimeserials: [String: String] = ["site1": "ts1"], tombstone: Bool = false, - createOp: ObjectOperation? = nil, + createOp: ProtocolTypes.ObjectOperation? = nil, count: Int? = 42, - ) -> ObjectState { + ) -> ProtocolTypes.ObjectState { objectState( objectId: objectId, siteTimeserials: siteTimeserials, @@ -246,8 +246,8 @@ struct TestFactories { /// Creates an ObjectState for the root object static func rootObjectState( siteTimeserials: [String: String] = ["site1": "ts1"], - entries: [String: ObjectsMapEntry]? = nil, - ) -> ObjectState { + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, + ) -> ProtocolTypes.ObjectState { mapObjectState( objectId: "root", siteTimeserials: siteTimeserials, @@ -264,13 +264,13 @@ struct TestFactories { connectionId: String? = nil, extras: [String: JSONValue]? = nil, timestamp: Date? = nil, - operation: ObjectOperation? = nil, - object: ObjectState? = nil, + operation: ProtocolTypes.ObjectOperation? = nil, + object: ProtocolTypes.ObjectState? = nil, serial: String? = nil, siteCode: String? = nil, serialTimestamp: Date? = nil, - ) -> InboundObjectMessage { - InboundObjectMessage( + ) -> ProtocolTypes.InboundObjectMessage { + ProtocolTypes.InboundObjectMessage( id: id, clientId: clientId, connectionId: connectionId, @@ -288,8 +288,8 @@ struct TestFactories { static func mapObjectMessage( objectId: String = "map:test@123", siteTimeserials: [String: String] = ["site1": "ts1"], - entries: [String: ObjectsMapEntry]? = nil, - ) -> InboundObjectMessage { + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( object: mapObjectState( objectId: objectId, @@ -304,7 +304,7 @@ struct TestFactories { objectId: String = "counter:test@123", siteTimeserials: [String: String] = ["site1": "ts1"], count: Int? = 42, - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( object: counterObjectState( objectId: objectId, @@ -317,8 +317,8 @@ struct TestFactories { /// Creates an InboundObjectMessage with a root ObjectState static func rootObjectMessage( siteTimeserials: [String: String] = ["site1": "ts1"], - entries: [String: ObjectsMapEntry]? = nil, - ) -> InboundObjectMessage { + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( object: rootObjectState( siteTimeserials: siteTimeserials, @@ -328,7 +328,7 @@ struct TestFactories { } /// Creates an InboundObjectMessage without an ObjectState - static func objectMessageWithoutState() -> InboundObjectMessage { + static func objectMessageWithoutState() -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage(object: nil) } @@ -336,19 +336,19 @@ struct TestFactories { /// Creates an ObjectOperation with sensible defaults static func objectOperation( - action: WireEnum = .known(.mapCreate), + action: WireEnum = .known(.mapCreate), objectId: String = "test:object@123", - mapCreate: MapCreate? = nil, - mapSet: MapSet? = nil, + mapCreate: ProtocolTypes.MapCreate? = nil, + mapSet: ProtocolTypes.MapSet? = nil, mapRemove: WireMapRemove? = nil, counterCreate: WireCounterCreate? = nil, counterInc: WireCounterInc? = nil, objectDelete: WireObjectDelete? = nil, - mapCreateWithObjectId: MapCreateWithObjectId? = nil, - counterCreateWithObjectId: CounterCreateWithObjectId? = nil, + mapCreateWithObjectId: ProtocolTypes.MapCreateWithObjectId? = nil, + counterCreateWithObjectId: ProtocolTypes.CounterCreateWithObjectId? = nil, mapClear: WireMapClear? = nil, - ) -> ObjectOperation { - ObjectOperation( + ) -> ProtocolTypes.ObjectOperation { + ProtocolTypes.ObjectOperation( action: action, objectId: objectId, mapCreate: mapCreate, @@ -366,12 +366,12 @@ struct TestFactories { /// Creates a map create operation static func mapCreateOperation( objectId: String = "map:test@123", - entries: [String: ObjectsMapEntry]? = nil, - ) -> ObjectOperation { + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, + ) -> ProtocolTypes.ObjectOperation { objectOperation( action: .known(.mapCreate), objectId: objectId, - mapCreate: MapCreate( + mapCreate: ProtocolTypes.MapCreate( semantics: .known(.lww), entries: entries, ), @@ -382,7 +382,7 @@ struct TestFactories { static func counterCreateOperation( objectId: String = "counter:test@123", count: Int? = 42, - ) -> ObjectOperation { + ) -> ProtocolTypes.ObjectOperation { objectOperation( action: .known(.counterCreate), objectId: objectId, @@ -401,9 +401,9 @@ struct TestFactories { static func mapEntry( tombstone: Bool? = false, timeserial: String? = "ts1", - data: ObjectData?, - ) -> ObjectsMapEntry { - ObjectsMapEntry( + data: ProtocolTypes.ObjectData?, + ) -> ProtocolTypes.ObjectsMapEntry { + ProtocolTypes.ObjectsMapEntry( tombstone: tombstone, timeserial: timeserial, data: data, @@ -416,7 +416,7 @@ struct TestFactories { static func internalMapEntry( tombstonedAt: Date? = nil, timeserial: String? = "ts1", - data: ObjectData, + data: ProtocolTypes.ObjectData, ) -> InternalObjectsMapEntry { InternalObjectsMapEntry( tombstonedAt: tombstonedAt, @@ -431,13 +431,13 @@ struct TestFactories { value: String = "testValue", tombstone: Bool? = false, timeserial: String? = "ts1", - ) -> (key: String, entry: ObjectsMapEntry) { + ) -> (key: String, entry: ProtocolTypes.ObjectsMapEntry) { ( key: key, entry: mapEntry( tombstone: tombstone, timeserial: timeserial, - data: ObjectData(string: value), + data: ProtocolTypes.ObjectData(string: value), ), ) } @@ -456,7 +456,7 @@ struct TestFactories { entry: internalMapEntry( tombstonedAt: tombstonedAt, timeserial: timeserial, - data: ObjectData(string: value), + data: ProtocolTypes.ObjectData(string: value), ), ) } @@ -467,13 +467,13 @@ struct TestFactories { value: NSNumber = NSNumber(value: 42), tombstone: Bool? = false, timeserial: String? = "ts1", - ) -> (key: String, entry: ObjectsMapEntry) { + ) -> (key: String, entry: ProtocolTypes.ObjectsMapEntry) { ( key: key, entry: mapEntry( tombstone: tombstone, timeserial: timeserial, - data: ObjectData(number: value), + data: ProtocolTypes.ObjectData(number: value), ), ) } @@ -484,13 +484,13 @@ struct TestFactories { value: Bool = true, tombstone: Bool? = false, timeserial: String? = "ts1", - ) -> (key: String, entry: ObjectsMapEntry) { + ) -> (key: String, entry: ProtocolTypes.ObjectsMapEntry) { ( key: key, entry: mapEntry( tombstone: tombstone, timeserial: timeserial, - data: ObjectData(boolean: value), + data: ProtocolTypes.ObjectData(boolean: value), ), ) } @@ -501,13 +501,13 @@ struct TestFactories { value: Data = Data([0x01, 0x02, 0x03]), tombstone: Bool? = false, timeserial: String? = "ts1", - ) -> (key: String, entry: ObjectsMapEntry) { + ) -> (key: String, entry: ProtocolTypes.ObjectsMapEntry) { ( key: key, entry: mapEntry( tombstone: tombstone, timeserial: timeserial, - data: ObjectData(bytes: value), + data: ProtocolTypes.ObjectData(bytes: value), ), ) } @@ -518,13 +518,13 @@ struct TestFactories { objectId: String = "map:referenced@123", tombstone: Bool? = false, timeserial: String? = "ts1", - ) -> (key: String, entry: ObjectsMapEntry) { + ) -> (key: String, entry: ProtocolTypes.ObjectsMapEntry) { ( key: key, entry: mapEntry( tombstone: tombstone, timeserial: timeserial, - data: ObjectData(objectId: objectId), + data: ProtocolTypes.ObjectData(objectId: objectId), ), ) } @@ -533,11 +533,11 @@ struct TestFactories { /// Creates an ObjectsMap with sensible defaults static func objectsMap( - semantics: WireEnum = .known(.lww), - entries: [String: ObjectsMapEntry]? = nil, + semantics: WireEnum = .known(.lww), + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, clearTimeserial: String? = nil, - ) -> ObjectsMap { - ObjectsMap( + ) -> ProtocolTypes.ObjectsMap { + ProtocolTypes.ObjectsMap( semantics: semantics, entries: entries, clearTimeserial: clearTimeserial, @@ -547,9 +547,9 @@ struct TestFactories { /// Creates an ObjectsMap with string entries static func objectsMapWithStringEntries( entries: [String: String] = ["key1": "value1", "key2": "value2"], - ) -> ObjectsMap { + ) -> ProtocolTypes.ObjectsMap { let mapEntries = entries.mapValues { value in - mapEntry(data: ObjectData(string: value)) + mapEntry(data: ProtocolTypes.ObjectData(string: value)) } return objectsMap(entries: mapEntries) } @@ -570,14 +570,14 @@ struct TestFactories { value: String = "testValue", serial: String = "ts1", siteCode: String = "site1", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( operation: objectOperation( action: .known(.mapSet), objectId: objectId, - mapSet: MapSet( + mapSet: ProtocolTypes.MapSet( key: key, - value: ObjectData(string: value), + value: ProtocolTypes.ObjectData(string: value), ), ), serial: serial, @@ -591,7 +591,7 @@ struct TestFactories { key: String = "testKey", serial: String = "ts1", siteCode: String = "site1", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( operation: objectOperation( action: .known(.mapRemove), @@ -608,7 +608,7 @@ struct TestFactories { objectId: String = "map:test@123", serial: String = "ts1", siteCode: String = "site1", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( operation: objectOperation( action: .known(.mapClear), @@ -623,10 +623,10 @@ struct TestFactories { /// Creates an InboundObjectMessage with a MAP_CREATE operation static func mapCreateOperationMessage( objectId: String = "map:test@123", - entries: [String: ObjectsMapEntry]? = nil, + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, serial: String = "ts1", siteCode: String = "site1", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( operation: mapCreateOperation( objectId: objectId, @@ -643,7 +643,7 @@ struct TestFactories { count: Int? = 42, serial: String = "ts1", siteCode: String = "site1", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( operation: counterCreateOperation( objectId: objectId, @@ -660,7 +660,7 @@ struct TestFactories { number: Int = 10, serial: String = "ts1", siteCode: String = "site1", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( operation: objectOperation( action: .known(.counterInc), @@ -679,7 +679,7 @@ struct TestFactories { objectId: String = "map:simple@123", key: String = "testKey", value: String = "testValue", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { let (entryKey, entry) = stringMapEntry(key: key, value: value) return mapObjectMessage( objectId: objectId, @@ -691,7 +691,7 @@ struct TestFactories { static func simpleCounterMessage( objectId: String = "counter:simple@123", count: Int = 42, - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { counterObjectMessage( objectId: objectId, count: count, @@ -701,9 +701,9 @@ struct TestFactories { /// Creates a root object message with multiple entries static func rootMessageWithEntries( entries: [String: String] = ["key1": "value1", "key2": "value2"], - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { let mapEntries = entries.mapValues { value in - mapEntry(data: ObjectData(string: value)) + mapEntry(data: ProtocolTypes.ObjectData(string: value)) } return rootObjectMessage(entries: mapEntries) } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index a8104628..3d23f2b7 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -677,7 +677,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let realtimeObjects = MockRealtimeObjects() - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] realtimeObjects.setPublishAndApplyHandler { messages in publishedMessages.append(contentsOf: messages) return .success(()) @@ -685,8 +685,8 @@ struct InternalDefaultLiveCounterTests { try await counter.increment(amount: 10.5, coreSDK: coreSDK, realtimeObjects: realtimeObjects) - let expectedMessage = OutboundObjectMessage( - operation: ObjectOperation( + let expectedMessage = ProtocolTypes.OutboundObjectMessage( + operation: ProtocolTypes.ObjectOperation( // RTLC12e2 action: .known(.counterInc), // RTLC12e3 @@ -734,7 +734,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let realtimeObjects = MockRealtimeObjects() - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] realtimeObjects.setPublishAndApplyHandler { messages in publishedMessages.append(contentsOf: messages) return .success(()) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 6d5a8920..6fcdf900 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -43,7 +43,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.internalMapEntry( tombstonedAt: Date(), - data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned + data: ProtocolTypes.ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) let internalQueue = TestFactories.createInternalQueue() let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) @@ -56,7 +56,7 @@ struct InternalDefaultLiveMapTests { func returnsBooleanValue() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let entry = TestFactories.internalMapEntry(data: ObjectData(boolean: true)) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(boolean: true)) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) @@ -69,7 +69,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let bytes = Data([0x01, 0x02, 0x03]) - let entry = TestFactories.internalMapEntry(data: ObjectData(bytes: bytes)) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(bytes: bytes)) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) @@ -81,7 +81,7 @@ struct InternalDefaultLiveMapTests { func returnsNumberValue() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let entry = TestFactories.internalMapEntry(data: ObjectData(number: NSNumber(value: 123.456))) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(number: NSNumber(value: 123.456))) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) @@ -93,7 +93,7 @@ struct InternalDefaultLiveMapTests { func returnsStringValue() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let entry = TestFactories.internalMapEntry(data: ObjectData(string: "test")) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "test")) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) @@ -106,7 +106,7 @@ struct InternalDefaultLiveMapTests { func returnsJSONArrayValue() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let entry = TestFactories.internalMapEntry(data: ObjectData(json: .array(["foo"]))) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(json: .array(["foo"]))) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) @@ -119,7 +119,7 @@ struct InternalDefaultLiveMapTests { func returnsJSONObjectValue() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let entry = TestFactories.internalMapEntry(data: ObjectData(json: .object(["foo": "bar"]))) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(json: .object(["foo": "bar"]))) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) @@ -130,7 +130,7 @@ struct InternalDefaultLiveMapTests { @Test func returnsNilWhenReferencedObjectDoesNotExist() throws { let logger = TestLogger() - let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: "missing")) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "missing")) let internalQueue = TestFactories.createInternalQueue() let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) @@ -144,7 +144,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let objectId = "map1" - let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -161,7 +161,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let objectId = "counter1" - let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -177,7 +177,7 @@ struct InternalDefaultLiveMapTests { func returnsNullOtherwise() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let entry = TestFactories.internalMapEntry(data: ObjectData()) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData()) let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -276,7 +276,7 @@ struct InternalDefaultLiveMapTests { "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, ], ), - map: ObjectsMap( + map: ProtocolTypes.ObjectsMap( semantics: .known(.lww), entries: [ "keyFromMapEntries": TestFactories.stringMapEntry(key: "keyFromMapEntries", value: "valueFromMapEntries").entry, @@ -418,7 +418,7 @@ struct InternalDefaultLiveMapTests { "fromCreateOp": TestFactories.stringMapEntry(key: "fromCreateOp", value: "value").entry, ], ), - map: ObjectsMap( + map: ProtocolTypes.ObjectsMap( semantics: .known(.lww), entries: [ "fromEntries": TestFactories.stringMapEntry(key: "fromEntries", value: "value").entry, @@ -492,10 +492,10 @@ struct InternalDefaultLiveMapTests { let map = InternalDefaultLiveMap( testsOnly_data: [ // tombstonedAt is nil, so not considered tombstoned - "active1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "active1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), // tombstonedAt is false, so not considered tombstoned - "tombstoned": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "tombstoned")), - "tombstoned2": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "tombstoned2")), + "tombstoned": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ProtocolTypes.ObjectData(string: "tombstoned")), + "tombstoned2": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ProtocolTypes.ObjectData(string: "tombstoned2")), ], objectID: "arbitrary", logger: logger, @@ -538,9 +538,9 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let map = InternalDefaultLiveMap( testsOnly_data: [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), - "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), - "key3": TestFactories.internalMapEntry(data: ObjectData(string: "value3")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value2")), + "key3": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value3")), ], objectID: "arbitrary", logger: logger, @@ -585,14 +585,14 @@ struct InternalDefaultLiveMapTests { let map = InternalDefaultLiveMap( testsOnly_data: [ - "boolean": TestFactories.internalMapEntry(data: ObjectData(boolean: true)), // RTLM5d2b - "bytes": TestFactories.internalMapEntry(data: ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c - "number": TestFactories.internalMapEntry(data: ObjectData(number: NSNumber(value: 42))), // RTLM5d2d - "string": TestFactories.internalMapEntry(data: ObjectData(string: "hello")), // RTLM5d2e - "jsonArray": TestFactories.internalMapEntry(data: ObjectData(json: .array(["foo"]))), // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) - "jsonObject": TestFactories.internalMapEntry(data: ObjectData(json: .object(["foo": "bar"]))), // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) - "mapRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 - "counterRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 + "boolean": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(boolean: true)), // RTLM5d2b + "bytes": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c + "number": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(number: NSNumber(value: 42))), // RTLM5d2d + "string": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "hello")), // RTLM5d2e + "jsonArray": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(json: .array(["foo"]))), // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + "jsonObject": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(json: .object(["foo": "bar"]))), // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + "mapRef": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 + "counterRef": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 ], objectID: "arbitrary", logger: logger, @@ -677,7 +677,7 @@ struct InternalDefaultLiveMapTests { let update = map.testsOnly_applyMapSetOperation( key: "key1", operationTimeserial: operationSerial, - operationData: ObjectData(string: "new"), + operationData: ProtocolTypes.ObjectData(string: "new"), objectsPool: &pool, ) @@ -701,7 +701,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -714,7 +714,7 @@ struct InternalDefaultLiveMapTests { let update = map.testsOnly_applyMapSetOperation( key: "key1", operationTimeserial: "ts1", - operationData: ObjectData(objectId: "new"), + operationData: ProtocolTypes.ObjectData(objectId: "new"), objectsPool: &pool, ) @@ -731,19 +731,19 @@ struct InternalDefaultLiveMapTests { // @specOneOf(1/2) RTLM7f @Test(arguments: [ // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectsPool per RTLM7g) - (operationData: ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), + (operationData: ProtocolTypes.ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), // Case 2: ObjectData refers to an object value but the object ID is an empty string (shouldn't modify the ObjectsPool per RTLM7g) - (operationData: ObjectData(objectId: ""), expectedCreatedObjectID: nil), + (operationData: ProtocolTypes.ObjectData(objectId: ""), expectedCreatedObjectID: nil), // Case 3: ObjectData refers to an object value (should modify the ObjectsPool per RTLM7g and RTLM7g1) - (operationData: ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), - ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) - func appliesOperationWhenCanBeApplied(operationData: ObjectData, expectedCreatedObjectID: String?) throws { + (operationData: ProtocolTypes.ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), + ] as [(operationData: ProtocolTypes.ObjectData, expectedCreatedObjectID: String?)]) + func appliesOperationWhenCanBeApplied(operationData: ProtocolTypes.ObjectData, expectedCreatedObjectID: String?) throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstonedAt: Date(), timeserial: "ts1", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstonedAt: Date(), timeserial: "ts1", data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -806,13 +806,13 @@ struct InternalDefaultLiveMapTests { // @specOneOf(2/2) RTLM7f @Test(arguments: [ // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectsPool per RTLM7g) - (operationData: ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), + (operationData: ProtocolTypes.ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), // Case 2: ObjectData refers to an object value but the object ID is an empty string (shouldn't modify the ObjectsPool per RTLM7g) - (operationData: ObjectData(objectId: ""), expectedCreatedObjectID: nil), + (operationData: ProtocolTypes.ObjectData(objectId: ""), expectedCreatedObjectID: nil), // Case 3: ObjectData refers to an object value (should modify the ObjectsPool per RTLM7g and RTLM7g1) - (operationData: ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), - ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) - func createsNewEntryWhenNoExistingEntry(operationData: ObjectData, expectedCreatedObjectID: String?) throws { + (operationData: ProtocolTypes.ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), + ] as [(operationData: ProtocolTypes.ObjectData, expectedCreatedObjectID: String?)]) + func createsNewEntryWhenNoExistingEntry(operationData: ProtocolTypes.ObjectData, expectedCreatedObjectID: String?) throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) @@ -894,7 +894,7 @@ struct InternalDefaultLiveMapTests { _ = map.testsOnly_applyMapSetOperation( key: "referenceKey", operationTimeserial: "ts1", - operationData: ObjectData(objectId: existingObjectId), + operationData: ProtocolTypes.ObjectData(objectId: existingObjectId), objectsPool: &pool, ) @@ -931,7 +931,7 @@ struct InternalDefaultLiveMapTests { // Given: a map with an existing entry and the specified clearTimeserial let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts1", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts1", data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -980,7 +980,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -1008,7 +1008,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstonedAt: nil, timeserial: "ts1", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstonedAt: nil, timeserial: "ts1", data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -1128,7 +1128,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: entrySerial, data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: entrySerial, data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -1140,7 +1140,7 @@ struct InternalDefaultLiveMapTests { _ = map.testsOnly_applyMapSetOperation( key: "key1", operationTimeserial: operationSerial, - operationData: ObjectData(string: "new"), + operationData: ProtocolTypes.ObjectData(string: "new"), objectsPool: &pool, ) @@ -1201,7 +1201,7 @@ struct InternalDefaultLiveMapTests { mapCreateWithObjectId: .init( initialValue: "arbitrary", nonce: "arbitrary", - derivedFrom: MapCreate( + derivedFrom: ProtocolTypes.MapCreate( semantics: .known(.lww), entries: [ "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, @@ -1241,7 +1241,7 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.mapEntry( tombstone: true, timeserial: "ts2", // Must be greater than existing entry's timeserial "ts1" - data: ObjectData(), + data: ProtocolTypes.ObjectData(), ) let operation = TestFactories.mapCreateOperation( objectId: "arbitrary-id", @@ -1280,12 +1280,12 @@ struct InternalDefaultLiveMapTests { "keyThatWillBeRemoved": TestFactories.mapEntry( tombstone: true, timeserial: "ts2", // Must be greater than existing entry's timeserial "ts1" - data: ObjectData(), + data: ProtocolTypes.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 RTLM23c) - data: ObjectData(), + data: ProtocolTypes.ObjectData(), ), "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, ], @@ -1404,7 +1404,7 @@ struct InternalDefaultLiveMapTests { // Given: a map with an existing entry and the specified clearTimeserial let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts1", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts1", data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -1455,11 +1455,11 @@ struct InternalDefaultLiveMapTests { // Given: a map with multiple entries at different timeserials, including one with nil timeserial let map = InternalDefaultLiveMap( testsOnly_data: [ - "olderThanClear": TestFactories.internalMapEntry(timeserial: "ts1", data: ObjectData(string: "value1")), + "olderThanClear": TestFactories.internalMapEntry(timeserial: "ts1", data: ProtocolTypes.ObjectData(string: "value1")), // Note that this shouldn't happen in real life — timeserials are unique - "equalToClear": TestFactories.internalMapEntry(timeserial: "ts3", data: ObjectData(string: "value2")), - "newerThanClear": TestFactories.internalMapEntry(timeserial: "ts5", data: ObjectData(string: "value3")), - "nilTimeserial": TestFactories.internalMapEntry(timeserial: nil, data: ObjectData(string: "value4")), + "equalToClear": TestFactories.internalMapEntry(timeserial: "ts3", data: ProtocolTypes.ObjectData(string: "value2")), + "newerThanClear": TestFactories.internalMapEntry(timeserial: "ts5", data: ProtocolTypes.ObjectData(string: "value3")), + "nilTimeserial": TestFactories.internalMapEntry(timeserial: nil, data: ProtocolTypes.ObjectData(string: "value4")), ], objectID: "arbitrary", logger: logger, @@ -1513,7 +1513,7 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.objectOperation( action: .known(.mapSet), - mapSet: MapSet(key: "key1", value: ObjectData(string: "new")), + mapSet: ProtocolTypes.MapSet(key: "key1", value: ProtocolTypes.ObjectData(string: "new")), ) // 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) @@ -1614,7 +1614,7 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.objectOperation( action: .known(.mapSet), - mapSet: MapSet(key: "key1", value: ObjectData(string: "new")), + mapSet: ProtocolTypes.MapSet(key: "key1", value: ProtocolTypes.ObjectData(string: "new")), ) // Apply MAP_SET operation @@ -1770,7 +1770,7 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.objectOperation( action: .known(.mapSet), - mapSet: MapSet(key: "key1", value: ObjectData(string: "new")), + mapSet: ProtocolTypes.MapSet(key: "key1", value: ProtocolTypes.ObjectData(string: "new")), ) var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -1874,15 +1874,15 @@ struct InternalDefaultLiveMapTests { (value: { @Sendable _ in .bool(true) }, expectedData: .init(boolean: true)), // RTLM20e7f (value: { @Sendable _ in .data(Data([0x01, 0x02])) }, expectedData: .init(bytes: Data([0x01, 0x02]))), - ] as [(value: @Sendable (DispatchQueue) -> InternalLiveMapValue, expectedData: ObjectData)]) - func publishesCorrectObjectMessageForDifferentValueTypes(value: @escaping @Sendable (DispatchQueue) -> InternalLiveMapValue, expectedData: ObjectData) async throws { + ] as [(value: @Sendable (DispatchQueue) -> InternalLiveMapValue, expectedData: ProtocolTypes.ObjectData)]) + func publishesCorrectObjectMessageForDifferentValueTypes(value: @escaping @Sendable (DispatchQueue) -> InternalLiveMapValue, expectedData: ProtocolTypes.ObjectData) async throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let realtimeObjects = MockRealtimeObjects() - var publishedMessage: OutboundObjectMessage? + var publishedMessage: ProtocolTypes.OutboundObjectMessage? realtimeObjects.setPublishAndApplyHandler { messages in publishedMessage = messages.first return .success(()) @@ -1890,13 +1890,13 @@ struct InternalDefaultLiveMapTests { try await map.set(key: "testKey", value: value(internalQueue), coreSDK: coreSDK, realtimeObjects: realtimeObjects) - let expectedMessage = OutboundObjectMessage( - operation: ObjectOperation( + let expectedMessage = ProtocolTypes.OutboundObjectMessage( + operation: ProtocolTypes.ObjectOperation( // RTLM20e2 action: .known(.mapSet), // RTLM20e3 objectId: "map:test@123", - mapSet: MapSet( + mapSet: ProtocolTypes.MapSet( // RTLM20e6 key: "testKey", // RTLM20e7 @@ -1968,7 +1968,7 @@ struct InternalDefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let realtimeObjects = MockRealtimeObjects() - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] realtimeObjects.setPublishAndApplyHandler { messages in publishedMessages.append(contentsOf: messages) return .success(()) @@ -1976,8 +1976,8 @@ struct InternalDefaultLiveMapTests { try await map.remove(key: "testKey", coreSDK: coreSDK, realtimeObjects: realtimeObjects) - let expectedMessage = OutboundObjectMessage( - operation: ObjectOperation( + let expectedMessage = ProtocolTypes.OutboundObjectMessage( + operation: ProtocolTypes.ObjectOperation( // RTLM21e2 action: .known(.mapRemove), // RTLM21e3 diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index f3759545..171d309c 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -1409,7 +1409,7 @@ struct InternalDefaultRealtimeObjectsTests { } // Track published messages - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] coreSDK.setPublishHandler { messages in publishedMessages.append(contentsOf: messages) return PublishResult(serials: messages.map { _ in "serial_\(UUID().uuidString)" }) @@ -1438,7 +1438,7 @@ struct InternalDefaultRealtimeObjectsTests { #expect(mapInitialValue["entries"] == .object(["stringKey": .object(["data": .object(["string": "stringValue"])])])) // Sense check that create op was applied locally by publishAndApply ( - #expect(returnedMap.testsOnly_data == ["stringKey": InternalObjectsMapEntry(data: ObjectData(string: "stringValue"))]) + #expect(returnedMap.testsOnly_data == ["stringKey": InternalObjectsMapEntry(data: ProtocolTypes.ObjectData(string: "stringValue"))]) #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.mapValue === returnedMap) } @@ -1458,7 +1458,7 @@ struct InternalDefaultRealtimeObjectsTests { } // Track published messages - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] coreSDK.setPublishHandler { messages in publishedMessages.append(contentsOf: messages) return PublishResult(serials: messages.map { _ in "serial_\(UUID().uuidString)" }) @@ -1494,7 +1494,7 @@ struct InternalDefaultRealtimeObjectsTests { } // Track published messages and the generated objectId - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] var maybeGeneratedObjectID: String? var maybeExistingObject: AnyObject? @@ -1564,7 +1564,7 @@ struct InternalDefaultRealtimeObjectsTests { } // Track published messages - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] coreSDK.setPublishHandler { messages in publishedMessages.append(contentsOf: messages) return PublishResult(serials: messages.map { _ in "serial_\(UUID().uuidString)" }) @@ -1608,7 +1608,7 @@ struct InternalDefaultRealtimeObjectsTests { } // Track published messages - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] coreSDK.setPublishHandler { messages in publishedMessages.append(contentsOf: messages) return PublishResult(serials: messages.map { _ in "serial_\(UUID().uuidString)" }) @@ -1644,7 +1644,7 @@ struct InternalDefaultRealtimeObjectsTests { } // Track published messages and the generated objectId - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] var maybeGeneratedObjectID: String? var maybeExistingObject: AnyObject? @@ -1985,7 +1985,7 @@ struct InternalDefaultRealtimeObjectsTests { } // RTO20b: Capture the outbound message published via the core SDK, and return a real serial - var capturedOutboundMessages: [OutboundObjectMessage] = [] + var capturedOutboundMessages: [ProtocolTypes.OutboundObjectMessage] = [] coreSDK.setPublishHandler { messages in capturedOutboundMessages = messages return PublishResult(serials: messages.map { _ in serial }) @@ -2002,7 +2002,7 @@ struct InternalDefaultRealtimeObjectsTests { #expect(outboundMessage.siteCode == nil) // RTO20f: The synthetic message was applied (data is present) - #expect(returnedMap.testsOnly_data == ["key": InternalObjectsMapEntry(data: ObjectData(string: "value"))]) + #expect(returnedMap.testsOnly_data == ["key": InternalObjectsMapEntry(data: ProtocolTypes.ObjectData(string: "value"))]) // RTO20f: The synthetic message was applied with source LOCAL // (confirmed because siteTimeserials were not updated, per RTLM15c / RTLC7c) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift deleted file mode 100644 index 34cd3b18..00000000 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift +++ /dev/null @@ -1,595 +0,0 @@ -import _AblyPluginSupportPrivate -import Ably -@testable import AblyLiveObjects -import Foundation - -// This file is copied from the file objects.test.js in ably-js. - -/// This is a Swift port of the JavaScript ObjectsHelper class used for testing. -final class ObjectsHelper: Sendable { - // MARK: - Constants - - /// Object operation actions - enum Actions: Int { - case mapCreate = 0 - case mapSet = 1 - case mapRemove = 2 - case counterCreate = 3 - case counterInc = 4 - case objectDelete = 5 - case mapClear = 6 - - var stringValue: String { - switch self { - case .mapCreate: - "MAP_CREATE" - case .mapSet: - "MAP_SET" - case .mapRemove: - "MAP_REMOVE" - case .counterCreate: - "COUNTER_CREATE" - case .counterInc: - "COUNTER_INC" - case .objectDelete: - "OBJECT_DELETE" - case .mapClear: - "MAP_CLEAR" - } - } - } - - // MARK: - Properties - - private let rest: ARTRest - - // MARK: - Initialization - - init() async throws { - let options = try await ARTClientOptions(key: Sandbox.fetchSharedAPIKey()) - options.useBinaryProtocol = false - options.environment = "sandbox" - rest = ARTRest(options: options) - } - - // MARK: - Static Properties and Methods - - /// Static access to the Actions enum (equivalent to JavaScript static ACTIONS) - static let ACTIONS = Actions.self - - /// Returns the root keys used in the fixture objects tree - static func fixtureRootKeys() -> [String] { - ["emptyCounter", "initialValueCounter", "referencedCounter", "emptyMap", "referencedMap", "valuesMap"] - } - - // MARK: - Channel Initialization - - /// Sends Objects REST API requests to create objects tree on a provided channel: - /// - /// - root "emptyMap" -> Map#1 {} -- empty map - /// - root "referencedMap" -> Map#2 { "counterKey": } - /// - root "valuesMap" -> Map#3 { "stringKey": "stringValue", "emptyStringKey": "", "bytesKey": , "emptyBytesKey": , "numberKey": 1, "zeroKey": 0, "trueKey": true, "falseKey": false, "mapKey": } - /// - root "emptyCounter" -> Counter#1 -- no initial value counter, should be 0 - /// - root "initialValueCounter" -> Counter#2 count=10 - /// - root "referencedCounter" -> Counter#3 count=20 - func initForChannel(_ channelName: String) async throws { - _ = try await createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "emptyCounter", - createOp: counterCreateRestOp(), - ) - - _ = try await createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "initialValueCounter", - createOp: counterCreateRestOp(number: 10), - ) - - let referencedCounter = try await createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "referencedCounter", - createOp: counterCreateRestOp(number: 20), - ) - - _ = try await createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "emptyMap", - createOp: mapCreateRestOp(), - ) - - let referencedMapData: [String: JSONValue] = [ - "counterKey": .object(["objectId": .string(referencedCounter.objectId)]), - ] - let referencedMap = try await createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "referencedMap", - createOp: mapCreateRestOp(data: referencedMapData), - ) - - let valuesMapData: [String: JSONValue] = [ - "stringKey": .object(["string": .string("stringValue")]), - "emptyStringKey": .object(["string": .string("")]), - "bytesKey": .object(["bytes": .string("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")]), - "emptyBytesKey": .object(["bytes": .string("")]), - "numberKey": .object(["number": .number(1)]), - "zeroKey": .object(["number": .number(0)]), - "trueKey": .object(["boolean": .bool(true)]), - "falseKey": .object(["boolean": .bool(false)]), - "mapKey": .object(["objectId": .string(referencedMap.objectId)]), - ] - _ = try await createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "valuesMap", - createOp: mapCreateRestOp(data: valuesMapData), - ) - } - - // MARK: - Wire Object Messages - - /// Creates a map create operation - func mapCreateOp(objectId: String? = nil, entries: [String: WireValue]? = nil) -> [String: WireValue] { - var mapCreate: [String: WireValue] = [ - "semantics": .number(NSNumber(value: 0)), - ] - - mapCreate["entries"] = .object(entries ?? [:]) - - var operation: [String: WireValue] = [ - "action": .number(NSNumber(value: Actions.mapCreate.rawValue)), - "mapCreate": .object(mapCreate), - ] - - if let objectId { - operation["objectId"] = .string(objectId) - } - - return ["operation": .object(operation)] - } - - /// Creates a map set operation - func mapSetOp(objectId: String, key: String, data: WireValue) -> [String: WireValue] { - [ - "operation": .object([ - "action": .number(NSNumber(value: Actions.mapSet.rawValue)), - "objectId": .string(objectId), - "mapSet": .object([ - "key": .string(key), - "value": data, - ]), - ]), - ] - } - - /// Creates a map remove operation - func mapRemoveOp(objectId: String, key: String) -> [String: WireValue] { - [ - "operation": .object([ - "action": .number(NSNumber(value: Actions.mapRemove.rawValue)), - "objectId": .string(objectId), - "mapRemove": .object([ - "key": .string(key), - ]), - ]), - ] - } - - /// Creates a counter create operation - func counterCreateOp(objectId: String? = nil, count: Int? = nil) -> [String: WireValue] { - var counterCreate: [String: WireValue] = [:] - if let count { - counterCreate["count"] = .number(NSNumber(value: count)) - } - - var operation: [String: WireValue] = [ - "action": .number(NSNumber(value: Actions.counterCreate.rawValue)), - "counterCreate": .object(counterCreate), - ] - - if let objectId { - operation["objectId"] = .string(objectId) - } - - return ["operation": .object(operation)] - } - - /// Creates a counter increment operation - func counterIncOp(objectId: String, number: Int) -> [String: WireValue] { - [ - "operation": .object([ - "action": .number(NSNumber(value: Actions.counterInc.rawValue)), - "objectId": .string(objectId), - "counterInc": .object([ - "number": .number(NSNumber(value: number)), - ]), - ]), - ] - } - - /// Creates an object delete operation - func objectDeleteOp(objectId: String) -> [String: WireValue] { - [ - "operation": .object([ - "action": .number(NSNumber(value: Actions.objectDelete.rawValue)), - "objectId": .string(objectId), - "objectDelete": .object([:]), - ]), - ] - } - - /// Sends a MAP_CLEAR operation to the server via `testsOnly_publish`. - /// - /// MAP_CLEAR is server-initiated and has no production client-side API, - /// but it is enabled over realtime connections on non-prod clusters for testing. - func sendMapClearOnChannel(objects: any RealtimeObjects, objectId: String) async throws { - guard let internallyTypedObjects = objects as? PublicDefaultRealtimeObjects else { - preconditionFailure("Expected PublicDefaultRealtimeObjects") - } - try await internallyTypedObjects.testsOnly_publish(objectMessages: [ - OutboundObjectMessage( - operation: ObjectOperation( - action: .known(.mapClear), - objectId: objectId, - mapClear: WireMapClear(), - ), - ), - ]) - } - - /// Creates a map clear operation - func mapClearOp(objectId: String) -> [String: WireValue] { - [ - "operation": .object([ - "action": .number(NSNumber(value: Actions.mapClear.rawValue)), - "objectId": .string(objectId), - "mapClear": .object([:]), - ]), - ] - } - - /// Creates a map object structure - func mapObject( - objectId: String, - siteTimeserials: [String: String], - initialEntries: [String: WireValue]? = nil, - materialisedEntries: [String: WireValue]? = nil, - tombstone: Bool = false, - clearTimeserial: String? = nil, - ) -> [String: WireValue] { - var mapDict: [String: WireValue] = [ - "semantics": .number(NSNumber(value: 0)), - "entries": .object(materialisedEntries ?? [:]), - ] - - if let clearTimeserial { - mapDict["clearTimeserial"] = .string(clearTimeserial) - } - - var object: [String: WireValue] = [ - "objectId": .string(objectId), - "siteTimeserials": .object(siteTimeserials.mapValues { .string($0) }), - "tombstone": .bool(tombstone), - "map": .object(mapDict), - ] - - if let initialEntries { - let createOp = mapCreateOp(objectId: objectId, entries: initialEntries) - object["createOp"] = createOp["operation"]! - } - - return ["object": .object(object)] - } - - /// Creates a counter object structure - func counterObject( - objectId: String, - siteTimeserials: [String: String], - initialCount: Int? = nil, - materialisedCount: Int? = nil, - tombstone: Bool = false, - ) -> [String: WireValue] { - let materialisedCountValue: WireValue = if let materialisedCount { - .number(NSNumber(value: materialisedCount)) - } else { - .null - } - - var object: [String: WireValue] = [ - "objectId": .string(objectId), - "siteTimeserials": .object(siteTimeserials.mapValues { .string($0) }), - "tombstone": .bool(tombstone), - "counter": .object([ - "count": materialisedCountValue, - ]), - ] - - if let initialCount { - let createOp = counterCreateOp(objectId: objectId, count: initialCount) - object["createOp"] = createOp["operation"]! - } - - return ["object": .object(object)] - } - - /// Creates an object operation message - func objectOperationMessage( - channelName: String, - serial: String, - siteCode: String, - state: [[String: WireValue]]? = nil, - ) -> [String: WireValue] { - let stateWithSerials = state?.map { objectMessage in - var message = objectMessage - message["serial"] = .string(serial) - message["siteCode"] = .string(siteCode) - return message - } - - let stateArray = stateWithSerials?.map { dict in WireValue.object(dict) } ?? [] - - return [ - "action": .number(NSNumber(value: 19)), // OBJECT - "channel": .string(channelName), - "channelSerial": .string(serial), - "state": .array(stateArray), - ] - } - - /// Creates an object state message - func objectStateMessage( - channelName: String, - syncSerial: String, - state: [[String: WireValue]]? = nil, - ) -> [String: WireValue] { - let stateArray = state?.map { dict in WireValue.object(dict) } ?? [] - return [ - "action": .number(NSNumber(value: 20)), // OBJECT_SYNC - "channel": .string(channelName), - "channelSerial": .string(syncSerial), - "state": .array(stateArray), - ] - } - - /// This is the equivalent of the JS ObjectHelper's channel.processMessage(createPM(…)). - private func processDeserializedProtocolMessage( - _ deserialized: [String: WireValue], - channel: ARTRealtimeChannel, - ) async { - await withCheckedContinuation { continuation in - channel.internal.queue.async { - let useBinaryProtocol = channel.realtimeInternal.options.useBinaryProtocol - let jsonLikeEncoderDelegate: ARTJsonLikeEncoderDelegate = useBinaryProtocol ? ARTMsgPackEncoder() : ARTJsonEncoder() - - let encoder = ARTJsonLikeEncoder( - rest: channel.internal.realtime!.rest, - delegate: jsonLikeEncoderDelegate, - logger: channel.internal.logger, - ) - - let foundationObject = deserialized.toPluginSupportDataDictionary - let protocolMessage = withExtendedLifetime(jsonLikeEncoderDelegate) { - encoder.protocolMessage(from: foundationObject)! - } - - channel.internal.onChannelMessage(protocolMessage) - continuation.resume() - } - } - } - - /// Processes an object operation message on a channel - func processObjectOperationMessageOnChannel( - channel: ARTRealtimeChannel, - serial: String, - siteCode: String, - state: [[String: WireValue]]? = nil, - ) async { - await processDeserializedProtocolMessage( - objectOperationMessage( - channelName: channel.name, - serial: serial, - siteCode: siteCode, - state: state, - ), - channel: channel, - ) - } - - /// Processes an object state message on a channel - func processObjectStateMessageOnChannel( - channel: ARTRealtimeChannel, - syncSerial: String, - state: [[String: WireValue]]? = nil, - ) async { - await processDeserializedProtocolMessage( - objectStateMessage( - channelName: channel.name, - syncSerial: syncSerial, - state: state, - ), - channel: channel, - ) - } - - // MARK: - REST API Operations - - /// Result of a REST API operation - struct OperationResult { - let objectId: String - let success: Bool - } - - /// Creates an object and sets it on a map - func createAndSetOnMap( - channelName: String, - mapObjectId: String, - key: String, - createOp: [String: JSONValue], - ) async throws -> OperationResult { - let createResult = try await operationRequest(channelName: channelName, opBody: createOp) - let objectId = createResult.objectId - - let setOp = mapSetRestOp( - objectId: mapObjectId, - key: key, - value: ["objectId": .string(objectId)], - ) - _ = try await operationRequest(channelName: channelName, opBody: setOp) - - return createResult - } - - /// Creates a map create REST operation - func mapCreateRestOp(objectId: String? = nil, nonce: String? = nil, data: [String: JSONValue]? = nil) -> [String: JSONValue] { - var mapCreate: [String: JSONValue] = [ - "semantics": .number(0), - ] - - if let data { - // Wrap each entry value in { "data": value } to match v6 format - let entries = Dictionary(uniqueKeysWithValues: data.map { key, value in - (key, JSONValue.object(["data": value])) - }) - mapCreate["entries"] = .object(entries) - } - - var opBody: [String: JSONValue] = [ - "mapCreate": .object(mapCreate), - ] - - if let objectId { - opBody["objectId"] = .string(objectId) - opBody["nonce"] = .string(nonce ?? "") - } - - return opBody - } - - /// Creates a map set REST operation - func mapSetRestOp(objectId: String, key: String, value: [String: JSONValue]) -> [String: JSONValue] { - [ - "objectId": .string(objectId), - "mapSet": .object([ - "key": .string(key), - "value": .object(value), - ]), - ] - } - - /// Creates a map remove REST operation - func mapRemoveRestOp(objectId: String, key: String) -> [String: JSONValue] { - [ - "objectId": .string(objectId), - "mapRemove": .object([ - "key": .string(key), - ]), - ] - } - - /// Creates a counter create REST operation - func counterCreateRestOp(objectId: String? = nil, nonce: String? = nil, number: Double? = nil) -> [String: JSONValue] { - var counterCreate: [String: JSONValue] = [:] - - if let number { - counterCreate["count"] = .number(number) - } - - var opBody: [String: JSONValue] = [ - "counterCreate": .object(counterCreate), - ] - - if let objectId { - opBody["objectId"] = .string(objectId) - opBody["nonce"] = .string(nonce ?? "") - } - - return opBody - } - - /// Creates a counter increment REST operation - func counterIncRestOp(objectId: String, number: Double) -> [String: JSONValue] { - [ - "objectId": .string(objectId), - "counterInc": .object(["number": .number(number)]), - ] - } - - /// Sends an operation request to the REST API - func operationRequest(channelName: String, opBody: [String: JSONValue]) async throws -> OperationResult { - let path = "/channels/\(channelName)/objects" - - do { - let response = try await rest.requestAsync("POST", path: path, params: nil, body: opBody.toJSONSerializationInput, headers: nil) - - guard (200 ..< 300).contains(response.statusCode) else { - throw NSError( - domain: "ObjectsHelper", - code: response.statusCode, - userInfo: [ - NSLocalizedDescriptionKey: "REST API request failed", - "path": path, - "operation": opBody.toJSONSerializationInput, - ], - ) - } - - guard let firstItem = response.items.first as? [String: Any] else { - throw NSError( - domain: "ObjectsHelper", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Invalid response format - no items"], - ) - } - - // Extract objectId from the response - let objectId: String - if let objectIds = firstItem["objectIds"] as? [String], let firstObjectId = objectIds.first { - objectId = firstObjectId - } else if let directObjectId = firstItem["objectId"] as? String { - objectId = directObjectId - } else { - throw NSError( - domain: "ObjectsHelper", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "No objectId found in response"], - ) - } - - return OperationResult(objectId: objectId, success: true) - } catch let error as ARTErrorInfo { - throw error - } catch { - throw error - } - } - - // MARK: - Utility Methods - - /// Generates a fake map object ID - func fakeMapObjectId() -> String { - "map:\(randomString())@\(Int(Date().timeIntervalSince1970 * 1000))" - } - - /// Generates a fake counter object ID - func fakeCounterObjectId() -> String { - "counter:\(randomString())@\(Int(Date().timeIntervalSince1970 * 1000))" - } - - // MARK: - Private Methods - - /// Generates a random nonce - private func nonce() -> String { - randomString() - } - - /// Generates a random string - private func randomString() -> String { - let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - return String((0 ..< 16).map { _ in letters.randomElement()! }) - } -} diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift deleted file mode 100644 index c19bc8fa..00000000 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ /dev/null @@ -1,5684 +0,0 @@ -import Ably -@testable import AblyLiveObjects -import Testing - -// This file is copied from the file objects.test.js in ably-js. - -// Disable trailing_closure so that we can pass `action:` to the TestScenario initializer, consistent with the JS code -// swiftlint:disable trailing_closure - -// MARK: - Top-level helpers - -private func realtimeWithObjects(options: ClientHelper.PartialClientOptions) async throws -> ARTRealtime { - try await ClientHelper.realtimeWithObjects(options: options) -} - -private func channelOptionsWithObjects() -> ARTRealtimeChannelOptions { - ClientHelper.channelOptionsWithObjects() -} - -// Swift version of the JS lexicoTimeserial function -// -// Example: -// -// 01726585978590-001@abcdefghij:001 -// |____________| |_| |________| |_| -// | | | | -// timestamp counter seriesId idx -private func lexicoTimeserial(seriesId: String, timestamp: Int64, counter: Int, index: Int? = nil) -> String { - let paddedTimestamp = String(format: "%014d", timestamp) - let paddedCounter = String(format: "%03d", counter) - - var result = "\(paddedTimestamp)-\(paddedCounter)@\(seriesId)" - - if let index { - let paddedIndex = String(format: "%03d", index) - result += ":\(paddedIndex)" - } - - return result -} - -func monitorConnectionThenCloseAndFinishAsync(_ realtime: ARTRealtime, action: @escaping @Sendable () async throws -> Void) async throws { - defer { realtime.connection.close() } - - try await withThrowingTaskGroup { group in - // Monitor connection state - for state in [ARTRealtimeConnectionEvent.failed, .suspended] { - group.addTask { - let (stream, continuation) = AsyncThrowingStream.makeStream() - - let subscription = realtime.connection.on(state) { _ in - realtime.close() - - let error = NSError( - domain: "IntegrationTestsError", - code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "Connection monitoring: state changed to \(state), aborting test", - ], - ) - continuation.finish(throwing: error) - } - continuation.onTermination = { _ in - realtime.connection.off(subscription) - } - - try await stream.first { _ in true } - } - } - - // Perform the action - group.addTask { - try await action() - } - - // Wait for either connection monitoring to throw an error or for the action to complete - guard let result = await group.nextResult() else { - return - } - - group.cancelAll() - try result.get() - } -} - -func waitFixtureChannelIsReady(_: ARTRealtime) async throws { - // TODO: Implement this using the subscription APIs once we've got a spec for those, but this should be fine for now - try await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC) -} - -func waitForMapKeyUpdate(_ updates: AsyncStream, _ key: String) async { - _ = await updates.first { $0.update[key] != nil } -} - -func waitForCounterUpdate(_ updates: AsyncStream) async { - _ = await updates.first { _ in true } -} - -/// Waits for a MAP_CLEAR operation to be applied to a LiveMap, by waiting for an update where -/// all of the specified keys have `.removed` status. -/// -/// The JS equivalent subscribes and checks `message.operation.action === 'map.clear'`, but Swift's -/// `LiveMapUpdate` doesn't expose the operation action — it only provides per-key changes via -/// `update: [String: LiveMapUpdateAction]`. So we use the per-key `.removed` entries as a proxy -/// instead. -func waitForMapClear(_ updates: AsyncStream, expectedRemovedKeys: Set) async { - _ = await updates.first { update in - expectedRemovedKeys.allSatisfy { update.update[$0] == .removed } - } -} - -// Note that Cursor decided to implement this in a different way to the waitForObjectSync that I'd already implemented; TODO pick one of the two approaches (this one might be cleaner). -func waitForObjectOperation(_ objects: any RealtimeObjects, _ action: ObjectOperationAction) async throws { - // Cast to access internal API for testing - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - let objectMessages = internallyTypedObjects.testsOnly_receivedObjectProtocolMessages - - // Wait for an object protocol message containing the specified action - _ = await objectMessages.first { messages in - messages.contains { message in - message.operation?.action == .known(action) - } - } -} - -// I added this @MainActor as an "I don't understand what's going on there; let's try this" when observing that for some reason the setter of setListenerAfterProcessingIncomingMessage was hanging inside `-[ARTSRDelegateController dispatchQueue]`. This seems to avoid it and I have not investigated more deeply 🤷 -@MainActor -func waitForObjectSync(_ realtime: ARTRealtime) async throws { - let testProxyTransport = try #require(realtime.internal.transport as? TestProxyTransport) - - await withCheckedContinuation { (continuation: CheckedContinuation) in - testProxyTransport.setListenerAfterProcessingIncomingMessage { protocolMessage in - if protocolMessage.action == .objectSync { - testProxyTransport.setListenerAfterProcessingIncomingMessage(nil) - continuation.resume() - } - } - } -} - -// MARK: - ARTProtocolMessage test-only extension - -extension ARTProtocolMessage { - /// Extract `InboundObjectMessage`s from the protocol message's state array. - var testsOnly_inboundObjectMessages: [InboundObjectMessage] { - // Claude's explanation, which I don't really have the time to verify or try and do better than — it's only test code so good enough: - // - // > The `state` property on `ARTProtocolMessage` is behind `#ifdef ABLY_SUPPORTS_PLUGINS`, - // > which is defined as a C flag on the ably-cocoa SPM target. However, this define is not - // > visible when the Clang module is built for the `Ably.Private` submodule, so the property - // > is not importable into Swift. We use KVC to access it instead. - guard let stateArray = value(forKey: "state") as? [AnyObject] else { - return [] - } - return stateArray.compactMap { item -> InboundObjectMessage? in - guard let box = item as? DefaultInternalPlugin.ObjectMessageBox - else { return nil } - return box.objectMessage - } - } -} - -// MARK: - Echo/ACK interceptors for apply-on-ACK tests - -/// Intercepts OBJECT messages (action 19) arriving from Realtime, holding them so tests can -/// control the timing of echo delivery, to test ACK-before-echo scenarios. Call when CONNECTING -/// or CONNECTED to intercept messages on the active transport. -/// -/// An echo is a subset of OBJECT messages — specifically, OBJECT messages that originated from -/// this client and are being echoed back. The name "echo" reflects how this interceptor is -/// intended to be used in tests, but it doesn't actually filter for echoes — it intercepts all -/// OBJECT messages. -/// -/// - ``waitForEcho()`` / ``waitForEchoCount(_:)``: resolve immediately if enough OBJECT messages -/// have already been intercepted, otherwise wait for one. Only one call can be pending at a -/// time; subsequent calls will trap. -/// - ``releaseAll()``: replays all held OBJECT messages through the channel's internal message -/// handler. -/// - ``releaseFirst()``: replays only the first held OBJECT message. -/// - ``restore()``: removes the interceptor, restoring normal message handling. -private final class EchoInterceptor: @unchecked Sendable { - private let transport: TestProxyTransport - private let channel: ARTRealtimeChannel - private let lock = NSLock() - private var _heldEchoes: [ARTProtocolMessage] = [] - private var echoContinuation: CheckedContinuation? - - var heldEchoes: [ARTProtocolMessage] { - lock.withLock { _heldEchoes } - } - - init(client: ARTRealtime, channel: ARTRealtimeChannel) { - // swiftlint:disable:next force_cast - transport = client.internal.transport as! TestProxyTransport - self.channel = channel - - transport.setBeforeIncomingMessageModifier { [weak self] message in - guard let self else { - return message - } - - if message.action == .object { - lock.withLock { - _heldEchoes.append(message) - echoContinuation?.resume() - echoContinuation = nil - } - return nil // suppress the OBJECT message - } - return message - } - } - - /// Waits until at least one new OBJECT message has been intercepted since the last - /// ``releaseAll()`` / ``releaseFirst()`` (or since construction). - func waitForEcho() async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - lock.withLock { - if !_heldEchoes.isEmpty { - continuation.resume() - } else { - precondition(echoContinuation == nil, "Only one waitForEcho/waitForEchoCount call can be pending at a time") - echoContinuation = continuation - } - } - } - } - - /// Waits until at least `count` OBJECT messages have been intercepted. - func waitForEchoCount(_ count: Int) async { - while true { - await withCheckedContinuation { (continuation: CheckedContinuation) in - lock.withLock { - if _heldEchoes.count >= count { - continuation.resume() - } else { - precondition(echoContinuation == nil, "Only one waitForEcho/waitForEchoCount call can be pending at a time") - echoContinuation = continuation - } - } - } - let current = lock.withLock { _heldEchoes.count } - if current >= count { break } - } - } - - /// Replays all held OBJECT messages through the channel's internal message handler. - func releaseAll() async { - let echoes = lock.withLock { - let e = _heldEchoes - _heldEchoes.removeAll() - return e - } - for echo in echoes { - nonisolated(unsafe) let echo = echo - await withCheckedContinuation { (continuation: CheckedContinuation) in - channel.internal.queue.async { - self.channel.internal.onChannelMessage(echo) - continuation.resume() - } - } - } - } - - /// Replays only the first held OBJECT message. - func releaseFirst() async { - let echo: ARTProtocolMessage? = lock.withLock { - _heldEchoes.isEmpty ? nil : _heldEchoes.removeFirst() - } - guard let echo else { - return - } - - nonisolated(unsafe) let unsafeEcho = echo - await withCheckedContinuation { (continuation: CheckedContinuation) in - channel.internal.queue.async { - self.channel.internal.onChannelMessage(unsafeEcho) - continuation.resume() - } - } - } - - /// Removes the interceptor, restoring normal message handling. - func restore() { - transport.setBeforeIncomingMessageModifier(nil) - } -} - -/// Intercepts ACK messages (action 1) arriving from Realtime, holding them so tests can control -/// the timing of ACK delivery, to test echo-before-ACK scenarios. Call when CONNECTING or -/// CONNECTED to intercept messages on the active transport. -/// -/// - ``waitForAck()``: resolves immediately if an ACK has already been intercepted, otherwise -/// waits for one. Only one call can be pending at a time; subsequent calls will trap. -/// - ``releaseAll()``: replays all held ACKs through the client's internal ACK handler. -/// - ``restore()``: removes the interceptor, restoring normal message handling. -private final class AckInterceptor: @unchecked Sendable { - private let transport: TestProxyTransport - private let client: ARTRealtime - private let lock = NSLock() - private var _heldAcks: [ARTProtocolMessage] = [] - private var ackContinuation: CheckedContinuation? - - var heldAcks: [ARTProtocolMessage] { - lock.withLock { _heldAcks } - } - - init(client: ARTRealtime) { - self.client = client - // swiftlint:disable:next force_cast - transport = client.internal.transport as! TestProxyTransport - - transport.setBeforeIncomingMessageModifier { [weak self] message in - guard let self else { - return message - } - - if message.action == .ack { - lock.withLock { - _heldAcks.append(message) - ackContinuation?.resume() - ackContinuation = nil - } - return nil // suppress the ACK - } - return message - } - } - - /// Waits until at least one ACK has been intercepted. - func waitForAck() async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - lock.withLock { - if !_heldAcks.isEmpty { - continuation.resume() - } else { - precondition(ackContinuation == nil, "Only one waitForAck call can be pending at a time") - ackContinuation = continuation - } - } - } - } - - /// Replays all held ACK messages through the client's internal ACK handler. - func releaseAll() async { - let acks = lock.withLock { - let a = _heldAcks - _heldAcks.removeAll() - return a - } - for ack in acks { - nonisolated(unsafe) let ack = ack - await withCheckedContinuation { (continuation: CheckedContinuation) in - client.internal.queue.async { - self.client.internal.onAck(ack) - continuation.resume() - } - } - } - } - - /// Removes the interceptor, restoring normal message handling. - func restore() { - transport.setBeforeIncomingMessageModifier(nil) - } -} - -/// Injects an ATTACHED protocol message into the channel with the given flags. -private func injectAttachedMessage(channel: ARTRealtimeChannel, flags: ARTProtocolMessageFlag = []) async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - channel.internal.queue.async { - let pm = ARTProtocolMessage() - pm.action = .attached - pm.channel = channel.name - pm.flags = Int64(flags.rawValue) - channel.internal.onChannelMessage(pm) - continuation.resume() - } - } -} - -// MARK: - Constants - -private let objectsFixturesChannel = "objects_fixtures" - -// MARK: - Top-level fixtures (ported from JS objects.test.js) - -// The value of JS's `Number.MAX_SAFE_INTEGER` — the maximum integer that a `Double` can represent exactly. -private let maxSafeInteger = Double((1 << 53) - 1) - -// Primitive key data fixture used across multiple test scenarios -// liveMapValue field contains the value as LiveMapValue for use in map operations -private let primitiveKeyData: [(key: String, data: [String: JSONValue], liveMapValue: LiveMapValue)] = [ - ( - key: "stringKey", - data: ["string": .string("stringValue")], - liveMapValue: "stringValue" - ), - ( - key: "emptyStringKey", - data: ["string": .string("")], - liveMapValue: "" - ), - ( - key: "bytesKey", - data: ["bytes": .string("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")], - liveMapValue: .data(Data(base64Encoded: "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")!) - ), - ( - key: "emptyBytesKey", - data: ["bytes": .string("")], - liveMapValue: .data(Data(base64Encoded: "")!) - ), - ( - key: "maxSafeIntegerKey", - data: ["number": .number(maxSafeInteger)], - liveMapValue: .number(maxSafeInteger) - ), - ( - key: "negativeMaxSafeIntegerKey", - data: ["number": .number(-maxSafeInteger)], - liveMapValue: .number(-maxSafeInteger) - ), - ( - key: "numberKey", - data: ["number": .number(1)], - liveMapValue: 1 - ), - ( - key: "zeroKey", - data: ["number": .number(0)], - liveMapValue: 0 - ), - ( - key: "trueKey", - data: ["boolean": .bool(true)], - liveMapValue: true - ), - ( - key: "falseKey", - data: ["boolean": .bool(false)], - liveMapValue: false - ), -] - -// Primitive maps fixtures used in map creation and write API scenarios -// entries field contains the map entries in the format expected by ObjectsHelper -// restData field contains the data in the format expected by REST API operations -// liveMapEntries field contains entries as LiveMapValue for direct map operations -private let primitiveMapsFixtures: [(name: String, entries: [String: [String: JSONValue]]?, restData: [String: JSONValue]?, liveMapEntries: [String: LiveMapValue]?)] = [ - (name: "emptyMap", entries: nil, restData: nil, liveMapEntries: nil), - (name: "valuesMap", - entries: Dictionary(uniqueKeysWithValues: primitiveKeyData.map { ($0.key, ["data": .object($0.data)]) }), - restData: Dictionary(uniqueKeysWithValues: primitiveKeyData.map { ($0.key, .object($0.data)) }), - liveMapEntries: Dictionary(uniqueKeysWithValues: primitiveKeyData.map { ($0.key, $0.liveMapValue) })), -] - -// Counters fixtures used in counter creation and write API scenarios -// count field supports both Int and Double types depending on the test scenario -private let countersFixtures: [(name: String, count: Double?)] = [ - (name: "emptyCounter", count: nil), - (name: "zeroCounter", count: 0), - (name: "valueCounter", count: 10), - (name: "negativeValueCounter", count: -10), - (name: "maxSafeIntegerCounter", count: Double(Int.max)), - (name: "negativeMaxSafeIntegerCounter", count: -Double(Int.max)), -] - -// MARK: - Support for parameterised tests - -/// The output of `forScenarios`. One element of the one-dimensional arguments array that is passed to a Swift Testing test. -private struct TestCase: Identifiable, CustomStringConvertible { - var disabled: Bool - var scenario: TestScenario - var options: ClientHelper.PartialClientOptions - var channelName: String - - /// This `Identifiable` conformance allows us to re-run individual test cases from the Xcode UI (https://developer.apple.com/documentation/testing/parameterizedtesting#Run-selected-test-cases) - var id: TestCaseID { - .init(description: scenario.description, options: options) - } - - /// This seems to determine the nice name that you see for this when it's used as a test case parameter. (I can't see anywhere that this is documented; found it by experimentation). - var description: String { - var result = scenario.description - - if let useBinaryProtocol = options.useBinaryProtocol { - result += " (\(useBinaryProtocol ? "binary" : "text"))" - } - - return result - } -} - -/// Enables `TestCase`'s conformance to `Identifiable`. -private struct TestCaseID: Encodable, Hashable { - var description: String - var options: ClientHelper.PartialClientOptions? -} - -/// The input to `forScenarios`. -private struct TestScenario { - var disabled: Bool - var allTransportsAndProtocols: Bool - var description: String - var action: @Sendable (Context) async throws -> Void -} - -private func forScenarios(_ scenarios: [TestScenario]) -> [TestCase] { - scenarios.map { scenario -> [TestCase] in - var clientOptions = ClientHelper.PartialClientOptions(logIdentifier: "client1") - - if scenario.allTransportsAndProtocols { - return [true, false].map { useBinaryProtocol -> TestCase in - clientOptions.useBinaryProtocol = useBinaryProtocol - - return .init( - disabled: scenario.disabled, - scenario: scenario, - options: clientOptions, - channelName: "\(scenario.description) \(useBinaryProtocol ? "binary" : "text")", - ) - } - } else { - return [.init(disabled: scenario.disabled, scenario: scenario, options: clientOptions, channelName: scenario.description)] - } - } - .flatMap(\.self) -} - -private protocol Scenarios { - associatedtype Context - static var scenarios: [TestScenario] { get } -} - -private extension Scenarios { - static var testCases: [TestCase] { - forScenarios(scenarios) - } -} - -// MARK: - Test lifecycle - -/// Creates the fixtures on ``objectsFixturesChannel`` if not yet created. -/// -/// This fulfils the role of JS's `before` hook. -private actor ObjectsFixturesTrait: SuiteTrait, TestScoping { - private actor SetupManager { - private var setupTask: Task? - - func setUpFixtures() async throws { - let setupTask: Task = if let existingSetupTask = self.setupTask { - existingSetupTask - } else { - Task { - let helper = try await ObjectsHelper() - try await helper.initForChannel(objectsFixturesChannel) - } - } - self.setupTask = setupTask - - try await setupTask.value - } - } - - private static let setupManager = SetupManager() - - func provideScope(for _: Test, testCase _: Test.Case?, performing function: () async throws -> Void) async throws { - try await Self.setupManager.setUpFixtures() - try await function() - } -} - -extension Trait where Self == ObjectsFixturesTrait { - static var objectsFixtures: Self { Self() } -} - -// MARK: - Utility types - -/// A class that isolates arbitrary mutable state to the main actor. -/// -/// Intended for allowing a subscription callback to mutate some state that is shared between multiple callbacks. This allows us to port the JS pattern where callbacks synchronously mutate some local variable that's stored outside the callback (in Swift, local variables cannot be isolated to an actor). -@MainActor -class MainActorStorage { - var value: T - - init(value: T) { - self.value = value - } -} - -// MARK: - Test suite - -@Suite( - .tags(.integration), - .objectsFixtures, - // These tests exhibit flakiness (hanging, timeouts, occasional Realtime - // connection limits) when run concurrently, where I think that we had up to - // 100 ARTRealtime instances active at the same time. So we're running them in - // serial to unblock CI builds until we can understand the issue better. See - // https://github.com/ably/ably-liveobjects-swift-plugin/issues/72. - .serialized, -) -private struct ObjectsIntegrationTests { - // TODO: Add the non-parameterised tests - - enum FirstSetOfScenarios: Scenarios { - struct Context { - var objects: any RealtimeObjects - var root: any LiveMap - var objectsHelper: ObjectsHelper - var channelName: String - var channel: ARTRealtimeChannel - var client: ARTRealtime - var clientOptions: ClientHelper.PartialClientOptions - } - - static let scenarios: [TestScenario] = { - let objectSyncSequenceScenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "on ATTACHED without HAS_OBJECTS clears local state", - action: { ctx in - // set a key on root so we can verify it gets cleared after ATTACHED - try await ctx.root.set(key: "foo", value: "bar") - #expect(try #require(ctx.root.get(key: "foo")?.stringValue) == "bar", "Check root has key before ATTACHED") - - // inject ATTACHED without HAS_OBJECTS flag - await injectAttachedMessage(channel: ctx.channel) - - // local state should be cleared — root should have no keys - #expect(try ctx.root.size == 0, "Check root has no keys after ATTACHED without HAS_OBJECTS") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "OBJECT_SYNC sequence builds object tree on channel attachment", - action: { ctx in - let client = ctx.client - - try await waitFixtureChannelIsReady(client) - - let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - let counterKeys = ["emptyCounter", "initialValueCounter", "referencedCounter"] - let mapKeys = ["emptyMap", "referencedMap", "valuesMap"] - let rootKeysCount = counterKeys.count + mapKeys.count - - #expect(try root.size == rootKeysCount, "Check root has correct number of keys") - - for key in counterKeys { - let counter = try #require(try root.get(key: key)) - #expect(counter.liveCounterValue != nil, "Check counter at key=\"\(key)\" in root is of type LiveCounter") - } - - for key in mapKeys { - let map = try #require(try root.get(key: key)) - #expect(map.liveMapValue != nil, "Check map at key=\"\(key)\" in root is of type LiveMap") - } - - let valuesMap = try #require(root.get(key: "valuesMap")?.liveMapValue) - let valueMapKeys = [ - "stringKey", - "emptyStringKey", - "bytesKey", - "emptyBytesKey", - "numberKey", - "zeroKey", - "trueKey", - "falseKey", - "mapKey", - ] - #expect(try valuesMap.size == valueMapKeys.count, "Check nested map has correct number of keys") - for key in valueMapKeys { - #expect(try valuesMap.get(key: key) != nil, "Check value at key=\"\(key)\" in nested map exists") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "OBJECT_SYNC sequence builds object tree with all operations applied", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - - // MAP_CREATE - let map = try await objects.createMap(entries: ["shouldStay": "foo", "shouldDelete": "bar"]) - // COUNTER_CREATE - let counter = try await objects.createCounter(count: 1) - - // Set the values - async let setMapPromise: Void = root.set(key: "map", value: .liveMap(map)) - async let setCounterPromise: Void = root.set(key: "counter", value: .liveCounter(counter)) - _ = try await (setMapPromise, setCounterPromise) - - // Perform the operations - async let setAnotherKeyPromise: Void = map.set(key: "anotherKey", value: "baz") - async let removeKeyPromise: Void = map.remove(key: "shouldDelete") - async let incrementPromise: Void = counter.increment(amount: 10) - _ = try await (setAnotherKeyPromise, removeKeyPromise, incrementPromise) - - // create a new client and check it syncs with the aggregated data - let client2 = try await realtimeWithObjects(options: ctx.clientOptions) - - try await monitorConnectionThenCloseAndFinishAsync(client2) { - let channel2 = client2.channels.get(ctx.channelName, options: channelOptionsWithObjects()) - let objects2 = channel2.objects - - try await channel2.attachAsync() - let root2 = try await objects2.getRoot() - - let counter2 = try #require(root2.get(key: "counter")?.liveCounterValue) - #expect(try counter2.value == 11, "Check counter has correct value") - - let map2 = try #require(root2.get(key: "map")?.liveMapValue) - #expect(try map2.size == 2, "Check map has correct number of keys") - #expect(try #require(map2.get(key: "shouldStay")?.stringValue) == "foo", "Check map has correct value for \"shouldStay\" key") - #expect(try #require(map2.get(key: "anotherKey")?.stringValue) == "baz", "Check map has correct value for \"anotherKey\" key") - #expect(try map2.get(key: "shouldDelete") == nil, "Check map does not have \"shouldDelete\" key") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC sequence does not change references to existing objects", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - let channel = ctx.channel - let client = ctx.client - - let map = try await objects.createMap() - let counter = try await objects.createCounter() - - // Set the values - async let setMapPromise: Void = root.set(key: "map", value: .liveMap(map)) - async let setCounterPromise: Void = root.set(key: "counter", value: .liveCounter(counter)) - _ = try await (setMapPromise, setCounterPromise) - - try await channel.detachAsync() - - // wait for the actual OBJECT_SYNC message to confirm it was received and processed - async let objectSyncPromise: Void = waitForObjectSync(client) - try await channel.attachAsync() - try await objectSyncPromise - - let newRootRef = try await channel.objects.getRoot() - let newMapRefMap = try #require(newRootRef.get(key: "map")?.liveMapValue) - let newCounterRef = try #require(newRootRef.get(key: "counter")?.liveCounterValue) - - #expect(newRootRef === root, "Check root reference is the same after OBJECT_SYNC sequence") - #expect(newMapRefMap === map, "Check map reference is the same after OBJECT_SYNC sequence") - #expect(newCounterRef === counter, "Check counter reference is the same after OBJECT_SYNC sequence") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC sequence builds object tree across multiple sync messages", - action: { ctx throws in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let counterId = objectsHelper.fakeCounterObjectId() - let mapId = objectsHelper.fakeMapObjectId() - - // send three separate OBJECT_SYNC messages: one for root, one for counter, one for map - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor1", - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "stringKey": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("hello")]), - ]), - "counter": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(counterId)]), - ]), - "map": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(mapId)]), - ]), - ], - ), - ], - ) - - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor2", - state: [ - objectsHelper.counterObject( - objectId: counterId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialCount: 10, - materialisedCount: 5, - ), - ], - ) - - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // end sync sequence - state: [ - objectsHelper.mapObject( - objectId: mapId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "foo": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - materialisedEntries: [ - "baz": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("qux")]), - ]), - ], - ), - ], - ) - - #expect(try #require(root.get(key: "stringKey")?.stringValue) == "hello", "Check root has correct string value") - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - #expect(try counter.value == 15, "Check counter has correct aggregated value") - let map = try #require(root.get(key: "map")?.liveMapValue) - #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check map has initial entries") - #expect(try #require(map.get(key: "baz")?.stringValue) == "qux", "Check map has materialised entries") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "partial OBJECT_SYNC merges map entries across multiple messages for the same objectId", - action: { ctx throws in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let mapId = objectsHelper.fakeMapObjectId() - - // assign map object to root - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor1", - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "map": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(mapId)]), - ]), - ], - ), - ], - ) - - // send partial sync messages for the same map object, each with different materialised entries. - // initialEntries are identical across all partial messages for the same object — a server guarantee. - let partialMessages: [(syncSerial: String, materialisedEntries: [String: WireValue])] = [ - ( - syncSerial: "serial:cursor2", - materialisedEntries: [ - "key1": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["number": .number(1)]), - ]), - "key2": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("two")]), - ]), - ] - ), - ( - syncSerial: "serial:cursor3", - materialisedEntries: [ - "key3": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["number": .number(3)]), - ]), - "key4": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["boolean": .bool(true)]), - ]), - ] - ), - ( - syncSerial: "serial:", // end sync sequence - materialisedEntries: [ - "key5": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("five")]), - ]), - ] - ), - ] - - for partial in partialMessages { - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: partial.syncSerial, - state: [ - objectsHelper.mapObject( - objectId: mapId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "initialKey": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("initial")]), - ]), - ], - materialisedEntries: partial.materialisedEntries, - ), - ], - ) - } - - let map = try #require(root.get(key: "map")?.liveMapValue) - - #expect(try #require(map.get(key: "initialKey")?.stringValue) == "initial", "Check keys from the create operation are present") - - // check that materialised entries from all partial messages were merged - #expect(try #require(map.get(key: "key1")?.numberValue) == 1, "Check key1 from first partial sync") - #expect(try #require(map.get(key: "key2")?.stringValue) == "two", "Check key2 from first partial sync") - #expect(try #require(map.get(key: "key3")?.numberValue) == 3, "Check key3 from second partial sync") - #expect(try #require(map.get(key: "key4")?.boolValue as Bool?) == true, "Check key4 from second partial sync") - #expect(try #require(map.get(key: "key5")?.stringValue) == "five", "Check key5 from third partial sync") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC does not break when receiving an unknown object type", - action: { ctx throws in - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - let objects = ctx.objects - - // first message: unknown object type (no counter or map field set) - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - state: [ - [ - "object": .object([ - "objectId": .string("unknown:object123"), - "siteTimeserials": .object(["aaa": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0))]), - "tombstone": .bool(false), - // intentionally not setting counter or map fields - ]), - ], - ], - ) - - // second message: root with a key, ends sync sequence - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "foo": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - ), - ], - ) - - let root = try await objects.getRoot() - - // verify root has the expected key — SDK should not break due to unknown object type - #expect(try #require(root.get(key: "foo")?.stringValue) == "bar", "Check root has correct value after unknown object type in sync") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveCounter is initialized with initial value from OBJECT_SYNC sequence", - action: { ctx in - let client = ctx.client - - try await waitFixtureChannelIsReady(client) - - let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - let counters = [ - (key: "emptyCounter", value: 0), - (key: "initialValueCounter", value: 10), - (key: "referencedCounter", value: 20), - ] - - for counter in counters { - let counterObj = try #require(root.get(key: counter.key)?.liveCounterValue) - #expect(try counterObj.value == Double(counter.value), "Check counter at key=\"\(counter.key)\" in root has correct value") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveMap is initialized with initial value from OBJECT_SYNC sequence", - action: { ctx in - let client = ctx.client - - try await waitFixtureChannelIsReady(client) - - let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - let emptyMap = try #require(root.get(key: "emptyMap")?.liveMapValue) - #expect(try emptyMap.size == 0, "Check empty map in root has no keys") - - let referencedMap = try #require(root.get(key: "referencedMap")?.liveMapValue) - #expect(try referencedMap.size == 1, "Check referenced map in root has correct number of keys") - - let counterFromReferencedMap = try #require(referencedMap.get(key: "counterKey")?.liveCounterValue) - #expect(try counterFromReferencedMap.value == 20, "Check nested counter has correct value") - - let valuesMap = try #require(root.get(key: "valuesMap")?.liveMapValue) - #expect(try valuesMap.size == 9, "Check values map in root has correct number of keys") - - #expect(try #require(valuesMap.get(key: "stringKey")?.stringValue) == "stringValue", "Check values map has correct string value key") - #expect(try #require(valuesMap.get(key: "emptyStringKey")?.stringValue).isEmpty, "Check values map has correct empty string value key") - #expect(try #require(valuesMap.get(key: "bytesKey")?.dataValue) == Data(base64Encoded: "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9"), "Check values map has correct bytes value key") - #expect(try #require(valuesMap.get(key: "emptyBytesKey")?.dataValue) == Data(base64Encoded: ""), "Check values map has correct empty bytes values key") - #expect(try #require(valuesMap.get(key: "numberKey")?.numberValue) == 1, "Check values map has correct number value key") - #expect(try #require(valuesMap.get(key: "zeroKey")?.numberValue) == 0, "Check values map has correct zero number value key") - #expect(try #require(valuesMap.get(key: "trueKey")?.boolValue as Bool?) == true, "Check values map has correct 'true' value key") - #expect(try #require(valuesMap.get(key: "falseKey")?.boolValue as Bool?) == false, "Check values map has correct 'false' value key") - - let mapFromValuesMap = try #require(valuesMap.get(key: "mapKey")?.liveMapValue) - #expect(try mapFromValuesMap.size == 1, "Check nested map has correct number of keys") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveMap can reference the same object in their keys", - action: { ctx in - let client = ctx.client - - try await waitFixtureChannelIsReady(client) - - let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - let referencedCounter = try #require(root.get(key: "referencedCounter")?.liveCounterValue) - let referencedMap = try #require(root.get(key: "referencedMap")?.liveMapValue) - let valuesMap = try #require(root.get(key: "valuesMap")?.liveMapValue) - - let counterFromReferencedMap = try #require(referencedMap.get(key: "counterKey")?.liveCounterValue, "Check nested counter is of type LiveCounter") - #expect(counterFromReferencedMap === referencedCounter, "Check nested counter is the same object instance as counter on the root") - #expect(try counterFromReferencedMap.value == 20, "Check nested counter has correct value") - - let mapFromValuesMap = try #require(valuesMap.get(key: "mapKey")?.liveMapValue, "Check nested map is of type LiveMap") - #expect(try mapFromValuesMap.size == 1, "Check nested map has correct number of keys") - #expect(mapFromValuesMap === referencedMap, "Check nested map is the same object instance as map on the root") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC sequence with object state \"tombstone\" property creates tombstoned object", - action: { ctx throws in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let mapId = objectsHelper.fakeMapObjectId() - let counterId = objectsHelper.fakeCounterObjectId() - - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // empty serial so sync sequence ends immediately - // add object states with tombstone=true - state: [ - objectsHelper.mapObject( - objectId: mapId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [:], - tombstone: true, - ), - objectsHelper.counterObject( - objectId: counterId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialCount: 1, - tombstone: true, - ), - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "map": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(mapId)]), - ]), - "counter": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(counterId)]), - ]), - "foo": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - ), - ], - ) - - #expect(try root.get(key: "map") == nil, "Check map does not exist on root after OBJECT_SYNC with \"tombstone=true\" for a map object") - #expect(try root.get(key: "counter") == nil, "Check counter does not exist on root after OBJECT_SYNC with \"tombstone=true\" for a counter object") - // control check that OBJECT_SYNC was applied at all - #expect(try root.get(key: "foo") != nil, "Check property exists on root after OBJECT_SYNC") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "OBJECT_SYNC sequence with object state \"tombstone\" property deletes existing object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(number: 1), - ) - _ = await counterCreatedPromise - - #expect(try root.get(key: "counter") != nil, "Check counter exists on root before OBJECT_SYNC sequence with \"tombstone=true\"") - - // inject an OBJECT_SYNC message where a counter is now tombstoned - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // empty serial so sync sequence ends immediately - state: [ - objectsHelper.counterObject( - objectId: counterResult.objectId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialCount: 1, - tombstone: true, - ), - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "counter": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(counterResult.objectId)]), - ]), - "foo": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - ), - ], - ) - - #expect(try root.get(key: "counter") == nil, "Check counter does not exist on root after OBJECT_SYNC with \"tombstone=true\" for an existing counter object") - // control check that OBJECT_SYNC was applied at all - #expect(try root.get(key: "foo") != nil, "Check property exists on root after OBJECT_SYNC") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "OBJECT_SYNC sequence with object state \"tombstone\" property triggers subscription callback for existing object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(number: 1), - ) - _ = await counterCreatedPromise - - let counterSubPromiseUpdates = try #require(root.get(key: "counter")?.liveCounterValue).updates() - async let counterSubPromise: Void = { - let update = try await #require(counterSubPromiseUpdates.first { _ in true }) - #expect(update.amount == -1, "Check counter subscription callback is called with an expected update object after OBJECT_SYNC sequence with \"tombstone=true\"") - }() - - // inject an OBJECT_SYNC message where a counter is now tombstoned - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // empty serial so sync sequence ends immediately - state: [ - objectsHelper.counterObject( - objectId: counterResult.objectId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialCount: 1, - tombstone: true, - ), - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "counter": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(counterResult.objectId)]), - ]), - ], - ), - ], - ) - - _ = try await counterSubPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC sequence with clearTimeserial records the clearTimeserial", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let clearTimeserial = lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0) - - // send OBJECT_SYNC with a map that has clearTimeserial and no entries - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // empty cursor so sync completes immediately - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0)], - clearTimeserial: clearTimeserial, - ), - ], - ) - - #expect(try root.size == 0, "Check root is empty after sync") - - // verify subsequent MAP_SETs are filtered based on clearTimeserial. - // use different sites so operations pass siteTimeserials check - let ops: [(serial: String, siteCode: String, key: String, applied: Bool)] = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 4, counter: 0), siteCode: "bbb", key: "earlyKey", applied: false), // < clearTimeserial - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 6, counter: 0), siteCode: "ccc", key: "laterKey", applied: true), // > clearTimeserial - ] - - for op in ops { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: op.serial, - siteCode: op.siteCode, - state: [objectsHelper.mapSetOp(objectId: "root", key: op.key, data: .object(["string": .string("value")]))], - ) - - if op.applied { - let value = try #require(root.get(key: op.key)?.stringValue) - #expect(value == "value", "Check MAP_SET for \"\(op.key)\" is applied") - } else { - let value = try root.get(key: op.key) - #expect(value == nil, "Check MAP_SET for \"\(op.key)\" is rejected") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC sequence with clearTimeserial does not surface initial entries from createOp", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let clearTimeserial = lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0) - - // send OBJECT_SYNC with a map that has clearTimeserial and initial entries via createOp. - // initial entries do not have timeserials set, so they should all be considered - // as predating the clear and not be surfaced to the end user. - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // empty cursor so sync completes immediately - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0)], - initialEntries: [ - "foo": .object(["data": .object(["string": .string("bar")])]), - "baz": .object(["data": .object(["number": .number(NSNumber(value: 123))])]), - ], - clearTimeserial: clearTimeserial, - ), - ], - ) - - let fooValue = try root.get(key: "foo") - #expect(fooValue == nil, "Check \"foo\" from initialEntries is not visible") - let bazValue = try root.get(key: "baz") - #expect(bazValue == nil, "Check \"baz\" from initialEntries is not visible") - #expect(try root.size == 0, "Check root has no visible keys") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC sequence with clearTimeserial and materialised entries processes entries correctly", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let clearTimeserial = lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0) - - // check that even with clearTimeserial set, the entries from materialised entries are - // processed correctly. - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // empty cursor so sync completes immediately - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 8, counter: 0)], - materialisedEntries: [ - // entry set after the clear - should be visible - "lateKey": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 8, counter: 0)), - "data": .object(["string": .string("late")]), - ]), - ], - clearTimeserial: clearTimeserial, - ), - ], - ) - - let lateKeyValue = try #require(root.get(key: "lateKey")?.stringValue) - #expect(lateKeyValue == "late", "Check lateKey is visible (postdates clear)") - #expect(try root.size == 1, "Check root has 1 visible key") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "reattach with HAS_OBJECTS=false resets clearTimeserial to null on root map", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // apply MAP_CLEAR to set clearTimeserial on root - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapClearOp(objectId: "root")], - ) - - // verify clearTimeserial is set - let internallyTypedRoot = try #require(root as? PublicDefaultLiveMap) - let internalRoot = internallyTypedRoot.proxied - #expect(internalRoot.testsOnly_clearTimeserial == lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), "Check clearTimeserial is set after MAP_CLEAR") - - // simulate reattach with HAS_OBJECTS=false, which resets root to a zero-value - await injectAttachedMessage(channel: channel) - - // clearTimeserial should be now set to null for root - #expect(internalRoot.testsOnly_clearTimeserial == nil, "Check clearTimeserial is null after reattach with HAS_OBJECTS=false") - }, - ), - ] - - let applyOperationsScenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply MAP_CREATE with primitives object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - // Check no maps exist on root - for fixture in primitiveMapsFixtures { - let key = fixture.name - #expect(try root.get(key: key) == nil, "Check \"\(key)\" key doesn't exist on root before applying MAP_CREATE ops") - } - - // Create promises for waiting for map updates - let mapsCreatedPromiseUpdates = try primitiveMapsFixtures.map { _ in try root.updates() } - async let mapsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - for (i, fixture) in primitiveMapsFixtures.enumerated() { - group.addTask { - await waitForMapKeyUpdate(mapsCreatedPromiseUpdates[i], fixture.name) - } - } - while try await group.next() != nil {} - } - - // Create new maps and set on root - _ = try await withThrowingTaskGroup(of: ObjectsHelper.OperationResult.self) { group in - for fixture in primitiveMapsFixtures { - group.addTask { - try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: fixture.name, - createOp: objectsHelper.mapCreateRestOp(data: fixture.restData), - ) - } - } - var results: [ObjectsHelper.OperationResult] = [] - while let result = try await group.next() { - results.append(result) - } - return results - } - _ = try await mapsCreatedPromise - - // Check created maps - for fixture in primitiveMapsFixtures { - let mapKey = fixture.name - let mapObj = try #require(root.get(key: mapKey)?.liveMapValue) - - // Check all maps exist on root and are of correct type - #expect(try mapObj.size == (fixture.entries?.count ?? 0), "Check map \"\(mapKey)\" has correct number of keys") - - if let entries = fixture.entries { - for (key, keyData) in entries { - let data = keyData["data"]!.objectValue! - - if let bytesString = data["bytes"]?.stringValue { - let expectedData = Data(base64Encoded: bytesString) - #expect(try mapObj.get(key: key)?.dataValue == expectedData, "Check map \"\(mapKey)\" has correct value for \"\(key)\" key") - } else if let numberValue = data["number"]?.numberValue { - #expect(try mapObj.get(key: key)?.numberValue == numberValue, "Check map \"\(mapKey)\" has correct value for \"\(key)\" key") - } else if let stringValue = data["string"]?.stringValue { - #expect(try mapObj.get(key: key)?.stringValue == stringValue, "Check map \"\(mapKey)\" has correct value for \"\(key)\" key") - } else if let boolValue = data["boolean"]?.boolValue { - #expect(try mapObj.get(key: key)?.boolValue == boolValue, "Check map \"\(mapKey)\" has correct value for \"\(key)\" key") - } - } - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply MAP_CREATE with object ids object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let withReferencesMapKey = "withReferencesMap" - - // Check map does not exist on root - #expect(try root.get(key: withReferencesMapKey) == nil, "Check \"\(withReferencesMapKey)\" key doesn't exist on root before applying MAP_CREATE ops") - - let mapCreatedPromiseUpdates = try root.updates() - async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, withReferencesMapKey) - - // Create map with references - need to create referenced objects first to obtain their object ids - // We'll create them separately first, then reference them - let tempMapUpdates = try root.updates() - let tempCounterUpdates = try root.updates() - async let tempObjectsPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(tempMapUpdates, "tempMap") - } - group.addTask { - await waitForMapKeyUpdate(tempCounterUpdates, "tempCounter") - } - while try await group.next() != nil {} - } - - let referencedMapResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "tempMap", - createOp: objectsHelper.mapCreateRestOp(data: ["stringKey": .object(["string": .string("stringValue")])]), - ) - let referencedCounterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "tempCounter", - createOp: objectsHelper.counterCreateRestOp(number: 1), - ) - _ = try await tempObjectsPromise - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: withReferencesMapKey, - createOp: objectsHelper.mapCreateRestOp(data: [ - "mapReference": .object(["objectId": .string(referencedMapResult.objectId)]), - "counterReference": .object(["objectId": .string(referencedCounterResult.objectId)]), - ]), - ) - _ = await mapCreatedPromise - - // Check map with references exist on root - let withReferencesMap = try #require(root.get(key: withReferencesMapKey)?.liveMapValue) - #expect(try withReferencesMap.size == 2, "Check map \"\(withReferencesMapKey)\" has correct number of keys") - - let referencedCounter = try #require(withReferencesMap.get(key: "counterReference")?.liveCounterValue) - #expect(try referencedCounter.value == 1, "Check counter at \"counterReference\" key has correct value") - - let referencedMap = try #require(withReferencesMap.get(key: "mapReference")?.liveMapValue) - #expect(try referencedMap.size == 1, "Check map at \"mapReference\" key has correct number of keys") - #expect(try #require(referencedMap.get(key: "stringKey")?.stringValue) == "stringValue", "Check map at \"mapReference\" key has correct \"stringKey\" value") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_CREATE object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Need to use multiple maps as MAP_CREATE op can only be applied once to a map object - let mapIds = [ - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - ] - - // Send MAP_SET ops first to create zero-value maps with forged site timeserials vector - for (i, mapId) in mapIds.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [objectsHelper.mapSetOp(objectId: mapId, key: "foo", data: .object(["string": .string("bar")]))], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: mapId, data: .object(["objectId": .string(mapId)]))], - ) - } - - // Inject operations with various timeserial values - let timeserialTestCases = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later CGO, applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier CGO, applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later CGO, applied - ] - - for (i, testCase) in timeserialTestCases.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: testCase.serial, - siteCode: testCase.siteCode, - state: [ - objectsHelper.mapCreateOp( - objectId: mapIds[i], - entries: [ - "baz": .object([ - "timeserial": .string(testCase.serial), - "data": .object(["string": .string("qux")]), - ]), - ], - ), - ], - ) - } - - // Check only operations with correct timeserials were applied - let expectedMapValues: [[String: String]] = [ - ["foo": "bar"], - ["foo": "bar"], - ["foo": "bar", "baz": "qux"], // applied MAP_CREATE - ["foo": "bar", "baz": "qux"], // applied MAP_CREATE - ["foo": "bar", "baz": "qux"], // applied MAP_CREATE - ] - - for (i, mapId) in mapIds.enumerated() { - let expectedMapValue = expectedMapValues[i] - let expectedKeysCount = expectedMapValue.count - - let mapObj = try #require(root.get(key: mapId)?.liveMapValue) - #expect(try mapObj.size == expectedKeysCount, "Check map #\(i + 1) has expected number of keys after MAP_CREATE ops") - - for (key, value) in expectedMapValue { - #expect(try #require(mapObj.get(key: key)?.stringValue) == value, "Check map #\(i + 1) has expected value for \"\(key)\" key after MAP_CREATE ops") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply MAP_SET with primitives object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - // Check root is empty before ops - for keyData in primitiveKeyData { - #expect(try root.get(key: keyData.key) == nil, "Check \"\(keyData.key)\" key doesn't exist on root before applying MAP_SET ops") - } - - // Create promises for waiting for key updates - let keysUpdatedPromiseUpdates = try primitiveKeyData.map { _ in try root.updates() } - async let keysUpdatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - for (i, keyData) in primitiveKeyData.enumerated() { - group.addTask { - await waitForMapKeyUpdate(keysUpdatedPromiseUpdates[i], keyData.key) - } - } - while try await group.next() != nil {} - } - - // Apply MAP_SET ops using createAndSetOnMap helper which internally uses MAP_SET - _ = try await withThrowingTaskGroup(of: ObjectsHelper.OperationResult.self) { group in - for keyData in primitiveKeyData { - group.addTask { - // We'll create dummy objects and set them, which uses MAP_SET internally - try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: keyData.key, - createOp: objectsHelper.mapCreateRestOp(data: ["value": .object(keyData.data)]), - ) - } - } - var results: [ObjectsHelper.OperationResult] = [] - while let result = try await group.next() { - results.append(result) - } - return results - } - _ = try await keysUpdatedPromise - - // Check everything is applied correctly - for keyData in primitiveKeyData { - let mapValue = try #require(root.get(key: keyData.key)?.liveMapValue) - - if let bytesString = keyData.data["bytes"]?.stringValue { - let expectedData = Data(base64Encoded: bytesString) - #expect(try mapValue.get(key: "value")?.dataValue == expectedData, "Check root has correct value for \"\(keyData.key)\" key after MAP_SET op") - } else if let numberValue = keyData.data["number"]?.numberValue { - #expect(try mapValue.get(key: "value")?.numberValue == numberValue, "Check root has correct value for \"\(keyData.key)\" key after MAP_SET op") - } else if let stringValue = keyData.data["string"]?.stringValue { - #expect(try mapValue.get(key: "value")?.stringValue == stringValue, "Check root has correct value for \"\(keyData.key)\" key after MAP_SET op") - } else if let boolValue = keyData.data["boolean"]?.boolValue { - #expect(try mapValue.get(key: "value")?.boolValue == boolValue, "Check root has correct value for \"\(keyData.key)\" key after MAP_SET op") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply MAP_SET with object ids object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - // Check no object ids are set on root - #expect(try root.get(key: "keyToCounter") == nil, "Check \"keyToCounter\" key doesn't exist on root before applying MAP_SET ops") - #expect(try root.get(key: "keyToMap") == nil, "Check \"keyToMap\" key doesn't exist on root before applying MAP_SET ops") - - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "keyToCounter") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "keyToMap") - } - while try await group.next() != nil {} - } - - // Create new objects and set on root - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "keyToCounter", - createOp: objectsHelper.counterCreateRestOp(number: 1), - ) - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "keyToMap", - createOp: objectsHelper.mapCreateRestOp(data: ["stringKey": .object(["string": .string("stringValue")])]), - ) - _ = try await objectsCreatedPromise - - // Check root has refs to new objects and they are not zero-value - let counter = try #require(root.get(key: "keyToCounter")?.liveCounterValue) - #expect(try counter.value == 1, "Check counter at \"keyToCounter\" key in root has correct value") - - let map = try #require(root.get(key: "keyToMap")?.liveMapValue) - #expect(try map.size == 1, "Check map at \"keyToMap\" key in root has correct number of keys") - #expect(try #require(map.get(key: "stringKey")?.stringValue) == "stringValue", "Check map at \"keyToMap\" key in root has correct \"stringKey\" value") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply COUNTER_CREATE object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - // Check no counters exist on root - for fixture in countersFixtures { - let key = fixture.name - #expect(try root.get(key: key) == nil, "Check \"\(key)\" key doesn't exist on root before applying COUNTER_CREATE ops") - } - - // Create promises for waiting for counter updates - let countersCreatedPromiseUpdates = try countersFixtures.map { _ in try root.updates() } - async let countersCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - for (i, fixture) in countersFixtures.enumerated() { - group.addTask { - await waitForMapKeyUpdate(countersCreatedPromiseUpdates[i], fixture.name) - } - } - while try await group.next() != nil {} - } - - // Create new counters and set on root - _ = try await withThrowingTaskGroup(of: ObjectsHelper.OperationResult.self) { group in - for fixture in countersFixtures { - group.addTask { - try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: fixture.name, - createOp: objectsHelper.counterCreateRestOp(number: fixture.count), - ) - } - } - var results: [ObjectsHelper.OperationResult] = [] - while let result = try await group.next() { - results.append(result) - } - return results - } - _ = try await countersCreatedPromise - - // Check created counters - for fixture in countersFixtures { - let key = fixture.name - let counterObj = try #require(root.get(key: key)?.liveCounterValue) - - // Check counters have correct values - let expectedValue = fixture.count ?? 0 - #expect(try counterObj.value == expectedValue, "Check counter at \"\(key)\" key in root has correct value") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply COUNTER_INC object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let counterKey = "counter" - var expectedCounterValue = 0.0 - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, counterKey) - - // Create new counter and set on root - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: counterKey, - createOp: objectsHelper.counterCreateRestOp(number: expectedCounterValue), - ) - _ = await counterCreatedPromise - - let counter = try #require(root.get(key: counterKey)?.liveCounterValue) - // Check counter has expected value before COUNTER_INC - #expect(try counter.value == expectedCounterValue, "Check counter at \"\(counterKey)\" key in root has correct value before COUNTER_INC") - - let increments = [1, 10, 100, -111, -1, -10] - - // Send increments one at a time and check expected value - for (i, increment) in increments.enumerated() { - expectedCounterValue += Double(increment) - - // Use the public API to increment - this will send COUNTER_INC internally - try await counter.increment(amount: Double(increment)) - - #expect(try counter.value == expectedCounterValue, "Check counter at \"\(counterKey)\" key in root has correct value after \(i + 1) COUNTER_INC ops") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "can apply OBJECT_DELETE object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "map") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "counter") - } - while try await group.next() != nil {} - } - - // Create initial objects and set on root - let mapResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(), - ) - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = try await objectsCreatedPromise - - #expect(try root.get(key: "map") != nil, "Check map exists on root before OBJECT_DELETE") - #expect(try root.get(key: "counter") != nil, "Check counter exists on root before OBJECT_DELETE") - - // Inject OBJECT_DELETE operations - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: mapResult.objectId)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: counterResult.objectId)], - ) - - #expect(try root.get(key: "map") == nil, "Check map is not accessible on root after OBJECT_DELETE") - #expect(try root.get(key: "counter") == nil, "Check counter is not accessible on root after OBJECT_DELETE") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply MAP_REMOVE object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let mapKey = "map" - - let mapCreatedPromiseUpdates = try root.updates() - async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, mapKey) - - // Create new map and set on root - let mapResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: mapKey, - createOp: objectsHelper.mapCreateRestOp(data: [ - "shouldStay": .object(["string": .string("foo")]), - "shouldDelete": .object(["string": .string("bar")]), - ]), - ) - _ = await mapCreatedPromise - - let map = try #require(root.get(key: mapKey)?.liveMapValue) - // Check map has expected keys before MAP_REMOVE ops - #expect(try map.size == 2, "Check map at \"\(mapKey)\" key in root has correct number of keys before MAP_REMOVE") - #expect(try #require(map.get(key: "shouldStay")?.stringValue) == "foo", "Check map at \"\(mapKey)\" key in root has correct \"shouldStay\" value before MAP_REMOVE") - #expect(try #require(map.get(key: "shouldDelete")?.stringValue) == "bar", "Check map at \"\(mapKey)\" key in root has correct \"shouldDelete\" value before MAP_REMOVE") - - // Send MAP_REMOVE op using the public API - try await map.remove(key: "shouldDelete") - - // Check map has correct keys after MAP_REMOVE ops - #expect(try map.size == 1, "Check map at \"\(mapKey)\" key in root has correct number of keys after MAP_REMOVE") - #expect(try #require(map.get(key: "shouldStay")?.stringValue) == "foo", "Check map at \"\(mapKey)\" key in root has correct \"shouldStay\" value after MAP_REMOVE") - #expect(try map.get(key: "shouldDelete") == nil, "Check map at \"\(mapKey)\" key in root has no \"shouldDelete\" key after MAP_REMOVE") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_DELETE for unknown object id creates zero-value tombstoned object", - action: { ctx throws in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let counterId = objectsHelper.fakeCounterObjectId() - // Inject OBJECT_DELETE - should create a zero-value tombstoned object which can't be modified - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: counterId)], - ) - - // Try to create and set tombstoned object on root - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), - siteCode: "bbb", - state: [objectsHelper.counterCreateOp(objectId: counterId)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [objectsHelper.mapSetOp(objectId: "root", key: "counter", data: .object(["objectId": .string(counterId)]))], - ) - - #expect(try root.get(key: "counter") == nil, "Check counter is not accessible on root") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_SET with reference to a tombstoned object results in undefined value on key", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - - let objectCreatedPromiseUpdates = try root.updates() - async let objectCreatedPromise: Void = waitForMapKeyUpdate(objectCreatedPromiseUpdates, "foo") - - // Create initial objects and set on root - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "foo", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = await objectCreatedPromise - - #expect(try root.get(key: "foo") != nil, "Check counter exists on root before OBJECT_DELETE") - - // Inject OBJECT_DELETE - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: counterResult.objectId)], - ) - - // Set tombstoned counter to another key on root - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "bar", data: .object(["objectId": .string(counterResult.objectId)]))], - ) - - #expect(try root.get(key: "bar") == nil, "Check counter is not accessible on new key in root after OBJECT_DELETE") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "object operation message on a tombstoned object does not revive it", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - let objectsCreatedPromiseUpdates3 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "map1") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "map2") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates3, "counter1") - } - while try await group.next() != nil {} - } - - // Create initial objects and set on root - let mapResult1 = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map1", - createOp: objectsHelper.mapCreateRestOp(), - ) - let mapResult2 = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map2", - createOp: objectsHelper.mapCreateRestOp(data: ["foo": .object(["string": .string("bar")])]), - ) - let counterResult1 = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter1", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = try await objectsCreatedPromise - - #expect(try root.get(key: "map1") != nil, "Check map1 exists on root before OBJECT_DELETE") - #expect(try root.get(key: "map2") != nil, "Check map2 exists on root before OBJECT_DELETE") - #expect(try root.get(key: "counter1") != nil, "Check counter1 exists on root before OBJECT_DELETE") - - // Inject OBJECT_DELETE operations - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: mapResult1.objectId)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: mapResult2.objectId)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 2, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: counterResult1.objectId)], - ) - - // Inject object operations on tombstoned objects - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 3, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: mapResult1.objectId, key: "baz", data: .object(["string": .string("qux")]))], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 4, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapRemoveOp(objectId: mapResult2.objectId, key: "foo")], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0), - siteCode: "aaa", - state: [objectsHelper.counterIncOp(objectId: counterResult1.objectId, number: 1)], - ) - - // Objects should still be deleted - #expect(try root.get(key: "map1") == nil, "Check map1 does not exist on root after OBJECT_DELETE and another object op") - #expect(try root.get(key: "map2") == nil, "Check map2 does not exist on root after OBJECT_DELETE and another object op") - #expect(try root.get(key: "counter1") == nil, "Check counter1 does not exist on root after OBJECT_DELETE and another object op") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_SET object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Create new map and set it on a root with forged timeserials - let mapId = objectsHelper.fakeMapObjectId() - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [ - objectsHelper.mapCreateOp( - objectId: mapId, - entries: [ - "foo1": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo2": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo3": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo4": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo5": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo6": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - ), - ], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "map", data: .object(["objectId": .string(mapId)]))], - ) - - // Inject operations with various timeserial values - let timeserialTestCases = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier site CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same site CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later site CGO, applied, site timeserials updated - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, same site CGO (updated from last op), not applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier entry CGO, not applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later entry CGO, applied - ] - - for (i, testCase) in timeserialTestCases.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: testCase.serial, - siteCode: testCase.siteCode, - state: [objectsHelper.mapSetOp(objectId: mapId, key: "foo\(i + 1)", data: .object(["string": .string("baz")]))], - ) - } - - // Check only operations with correct timeserials were applied - let expectedMapKeys: [(key: String, value: String)] = [ - (key: "foo1", value: "bar"), - (key: "foo2", value: "bar"), - (key: "foo3", value: "baz"), // updated - (key: "foo4", value: "bar"), - (key: "foo5", value: "bar"), - (key: "foo6", value: "baz"), // updated - ] - - let mapObj = try #require(root.get(key: "map")?.liveMapValue) - for expectedMapKey in expectedMapKeys { - #expect(try #require(mapObj.get(key: expectedMapKey.key)?.stringValue) == expectedMapKey.value, "Check \"\(expectedMapKey.key)\" key on map has expected value after MAP_SET ops") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "COUNTER_INC object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Create new counter and set it on a root with forged timeserials - let counterId = objectsHelper.fakeCounterObjectId() - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [objectsHelper.counterCreateOp(objectId: counterId, count: 1)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "counter", data: .object(["objectId": .string(counterId)]))], - ) - - // Inject operations with various timeserial values - let timeserialTestCases = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb", amount: 10), // existing site, earlier CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb", amount: 100), // existing site, same CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb", amount: 1000), // existing site, later CGO, applied, site timeserials updated - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb", amount: 10000), // existing site, same CGO (updated from last op), not applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa", amount: 100_000), // different site, earlier CGO, applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc", amount: 1_000_000), // different site, later CGO, applied - ] - - for testCase in timeserialTestCases { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: testCase.serial, - siteCode: testCase.siteCode, - state: [objectsHelper.counterIncOp(objectId: counterId, number: testCase.amount)], - ) - } - - // Check only operations with correct timeserials were applied - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - let expectedValue = 1.0 + 1000.0 + 100_000.0 + 1_000_000.0 // sum of passing operations and the initial value - #expect(try counter.value == expectedValue, "Check counter has expected value after COUNTER_INC ops") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_REMOVE object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Create new map and set it on a root with forged timeserials - let mapId = objectsHelper.fakeMapObjectId() - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [ - objectsHelper.mapCreateOp( - objectId: mapId, - entries: [ - "foo1": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo2": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo3": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo4": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo5": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo6": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - ), - ], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "map", data: .object(["objectId": .string(mapId)]))], - ) - - // Inject operations with various timeserial values - let timeserialTestCases = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier site CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same site CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later site CGO, applied, site timeserials updated - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, same site CGO (updated from last op), not applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier entry CGO, not applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later entry CGO, applied - ] - - for (i, testCase) in timeserialTestCases.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: testCase.serial, - siteCode: testCase.siteCode, - state: [objectsHelper.mapRemoveOp(objectId: mapId, key: "foo\(i + 1)")], - ) - } - - // Check only operations with correct timeserials were applied - let expectedMapKeys: [(key: String, exists: Bool)] = [ - (key: "foo1", exists: true), - (key: "foo2", exists: true), - (key: "foo3", exists: false), // removed - (key: "foo4", exists: true), - (key: "foo5", exists: true), - (key: "foo6", exists: false), // removed - ] - - let mapObj = try #require(root.get(key: "map")?.liveMapValue) - for expectedMapKey in expectedMapKeys { - if expectedMapKey.exists { - #expect(try mapObj.get(key: expectedMapKey.key) != nil, "Check \"\(expectedMapKey.key)\" key on map still exists after MAP_REMOVE ops") - } else { - #expect(try mapObj.get(key: expectedMapKey.key) == nil, "Check \"\(expectedMapKey.key)\" key on map does not exist after MAP_REMOVE ops") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "COUNTER_CREATE object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Need to use multiple counters as COUNTER_CREATE op can only be applied once to a counter object - let counterIds = [ - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - ] - - // Send COUNTER_INC ops first to create zero-value counters with forged site timeserials vector - for (i, counterId) in counterIds.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [objectsHelper.counterIncOp(objectId: counterId, number: 1)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: counterId, data: .object(["objectId": .string(counterId)]))], - ) - } - - // Inject operations with various timeserial values - let timeserialTestCases = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later CGO, applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier CGO, applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later CGO, applied - ] - - for (i, testCase) in timeserialTestCases.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: testCase.serial, - siteCode: testCase.siteCode, - state: [objectsHelper.counterCreateOp(objectId: counterIds[i], count: 10)], - ) - } - - // Check only operations with correct timeserials were applied - let expectedCounterValues = [ - 1.0, - 1.0, - 11.0, // applied COUNTER_CREATE - 11.0, // applied COUNTER_CREATE - 11.0, // applied COUNTER_CREATE - ] - - for (i, counterId) in counterIds.enumerated() { - let expectedValue = expectedCounterValues[i] - let counter = try #require(root.get(key: counterId)?.liveCounterValue) - #expect(try counter.value == expectedValue, "Check counter #\(i + 1) has expected value after COUNTER_CREATE ops") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_DELETE object operation messages are applied based on the site timeserials vector of the object", - action: { ctx throws in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Need to use multiple objects as OBJECT_DELETE op can only be applied once to an object - let counterIds = [ - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - ] - - // Create objects and set them on root with forged timeserials - for (i, counterId) in counterIds.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [objectsHelper.counterCreateOp(objectId: counterId)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: counterId, data: .object(["objectId": .string(counterId)]))], - ) - } - - // Inject OBJECT_DELETE operations with various timeserial values - let timeserialTestCases = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later CGO, applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier CGO, applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later CGO, applied - ] - - for (i, testCase) in timeserialTestCases.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: testCase.serial, - siteCode: testCase.siteCode, - state: [objectsHelper.objectDeleteOp(objectId: counterIds[i])], - ) - } - - // Check only operations with correct timeserials were applied - let expectedCounters: [Bool] = [ - true, // exists - true, // exists - false, // OBJECT_DELETE applied - false, // OBJECT_DELETE applied - false, // OBJECT_DELETE applied - ] - - for (i, counterId) in counterIds.enumerated() { - let exists = expectedCounters[i] - - if exists { - #expect(try root.get(key: counterId) != nil, "Check counter #\(i + 1) exists on root as OBJECT_DELETE op was not applied") - } else { - #expect(try root.get(key: counterId) == nil, "Check counter #\(i + 1) does not exist on root as OBJECT_DELETE op was applied") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_DELETE triggers subscription callback with deleted data", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "map") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "counter") - } - while try await group.next() != nil {} - } - - // Create initial objects and set on root - let mapResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(data: [ - "foo": .object(["string": .string("bar")]), - "baz": .object(["number": .number(1)]), - ]), - ) - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(number: 1), - ) - _ = try await objectsCreatedPromise - - let mapSubPromiseUpdates = try #require(root.get(key: "map")?.liveMapValue).updates() - let counterSubPromiseUpdates = try #require(root.get(key: "counter")?.liveCounterValue).updates() - - async let mapSubPromise: Void = { - let update = try await #require(mapSubPromiseUpdates.first { _ in true }) - #expect(update.update["foo"] == .removed, "Check map subscription callback is called with an expected update object after OBJECT_DELETE operation for 'foo' key") - #expect(update.update["baz"] == .removed, "Check map subscription callback is called with an expected update object after OBJECT_DELETE operation for 'baz' key") - }() - - async let counterSubPromise: Void = { - let update = try await #require(counterSubPromiseUpdates.first { _ in true }) - #expect(update.amount == -1, "Check counter subscription callback is called with an expected update object after OBJECT_DELETE operation") - }() - - // Inject OBJECT_DELETE - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: mapResult.objectId)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: counterResult.objectId)], - ) - - _ = try await (mapSubPromise, counterSubPromise) - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "can apply MAP_CLEAR object operation messages on root", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - - // set some keys on root - try await root.set(key: "foo", value: "bar") - try await root.set(key: "baz", value: 42) - - // verify keys exist before clear - let fooValue = try #require(root.get(key: "foo")?.stringValue) - #expect(fooValue == "bar", "Check foo exists before MAP_CLEAR") - let bazValue = try #require(root.get(key: "baz")?.numberValue) - #expect(bazValue == 42, "Check baz exists before MAP_CLEAR") - #expect(try root.size == 2, "Check root has 2 keys before MAP_CLEAR") - - // send MAP_CLEAR - let clearAppliedPromiseUpdates = try root.updates() - async let clearAppliedPromise: Void = waitForMapClear(clearAppliedPromiseUpdates, expectedRemovedKeys: ["foo", "baz"]) - try await objectsHelper.sendMapClearOnChannel(objects: objects, objectId: "root") - await clearAppliedPromise - - // verify all keys are cleared - #expect(try root.size == 0, "Check root has 0 keys after MAP_CLEAR") - let fooAfterClear = try root.get(key: "foo") - #expect(fooAfterClear == nil, "Check foo does not exist after MAP_CLEAR") - let bazAfterClear = try root.get(key: "baz") - #expect(bazAfterClear == nil, "Check baz does not exist after MAP_CLEAR") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - // MAP_CLEAR is currently server-initiated and only emitted for root objects, - // but the client must be future-proof and support it for any map object. - description: "can apply MAP_CLEAR object operation messages on non-root map objects", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // create a non-root map with entries - try await root.set(key: "map", value: .liveMap(objects.createMap(entries: ["foo": "bar", "baz": 42]))) - - let map = try #require(try root.get(key: "map")?.liveMapValue) - #expect(try map.size == 2, "Check map has 2 keys before MAP_CLEAR") - let mapFooValue = try #require(map.get(key: "foo")?.stringValue) - #expect(mapFooValue == "bar", "Check \"foo\" key has correct value") - let mapBazValue = try #require(map.get(key: "baz")?.numberValue) - #expect(mapBazValue == 42, "Check \"baz\" key has correct value") - - // apply MAP_CLEAR on non-root map via internal API call, - // as the server won't accept MAP_CLEAR for non-root object ids. - let internalMap = try #require(map as? PublicDefaultLiveMap) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "zzz", timestamp: 99_999_999_999_999, counter: 0), - siteCode: "zzz", - state: [objectsHelper.mapClearOp(objectId: internalMap.proxied.testsOnly_objectID)], - ) - - // verify all keys are cleared - #expect(try map.size == 0, "Check map has 0 keys after MAP_CLEAR") - let mapFooAfterClear = try map.get(key: "foo") - #expect(mapFooAfterClear == nil, "Check \"foo\" key does not exist after MAP_CLEAR") - let mapBazAfterClear = try map.get(key: "baz") - #expect(mapBazAfterClear == nil, "Check \"baz\" key does not exist after MAP_CLEAR") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_CLEAR with older serial than current clearTimeserial is a noop", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], - ) - - // apply a sequence of operations and check expected state after each - let steps: [(op: [String: WireValue], serial: String, siteCode: String, description: String, expectedSize: Int, expectedKeys: [String: String])] = [ - ( - op: objectsHelper.mapClearOp(objectId: "root"), - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), - siteCode: "aaa", - description: "first MAP_CLEAR", - expectedSize: 0, - expectedKeys: [:] - ), - ( - op: objectsHelper.mapSetOp(objectId: "root", key: "key1", data: .object(["string": .string("value")])), - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 11, counter: 0), - siteCode: "aaa", - description: "MAP_SET #1 after clear", - expectedSize: 1, - expectedKeys: ["key1": "value"] - ), - ( - op: objectsHelper.mapClearOp(objectId: "root"), - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), // different site so siteTimeserials check passes, older than first clear - noop - siteCode: "bbb", - description: "second MAP_CLEAR with older serial (noop)", - expectedSize: 1, - expectedKeys: ["key1": "value"] - ), - ] - - for step in steps { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: step.serial, - siteCode: step.siteCode, - state: [step.op], - ) - - #expect(try root.size == step.expectedSize, "Check map size after \(step.description)") - for (key, value) in step.expectedKeys { - let keyValue = try #require(root.get(key: key)?.stringValue) - #expect(keyValue == value, "Check \"\(key)\" after \(step.description)") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_CLEAR does not remove entries with serial greater than clearTimeserial", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // set keys with different timeserials - let keys: [(key: String, serial: String, siteCode: String, survivesClear: Bool)] = [ - (key: "key1", serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa", survivesClear: false), // different site, earlier CGO, cleared - (key: "key2", serial: lexicoTimeserial(seriesId: "aaa", timestamp: 999, counter: 0), siteCode: "aaa", survivesClear: true), // different site, later CGO, survives - (key: "key3", serial: lexicoTimeserial(seriesId: "bbb", timestamp: 5, counter: 0), siteCode: "bbb", survivesClear: false), // same site, earlier CGO, cleared - (key: "key4", serial: lexicoTimeserial(seriesId: "ccc", timestamp: 0, counter: 0), siteCode: "ccc", survivesClear: false), // different site, earlier CGO, cleared - (key: "key5", serial: lexicoTimeserial(seriesId: "ccc", timestamp: 999, counter: 0), siteCode: "ccc", survivesClear: true), // different site, later CGO, survives - ] - - for keyEntry in keys { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: keyEntry.serial, - siteCode: keyEntry.siteCode, - state: [objectsHelper.mapSetOp(objectId: "root", key: keyEntry.key, data: .object(["string": .string(keyEntry.key)]))], - ) - } - - #expect(try root.size == keys.count, "Check map has correct number of keys before MAP_CLEAR") - - // apply MAP_CLEAR with serial between existing keys - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 6, counter: 0), - siteCode: "bbb", - state: [objectsHelper.mapClearOp(objectId: "root")], - ) - - var expectedToSurviveCount = 0 - for keyEntry in keys { - expectedToSurviveCount += keyEntry.survivesClear ? 1 : 0 - if keyEntry.survivesClear { - let keyValue = try #require(root.get(key: keyEntry.key)?.stringValue) - #expect(keyValue == keyEntry.key, "Check \(keyEntry.key) survives MAP_CLEAR") - } else { - let keyValue = try root.get(key: keyEntry.key) - #expect(keyValue == nil, "Check \(keyEntry.key) is cleared") - } - } - #expect(try root.size == expectedToSurviveCount, "Check map has \(expectedToSurviveCount) keys after MAP_CLEAR") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_CLEAR object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let mapIds = [ - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - ] - - for (i, mapId) in mapIds.enumerated() { - // for each map, send two operations: - // 1. create a map with visible data that can be verified after MAP_CLEAR. - // use earliest possible timeserial to ensure entries can be cleared by MAP_CLEAR ops - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [ - objectsHelper.mapCreateOp( - objectId: mapId, - entries: ["foo": .object(["timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), "data": .object(["string": .string("bar")])])], - ), - ], - ) - // 2. send a no-op remove to establish site 'ccc' in the map's siteTimeserials at a known serial, - // which the MAP_CLEAR ops below will be compared against - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "ccc", timestamp: 5, counter: 0), - siteCode: "ccc", - state: [objectsHelper.mapRemoveOp(objectId: mapId, key: "baz")], - ) - - // set map on root - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: mapId, data: .object(["objectId": .string(mapId)]))], - ) - } - - // inject MAP_CLEAR operations with various timeserial values - // relative to the lexicoTimeserial('ccc', 5, 0) from the remove op above - let ops: [(serial: String, siteCode: String, cleared: Bool)] = [ - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 4, counter: 0), siteCode: "ccc", cleared: false), // existing site, earlier CGO, not applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 5, counter: 0), siteCode: "ccc", cleared: false), // existing site, same CGO, not applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 6, counter: 0), siteCode: "ccc", cleared: true), // existing site, later CGO, applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb", cleared: true), // different site, earlier CGO, applied - (serial: lexicoTimeserial(seriesId: "ddd", timestamp: 9, counter: 0), siteCode: "ddd", cleared: true), // different site, later CGO, applied - ] - - for (i, op) in ops.enumerated() { - let mapId = mapIds[i] - - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: op.serial, - siteCode: op.siteCode, - state: [objectsHelper.mapClearOp(objectId: mapId)], - ) - - let map = try #require(try root.get(key: mapId)?.liveMapValue) - if op.cleared { - #expect(try map.size == 0, "Check map #\(i + 1) is cleared") - } else { - #expect(try map.size == 1, "Check map #\(i + 1) is not cleared") - let fooValue = try #require(map.get(key: "foo")?.stringValue) - #expect(fooValue == "bar", "Check map #\(i + 1) retains \"foo\" key") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_SET with serial <= clearTimeserial is ignored after MAP_CLEAR", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // apply MAP_CLEAR, stores clearTimeserial on a map - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapClearOp(objectId: "root")], - ) - - // inject MAP_SET operations with various serials relative to clearTimeserial. - // use different site codes to pass siteTimeserials check - let ops: [(serial: String, siteCode: String, key: String, applied: Bool)] = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 5, counter: 0), siteCode: "bbb", key: "early", applied: false), // earlier than clear, ignored - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), siteCode: "aaa", key: "equal", applied: false), // equal to clear, ignored - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 999, counter: 0), siteCode: "bbb", key: "later", applied: true), // later than clear, applied - ] - - for op in ops { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: op.serial, - siteCode: op.siteCode, - state: [objectsHelper.mapSetOp(objectId: "root", key: op.key, data: .object(["string": .string("value")]))], - ) - - if op.applied { - let value = try #require(root.get(key: op.key)?.stringValue) - #expect(value == "value", "Check MAP_SET for \"\(op.key)\" is applied") - } else { - let value = try root.get(key: op.key) - #expect(value == nil, "Check MAP_SET for \"\(op.key)\" is ignored") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_REMOVE with serial <= clearTimeserial is ignored after MAP_CLEAR", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // apply MAP_CLEAR, stores clearTimeserial on a map - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapClearOp(objectId: "root")], - ) - - // inject MAP_REMOVE operations with various serials relative to clearTimeserial. - // use different site codes to pass siteTimeserials check - let ops: [(serial: String, siteCode: String, key: String, applied: Bool)] = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 5, counter: 0), siteCode: "bbb", key: "early", applied: false), // earlier than clear, ignored - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), siteCode: "aaa", key: "equal", applied: false), // equal to clear, ignored - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 999, counter: 0), siteCode: "bbb", key: "later", applied: true), // later than clear, applied - ] - - for op in ops { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: op.serial, - siteCode: op.siteCode, - state: [objectsHelper.mapRemoveOp(objectId: "root", key: op.key)], - ) - - let internallyTypedRoot = try #require(root as? PublicDefaultLiveMap) - let internalRoot = internallyTypedRoot.proxied - let underlyingData = internalRoot.testsOnly_data - if op.applied { - let mapEntry = try #require(underlyingData[op.key]) - #expect(mapEntry.tombstone == true, "Check MAP_REMOVE for \"\(op.key)\" is tombstoned") - } else { - #expect(underlyingData[op.key] == nil, "Check MAP_REMOVE for \"\(op.key)\" is ignored") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_CLEAR removes entries from internal data map", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // set a key on root so the clear has something to remove - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], - ) - #expect(try root.get(key: "foo") != nil, "Check \"foo\" exists before clear") - - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapClearOp(objectId: "root")], - ) - - // entry should be fully removed from internal data - let internallyTypedRoot = try #require(root as? PublicDefaultLiveMap) - let internalRoot = internallyTypedRoot.proxied - let underlyingData = internalRoot.testsOnly_data - #expect(underlyingData["foo"] == nil, "Check \"foo\" is removed from internal data") - }, - ), - ] - - let applyOperationsDuringSyncScenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "object operation messages are buffered during OBJECT_SYNC sequence", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - let client = ctx.client - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - ) - - // Inject operations, they should not be applied as sync is in progress - // Note that unlike in the JS test we do not perform this concurrently because if we were to do that in Swift Concurrency we would not be able to guarantee that the operations are applied in the correct order (if they're not then messages will be discarded due to serials being out of order) - for keyData in primitiveKeyData { - var wireData = keyData.data.mapValues { WireValue(jsonValue: $0) } - - if let bytesValue = wireData["bytes"], client.internal.options.useBinaryProtocol { - let bytesString = try #require(bytesValue.stringValue) - wireData["bytes"] = try .data(#require(.init(base64Encoded: bytesString))) - } - - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: keyData.key, data: .object(wireData))], - ) - } - - // Check root doesn't have data from operations - for keyData in primitiveKeyData { - #expect(try root.get(key: keyData.key) == nil, "Check \"\(keyData.key)\" key doesn't exist on root during OBJECT_SYNC") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "buffered object operation messages are applied when OBJECT_SYNC sequence ends", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - let client = ctx.client - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - ) - - // Inject operations, they should be applied when sync ends - // Note that unlike in the JS test we do not perform this concurrently because if we were to do that in Swift Concurrency we would not be able to guarantee that the operations are applied in the correct order (if they're not then messages will be discarded due to serials being out of order) - for (i, keyData) in primitiveKeyData.enumerated() { - var wireData = keyData.data.mapValues { WireValue(jsonValue: $0) } - - if let bytesValue = wireData["bytes"], client.internal.options.useBinaryProtocol { - let bytesString = try #require(bytesValue.stringValue) - wireData["bytes"] = try .data(#require(.init(base64Encoded: bytesString))) - } - - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: keyData.key, data: .object(wireData))], - ) - } - - // End the sync with empty cursor - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - ) - - // Check everything is applied correctly - for keyData in primitiveKeyData { - if let bytesValue = keyData.data["bytes"] { - if case let .string(base64String) = bytesValue { - let expectedData = Data(base64Encoded: base64String) - #expect(try #require(root.get(key: keyData.key)?.dataValue) == expectedData, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } else { - // Handle other value types - if let stringValue = keyData.data["string"] { - if case let .string(expectedString) = stringValue { - #expect(try #require(root.get(key: keyData.key)?.stringValue) == expectedString, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } else if let numberValue = keyData.data["number"] { - if case let .number(expectedNumber) = numberValue { - #expect(try #require(root.get(key: keyData.key)?.numberValue) == expectedNumber, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } else if let boolValue = keyData.data["boolean"] { - if case let .bool(expectedBool) = boolValue { - #expect(try #require(root.get(key: keyData.key)?.boolValue as Bool?) == expectedBool, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "buffered object operation messages are discarded on ATTACHED", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - ) - - // Inject operation during sync sequence, expect it to be discarded when ATTACHED arrives - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], - ) - - // Any ATTACHED message must clear buffered operations and start a new sync sequence - await injectAttachedMessage(channel: channel, flags: .hasObjects) - - // Inject another operation that should be applied when sync ends - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), - siteCode: "bbb", - state: [objectsHelper.mapSetOp(objectId: "root", key: "baz", data: .object(["string": .string("qux")]))], - ) - - // End sync - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - ) - - // Check root doesn't have data from operations received before ATTACHED - let fooValue = try root.get(key: "foo") - #expect(fooValue == nil, "Check buffered ops before ATTACHED were discarded and not applied on root") - - // Check root has data from operations received after ATTACHED - #expect(try #require(root.get(key: "baz")?.stringValue) == "qux", "Check root has data from operations received after ATTACHED") - }, - ), - .init( - // Note: This comment re regression test is preserved from the JS test it's copied from, but this bug never actually existed in the Swift implementation. - // Regression test: an earlier implementation did not clear buffered operations when receiving - // an ATTACHED with RESUMED=true on an already-attached channel. The RESUMED flag is irrelevant - // — buffering is determined by HAS_OBJECTS, and any ATTACHED must clear buffered operations. - disabled: false, - allTransportsAndProtocols: false, - description: "buffered object operation messages are discarded when already-attached channel receives ATTACHED with RESUMED flag", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Channel is already attached from the test setup - #expect(channel.state == .attached, "Check channel is already attached before test begins") - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - ) - - // Inject operation, expect it to be discarded when ATTACHED arrives (even with RESUMED) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], - ) - - // The RESUMED flag is irrelevant for LiveObjects buffering — any ATTACHED must clear - // buffered operations and start a new sync sequence - await injectAttachedMessage(channel: channel, flags: [.hasObjects, .resumed]) - - // Inject another operation after ATTACHED - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), - siteCode: "bbb", - state: [objectsHelper.mapSetOp(objectId: "root", key: "baz", data: .object(["string": .string("qux")]))], - ) - - // End sync - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - ) - - // Check root doesn't have data from operations received before ATTACHED - let fooValue = try root.get(key: "foo") - #expect(fooValue == nil, "Check buffered ops before RESUMED ATTACHED were discarded and not applied on root") - - // Check root has data from operations received after ATTACHED - #expect(try #require(root.get(key: "baz")?.stringValue) == "qux", "Check root has data from operations received after RESUMED ATTACHED") - }, - ), - .init( - // Regression test: an earlier implementation incorrectly cleared buffered operations when a new - // OBJECT_SYNC sequence started. Only an ATTACHED message should clear buffered operations, not - // a new OBJECT_SYNC sequence. - disabled: false, - allTransportsAndProtocols: false, - description: "buffered object operation messages are NOT discarded on new OBJECT_SYNC sequence", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - ) - - // Inject operation during first sync sequence - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], - ) - - // Start new sync with new sequence id — buffered operations should NOT be discarded - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "otherserial:cursor", - ) - - // Inject another operation during second sync sequence - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), - siteCode: "bbb", - state: [objectsHelper.mapSetOp(objectId: "root", key: "baz", data: .object(["string": .string("qux")]))], - ) - - // End sync - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "otherserial:", - ) - - // Check root has data from operations received during first sync sequence - let fooStringValue = try #require(root.get(key: "foo")?.stringValue) - #expect(fooStringValue == "bar", "Check root has data from operations received during first OBJECT_SYNC sequence") - - // Check root has data from operations received during second sync - let bazStringValue = try #require(root.get(key: "baz")?.stringValue) - #expect(bazStringValue == "qux", "Check root has data from operations received during second OBJECT_SYNC sequence") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "operations are buffered when OBJECT_SYNC is received after completed sync without expected preceding ATTACHED", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Complete an initial sync sequence first - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - ) - - // Simulate receiving OBJECT_SYNC without preceding ATTACHED. - // Normally, for server-initiated resync the server is expected to send ATTACHED with RESUMED=false first. - // However, if that doesn't happen, the client handles it as a best-effort case by starting to buffer from this point. - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "resync:cursor", - ) - - // Inject operations during this server-initiated resync — they should be buffered - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], - ) - - // Check root doesn't have data yet — operations should be buffered during resync - let fooValueDuringResync = try root.get(key: "foo") - #expect(fooValueDuringResync == nil, "Check \"foo\" key doesn't exist during server-initiated resync") - - // End the resync - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "resync:", - ) - - // Check buffered operations are now applied - let fooStringValue = try #require(root.get(key: "foo")?.stringValue) - #expect(fooStringValue == "bar", "Check root has correct value for \"foo\" key after server-initiated resync completed") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "buffered object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - let mapId = objectsHelper.fakeMapObjectId() - let counterId = objectsHelper.fakeCounterObjectId() - - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - // Add object state messages with non-empty site timeserials - state: [ - // Next map and counter objects will be checked to have correct operations applied on them based on site timeserials - objectsHelper.mapObject( - objectId: mapId, - siteTimeserials: [ - "bbb": lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), - "ccc": lexicoTimeserial(seriesId: "ccc", timestamp: 5, counter: 0), - ], - materialisedEntries: [ - "foo1": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo2": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo3": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "ccc", timestamp: 5, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo4": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo5": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo6": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "ccc", timestamp: 2, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo7": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "ccc", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo8": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "ccc", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - ), - objectsHelper.counterObject( - objectId: counterId, - siteTimeserials: [ - "bbb": lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - ], - initialCount: 1, - ), - // Add objects to the root so they're discoverable in the object tree - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "map": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(mapId)]), - ]), - "counter": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(counterId)]), - ]), - ], - ), - ], - ) - - // Inject operations with various timeserial values - // Map: - let mapOperations: [(serial: String, siteCode: String)] = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, earlier site CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, same site CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 3, counter: 0), siteCode: "bbb"), // existing site, later site CGO, earlier entry CGO, not applied but site timeserial updated - // message with later site CGO, same entry CGO case is not possible, as timeserial from entry would be set for the corresponding site code or be less than that - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 3, counter: 0), siteCode: "bbb"), // existing site, same site CGO (updated from last op), later entry CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 4, counter: 0), siteCode: "bbb"), // existing site, later site CGO, later entry CGO, applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), siteCode: "aaa"), // different site, earlier entry CGO, not applied but site timeserial updated - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), siteCode: "aaa"), // different site, same site CGO (updated from last op), later entry CGO, not applied - // different site with matching entry CGO case is not possible, as matching entry timeserial means that that timeserial is in the site timeserials vector - (serial: lexicoTimeserial(seriesId: "ddd", timestamp: 1, counter: 0), siteCode: "ddd"), // different site, later entry CGO, applied - ] - - for (i, operation) in mapOperations.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: operation.serial, - siteCode: operation.siteCode, - state: [objectsHelper.mapSetOp(objectId: mapId, key: "foo\(i + 1)", data: .object(["string": .string("baz")]))], - ) - } - - // Counter: - let counterOperations: [(serial: String, siteCode: String, amount: Double)] = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb", amount: 10), // existing site, earlier CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb", amount: 100), // existing site, same CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb", amount: 1000), // existing site, later CGO, applied, site timeserials updated - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb", amount: 10000), // existing site, same CGO (updated from last op), not applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa", amount: 100_000), // different site, earlier CGO, applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc", amount: 1_000_000), // different site, later CGO, applied - ] - - for operation in counterOperations { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: operation.serial, - siteCode: operation.siteCode, - state: [objectsHelper.counterIncOp(objectId: counterId, number: Int(operation.amount))], - ) - } - - // End sync - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - ) - - // Check only operations with correct timeserials were applied - let expectedMapKeys: [(key: String, value: String)] = [ - (key: "foo1", value: "bar"), - (key: "foo2", value: "bar"), - (key: "foo3", value: "bar"), - (key: "foo4", value: "bar"), - (key: "foo5", value: "baz"), // updated - (key: "foo6", value: "bar"), - (key: "foo7", value: "bar"), - (key: "foo8", value: "baz"), // updated - ] - - let map = try #require(root.get(key: "map")?.liveMapValue) - for expectedMapKey in expectedMapKeys { - #expect(try #require(map.get(key: expectedMapKey.key)?.stringValue) == expectedMapKey.value, "Check \"\(expectedMapKey.key)\" key on map has expected value after OBJECT_SYNC has ended") - } - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - let expectedCounterValue = 1.0 + 1000.0 + 100_000.0 + 1_000_000.0 // sum of passing operations and the initial value - #expect(try counter.value == expectedCounterValue, "Check counter has expected value after OBJECT_SYNC has ended") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "subsequent object operation messages are applied immediately after OBJECT_SYNC ended and buffered operations are applied", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - let channelName = ctx.channelName - let client = ctx.client - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - ) - - // Inject operations, they should be applied when sync ends - // Note that unlike in the JS test we do not perform this concurrently because if we were to do that in Swift Concurrency we would not be able to guarantee that the operations are applied in the correct order (if they're not then messages will be discarded due to serials being out of order) - for (i, keyData) in primitiveKeyData.enumerated() { - var wireData = keyData.data.mapValues { WireValue(jsonValue: $0) } - - if let bytesValue = wireData["bytes"], client.internal.options.useBinaryProtocol { - let bytesString = try #require(bytesValue.stringValue) - wireData["bytes"] = try .data(#require(.init(base64Encoded: bytesString))) - } - - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: keyData.key, data: .object(wireData))], - ) - } - - // End the sync with empty cursor - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - ) - - let keyUpdatedPromiseUpdates = try root.updates() - async let keyUpdatedPromise: Void = waitForMapKeyUpdate(keyUpdatedPromiseUpdates, "foo") - - // Send some more operations - let operationResult = try await objectsHelper.operationRequest( - channelName: channelName, - opBody: objectsHelper.mapSetRestOp( - objectId: "root", - key: "foo", - value: ["string": .string("bar")], - ), - ) - await keyUpdatedPromise - - // Check buffered operations are applied, as well as the most recent operation outside of the sync sequence is applied - for keyData in primitiveKeyData { - if let bytesValue = keyData.data["bytes"] { - if case let .string(base64String) = bytesValue { - let expectedData = Data(base64Encoded: base64String) - #expect(try #require(root.get(key: keyData.key)?.dataValue) == expectedData, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } else { - // Handle other value types - if let stringValue = keyData.data["string"] { - if case let .string(expectedString) = stringValue { - #expect(try #require(root.get(key: keyData.key)?.stringValue) == expectedString, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } else if let numberValue = keyData.data["number"] { - if case let .number(expectedNumber) = numberValue { - #expect(try #require(root.get(key: keyData.key)?.numberValue) == expectedNumber, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } else if let boolValue = keyData.data["boolean"] { - if case let .bool(expectedBool) = boolValue { - #expect(try #require(root.get(key: keyData.key)?.boolValue as Bool?) == expectedBool, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } - } - } - - #expect(try #require(root.get(key: "foo")?.stringValue) == "bar", "Check root has correct value for \"foo\" key from operation received outside of OBJECT_SYNC after other buffered operations were applied") - }, - ), - ] - - let writeApiScenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveCounter.increment sends COUNTER_INC operation", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = await counterCreatedPromise - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - let increments: [Double] = [ - 1, // value=1 - 10, // value=11 - -11, // value=0 - -1, // value=-1 - -10, // value=-11 - 11, // value=0 - Double(Int.max), // value=9223372036854775807 - -Double(Int.max), // value=0 - -Double(Int.max), // value=-9223372036854775807 - ] - var expectedCounterValue = 0.0 - - for (i, increment) in increments.enumerated() { - expectedCounterValue += increment - - try await counter.increment(amount: increment) - - #expect(try counter.value == expectedCounterValue, "Check counter has correct value after \(i + 1) LiveCounter.increment calls") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveCounter.increment throws on invalid input", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = await counterCreatedPromise - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - - // Test invalid numeric values - Swift type system prevents most invalid types - // OMITTED from JS tests due to Swift type system: increment(), increment(null), - // increment('foo'), increment(BigInt(1)), increment(true), increment(Symbol()), - // increment({}), increment([]), increment(counter) - all prevented by Swift's type system - await #expect(throws: Error.self, "Counter value increment should be a valid number") { - try await counter.increment(amount: Double.nan) - } - await #expect(throws: Error.self, "Counter value increment should be a valid number") { - try await counter.increment(amount: Double.infinity) - } - await #expect(throws: Error.self, "Counter value increment should be a valid number") { - try await counter.increment(amount: -Double.infinity) - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveCounter.decrement sends COUNTER_INC operation", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = await counterCreatedPromise - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - let decrements: [Double] = [ - 1, // value=-1 - 10, // value=-11 - -11, // value=0 - -1, // value=1 - -10, // value=11 - 11, // value=0 - Double(Int.max), // value=-9223372036854775807 - -Double(Int.max), // value=0 - -Double(Int.max), // value=9223372036854775807 - ] - var expectedCounterValue = 0.0 - - for (i, decrement) in decrements.enumerated() { - expectedCounterValue -= decrement - - try await counter.decrement(amount: decrement) - - #expect(try counter.value == expectedCounterValue, "Check counter has correct value after \(i + 1) LiveCounter.decrement calls") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveCounter.decrement throws on invalid input", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = await counterCreatedPromise - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - - // Test invalid numeric values - Swift type system prevents most invalid types - // OMITTED from JS tests due to Swift type system: decrement(), decrement(null), - // decrement('foo'), decrement(BigInt(1)), decrement(true), decrement(Symbol()), - // decrement({}), decrement([]), decrement(counter) - all prevented by Swift's type system - await #expect(throws: Error.self, "Counter value decrement should be a valid number") { - try await counter.decrement(amount: Double.nan) - } - await #expect(throws: Error.self, "Counter value decrement should be a valid number") { - try await counter.decrement(amount: Double.infinity) - } - await #expect(throws: Error.self, "Counter value decrement should be a valid number") { - try await counter.decrement(amount: -Double.infinity) - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveMap.set sends MAP_SET operation with primitive values", - action: { ctx in - let root = ctx.root - - _ = try await withThrowingTaskGroup(of: Void.self) { group in - for keyData in primitiveKeyData { - group.addTask { - try await root.set(key: keyData.key, value: keyData.liveMapValue) - } - } - while try await group.next() != nil {} - } - - // Check everything is applied correctly - for keyData in primitiveKeyData { - let actualValue = try #require(try root.get(key: keyData.key)) - - switch keyData.liveMapValue { - case let .data(expectedData): - let actualData = try #require(actualValue.dataValue) - #expect(actualData == expectedData, "Check root has correct value for \"\(keyData.key)\" key after LiveMap.set call") - case let .string(expectedString): - let actualString = try #require(actualValue.stringValue) - #expect(actualString == expectedString, "Check root has correct value for \"\(keyData.key)\" key after LiveMap.set call") - case let .number(expectedNumber): - let actualNumber = try #require(actualValue.numberValue) - #expect(actualNumber == expectedNumber, "Check root has correct value for \"\(keyData.key)\" key after LiveMap.set call") - case let .bool(expectedBool): - let actualBool = try #require(actualValue.boolValue as Bool?) - #expect(actualBool == expectedBool, "Check root has correct value for \"\(keyData.key)\" key after LiveMap.set call") - default: - Issue.record("Unexpected value type in test") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveMap.set sends MAP_SET operation with reference to another LiveObject", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "counter") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "map") - } - while try await group.next() != nil {} - } - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(), - ) - _ = try await objectsCreatedPromise - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - let map = try #require(root.get(key: "map")?.liveMapValue) - - async let setCounter2Promise: Void = root.set(key: "counter2", value: .liveCounter(counter)) - async let setMap2Promise: Void = root.set(key: "map2", value: .liveMap(map)) - _ = try await (setCounter2Promise, setMap2Promise) - - let counter2 = try #require(root.get(key: "counter2")?.liveCounterValue) - let map2 = try #require(root.get(key: "map2")?.liveMapValue) - - #expect(counter2 === counter, "Check can set a reference to a LiveCounter object on a root via a LiveMap.set call") - #expect(map2 === map, "Check can set a reference to a LiveMap object on a root via a LiveMap.set call") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveMap.set throws on invalid input", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let mapCreatedPromiseUpdates = try root.updates() - async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(), - ) - _ = await mapCreatedPromise - - let map = try #require(root.get(key: "map")?.liveMapValue) - - // OMITTED from JS tests due to Swift type system: - // Key validation: map.set(), map.set(null), map.set(1), map.set(BigInt(1)), - // map.set(true), map.set(Symbol()), map.set({}), map.set([]), map.set(map) - // Value validation: map.set('key'), map.set('key', null), map.set('key', BigInt(1)), - // map.set('key', Symbol()), map.set('key', {}), map.set('key', []) - // All prevented by Swift's type system - String keys and LiveMapValue values are enforced - - // Note: Swift's LiveMap.set(key:value:) method signature enforces String keys and - // LiveMapValue values at compile time, making most JS validation tests unnecessary - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveMap.remove sends MAP_REMOVE operation", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let mapCreatedPromiseUpdates = try root.updates() - async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(data: [ - "foo": .object(["number": .number(1)]), - "bar": .object(["number": .number(1)]), - "baz": .object(["number": .number(1)]), - ]), - ) - _ = await mapCreatedPromise - - let map = try #require(root.get(key: "map")?.liveMapValue) - - async let removeFooPromise: Void = map.remove(key: "foo") - async let removeBarPromise: Void = map.remove(key: "bar") - _ = try await (removeFooPromise, removeBarPromise) - - #expect(try map.get(key: "foo") == nil, "Check can remove a key from a root via a LiveMap.remove call") - #expect(try map.get(key: "bar") == nil, "Check can remove a key from a root via a LiveMap.remove call") - #expect(try #require(map.get(key: "baz")?.numberValue) == 1, "Check non-removed keys are still present on a root after LiveMap.remove call for another keys") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveMap.remove throws on invalid input", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let mapCreatedPromiseUpdates = try root.updates() - async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(), - ) - _ = await mapCreatedPromise - - let map = try #require(root.get(key: "map")?.liveMapValue) - - // OMITTED from JS tests due to Swift type system: - // map.remove(), map.remove(null), map.remove(1), map.remove(BigInt(1)), - // map.remove(true), map.remove(Symbol()), map.remove({}), map.remove([]), map.remove(map) - // All prevented by Swift's type system - String key parameter is enforced - - // Note: Swift's LiveMap.remove(key:) method signature enforces String keys at compile time, - // making JS key validation tests unnecessary - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "Objects.createCounter sends COUNTER_CREATE operation", - action: { ctx in - let objects = ctx.objects - - let counters = try await withThrowingTaskGroup(of: (index: Int, counter: any LiveCounter).self, returning: [any LiveCounter].self) { group in - for (index, fixture) in countersFixtures.enumerated() { - group.addTask { - let counter = if let count = fixture.count { - try await objects.createCounter(count: count) - } else { - try await objects.createCounter() - } - return (index: index, counter: counter) - } - } - - var results: [(index: Int, counter: any LiveCounter)] = [] - while let result = try await group.next() { - results.append(result) - } - return results.sorted { $0.index < $1.index }.map(\.counter) - } - - for (i, counter) in counters.enumerated() { - let fixture = countersFixtures[i] - - // Note: counter is guaranteed to exist by Swift type system - // Note: Type check omitted - guaranteed by Swift type system that counter is PublicLiveCounter - #expect(try counter.value == fixture.count ?? 0, "Check counter #\(i + 1) has expected initial value") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveCounter created with Objects.createCounter can be assigned to the object tree", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - - let counter = try await objects.createCounter(count: 1) - try await root.set(key: "counter", value: .liveCounter(counter)) - _ = await counterCreatedPromise - - // Note: Type check omitted - guaranteed by Swift type system that counter is PublicLiveCounter - let rootCounter = try #require(root.get(key: "counter")?.liveCounterValue) - // Note: Type check omitted - guaranteed by Swift type system that rootCounter is PublicLiveCounter - #expect(rootCounter === counter, "Check counter object on root is the same as from create method") - #expect(try rootCounter.value == 1, "Check counter assigned to the object tree has the expected value") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "Objects.createCounter can return LiveCounter with initial value without applying CREATE operation", - action: { ctx in - let objects = ctx.objects - - // prevent publishing of ops to realtime so we guarantee that the initial value comes from local apply-on-ACK, not from a server echo - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages in - PublishResult(serials: objectMessages.map { _ in "fake-serial" }) - }) - - let counter = try await objects.createCounter(count: 1) - #expect(try counter.value == 1, "Check counter has expected initial value") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "Objects.createCounter can return LiveCounter with initial value from applied CREATE operation", - action: { ctx in - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Instead of sending CREATE op to the realtime, echo it immediately to the client - // with forged initial value so we can check that counter gets initialized with a value from a CREATE op - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - var capturedCounterId: String? - - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages throws(ARTErrorInfo) in - do { - let counterId = try #require(objectMessages[0].operation?.objectId) - capturedCounterId = counterId - - // This should result in executing regular operation application procedure and create an object in the pool with forged initial value - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1), - siteCode: "aaa", - state: [objectsHelper.counterCreateOp(objectId: counterId, count: 10)], - ) - } catch { - throw LiveObjectsError.other(error).toARTErrorInfo() - } - return PublishResult(serials: objectMessages.map { _ in "fake-serial" }) - }) - - let counter = try await objects.createCounter(count: 1) - - // The injected CREATE op (value=10) is processed first in the override. - // Then publishAndApply's local CREATE (value=1) is rejected as a duplicate - // COUNTER_CREATE. So the counter retains the injected op's value. - #expect(try counter.value == 10, "Check counter value has the expected initial value from the injected CREATE operation") - #expect(capturedCounterId != nil, "Check that Objects.publish was called with counter ID") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "initial value is not double counted for LiveCounter from Objects.createCounter when CREATE op is received", - action: { ctx in - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Prevent publishing of ops to realtime so we can guarantee order of operations - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages in - // Prevent publishing to realtime but return serials so apply-on-ACK works - PublishResult(serials: objectMessages.map { _ in "fake-serial" }) - }) - - // Create counter locally via apply-on-ACK - let counter = try await objects.createCounter(count: 1) - let internalCounter = try #require(counter as? PublicDefaultLiveCounter) - let counterId = internalCounter.proxied.testsOnly_objectID - - // Now inject CREATE op for a counter with a forged value. it should not be applied - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1), - siteCode: "aaa", - state: [objectsHelper.counterCreateOp(objectId: counterId, count: 10)], - ) - - #expect(try counter.value == 1, "Check counter initial value is not double counted after being created and receiving CREATE operation") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "Objects.createCounter throws on invalid input", - action: { ctx in - let objects = ctx.objects - - // Test invalid numeric values - Swift type system prevents most invalid types - // OMITTED from JS tests due to Swift type system: objects.createCounter(null), - // objects.createCounter('foo'), objects.createCounter(BigInt(1)), objects.createCounter(true), - // objects.createCounter(Symbol()), objects.createCounter({}), objects.createCounter([]), - // objects.createCounter(root) - all prevented by Swift's type system - await #expect(throws: Error.self, "Counter value should be a valid number") { - try await objects.createCounter(count: Double.nan) - } - await #expect(throws: Error.self, "Counter value should be a valid number") { - try await objects.createCounter(count: Double.infinity) - } - await #expect(throws: Error.self, "Counter value should be a valid number") { - try await objects.createCounter(count: -Double.infinity) - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "Objects.createMap sends MAP_CREATE operation with primitive values", - action: { ctx in - let objects = ctx.objects - - let maps = try await withThrowingTaskGroup(of: (index: Int, map: any LiveMap).self, returning: [any LiveMap].self) { group in - for (index, mapFixture) in primitiveMapsFixtures.enumerated() { - group.addTask { - let map = if let entries = mapFixture.liveMapEntries { - try await objects.createMap(entries: entries) - } else { - try await objects.createMap() - } - return (index: index, map: map) - } - } - - var results: [(index: Int, map: any LiveMap)] = [] - while let result = try await group.next() { - results.append(result) - } - return results.sorted { $0.index < $1.index }.map(\.map) - } - - for (i, map) in maps.enumerated() { - let fixture = primitiveMapsFixtures[i] - - // Note: map is guaranteed to exist by Swift type system - // Note: Type check omitted - guaranteed by Swift type system that map is PublicLiveMap - - #expect(try map.size == (fixture.liveMapEntries?.count ?? 0), "Check map #\(i + 1) has correct number of keys") - - if let entries = fixture.liveMapEntries { - for (key, expectedValue) in entries { - let actualValue = try map.get(key: key) - - switch expectedValue { - case let .data(expectedData): - let actualData = try #require(actualValue?.dataValue) - #expect(actualData == expectedData, "Check map #\(i + 1) has correct value for \"\(key)\" key") - case let .string(expectedString): - let actualString = try #require(actualValue?.stringValue) - #expect(actualString == expectedString, "Check map #\(i + 1) has correct value for \"\(key)\" key") - case let .number(expectedNumber): - let actualNumber = try #require(actualValue?.numberValue) - #expect(actualNumber == expectedNumber, "Check map #\(i + 1) has correct value for \"\(key)\" key") - case let .bool(expectedBool): - let actualBool = try #require(actualValue?.boolValue as Bool?) - #expect(actualBool == expectedBool, "Check map #\(i + 1) has correct value for \"\(key)\" key") - case .jsonArray, .jsonObject: - Issue.record("JSON array/object primitives not expected in test data") - case .liveCounter, .liveMap: - Issue.record("Nested objects not expected in primitive test data") - } - } - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "Objects.createMap sends MAP_CREATE operation with reference to another LiveObject", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let objects = ctx.objects - - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "counter") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "map") - } - while try await group.next() != nil {} - } - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(), - ) - _ = try await objectsCreatedPromise - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - let map = try #require(root.get(key: "map")?.liveMapValue) - - let newMap = try await objects.createMap(entries: ["counter": .liveCounter(counter), "map": .liveMap(map)]) - - // Note: newMap is guaranteed to exist by Swift type system - // Note: Type check omitted - guaranteed by Swift type system that newMap is PublicLiveMap - - let newMapCounter = try #require(newMap.get(key: "counter")?.liveCounterValue) - let newMapMap = try #require(newMap.get(key: "map")?.liveMapValue) - - #expect(newMapCounter === counter, "Check can set a reference to a LiveCounter object on a new map via a MAP_CREATE operation") - #expect(newMapMap === map, "Check can set a reference to a LiveMap object on a new map via a MAP_CREATE operation") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveMap created with Objects.createMap can be assigned to the object tree", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - - let mapCreatedPromiseUpdates = try root.updates() - async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") - - let counter = try await objects.createCounter() - let map = try await objects.createMap(entries: ["foo": "bar", "baz": .liveCounter(counter)]) - try await root.set(key: "map", value: .liveMap(map)) - _ = await mapCreatedPromise - - // Note: Type check omitted - guaranteed by Swift type system that map is PublicLiveMap - let rootMap = try #require(root.get(key: "map")?.liveMapValue) - // Note: Type check omitted - guaranteed by Swift type system that rootMap is PublicLiveMap - #expect(rootMap === map, "Check map object on root is the same as from create method") - #expect(try rootMap.size == 2, "Check map assigned to the object tree has the expected number of keys") - #expect(try #require(rootMap.get(key: "foo")?.stringValue) == "bar", "Check map assigned to the object tree has the expected value for its string key") - - let rootMapCounter = try #require(rootMap.get(key: "baz")?.liveCounterValue) - #expect(rootMapCounter === counter, "Check map assigned to the object tree has the expected value for its LiveCounter key") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "Objects.createMap can return LiveMap with initial value without applying CREATE operation", - action: { ctx in - let objects = ctx.objects - - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages in - PublishResult(serials: objectMessages.map { _ in "fake-serial" }) - }) - - // prevent publishing of ops to realtime so we guarantee that the initial value comes from local apply-on-ACK - let map = try await objects.createMap(entries: ["foo": "bar"]) - #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check map has expected initial value") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "Objects.createMap can return LiveMap with initial value from applied CREATE operation", - action: { ctx in - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Instead of sending CREATE op to the realtime, echo it immediately to the client - // with forged initial value so we can check that map gets initialized with a value from a CREATE op - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - var capturedMapId: String? - - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages throws(ARTErrorInfo) in - do { - let mapId = try #require(objectMessages[0].operation?.objectId) - capturedMapId = mapId - - // This should result in executing regular operation application procedure and create an object in the pool with forged initial value - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1), - siteCode: "aaa", - state: [ - objectsHelper.mapCreateOp( - objectId: mapId, - entries: [ - "baz": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1)), - "data": .object(["string": .string("qux")]), - ]), - ], - ), - ], - ) - } catch { - throw LiveObjectsError.other(error).toARTErrorInfo() - } - return PublishResult(serials: objectMessages.map { _ in "fake-serial" }) - }) - - let map = try await objects.createMap(entries: ["foo": "bar"]) - - // The injected CREATE op (with entry "baz") is processed first in the override. - // Then publishAndApply's local CREATE (with entry "foo") is rejected as a duplicate - // MAP_CREATE. So the map retains the injected op's entries. - #expect(try map.get(key: "foo") == nil, "Check key \"foo\" was not set on a map client-side") - #expect(try #require(map.get(key: "baz")?.stringValue) == "qux", "Check key \"baz\" was set on a map from the injected CREATE operation") - #expect(capturedMapId != nil, "Check that Objects.publish was called with map ID") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "initial value is not double counted for LiveMap from Objects.createMap when CREATE op is received", - action: { ctx in - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Prevent publishing of ops to realtime but return serials so apply-on-ACK works - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages in - PublishResult(serials: objectMessages.map { _ in "fake-serial" }) - }) - - // Create map locally via apply-on-ACK - let map = try await objects.createMap(entries: ["foo": "bar"]) - let internalMap = try #require(map as? PublicDefaultLiveMap) - let mapId = internalMap.proxied.testsOnly_objectID - - // Now inject CREATE op for a map with a forged value. it should not be applied - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1), - siteCode: "aaa", - state: [ - objectsHelper.mapCreateOp( - objectId: mapId, - entries: [ - "foo": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1)), - "data": .object(["string": .string("qux")]), - ]), - "baz": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1)), - "data": .object(["string": .string("qux")]), - ]), - ], - ), - ], - ) - - #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check key \"foo\" was not overridden by a CREATE operation after creating a map locally") - #expect(try map.get(key: "baz") == nil, "Check key \"baz\" was not set by a CREATE operation after creating a map locally") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "Objects.createMap throws on invalid input", - action: { ctx in - let objects = ctx.objects - - // Test invalid input types - Swift type system prevents most invalid types - // OMITTED from JS tests due to Swift type system: objects.createMap(null), - // objects.createMap('foo'), objects.createMap(1), objects.createMap(BigInt(1)), - // objects.createMap(true), objects.createMap(Symbol()) - all prevented by Swift's type system - - // Test invalid map value types - these would be caught at runtime - // OMITTED from JS tests due to Swift type system: objects.createMap({ key: undefined }), - // objects.createMap({ key: null }), objects.createMap({ key: BigInt(1) }), - // objects.createMap({ key: Symbol() }), objects.createMap({ key: {} }), - // objects.createMap({ key: [] }) - all prevented by Swift's type system requiring specific LiveMapValue types - - // Note: Swift's Objects.createMap(initialData:) method signature enforces [String: Any] initialData - // and LiveMapValue enum cases at compile time, making most JS validation tests unnecessary. - // Any invalid values would be caught during the conversion to LiveMapValue enum cases. - }, - ), - ] - - let liveMapEnumerationScenarios: [TestScenario] = [ - // TODO: Implement these scenarios - ] - - return [ - objectSyncSequenceScenarios, - applyOperationsScenarios, - applyOperationsDuringSyncScenarios, - writeApiScenarios, - liveMapEnumerationScenarios, - ].flatMap(\.self) - }() - } - - @Test(arguments: FirstSetOfScenarios.testCases) - func firstSetOfScenarios(testCase: TestCase) async throws { - guard !testCase.disabled else { - withKnownIssue { - Issue.record("Test case is disabled") - } - return - } - - let objectsHelper = try await ObjectsHelper() - let client = try await realtimeWithObjects(options: testCase.options) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channel = client.channels.get(testCase.channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - try await testCase.scenario.action( - .init( - objects: objects, - root: root, - objectsHelper: objectsHelper, - channelName: testCase.channelName, - channel: channel, - client: client, - clientOptions: testCase.options, - ), - ) - } - } - - @available(iOS 17.0.0, tvOS 17.0.0, *) - enum SubscriptionCallbacksScenarios: Scenarios { - struct Context { - var objects: any RealtimeObjects - var root: any LiveMap - var objectsHelper: ObjectsHelper - var channelName: String - var channel: ARTRealtimeChannel - var sampleMapKey: String - var sampleMapObjectId: String - var sampleCounterKey: String - var sampleCounterObjectId: String - } - - static let scenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can subscribe to the incoming COUNTER_INC operation on a LiveCounter", - action: { ctx in - // Have split this #require into two because one of our formatting tools was trying to remove the parentheses from #require when it was a one-liner, making it invalid Swift 🤷 - let sampleCounterValue = try #require(try ctx.root.get(key: ctx.sampleCounterKey)) - let counter = try #require(sampleCounterValue.liveCounterValue) - - let updates = try counter.updates() - async let subscriptionPromise: Void = { - let update = try #require(await updates.first { _ in true }) - #expect(update.amount == 1, "Check counter subscription callback is called with an expected update object for COUNTER_INC operation") - }() - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.counterIncRestOp(objectId: ctx.sampleCounterObjectId, number: 1), - ) - - try await subscriptionPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can subscribe to multiple incoming operations on a LiveCounter", - action: { @MainActor ctx in - let counter = try #require(ctx.root.get(key: ctx.sampleCounterKey)?.liveCounterValue) - let expectedCounterIncrements = [100.0, -100.0, Double(Int.max), Double(-Int.max)] - let currentUpdateIndex = MainActorStorage(value: 0) - - let subscriber = Subscriber(callbackQueue: .main) - try counter.subscribe(listener: subscriber.createListener()) - async let subscriptionPromise: Void = withCheckedContinuation { continuation in - subscriber.addListener { update, _ in - MainActor.assumeIsolated { - let expectedInc = expectedCounterIncrements[currentUpdateIndex.value] - #expect(update.amount == expectedInc, "Check counter subscription callback is called with an expected update object for \(currentUpdateIndex.value + 1) times") - - if currentUpdateIndex.value == expectedCounterIncrements.count - 1 { - continuation.resume() - } - - currentUpdateIndex.value += 1 - } - } - } - - for increment in expectedCounterIncrements { - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.counterIncRestOp(objectId: ctx.sampleCounterObjectId, number: increment), - ) - } - - await subscriptionPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can subscribe to the incoming MAP_SET operation on a LiveMap", - action: { ctx in - // Have split this #require into two because one of our formatting tools was trying to remove the parentheses from #require when it was a one-liner, making it invalid Swift 🤷 - let sampleMapValue = try #require(try ctx.root.get(key: ctx.sampleMapKey)) - let map = try #require(sampleMapValue.liveMapValue) - - let updates = try map.updates() - async let subscriptionPromise: Void = { - let update = try #require(await updates.first { _ in true }) - // Check that the update contains the expected key with "updated" status - #expect(update.update["stringKey"] == .updated, "Check map subscription callback is called with an expected update object for MAP_SET operation") - }() - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapSetRestOp( - objectId: ctx.sampleMapObjectId, - key: "stringKey", - value: ["string": "stringValue"], - ), - ) - - try await subscriptionPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can subscribe to the incoming MAP_REMOVE operation on a LiveMap", - action: { ctx in - // Have split this #require into two because one of our formatting tools was trying to remove the parentheses from #require when it was a one-liner, making it invalid Swift 🤷 - let sampleMapValue = try #require(try ctx.root.get(key: ctx.sampleMapKey)) - let map = try #require(sampleMapValue.liveMapValue) - - let updates = try map.updates() - async let subscriptionPromise: Void = { - let update = try #require(await updates.first { _ in true }) - // Check that the update contains the expected key with "removed" status - #expect(update.update["stringKey"] == .removed, "Check map subscription callback is called with an expected update object for MAP_REMOVE operation") - }() - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapRemoveRestOp( - objectId: ctx.sampleMapObjectId, - key: "stringKey", - ), - ) - - try await subscriptionPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can subscribe to multiple incoming operations on a LiveMap", - action: { @MainActor ctx in - let map = try #require(ctx.root.get(key: ctx.sampleMapKey)?.liveMapValue) - let expectedMapUpdates: [[String: LiveMapUpdateAction]] = [ - ["foo": .updated], - ["bar": .updated], - ["foo": .removed], - ["baz": .updated], - ["bar": .removed], - ] - let currentUpdateIndex = MainActorStorage(value: 0) - - let subscriber = Subscriber(callbackQueue: .main) - try map.subscribe(listener: subscriber.createListener()) - async let subscriptionPromise: Void = withCheckedContinuation { continuation in - subscriber.addListener { update, _ in - MainActor.assumeIsolated { - let expectedUpdate = expectedMapUpdates[currentUpdateIndex.value] - #expect(update.update == expectedUpdate, "Check map subscription callback is called with an expected update object for \(currentUpdateIndex.value + 1) times") - - if currentUpdateIndex.value == expectedMapUpdates.count - 1 { - continuation.resume() - } - - currentUpdateIndex.value += 1 - } - } - } - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapSetRestOp( - objectId: ctx.sampleMapObjectId, - key: "foo", - value: ["string": "something"], - ), - ) - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapSetRestOp( - objectId: ctx.sampleMapObjectId, - key: "bar", - value: ["string": "something"], - ), - ) - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapRemoveRestOp( - objectId: ctx.sampleMapObjectId, - key: "foo", - ), - ) - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapSetRestOp( - objectId: ctx.sampleMapObjectId, - key: "baz", - value: ["string": "something"], - ), - ) - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapRemoveRestOp( - objectId: ctx.sampleMapObjectId, - key: "bar", - ), - ) - - await subscriptionPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - // Deviation from JS: The JS test checks event.message.operation.action === 'map.clear' - // (operation metadata), but Swift's LiveMapUpdate has no operation action field — it - // only exposes per-key changes via update: [String: LiveMapUpdateAction]. So instead - // of checking the operation action, this test verifies that the subscription update - // contains .removed entries for each key that existed on the map before the clear. - // This is something the JS test doesn't verify (it only checks operation metadata, - // not the per-key update entries). - description: "can subscribe to the incoming MAP_CLEAR operation on a LiveMap", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - - let updates = try root.updates() - async let subscriptionPromise: Void = { - let update = try #require(await updates.first { _ in true }) - // verify per-key removed entries for all keys that existed before the clear. - // the test setup creates sampleMap and sampleCounter on root, so those are - // the keys that should be removed. - #expect(update.update[ctx.sampleMapKey] == .removed, "Check \"\(ctx.sampleMapKey)\" key has .removed action after MAP_CLEAR") - #expect(update.update[ctx.sampleCounterKey] == .removed, "Check \"\(ctx.sampleCounterKey)\" key has .removed action after MAP_CLEAR") - }() - - try await objectsHelper.sendMapClearOnChannel(objects: objects, objectId: "root") - - try await subscriptionPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "can unsubscribe from LiveCounter updates via returned unsubscribe callback", - action: { @MainActor ctx in - let counter = try #require(ctx.root.get(key: ctx.sampleCounterKey)?.liveCounterValue) - let callbackCalled = MainActorStorage(value: 0) - - let subscriber = Subscriber(callbackQueue: .main) - try counter.subscribe(listener: subscriber.createListener()) - async let subscriptionPromise: Void = withCheckedContinuation { continuation in - subscriber.addListener { _, subscriptionResponse in - MainActor.assumeIsolated { - callbackCalled.value += 1 - // unsubscribe from future updates after the first call - subscriptionResponse.unsubscribe() - continuation.resume() - } - } - } - - let increments = 3 - for i in 0 ..< increments { - let counterUpdatesStream = try counter.updates() - async let counterUpdatedPromise: Void = waitForCounterUpdate(counterUpdatesStream) - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.counterIncRestOp(objectId: ctx.sampleCounterObjectId, number: 1), - ) - await counterUpdatedPromise - } - - await subscriptionPromise - - #expect(try counter.value == 3, "Check counter has final expected value after all increments") - #expect(callbackCalled.value == 1, "Check subscription callback was only called once") - }, - ), - // Have not implemented "can unsubscribe from LiveCounter updates via LiveCounter.unsubscribe() call" because this method doesn't exist in the Swift SDK (functions don't have identity) - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "can remove all LiveCounter update listeners via LiveCounter.unsubscribeAll() call", - action: { @MainActor ctx in - let counter = try #require(ctx.root.get(key: ctx.sampleCounterKey)?.liveCounterValue) - let callbacks = 3 - let callbacksCalled = MainActorStorage<[Int]>(value: Array(repeating: 0, count: callbacks)) - - // Create multiple subscribers synchronously - let subscribers = try (0 ..< callbacks).map { _ in - let subscriber = Subscriber(callbackQueue: .main) - try counter.subscribe(listener: subscriber.createListener()) - return subscriber - } - - // Set up subscription promises using TaskGroup - async let subscriptionPromises: Void = withTaskGroup(of: Void.self) { group in - for (index, subscriber) in subscribers.enumerated() { - group.addTask { - await withCheckedContinuation { continuation in - subscriber.addListener { _, _ in - MainActor.assumeIsolated { - callbacksCalled.value[index] += 1 - continuation.resume() - } - } - } - } - } - - // Wait for all subscription tasks to complete - for await _ in group {} - } - - let increments = 3 - for i in 0 ..< increments { - let counterUpdatesStream = try counter.updates() - async let counterUpdatedPromise: Void = waitForCounterUpdate(counterUpdatesStream) - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.counterIncRestOp(objectId: ctx.sampleCounterObjectId, number: 1), - ) - await counterUpdatedPromise - - if i == 0 { - // unsub all after first operation - counter.unsubscribeAll() - } - } - - // Wait for all subscription promises to complete - await subscriptionPromises - - #expect(try counter.value == 3, "Check counter has final expected value after all increments") - for i in 0 ..< callbacks { - #expect(callbacksCalled.value[i] == 1, "Check subscription callback \(i) was called once") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "can unsubscribe from LiveMap updates via returned unsubscribe callback", - action: { @MainActor ctx in - let map = try #require(ctx.root.get(key: ctx.sampleMapKey)?.liveMapValue) - let callbackCalled = MainActorStorage(value: 0) - - let subscriber = Subscriber(callbackQueue: .main) - try map.subscribe(listener: subscriber.createListener()) - async let subscriptionPromise: Void = withCheckedContinuation { continuation in - subscriber.addListener { _, subscriptionResponse in - MainActor.assumeIsolated { - callbackCalled.value += 1 - // unsubscribe from future updates after the first call - subscriptionResponse.unsubscribe() - continuation.resume() - } - } - } - - let mapSets = 3 - for i in 0 ..< mapSets { - let mapUpdatesStream = try map.updates() - async let mapUpdatedPromise: Void = waitForMapKeyUpdate(mapUpdatesStream, "foo-\(i)") - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapSetRestOp( - objectId: ctx.sampleMapObjectId, - key: "foo-\(i)", - value: ["string": "exists"], - ), - ) - await mapUpdatedPromise - } - - await subscriptionPromise - - for i in 0 ..< mapSets { - let value = try #require(map.get(key: "foo-\(i)")?.stringValue) - #expect(value == "exists", "Check map has value for key \"foo-\(i)\" after all map sets") - } - #expect(callbackCalled.value == 1, "Check subscription callback was only called once") - }, - ), - // Have not implemented "can unsubscribe from LiveMap updates via LiveMap.unsubscribe() call" because this method doesn't exist in the Swift SDK (functions don't have identity) - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "can remove all LiveMap update listeners via LiveMap.unsubscribeAll() call", - action: { @MainActor ctx in - let map = try #require(ctx.root.get(key: ctx.sampleMapKey)?.liveMapValue) - let callbacks = 3 - let callbacksCalled = MainActorStorage<[Int]>(value: Array(repeating: 0, count: callbacks)) - - // Create multiple subscribers synchronously - let subscribers = try (0 ..< callbacks).map { _ in - let subscriber = Subscriber(callbackQueue: .main) - try map.subscribe(listener: subscriber.createListener()) - return subscriber - } - - // Set up subscription promises using TaskGroup - async let subscriptionPromises: Void = withTaskGroup(of: Void.self) { group in - for (index, subscriber) in subscribers.enumerated() { - group.addTask { - await withCheckedContinuation { continuation in - subscriber.addListener { _, _ in - MainActor.assumeIsolated { - callbacksCalled.value[index] += 1 - continuation.resume() - } - } - } - } - } - - // Wait for all subscription tasks to complete - for await _ in group {} - } - - let mapSets = 3 - for i in 0 ..< mapSets { - let mapUpdatesStream = try map.updates() - async let mapUpdatedPromise: Void = waitForMapKeyUpdate(mapUpdatesStream, "foo-\(i)") - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapSetRestOp( - objectId: ctx.sampleMapObjectId, - key: "foo-\(i)", - value: ["string": "exists"], - ), - ) - await mapUpdatedPromise - - if i == 0 { - // unsub all after first operation - map.unsubscribeAll() - } - } - - // Wait for all subscription promises to complete - await subscriptionPromises - - for i in 0 ..< mapSets { - let value = try #require(map.get(key: "foo-\(i)")?.stringValue) - #expect(value == "exists", "Check map has value for key \"foo-\(i)\" after all map sets") - } - for i in 0 ..< callbacks { - #expect(callbacksCalled.value[i] == 1, "Check subscription callback \(i) was called once") - } - }, - ), - ] - } - - @available(iOS 17.0.0, tvOS 17.0.0, *) - @Test(arguments: SubscriptionCallbacksScenarios.testCases) - func subscriptionCallbacksScenarios(testCase: TestCase) async throws { - guard !testCase.disabled else { - withKnownIssue { - Issue.record("Test case is disabled") - } - return - } - - let objectsHelper = try await ObjectsHelper() - let client = try await realtimeWithObjects(options: testCase.options) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channel = client.channels.get(testCase.channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - let sampleMapKey = "sampleMap" - let sampleCounterKey = "sampleCounter" - - // Create promises for waiting for object updates - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, sampleMapKey) - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, sampleCounterKey) - } - while try await group.next() != nil {} - } - - // Prepare map and counter objects for use by the scenario - let sampleMapResult = try await objectsHelper.createAndSetOnMap( - channelName: testCase.channelName, - mapObjectId: "root", - key: sampleMapKey, - createOp: objectsHelper.mapCreateRestOp(), - ) - let sampleCounterResult = try await objectsHelper.createAndSetOnMap( - channelName: testCase.channelName, - mapObjectId: "root", - key: sampleCounterKey, - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = try await objectsCreatedPromise - - try await testCase.scenario.action( - .init( - objects: objects, - root: root, - objectsHelper: objectsHelper, - channelName: testCase.channelName, - channel: channel, - sampleMapKey: sampleMapKey, - sampleMapObjectId: sampleMapResult.objectId, - sampleCounterKey: sampleCounterKey, - sampleCounterObjectId: sampleCounterResult.objectId, - ), - ) - } - } - - // TODO: Implement the remaining scenarios - - // MARK: - GC Grace Period - - @Test("gcGracePeriod is set from connectionDetails.objectsGCGracePeriod") - func gcGracePeriod_isSetFromConnectionDetails() async throws { - let client = try await realtimeWithObjects(options: .init()) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - await client.connection.onceAsync(.connected) - - let channel = client.channels.get("channel", options: channelOptionsWithObjects()) - let objects = try #require(channel.objects as? PublicDefaultRealtimeObjects) - let connectionDetails = client.internal.latestConnectionDetails - - // gcGracePeriod should be set after the initial connection - let initialConnectionDetailsGracePeriod = try #require(connectionDetails?.objectsGCGracePeriod) - #expect(objects.testsOnly_gcGracePeriod == initialConnectionDetailsGracePeriod.doubleValue, "Check gcGracePeriod is set after initial connection from connectionDetails.objectsGCGracePeriod") - - let testProxyTransport = try #require(client.internal.transport as? TestProxyTransport) - let connectedProtocolMessage = ARTProtocolMessage() - connectedProtocolMessage.action = .connected - connectedProtocolMessage.connectionDetails = .init(clientId: nil, connectionKey: nil, maxMessageSize: 10, maxFrameSize: 10, maxInboundRate: 10, connectionStateTtl: 10, serverId: "", maxIdleInterval: 10, objectsGCGracePeriod: 0.999, siteCode: nil) // all arbitrary except objectsGCGracePeriod - client.internal.queue.ably_syncNoDeadlock { - testProxyTransport.receive(connectedProtocolMessage) - } - - #expect(objects.testsOnly_gcGracePeriod == 0.999, "Check gcGracePeriod is updated on new CONNECTED event") - } - } - - @Test("gcGracePeriod has a default value if connectionDetails.objectsGCGracePeriod is missing") - func gcGracePeriod_usesDefaultValue() async throws { - let client = try await realtimeWithObjects(options: .init()) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - await client.connection.onceAsync(.connected) - - let channel = client.channels.get("channel", options: channelOptionsWithObjects()) - let objects = try #require(channel.objects as? PublicDefaultRealtimeObjects) - - client.internal.queue.ably_syncNoDeadlock { - objects.testsOnly_proxied.nosync_setGarbageCollectionGracePeriod(0.999) - } - #expect(objects.testsOnly_gcGracePeriod == 0.999) - - // send a CONNECTED event without objectsGCGracePeriod, it should use the default value instead - let testProxyTransport = try #require(client.internal.transport as? TestProxyTransport) - let connectedProtocolMessage = ARTProtocolMessage() - connectedProtocolMessage.action = .connected - connectedProtocolMessage.connectionDetails = .init(clientId: nil, connectionKey: nil, maxMessageSize: 10, maxFrameSize: 10, maxInboundRate: 10, connectionStateTtl: 10, serverId: "", maxIdleInterval: 10, objectsGCGracePeriod: nil, siteCode: nil) // all arbitrary except objectsGCGracePeriod - client.internal.queue.ably_syncNoDeadlock { - testProxyTransport.receive(connectedProtocolMessage) - } - - #expect(objects.testsOnly_gcGracePeriod == InternalDefaultRealtimeObjects.GarbageCollectionOptions.defaultGracePeriod, "Check gcGracePeriod is set to a default value if connectionDetails.objectsGCGracePeriod is missing") - } - } - - // MARK: - Tombstones GC Scenarios - - enum TombstonesGCScenarios: Scenarios { - struct Context { - var root: any LiveMap - var objectsHelper: ObjectsHelper - var channelName: String - var channel: ARTRealtimeChannel - var objects: any RealtimeObjects - var client: ARTRealtime - var waitForTombstonedObjectsToBeCollected: @Sendable (Date) async throws -> Void - } - - static let scenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "tombstoned object is removed from the pool after the GC grace period", - action: { ctx in - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - let objects = ctx.objects - let waitForTombstonedObjectsToBeCollected = ctx.waitForTombstonedObjectsToBeCollected - - // Wait for counter creation - async let counterCreatedPromise: Void = waitForObjectOperation(ctx.objects, .counterCreate) - - // Send a CREATE op, this adds an object to the pool - let createResult = try await objectsHelper.operationRequest( - channelName: channelName, - opBody: objectsHelper.counterCreateRestOp(number: 1), - ) - let objectId = createResult.objectId - _ = try await counterCreatedPromise - - // Cast to access internal API for testing - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - - #expect(internallyTypedObjects.testsOnly_proxied.testsOnly_objectsPool.entries[objectId] != nil, "Check object exists in the pool after creation") - - // Inject OBJECT_DELETE for the object. This should tombstone the object and make it - // inaccessible to the end user, but still keep it in memory in the local pool - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: objectId)], - ) - - #expect( - internallyTypedObjects.testsOnly_proxied.testsOnly_objectsPool.entries[objectId] != nil, - "Check object exists in the pool immediately after OBJECT_DELETE", - ) - - let poolEntry = try #require(internallyTypedObjects.testsOnly_proxied.testsOnly_objectsPool.entries[objectId]) - #expect( - poolEntry.testsOnly_isTombstone == true, - "Check object's \"tombstone\" flag is set to \"true\" after OBJECT_DELETE", - ) - - let tombstonedAt = try #require(poolEntry.testsOnly_tombstonedAt) - - // Wait for objects tombstoned at this time to be garbage collected - try await waitForTombstonedObjectsToBeCollected(tombstonedAt) - - // Object should be removed from the local pool entirely now, as the GC grace period has passed - #expect( - internallyTypedObjects.testsOnly_proxied.testsOnly_objectsPool.entries[objectId] == nil, - "Check object does not exist in the pool after the GC grace period expiration", - ) - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "tombstoned map entry is removed from the LiveMap after the GC grace period", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let waitForTombstonedObjectsToBeCollected = ctx.waitForTombstonedObjectsToBeCollected - - let keyUpdatedPromise = try root.updates() - async let keyUpdatedWait: Void = { - await waitForMapKeyUpdate(keyUpdatedPromise, "foo") - }() - - // Set a key on root - _ = try await objectsHelper.operationRequest( - channelName: channelName, - opBody: objectsHelper.mapSetRestOp( - objectId: "root", - key: "foo", - value: ["string": .string("bar")], - ), - ) - await keyUpdatedWait - - #expect( - try #require(root.get(key: "foo")?.stringValue) == "bar", - "Check key \"foo\" exists on root after MAP_SET", - ) - - let keyUpdatedPromise2 = try root.updates() - async let keyUpdatedWait2: Void = { - await waitForMapKeyUpdate(keyUpdatedPromise2, "foo") - }() - - // Remove the key from the root. This should tombstone the map entry and make it - // inaccessible to the end user, but still keep it in memory in the underlying map - _ = try await objectsHelper.operationRequest( - channelName: channelName, - opBody: objectsHelper.mapRemoveRestOp(objectId: "root", key: "foo"), - ) - await keyUpdatedWait2 - - #expect( - try root.get(key: "foo") == nil, - "Check key \"foo\" is inaccessible via public API on root after MAP_REMOVE", - ) - - // Cast to access internal API for testing - let internallyTypedRoot = try #require(root as? PublicDefaultLiveMap) - let internalRoot = internallyTypedRoot.proxied - let underlyingData = internalRoot.testsOnly_data - - #expect( - underlyingData["foo"] != nil, - "Check map entry for \"foo\" exists on root in the underlying data immediately after MAP_REMOVE", - ) - #expect( - underlyingData["foo"]?.tombstone == true, - "Check map entry for \"foo\" on root has \"tombstone\" flag set to \"true\" after MAP_REMOVE", - ) - - let tombstonedAt = try #require(underlyingData["foo"]?.tombstonedAt) - - // Wait for objects tombstoned at this time to be garbage collected - try await waitForTombstonedObjectsToBeCollected(tombstonedAt) - - // The entry should be removed from the underlying map now - let underlyingDataAfterGC = internalRoot.testsOnly_data - #expect( - underlyingDataAfterGC["foo"] == nil, - "Check map entry for \"foo\" does not exist on root in the underlying data after the GC grace period expiration", - ) - }, - ), - ] - } - - @Test(arguments: TombstonesGCScenarios.testCases) - func tombstonesGCScenarios(testCase: TestCase) async throws { - guard !testCase.disabled else { - withKnownIssue { - Issue.record("Test case is disabled") - } - return - } - - // Configure GC options with shorter intervals for testing - var options = testCase.options - let garbageCollectionOptions = InternalDefaultRealtimeObjects.GarbageCollectionOptions( - interval: 0.5, - gracePeriod: .fixed(0.25), - ) - options.garbageCollectionOptions = garbageCollectionOptions - - let objectsHelper = try await ObjectsHelper() - let client = try await realtimeWithObjects(options: options) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channel = client.channels.get(testCase.channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Helper function to wait for enough GC cycles to occur such that objects tombstoned at a specific time should have been garbage collected. This is a slightly different approach to the JS tests, which wait for a certain number of GC cycles to occur, but I think that this is a bit more robust in the face of clock skew between the local clock and whatever was used to generate the tombstonedAt timestamps server-side. - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - let waitForTombstonedObjectsToBeCollected: @Sendable (Date) async throws -> Void = { (tombstonedAt: Date) in - // Sleep until we're sure we're past tombstonedAt + gracePeriod - let timeUntilGracePeriodExpires = (tombstonedAt + garbageCollectionOptions.gracePeriod.toTimeInterval).timeIntervalSince(.init()) - if timeUntilGracePeriodExpires > 0 { - try await Task.sleep(nanoseconds: UInt64(timeUntilGracePeriodExpires * Double(NSEC_PER_SEC))) - } - - // Wait for the next GC event - await internallyTypedObjects.testsOnly_proxied.testsOnly_completedGarbageCollectionEventsWithoutBuffering.first { _ in true } - } - - try await testCase.scenario.action( - .init( - root: root, - objectsHelper: objectsHelper, - channelName: testCase.channelName, - channel: channel, - objects: objects, - client: client, - waitForTombstonedObjectsToBeCollected: waitForTombstonedObjectsToBeCollected, - ), - ) - } - } - - // MARK: - Apply on ACK tests - - // MARK: Group 1: Operations applied locally on ACK (parameterized) - - enum ApplyOnAckScenarios: Scenarios { - struct Context { - var objects: any RealtimeObjects - var root: any LiveMap - var objectsHelper: ObjectsHelper - var channelName: String - var channel: ARTRealtimeChannel - var client: ARTRealtime - } - - static let scenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "creating a LiveCounter applies immediately on ACK", - action: { ctx in - let counter = try await ctx.objects.createCounter(count: 42) - try await ctx.root.set(key: "newCounter", value: .liveCounter(counter)) - - // Value should be visible immediately via apply-on-ACK, not from echo - #expect(try counter.value == 42, "Check counter value is applied immediately on ACK") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveCounter.increment applies operation immediately on ACK", - action: { ctx in - let counter = try await ctx.objects.createCounter(count: 10) - try await ctx.root.set(key: "counter", value: .liveCounter(counter)) - #expect(try counter.value == 10, "Check counter has initial value of 10") - - try await counter.increment(amount: 5) - - #expect(try counter.value == 15, "Check counter value reflects increment applied on ACK") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "creating a LiveMap applies immediately on ACK", - action: { ctx in - let map = try await ctx.objects.createMap(entries: ["foo": "bar"]) - try await ctx.root.set(key: "newMap", value: .liveMap(map)) - - #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check map value is applied immediately on ACK") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveMap.set applies operation immediately on ACK", - action: { ctx in - try await ctx.root.set(key: "key", value: "value") - - #expect(try #require(ctx.root.get(key: "key")?.stringValue) == "value", "Check map set is applied immediately on ACK") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveMap.remove applies operation immediately on ACK", - action: { ctx in - try await ctx.root.set(key: "keyToRemove", value: "valueToRemove") - #expect(try #require(ctx.root.get(key: "keyToRemove")?.stringValue) == "valueToRemove", "Check key exists") - - try await ctx.root.remove(key: "keyToRemove") - - #expect(try ctx.root.get(key: "keyToRemove") == nil, "Check map remove is applied immediately on ACK") - }, - ), - ] - } - - @Test(arguments: ApplyOnAckScenarios.testCases) - func applyOnAckScenarios(testCase: TestCase) async throws { - guard !testCase.disabled else { - withKnownIssue { - Issue.record("Test case is disabled") - } - return - } - - let objectsHelper = try await ObjectsHelper() - let client = try await realtimeWithObjects(options: testCase.options) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channel = client.channels.get(testCase.channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Hold echoes so we can verify value comes from ACK, not echo - let echoInterceptor = EchoInterceptor(client: client, channel: channel) - defer { echoInterceptor.restore() } - - try await testCase.scenario.action( - .init( - objects: objects, - root: root, - objectsHelper: objectsHelper, - channelName: testCase.channelName, - channel: channel, - client: client, - ), - ) - } - } - - // MARK: Group 2: Does not double-apply - - @Test - func echoAfterAckDoesNotDoubleApply() async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "echoAfterAckDoesNotDoubleApply" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Create a counter with initial value 10 - let counter = try await objects.createCounter(count: 10) - try await root.set(key: "counter", value: .liveCounter(counter)) - - // Set up echo interceptor - let echoInterceptor = EchoInterceptor(client: client, channel: channel) - defer { echoInterceptor.restore() } - - // Increment by 5 — applied via ACK, echo held - try await counter.increment(amount: 5) - #expect(try counter.value == 15, "Check counter value after increment applied on ACK") - - // Wait for the echo to be intercepted - await echoInterceptor.waitForEcho() - - // Release the held echo - await echoInterceptor.releaseAll() - - // Value should still be 15 (not 20 from double-apply) - #expect(try counter.value == 15, "Check counter value is not double-applied after echo") - } - } - - @Test - func ackAfterEchoDoesNotDoubleApply() async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "ackAfterEchoDoesNotDoubleApply" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Create a counter with initial value 10 - let counter = try await objects.createCounter(count: 10) - try await root.set(key: "counter", value: .liveCounter(counter)) - - // Set up ACK interceptor (holds ACKs, lets echoes through) - let ackInterceptor = AckInterceptor(client: client) - defer { ackInterceptor.restore() } - - // Deviation from JS: JS uses a callback-based `waitForCounterUpdate(counter)` - // promise. Swift uses an `AsyncStream`-based subscription via `counter.updates()` - // and `waitForCounterUpdate` to detect when the echo has been applied. - let counterUpdates = try counter.updates() - - // Start increment without awaiting (it won't complete until ACK arrives) - let incrementTask = Task { - try await counter.increment(amount: 5) - } - - // Wait for the echo to be applied - await waitForCounterUpdate(counterUpdates) - - // Value should be 15 from the echo - #expect(try counter.value == 15, "Check counter value after echo received") - - // Release the held ACK - await ackInterceptor.waitForAck() - await ackInterceptor.releaseAll() - - // Wait for increment to complete - try await incrementTask.value - - // Value should still be 15 (not 20 from double-apply) - #expect(try counter.value == 15, "Check counter value is not double-applied after ACK") - } - } - - // MARK: Group 3: Does not incorrectly skip operations - - @Test - func applyOnAckDoesNotUpdateSiteTimeserials() async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "applyOnAckDoesNotUpdateSiteTimeserials" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - let objectsHelper = try await ObjectsHelper() - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Step 1: Set up echo interceptor - let echoInterceptor = EchoInterceptor(client: client, channel: channel) - defer { echoInterceptor.restore() } - - // Step 2: Create a counter with initial value 10 — echo held - let counter = try await objects.createCounter(count: 10) - try await root.set(key: "counter", value: .liveCounter(counter)) - - // Step 3: Wait for both echoes, extract COUNTER_CREATE serial and siteCode. - // createCounter + root.set generate two echoes (COUNTER_CREATE and MAP_SET); - // search all held echoes to find the COUNTER_CREATE. - await echoInterceptor.waitForEchoCount(2) - let heldEchoes = echoInterceptor.heldEchoes - let allStateItems = heldEchoes.flatMap(\.testsOnly_inboundObjectMessages) - let counterCreate = try #require(allStateItems.first { $0.operation?.action == .known(.counterCreate) }) - let counterCreateSerial = try #require(counterCreate.serial) - let counterCreateSiteCode = try #require(counterCreate.siteCode) - - // Step 4: Release the create echo (so siteTimeserials gets set) - await echoInterceptor.releaseAll() - - // Step 5: Increment by 5 — applies via ACK, echo held - try await counter.increment(amount: 5) - #expect(try counter.value == 15, "Check counter value after increment applied on ACK") - - // Step 6: Wait for the increment echo, extract its serial - await echoInterceptor.waitForEcho() - let incrementEchoes = echoInterceptor.heldEchoes - let incrementEcho = incrementEchoes.last! - let incrementStateItems = incrementEcho.testsOnly_inboundObjectMessages - let incrementOp = try #require(incrementStateItems.first { $0.operation?.action == .known(.counterInc) }) - let incrementSerial = try #require(incrementOp.serial) - - // Step 7: Construct injectedSerial = counterCreateSerial + "a" - // This serial is between create and increment, so if siteTimeserials were - // updated by apply-on-ACK, this operation would be rejected - let injectedSerial = counterCreateSerial + "a" - - // Verify our assumptions - #expect(injectedSerial > counterCreateSerial, "injectedSerial > counterCreateSerial") - #expect(injectedSerial < incrementSerial, "injectedSerial < incrementSerial") - - // Step 8: Inject a COUNTER_INC operation with injectedSerial - let internalCounter = try #require(counter as? PublicDefaultLiveCounter) - let counterId = internalCounter.proxied.testsOnly_objectID - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: injectedSerial, - siteCode: counterCreateSiteCode, - state: [objectsHelper.counterIncOp(objectId: counterId, number: 100)], - ) - - // Step 9: Assert counter.value == 115 - // If siteTimeserials had been updated by apply-on-ACK, the injected operation - // would have been rejected, and counter would be 15 - #expect(try counter.value == 115, "Check injected operation was applied (proving siteTimeserials not updated by apply-on-ACK)") - } - } - - // MARK: Group 4: ACKs buffered during OBJECT_SYNC - - @Test - func operationBufferedDuringSyncIsAppliedAfterSyncCompletes() async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "operationBufferedDuringSyncApplied" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - let objectsHelper = try await ObjectsHelper() - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Create counter with value 10 - let counter = try await objects.createCounter(count: 10) - try await root.set(key: "counter", value: .liveCounter(counter)) - #expect(try counter.value == 10, "Check counter initial value") - - let internalCounter = try #require(counter as? PublicDefaultLiveCounter) - let counterId = internalCounter.proxied.testsOnly_objectID - - // Inject ATTACHED with HAS_OBJECTS to trigger SYNCING state - await injectAttachedMessage(channel: channel, flags: .hasObjects) - - // Set up ACK interceptor so we can control when the ACK is delivered - let ackInterceptor = AckInterceptor(client: client) - - // Increment while syncing — don't await because publishAndApply will - // wait for sync to complete, and we need to complete the sync below. - let incrementTask = Task { - try await counter.increment(amount: 5) - } - - // Wait for the ACK to be intercepted, then release it. - // releaseAll() dispatches onto the internal queue and waits for - // the ACK to be processed synchronously (onAck → publish callback - // → sync-wait entry all happen on the same queue dispatch), so by - // the time it returns, publishAndApply has entered the sync-wait. - // (publishAndApplyRejectsOnChannelStateChangeDuringSync uses the - // same mechanism and validates it is sufficient — if publishAndApply - // had not yet entered the sync-wait, the channel state change - // would not cause it to reject.) - await ackInterceptor.waitForAck() - await ackInterceptor.releaseAll() - ackInterceptor.restore() - - // Complete the sync sequence with an OBJECT_SYNC message containing counter=10 - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - state: [ - objectsHelper.counterObject( - objectId: counterId, - siteTimeserials: [:], - materialisedCount: 10, - ), - ], - ) - - // Wait for the increment task to complete - try await incrementTask.value - - // After sync completes, the buffered ACK should be applied - #expect(try counter.value == 15, "Check counter value after sync completes with buffered ACK applied") - } - } - - @Test - func appliedOnAckSerialsIsClearedOnSync() async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "appliedOnAckSerialsCleared" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - let objectsHelper = try await ObjectsHelper() - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Create counter and increment via apply-on-ACK (echo held) - let counter = try await objects.createCounter(count: 10) - try await root.set(key: "counter", value: .liveCounter(counter)) - - let echoInterceptor = EchoInterceptor(client: client, channel: channel) - - try await counter.increment(amount: 5) - #expect(try counter.value == 15, "Check counter value after increment on ACK") - - // Wait for the echo to be intercepted - await echoInterceptor.waitForEcho() - - let internalCounter = try #require(counter as? PublicDefaultLiveCounter) - let counterId = internalCounter.proxied.testsOnly_objectID - - // Inject ATTACHED+HAS_OBJECTS to trigger sync - await injectAttachedMessage(channel: channel, flags: .hasObjects) - - // Complete sync with state that uses a fake siteCode. - // Using a clearly fake siteCode ensures the echo (which has the real siteCode) - // will pass the siteTimeserials check since there's no entry for it. - let fakeSiteSerial = lexicoTimeserial(seriesId: "fakeSite", timestamp: 0, counter: 0) - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["fakeSite": fakeSiteSerial], - initialEntries: [ - "counter": .object([ - "timeserial": .string(fakeSiteSerial), - "data": .object(["objectId": .string(counterId)]), - ]), - ], - ), - objectsHelper.counterObject( - objectId: counterId, - siteTimeserials: ["fakeSite": fakeSiteSerial], - materialisedCount: 10, - ), - ], - ) - - // After sync, value should be 10 (from sync state, appliedOnAckSerials cleared) - #expect(try counter.value == 10, "Check counter value is reset to sync state") - - // Release the held echo — should be applied because appliedOnAckSerials was cleared - await echoInterceptor.releaseAll() - echoInterceptor.restore() - - #expect(try counter.value == 15, "Check counter value after releasing echo (proving appliedOnAckSerials was cleared on sync)") - } - } - - // Deviation from JS: JS uses `channel.requestState(targetState)` to trigger - // the channel state change. ably-cocoa doesn't expose `requestState` on channels, - // so we use `setSuspended`/`setFailed`/`detachChannel` on the internal queue - // instead (`setDetached` is not used because it initiates a reattach). We also - // use `Task.yield() + 100ms sleep` instead of JS's `setTimeout(resolve, 0)`. - @Test(arguments: [ - ARTRealtimeChannelState.detached, - ARTRealtimeChannelState.suspended, - ARTRealtimeChannelState.failed, - ]) - func publishAndApplyRejectsOnChannelStateChangeDuringSync(targetState: ARTRealtimeChannelState) async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "publishAndApplyRejects_\(targetState.rawValue)" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Create a counter - let counter = try await objects.createCounter(count: 10) - try await root.set(key: "counter", value: .liveCounter(counter)) - - // Inject ATTACHED+HAS_OBJECTS to trigger SYNCING state - await injectAttachedMessage(channel: channel, flags: .hasObjects) - - // Set up ACK interceptor and start increment - let ackInterceptor = AckInterceptor(client: client) - - let incrementTask = Task { - try await counter.increment(amount: 5) - } - - // Wait for the ACK to arrive - await ackInterceptor.waitForAck() - - // Release ACK so publishAndApply proceeds to sync-wait - await ackInterceptor.releaseAll() - ackInterceptor.restore() - - // Trigger channel state change - await withCheckedContinuation { (continuation: CheckedContinuation) in - channel.internal.queue.async { - switch targetState { - case .suspended: - let params = ChannelStateChangeParams(state: .ok) - channel.internal.setSuspended(params) - case .failed: - let params = ChannelStateChangeParams(state: .ok) - channel.internal.setFailed(params) - case .detached: - // Use detachChannel: directly instead of setDetached: because - // setDetached: initiates a reattach when the channel is attached. - let params = ChannelStateChangeParams(state: .ok) - channel.internal.detachChannel(params) - default: - break - } - continuation.resume() - } - } - - // The increment should throw with error 92008 - do { - try await incrementTask.value - Issue.record("Expected increment to throw during channel state change") - } catch let error as ARTErrorInfo { - #expect(error.code == 92008, "Check error code is 92008 for publishAndApply rejected during sync") - #expect(error.statusCode == 400, "Check statusCode is 400") - } - } - } - - // MARK: Group 5: Subscription events - - // Deviation from JS: JS asserts `{ action: 'counter.inc', number: N }` on each - // event. The Swift `LiveCounterUpdate` protocol only exposes `amount` (no `action` - // field), so we assert on the amount only. - @Test - func subscriptionCallbacksFireForBothLocallyAppliedAndRealtimeReceivedOperations() async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "subscriptionCallbacksApplyOnAck" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - let objectsHelper = try await ObjectsHelper() - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Create counter - let counter = try await objects.createCounter(count: 0) - try await root.set(key: "counter", value: .liveCounter(counter)) - - // Subscribe to counter updates - let counterUpdates = try counter.updates() - - // Set up echo interceptor - let echoInterceptor = EchoInterceptor(client: client, channel: channel) - - // Perform increment via SDK — applied locally on ACK with echoes held - try await counter.increment(amount: 5) - #expect(try counter.value == 5, "Check counter value after local increment") - - // The subscription should have fired immediately from the ACK path - // Collect the event - var receivedEvents: [LiveCounterUpdate] = [] - if let event = await counterUpdates.first(where: { _ in true }) { - receivedEvents.append(event) - } - - #expect(receivedEvents.count == 1, "Check 1 subscription event received after local increment") - #expect(receivedEvents[0].amount == 5, "Check event from local apply has amount 5") - - // Restore echo handling - echoInterceptor.restore() - - // Release held echoes (shouldn't cause another event since already applied) - await echoInterceptor.releaseAll() - - // Trigger another increment via REST — this is received over Realtime - let internalCounter = try #require(counter as? PublicDefaultLiveCounter) - let counterId = internalCounter.proxied.testsOnly_objectID - _ = try await objectsHelper.operationRequest( - channelName: channelName, - opBody: objectsHelper.counterIncRestOp(objectId: counterId, number: 10), - ) - - // Wait for counter update from Realtime - if let event = await counterUpdates.first(where: { _ in true }) { - receivedEvents.append(event) - } - - #expect(receivedEvents.count == 2, "Check 2 subscription events received total") - #expect(receivedEvents[1].amount == 10, "Check event from Realtime receive has amount 10") - #expect(try counter.value == 15, "Check final counter value after both operations") - } - } -} - -// swiftlint:enable trailing_closure diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 669c4b01..3e6309d4 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -5,8 +5,8 @@ import Ably final class MockCoreSDK: CoreSDK { /// Synchronizes access to `_publishHandler` and `_publishCallbackHandler`. private let mutex = NSLock() - private nonisolated(unsafe) var _publishHandler: (([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult)? - private nonisolated(unsafe) var _publishCallbackHandler: (([OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void)? + private nonisolated(unsafe) var _publishHandler: (([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult)? + private nonisolated(unsafe) var _publishCallbackHandler: (([ProtocolTypes.OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void)? private let channelStateMutex: DispatchQueueMutex<_AblyPluginSupportPrivate.RealtimeChannelState> private let serverTime: Date @@ -16,10 +16,10 @@ final class MockCoreSDK: CoreSDK { self.serverTime = serverTime } - func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { + func nosync_publish(objectMessages: [ProtocolTypes.OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { // We can't return _publishHandler from `mutex.withLock` because we get "error: runtime support for typed throws function types is only available in macOS 15.0.0 or newer" - var asyncHandler: (([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult)? - var callbackHandler: (([OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void)? + var asyncHandler: (([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult)? + var callbackHandler: (([ProtocolTypes.OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void)? mutex.withLock { asyncHandler = _publishHandler callbackHandler = _publishCallbackHandler @@ -42,7 +42,7 @@ final class MockCoreSDK: CoreSDK { } } - func testsOnly_overridePublish(with _: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { + func testsOnly_overridePublish(with _: @escaping ([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { protocolRequirementNotImplemented() } @@ -53,7 +53,7 @@ final class MockCoreSDK: CoreSDK { /// Sets a custom publish handler for testing. /// /// - Precondition: ``setPublishCallbackHandler(_:)`` must not have been called. - func setPublishHandler(_ handler: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { + func setPublishHandler(_ handler: @escaping ([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { mutex.withLock { precondition(_publishCallbackHandler == nil, "Cannot set both publishHandler and publishCallbackHandler") _publishHandler = handler @@ -69,7 +69,7 @@ final class MockCoreSDK: CoreSDK { /// explicit control over their ordering. /// /// - Precondition: ``setPublishHandler(_:)`` must not have been called. - func setPublishCallbackHandler(_ handler: @escaping ([OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void) { + func setPublishCallbackHandler(_ handler: @escaping ([ProtocolTypes.OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void) { mutex.withLock { // We use pattern matching instead of `== nil` to avoid "runtime support for typed // throws function types is only available in macOS 15.0.0 or newer". diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift b/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift index e6904b1f..7320bf7b 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift @@ -7,7 +7,7 @@ final class MockRealtimeObjects: InternalRealtimeObjectsProtocol { /// Synchronizes access to `_publishAndApplyHandler`. private let mutex = NSLock() - private nonisolated(unsafe) var _publishAndApplyHandler: (([OutboundObjectMessage]) -> Result)? + private nonisolated(unsafe) var _publishAndApplyHandler: (([ProtocolTypes.OutboundObjectMessage]) -> Result)? init(objectsPoolDelegate: MockLiveMapObjectsPoolDelegate? = nil) { self.objectsPoolDelegate = objectsPoolDelegate @@ -20,18 +20,18 @@ final class MockRealtimeObjects: InternalRealtimeObjectsProtocol { return objectsPoolDelegate.nosync_objectsPool } - func setPublishAndApplyHandler(_ handler: @escaping ([OutboundObjectMessage]) -> Result) { + func setPublishAndApplyHandler(_ handler: @escaping ([ProtocolTypes.OutboundObjectMessage]) -> Result) { mutex.withLock { _publishAndApplyHandler = handler } } func nosync_publishAndApply( - objectMessages: [OutboundObjectMessage], + objectMessages: [ProtocolTypes.OutboundObjectMessage], coreSDK: CoreSDK, callback: @escaping @Sendable (Result) -> Void, ) { - var handler: (([OutboundObjectMessage]) -> Result)? + var handler: (([ProtocolTypes.OutboundObjectMessage]) -> Result)? mutex.withLock { handler = _publishAndApplyHandler } diff --git a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift index 79a434b8..6e595f31 100644 --- a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift @@ -62,7 +62,7 @@ struct ObjectCreationHelpersTests { let deserializedInitialValue = try #require(try JSONObjectOrArray(jsonString: initialValueString).objectValue) #expect(deserializedInitialValue == [ // RTO11f14a - "semantics": .number(Double(ObjectsMapSemantics.lww.rawValue)), + "semantics": .number(Double(ProtocolTypes.ObjectsMapSemantics.lww.rawValue)), "entries": [ // RTO11f14c1a "mapRef": [ @@ -118,7 +118,7 @@ struct ObjectCreationHelpersTests { #expect(derivedMapCreate.semantics == .known(.lww)) - let expectedEntries: [String: ObjectsMapEntry] = [ + let expectedEntries: [String: ProtocolTypes.ObjectsMapEntry] = [ "mapRef": .init(data: .init(objectId: "referencedMapID")), "counterRef": .init(data: .init(objectId: "referencedCounterID")), "jsonArrayKey": .init(data: .init(json: .array(["arrayItem1", "arrayItem2"]))), diff --git a/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift b/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift index 640d4d83..f0104f8b 100644 --- a/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift @@ -22,11 +22,11 @@ struct ObjectDiffHelpersTests { @Test func detectsRemovedKeys() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), - "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value2")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -42,11 +42,11 @@ struct ObjectDiffHelpersTests { @Test func detectsAddedKeys() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), - "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value2")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -62,10 +62,10 @@ struct ObjectDiffHelpersTests { @Test func detectsUpdatedKeys() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "oldValue")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "oldValue")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "newValue")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "newValue")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -80,10 +80,10 @@ struct ObjectDiffHelpersTests { @Test func ignoresUnchangedKeys() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -98,11 +98,11 @@ struct ObjectDiffHelpersTests { @Test func ignoresTombstonedEntriesInPreviousData() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value1")), - "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ProtocolTypes.ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value2")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + "key2": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value2")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -119,10 +119,10 @@ struct ObjectDiffHelpersTests { @Test func ignoresTombstonedEntriesInNewData() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ProtocolTypes.ObjectData(string: "value1")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -138,10 +138,10 @@ struct ObjectDiffHelpersTests { @Test func ignoresTombstonedToTombstonedTransition() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ProtocolTypes.ObjectData(string: "value1")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value2")), + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ProtocolTypes.ObjectData(string: "value2")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -157,14 +157,14 @@ struct ObjectDiffHelpersTests { @Test func detectsMultipleChanges() { let previousData: [String: InternalObjectsMapEntry] = [ - "removed": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), - "updated": TestFactories.internalMapEntry(data: ObjectData(string: "oldValue")), - "unchanged": TestFactories.internalMapEntry(data: ObjectData(string: "sameValue")), + "removed": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), + "updated": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "oldValue")), + "unchanged": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "sameValue")), ] let newData: [String: InternalObjectsMapEntry] = [ - "added": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), - "updated": TestFactories.internalMapEntry(data: ObjectData(string: "newValue")), - "unchanged": TestFactories.internalMapEntry(data: ObjectData(string: "sameValue")), + "added": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value2")), + "updated": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "newValue")), + "unchanged": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "sameValue")), ] let update = ObjectDiffHelpers.calculateMapDiff( diff --git a/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift index d4a4cbba..db2f8b33 100644 --- a/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift @@ -4,7 +4,7 @@ import Testing @Suite(.tags(.integration)) struct ObjectLifetimesTests { - @Test("LiveObjects functionality works with only a strong reference to channel's public objects property") + @Test("LiveObjects functionality works with only a strong reference to channel's public object property") func withStrongReferenceToPublicObjectsProperty() async throws { // The objects that we'll create. struct CreatedObjects { @@ -15,7 +15,7 @@ struct ObjectLifetimesTests { weak var weakInternalRealtime: ARTRealtimeInternal? weak var weakPublicChannel: ARTRealtimeChannel? weak var weakInternalChannel: ARTRealtimeChannelInternal? - var strongPublicRealtimeObjects: PublicDefaultRealtimeObjects + var strongPublicRealtimeObject: PublicDefaultRealtimeObject weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? } @@ -28,7 +28,7 @@ struct ObjectLifetimesTests { weak var weakInternalRealtime: ARTRealtimeInternal? // weakPublicChannel is gone now weak var weakInternalChannel: ARTRealtimeChannelInternal? - weak var weakPublicRealtimeObjects: PublicDefaultRealtimeObjects? + weak var weakPublicRealtimeObject: PublicDefaultRealtimeObject? weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? } @@ -37,9 +37,9 @@ struct ObjectLifetimesTests { // We disable autoConnect since being connected extends the internal Realtime instance's lifetime (it stays alive whilst connected), and I don't want that interfering with this test. let realtime = try await ClientHelper.realtimeWithObjects(options: .init(autoConnect: false)) let channel = realtime.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) - let anyObjects = channel.objects - // For some reason putting `channel.objects as? PublicDefaultRealtimeObjects` inside the #require gives "no calls to throwing functions occur within 'try' expression" 🤷 - let objects = try #require(anyObjects as? PublicDefaultRealtimeObjects) + let anyObject = channel.object + // For some reason putting `channel.object as? PublicDefaultRealtimeObject` inside the #require gives "no calls to throwing functions occur within 'try' expression" 🤷 + let object = try #require(anyObject as? PublicDefaultRealtimeObject) return .init( realtimeDeallocQueue: realtime.internal.queue, @@ -47,18 +47,18 @@ struct ObjectLifetimesTests { weakInternalRealtime: realtime.internal, weakPublicChannel: channel, weakInternalChannel: channel.internal, - strongPublicRealtimeObjects: objects, - weakInternalRealtimeObjects: objects.testsOnly_proxied, + strongPublicRealtimeObject: object, + weakInternalRealtimeObjects: object.testsOnly_proxied, ) } let createdObjects = try await createObjects() - // The only public object we have a strong reference to is strongPublicRealtimeObjects, so the other public objects should have already been deallocated + // The only public object we have a strong reference to is strongPublicRealtimeObject, so the other public objects should have already been deallocated #expect(createdObjects.weakPublicRealtime == nil) #expect(createdObjects.weakPublicChannel == nil) - // Now we check that, since we still have a strong reference to strongPublicRealtimeObjects, none of the dependencies that it needs in order to function have been deallocated. + // Now we check that, since we still have a strong reference to strongPublicRealtimeObject, none of the dependencies that it needs in order to function have been deallocated. await withCheckedContinuation { continuation in // We wait for everything on realtimeDeallocQueue to execute, to be sure that we'd catch a dealloc that had been enqueued via ably-cocoa's QueuedDealloc mechanism. createdObjects.realtimeDeallocQueue.async { @@ -71,22 +71,22 @@ struct ObjectLifetimesTests { // TODO: test that we can receive events on a LiveObject (https://github.com/ably/ably-liveobjects-swift-plugin/issues/30) - // Note that after this return we no longer have a reference to createdObjects and thus no longer have a strong reference to our public RealtimeObjects instance + // Note that after this return we no longer have a reference to createdObjects and thus no longer have a strong reference to our public RealtimeObject instance return .init( realtimeDeallocQueue: createdObjects.realtimeDeallocQueue, weakInternalRealtime: createdObjects.weakInternalRealtime, weakInternalChannel: createdObjects.weakInternalChannel, - weakPublicRealtimeObjects: createdObjects.strongPublicRealtimeObjects, + weakPublicRealtimeObject: createdObjects.strongPublicRealtimeObject, weakInternalRealtimeObjects: createdObjects.weakInternalRealtimeObjects, ) } let remainingObjects = try await createAndDiscardObjects() - // Check that the public RealtimeObjects has been deallocated now that we've no longer got a strong reference to it - #expect(remainingObjects.weakPublicRealtimeObjects == nil) + // Check that the public RealtimeObject has been deallocated now that we've no longer got a strong reference to it + #expect(remainingObjects.weakPublicRealtimeObject == nil) - // Check that the internal objects that the public RealtimeObjects needed in order to function have now been deallocated + // Check that the internal objects that the public RealtimeObject needed in order to function have now been deallocated await withCheckedContinuation { continuation in // We wait for everything on realtimeDeallocQueue to execute, to be sure that we'd catch a dealloc that had been enqueued via ably-cocoa's QueuedDealloc mechanism. remainingObjects.realtimeDeallocQueue.async { @@ -99,7 +99,11 @@ struct ObjectLifetimesTests { #expect(remainingObjects.weakInternalRealtimeObjects == nil) } - @Test("LiveObjects functionality works with only a strong reference to a public LiveObject") + // TODO: uncomment @Test + // The `@Test` attribute is commented out because this test relies on the public path-based API, + // which is currently an unimplemented skeleton: `RealtimeObject.get()` traps via + // `notImplemented()`. Re-enable (uncomment `@Test`) once that API is implemented. + // @Test("LiveObjects functionality works with only a strong reference to a public LiveObject") func withStrongReferenceToPublicLiveObject() async throws { // Note: This test is very similar to withStrongReferenceToPublicObjectsProperty but "one layer down" — i.e. it checks that instead of a RealtimeObjects reference keeping everything working, a LiveObject reference keeps everything working. Keep these two tests in sync. @@ -112,10 +116,9 @@ struct ObjectLifetimesTests { weak var weakInternalRealtime: ARTRealtimeInternal? weak var weakPublicChannel: ARTRealtimeChannel? weak var weakInternalChannel: ARTRealtimeChannelInternal? - weak var weakPublicRealtimeObjects: PublicDefaultRealtimeObjects? + weak var weakPublicRealtimeObject: PublicDefaultRealtimeObject? weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? - var strongPublicLiveObject: PublicDefaultLiveMap - weak var weakInternalLiveObject: InternalDefaultLiveMap? + var strongPublicLiveObject: DefaultLiveMapPathObject } // What we're left with after discarding a CreatedObjects. @@ -127,23 +130,22 @@ struct ObjectLifetimesTests { weak var weakInternalRealtime: ARTRealtimeInternal? // weakPublicChannel is gone now weak var weakInternalChannel: ARTRealtimeChannelInternal? - // weakPublicRealtimeObjects is gone now + // weakPublicRealtimeObject is gone now weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? - weak var weakPublicLiveObject: PublicDefaultLiveMap? - weak var weakInternalLiveObject: InternalDefaultLiveMap? + weak var weakPublicLiveObject: DefaultLiveMapPathObject? } func createAndDiscardObjects() async throws -> RemainingObjects { func createObjects() async throws -> CreatedObjects { // We disable autoConnect since being connected extends the internal Realtime instance's lifetime (it stays alive whilst connected), and I don't want that interfering with this test. let realtime = try await ClientHelper.realtimeWithObjects() - // Unlike in withStrongReferenceToPublicObjectsProperty, we'll have to allow it to connect, because we need to attach so that getRoot() returns. We'll instead manually close the connection before proceeding with the test + // Unlike in withStrongReferenceToPublicObjectsProperty, we'll have to allow it to connect, because we need to attach so that get() returns. We'll instead manually close the connection before proceeding with the test let channel = realtime.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) try await channel.attachAsync() - let anyObjects = channel.objects - // For some reason putting `channel.objects as? PublicDefaultRealtimeObjects` inside the #require gives "no calls to throwing functions occur within 'try' expression" 🤷 - let objects = try #require(anyObjects as? PublicDefaultRealtimeObjects) - let root = try #require(try await anyObjects.getRoot() as? PublicDefaultLiveMap) + let anyObject = channel.object + // For some reason putting `channel.object as? PublicDefaultRealtimeObject` inside the #require gives "no calls to throwing functions occur within 'try' expression" 🤷 + let object = try #require(anyObject as? PublicDefaultRealtimeObject) + let root = try #require(try await object.get() as? DefaultLiveMapPathObject) // Wait for the connection to close, as mentioned above async let connectionClosedPromise: Void = withCheckedContinuation { continuation in @@ -160,10 +162,9 @@ struct ObjectLifetimesTests { weakInternalRealtime: realtime.internal, weakPublicChannel: channel, weakInternalChannel: channel.internal, - weakPublicRealtimeObjects: objects, - weakInternalRealtimeObjects: objects.testsOnly_proxied, + weakPublicRealtimeObject: object, + weakInternalRealtimeObjects: object.testsOnly_proxied, strongPublicLiveObject: root, - weakInternalLiveObject: root.proxied, ) } @@ -172,7 +173,7 @@ struct ObjectLifetimesTests { // The only public object we have a strong reference to is strongPublicLiveObject, so the other public objects should have already been deallocated #expect(createdObjects.weakPublicRealtime == nil) #expect(createdObjects.weakPublicChannel == nil) - #expect(createdObjects.weakPublicRealtimeObjects == nil) + #expect(createdObjects.weakPublicRealtimeObject == nil) // Now we check that, since we still have a strong reference to strongPublicLiveObject, none of the dependencies that it needs in order to function have been deallocated. await withCheckedContinuation { continuation in @@ -184,7 +185,6 @@ struct ObjectLifetimesTests { #expect(createdObjects.weakInternalRealtime != nil) #expect(createdObjects.weakInternalChannel != nil) #expect(createdObjects.weakInternalRealtimeObjects != nil) - #expect(createdObjects.weakInternalLiveObject != nil) // TODO: test that we can receive events on a LiveObject (https://github.com/ably/ably-liveobjects-swift-plugin/issues/30) @@ -195,7 +195,6 @@ struct ObjectLifetimesTests { weakInternalChannel: createdObjects.weakInternalChannel, weakInternalRealtimeObjects: createdObjects.weakInternalRealtimeObjects, weakPublicLiveObject: createdObjects.strongPublicLiveObject, - weakInternalLiveObject: createdObjects.weakInternalLiveObject, ) } @@ -215,23 +214,27 @@ struct ObjectLifetimesTests { #expect(remainingObjects.weakInternalRealtime == nil) #expect(remainingObjects.weakInternalChannel == nil) #expect(remainingObjects.weakInternalRealtimeObjects == nil) - #expect(remainingObjects.weakInternalLiveObject == nil) } - @Test("Public objects have a stable identity") + // TODO: uncomment @Test + // The `@Test` attribute is commented out because the map-identity half of this test relies on + // `RealtimeObject.get()`, which currently traps via `notImplemented()`. Re-enable (uncomment + // `@Test`) once the public path-based API is implemented and the path-based objects are cached in + // `PublicObjectsStore`. + // @Test("Public objects have a stable identity") func publicObjectIdentity() async throws { let realtime = try await ClientHelper.realtimeWithObjects() defer { realtime.close() } let channel = realtime.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) try await channel.attachAsync() - let objects = try #require(channel.objects as? PublicDefaultRealtimeObjects) - let root = try #require(try await objects.getRoot() as? PublicDefaultLiveMap) + let object = try #require(channel.object as? PublicDefaultRealtimeObject) + let root = try #require(try await object.get() as? DefaultLiveMapPathObject) - let objectsAgain = try #require(channel.objects as? PublicDefaultRealtimeObjects) - let rootAgain = try #require(try await objectsAgain.getRoot() as? PublicDefaultLiveMap) + let objectAgain = try #require(channel.object as? PublicDefaultRealtimeObject) + let rootAgain = try #require(try await objectAgain.get() as? DefaultLiveMapPathObject) - #expect(objects as AnyObject === objectsAgain as AnyObject) + #expect(object === objectAgain) #expect(root === rootAgain) // TODO: when we have an easy way of populating the ObjectsPool (i.e. once we have a write API) then also test with a non-root LiveMap and a counter (https://github.com/ably/ably-liveobjects-swift-plugin/issues/30) } diff --git a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift index 3fe457bf..73dfad5f 100644 --- a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift @@ -13,7 +13,7 @@ struct ObjectMessageTests { // @spec OD4c1 @Test func boolean() { - let objectData = ObjectData(boolean: true) + let objectData = ProtocolTypes.ObjectData(boolean: true) let wireData = objectData.toWire(format: .messagePack) // OD4c1: A boolean payload is encoded as a MessagePack boolean type, and the result is set on the ObjectData.boolean attribute @@ -28,7 +28,7 @@ struct ObjectMessageTests { @Test func binary() { let testData = Data([1, 2, 3, 4]) - let objectData = ObjectData(bytes: testData) + let objectData = ProtocolTypes.ObjectData(bytes: testData) let wireData = objectData.toWire(format: .messagePack) // OD4c2: A binary payload is encoded as a MessagePack binary type, and the result is set on the ObjectData.bytes attribute @@ -47,7 +47,7 @@ struct ObjectMessageTests { // @spec OD4c3 @Test(arguments: [15, 42.0]) func number(testNumber: NSNumber) throws { - let objectData = ObjectData(number: testNumber) + let objectData = ProtocolTypes.ObjectData(number: testNumber) let wireData = objectData.toWire(format: .messagePack) // OD4c3 A number payload is encoded as a MessagePack float64 type, and the result is set on the ObjectData.number attribute @@ -66,7 +66,7 @@ struct ObjectMessageTests { @Test func string() { let testString = "hello world" - let objectData = ObjectData(string: testString) + let objectData = ProtocolTypes.ObjectData(string: testString) let wireData = objectData.toWire(format: .messagePack) // OD4c4: A string payload is encoded as a MessagePack string type, and the result is set on the ObjectData.string attribute @@ -84,7 +84,7 @@ struct ObjectMessageTests { (jsonObjectOrArray: [123, "hello world"] as JSONObjectOrArray, expectedJSONString: #"[123,"hello world"]"#), ]) func json(jsonObjectOrArray: JSONObjectOrArray, expectedJSONString: String) { - let objectData = ObjectData(json: jsonObjectOrArray) + let objectData = ProtocolTypes.ObjectData(json: jsonObjectOrArray) let wireData = objectData.toWire(format: .messagePack) #expect(wireData.boolean == nil) @@ -99,7 +99,7 @@ struct ObjectMessageTests { // @spec OD4d1 @Test func boolean() { - let objectData = ObjectData(boolean: true) + let objectData = ProtocolTypes.ObjectData(boolean: true) let wireData = objectData.toWire(format: .json) // OD4d1: A boolean payload is represented as a JSON boolean and set on the ObjectData.boolean attribute @@ -114,7 +114,7 @@ struct ObjectMessageTests { @Test func binary() { let testData = Data([1, 2, 3, 4]) - let objectData = ObjectData(bytes: testData) + let objectData = ProtocolTypes.ObjectData(bytes: testData) let wireData = objectData.toWire(format: .json) // OD4d2: A binary payload is Base64-encoded and represented as a JSON string; the result is set on the ObjectData.bytes attribute @@ -134,7 +134,7 @@ struct ObjectMessageTests { @Test func number() { let testNumber = NSNumber(value: 42) - let objectData = ObjectData(number: testNumber) + let objectData = ProtocolTypes.ObjectData(number: testNumber) let wireData = objectData.toWire(format: .json) // OD4d3: A number payload is represented as a JSON number and set on the ObjectData.number attribute @@ -149,7 +149,7 @@ struct ObjectMessageTests { @Test func string() { let testString = "hello world" - let objectData = ObjectData(string: testString) + let objectData = ProtocolTypes.ObjectData(string: testString) let wireData = objectData.toWire(format: .json) // OD4d4: A string payload is represented as a JSON string and set on the ObjectData.string attribute @@ -167,7 +167,7 @@ struct ObjectMessageTests { (jsonObjectOrArray: [123, "hello world"] as JSONObjectOrArray, expectedJSONString: #"[123,"hello world"]"#), ]) func json(jsonObjectOrArray: JSONObjectOrArray, expectedJSONString: String) { - let objectData = ObjectData(json: jsonObjectOrArray) + let objectData = ProtocolTypes.ObjectData(json: jsonObjectOrArray) let wireData = objectData.toWire(format: .json) #expect(wireData.boolean == nil) @@ -185,7 +185,7 @@ struct ObjectMessageTests { @Test func boolean() throws { let wireData = WireObjectData(boolean: true) - let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types #expect(objectData.boolean == true) @@ -200,7 +200,7 @@ struct ObjectMessageTests { func binary() throws { let testData = Data([1, 2, 3, 4]) let wireData = WireObjectData(bytes: .data(testData)) - let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types #expect(objectData.boolean == nil) @@ -216,7 +216,7 @@ struct ObjectMessageTests { let testData = Data([1, 2, 3, 4]) let base64String = testData.base64EncodedString() let wireData = WireObjectData(bytes: .string(base64String)) - let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types #expect(objectData.boolean == nil) @@ -231,7 +231,7 @@ struct ObjectMessageTests { func number() throws { let testNumber = NSNumber(value: 42) let wireData = WireObjectData(number: testNumber) - let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types #expect(objectData.boolean == nil) @@ -246,7 +246,7 @@ struct ObjectMessageTests { func string() throws { let testString = "hello world" let wireData = WireObjectData(string: testString) - let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types #expect(objectData.boolean == nil) @@ -261,7 +261,7 @@ struct ObjectMessageTests { func json() throws { let jsonString = "{\"key\":\"value\",\"number\":123}" let wireData = WireObjectData(json: jsonString) - let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) #expect(objectData.boolean == nil) @@ -280,7 +280,7 @@ struct ObjectMessageTests { // Should throw when JSON parsing fails, even in MessagePack format #expect(throws: ARTErrorInfo.self) { - _ = try ObjectData(wireObjectData: wireData, format: .messagePack) + _ = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) } } @@ -303,7 +303,7 @@ struct ObjectMessageTests { // Should throw when JSON is valid but not an object or array #expect(throws: ARTErrorInfo.self) { - _ = try ObjectData(wireObjectData: wireData, format: .messagePack) + _ = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) } } } @@ -313,7 +313,7 @@ struct ObjectMessageTests { @Test func boolean() throws { let wireData = WireObjectData(boolean: true) - let objectData = try ObjectData(wireObjectData: wireData, format: .json) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) // OD5b1: The payloads in ObjectData.boolean, ObjectData.number, and ObjectData.string are decoded as their corresponding JSON types #expect(objectData.boolean == true) @@ -328,7 +328,7 @@ struct ObjectMessageTests { func number() throws { let testNumber = NSNumber(value: 42) let wireData = WireObjectData(number: testNumber) - let objectData = try ObjectData(wireObjectData: wireData, format: .json) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) // OD5b1: The payloads in ObjectData.boolean, ObjectData.number, and ObjectData.string are decoded as their corresponding JSON types #expect(objectData.boolean == nil) @@ -343,7 +343,7 @@ struct ObjectMessageTests { func string() throws { let testString = "hello world" let wireData = WireObjectData(string: testString) - let objectData = try ObjectData(wireObjectData: wireData, format: .json) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) // OD5b1: The payloads in ObjectData.boolean, ObjectData.number, and ObjectData.string are decoded as their corresponding JSON types #expect(objectData.boolean == nil) @@ -359,7 +359,7 @@ struct ObjectMessageTests { let testData = Data([1, 2, 3, 4]) let base64String = testData.base64EncodedString() let wireData = WireObjectData(bytes: .string(base64String)) - let objectData = try ObjectData(wireObjectData: wireData, format: .json) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) // OD5b2: The ObjectData.bytes payload is Base64-decoded into a binary value #expect(objectData.boolean == nil) @@ -377,7 +377,7 @@ struct ObjectMessageTests { // Should throw when Base64 decoding fails #expect(throws: ARTErrorInfo.self) { - _ = try ObjectData(wireObjectData: wireData, format: .json) + _ = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) } } @@ -386,7 +386,7 @@ struct ObjectMessageTests { func json() throws { let jsonString = "{\"key\":\"value\",\"number\":123}" let wireData = WireObjectData(json: jsonString) - let objectData = try ObjectData(wireObjectData: wireData, format: .json) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) #expect(objectData.boolean == nil) #expect(objectData.bytes == nil) @@ -404,7 +404,7 @@ struct ObjectMessageTests { // Should throw when JSON parsing fails #expect(throws: ARTErrorInfo.self) { - _ = try ObjectData(wireObjectData: wireData, format: .json) + _ = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) } } @@ -427,7 +427,7 @@ struct ObjectMessageTests { // Should throw when JSON is valid but not an object or array #expect(throws: ARTErrorInfo.self) { - _ = try ObjectData(wireObjectData: wireData, format: .json) + _ = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) } } } @@ -441,17 +441,17 @@ struct ObjectMessageTests { EncodingFormat.messagePack.rawValue, ], [ // Test each property type individually - ObjectData(boolean: true), - ObjectData(bytes: Data([1, 2, 3, 4])), - ObjectData(number: NSNumber(value: 42)), - ObjectData(string: "hello world"), - ObjectData(json: .object(["key": "value", "number": 123])), - ObjectData(json: .array([123, "hello world"])), + ProtocolTypes.ObjectData(boolean: true), + ProtocolTypes.ObjectData(bytes: Data([1, 2, 3, 4])), + ProtocolTypes.ObjectData(number: NSNumber(value: 42)), + ProtocolTypes.ObjectData(string: "hello world"), + ProtocolTypes.ObjectData(json: .object(["key": "value", "number": 123])), + ProtocolTypes.ObjectData(json: .array([123, "hello world"])), ]) - func roundTrip(formatRawValue: EncodingFormat.RawValue, originalData: ObjectData) throws { + func roundTrip(formatRawValue: EncodingFormat.RawValue, originalData: ProtocolTypes.ObjectData) throws { let format = try #require(EncodingFormat(rawValue: formatRawValue)) let wireData = originalData.toWire(format: format) - let decodedData = try ObjectData(wireObjectData: wireData, format: format) + let decodedData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: format) // Compare boolean values #expect(decodedData.boolean == originalData.boolean) diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 6bb3cbaf..53ad9f58 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -6,7 +6,7 @@ private extension SyncObjectsPool { /// Test-only convenience to create a `SyncObjectsPool` from an array of `(state, serialTimestamp)` pairs, /// wrapping each in an `InboundObjectMessage` and calling `accumulate`. static func testsOnly_fromStates( - _ states: [(state: ObjectState, serialTimestamp: Date?)], + _ states: [(state: ProtocolTypes.ObjectState, serialTimestamp: Date?)], logger: AblyLiveObjects.Logger = TestLogger(), ) -> SyncObjectsPool { var pool = SyncObjectsPool() @@ -327,7 +327,7 @@ struct ObjectsPoolTests { createOp: TestFactories.mapCreateOperation(objectId: "map:existing@1", entries: [ "createOpKey": TestFactories.stringMapEntry(value: "bar").entry, ]), - entries: ["updated": TestFactories.mapEntry(data: ObjectData(string: "updated"))], + entries: ["updated": TestFactories.mapEntry(data: ProtocolTypes.ObjectData(string: "updated"))], ), // Update existing counter TestFactories.counterObjectState( @@ -340,7 +340,7 @@ struct ObjectsPoolTests { TestFactories.mapObjectState( objectId: "map:new@1", siteTimeserials: ["site3": "ts3"], - entries: ["new": TestFactories.mapEntry(data: ObjectData(string: "new"))], + entries: ["new": TestFactories.mapEntry(data: ProtocolTypes.ObjectData(string: "new"))], ), // Create new counter TestFactories.counterObjectState( diff --git a/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift index 5aa56b3b..551380a3 100644 --- a/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift @@ -111,7 +111,7 @@ struct SyncObjectsPoolTests { var pool = SyncObjectsPool() let logger = TestLogger() - var expectedEntries: [String: ObjectsMapEntry] = [:] + var expectedEntries: [String: ProtocolTypes.ObjectsMapEntry] = [:] for i in 1 ... 3 { let (key, entry) = TestFactories.stringMapEntry(key: "key\(i)", value: "value\(i)") expectedEntries[key] = entry