From f942ce8a79b6d722bd8fa9ae5895bff769544768 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 31 Jul 2025 17:03:16 +0100 Subject: [PATCH 1/6] Make some stuff Equatable to help with tests --- .../AblyLiveObjects/Protocol/ObjectMessage.swift | 14 +++++++------- .../Protocol/WireObjectMessage.swift | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index a9adea8e..8a5e2a96 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -18,7 +18,7 @@ internal struct InboundObjectMessage { } /// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. -internal struct OutboundObjectMessage { +internal struct OutboundObjectMessage: Equatable { internal var id: String? // OM2a internal var clientId: String? // OM2b internal var connectionId: String? @@ -44,7 +44,7 @@ internal struct PartialObjectOperation { internal var initialValue: String? // OOP3h } -internal struct ObjectOperation { +internal struct ObjectOperation: Equatable { internal var action: WireEnum // OOP3a internal var objectId: String // OOP3b internal var mapOp: ObjectsMapOp? // OOP3c @@ -55,7 +55,7 @@ internal struct ObjectOperation { internal var initialValue: String? // OOP3h } -internal struct ObjectData { +internal struct ObjectData: Equatable { internal var objectId: String? // OD2a internal var boolean: Bool? // OD2c internal var bytes: Data? // OD2d @@ -64,24 +64,24 @@ internal struct ObjectData { internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) } -internal struct ObjectsMapOp { +internal struct ObjectsMapOp: Equatable { internal var key: String // OMO2a internal var data: ObjectData? // OMO2b } -internal struct ObjectsMapEntry { +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 ObjectsMap { +internal struct ObjectsMap: Equatable { internal var semantics: WireEnum // OMP3a internal var entries: [String: ObjectsMapEntry]? // OMP3b } -internal struct ObjectState { +internal struct ObjectState: Equatable { internal var objectId: String // OST2a internal var siteTimeserials: [String: String] // OST2b internal var tombstone: Bool // OST2c diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index b0d7514c..30ed3aef 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -356,7 +356,7 @@ extension WireObjectsMapOp: WireObjectCodable { } } -internal struct WireObjectsCounterOp { +internal struct WireObjectsCounterOp: Equatable { internal var amount: NSNumber // OCO2a } @@ -410,7 +410,7 @@ extension WireObjectsMap: WireObjectCodable { } } -internal struct WireObjectsCounter { +internal struct WireObjectsCounter: Equatable { internal var count: NSNumber? // OCN2a } From 51563836b276fc4c09cb97cbf5a40125562dc6e6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 1 Aug 2025 10:11:28 +0100 Subject: [PATCH 2/6] Remove action from PartialObjectOperation Should have done this in b701b46. --- .../AblyLiveObjects/Protocol/ObjectMessage.swift | 15 +++++---------- .../Protocol/WireObjectMessage.swift | 16 ++++++---------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 8a5e2a96..23719a19 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -31,11 +31,10 @@ internal struct OutboundObjectMessage: Equatable { internal var serialTimestamp: Date? // OM2j } -/// A partial version of `ObjectOperation` that excludes the `objectId` property. Used for encoding initial values where the `objectId` is not yet known. +/// A partial version of `ObjectOperation` that excludes the `action` and `objectId` property. Used for encoding initial values which don't include the `action` and where the `objectId` is not yet known. /// /// `ObjectOperation` delegates its encoding and decoding to `PartialObjectOperation`. internal struct PartialObjectOperation { - internal var action: WireEnum // OOP3a internal var mapOp: ObjectsMapOp? // OOP3c internal var counterOp: WireObjectsCounterOp? // OOP3d internal var map: ObjectsMap? // OOP3e @@ -148,13 +147,13 @@ internal extension ObjectOperation { wireObjectOperation: WireObjectOperation, format: AblyPlugin.EncodingFormat ) throws(InternalError) { - // Decode the objectId first since it's not part of PartialObjectOperation + // Decode the action and objectId first they're not part of PartialObjectOperation + action = wireObjectOperation.action objectId = wireObjectOperation.objectId // Delegate to PartialObjectOperation for decoding let partialOperation = try PartialObjectOperation( partialWireObjectOperation: PartialWireObjectOperation( - action: wireObjectOperation.action, mapOp: wireObjectOperation.mapOp, counterOp: wireObjectOperation.counterOp, map: wireObjectOperation.map, @@ -166,7 +165,6 @@ internal extension ObjectOperation { ) // Copy the decoded values - action = partialOperation.action mapOp = partialOperation.mapOp counterOp = partialOperation.counterOp map = partialOperation.map @@ -181,7 +179,6 @@ internal extension ObjectOperation { /// - format: The format to use when applying the encoding rules of OD4. func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectOperation { let partialWireOperation = PartialObjectOperation( - action: action, mapOp: mapOp, counterOp: counterOp, map: map, @@ -190,9 +187,9 @@ internal extension ObjectOperation { initialValue: initialValue, ).toWire(format: format) - // Create WireObjectOperation from PartialWireObjectOperation and add objectId + // Create WireObjectOperation from PartialWireObjectOperation and add action and objectId return WireObjectOperation( - action: partialWireOperation.action, + action: action, objectId: objectId, mapOp: partialWireOperation.mapOp, counterOp: partialWireOperation.counterOp, @@ -214,7 +211,6 @@ internal extension PartialObjectOperation { partialWireObjectOperation: PartialWireObjectOperation, format: AblyPlugin.EncodingFormat ) throws(InternalError) { - action = partialWireObjectOperation.action mapOp = try partialWireObjectOperation.mapOp.map { wireObjectsMapOp throws(InternalError) in try .init(wireObjectsMapOp: wireObjectsMapOp, format: format) } @@ -236,7 +232,6 @@ internal extension PartialObjectOperation { /// - format: The format to use when applying the encoding rules of OD4. func toWire(format: AblyPlugin.EncodingFormat) -> PartialWireObjectOperation { .init( - action: action, mapOp: mapOp?.toWire(format: format), counterOp: counterOp, map: map?.toWire(format: format), diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 30ed3aef..d7316fc5 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -157,11 +157,10 @@ internal enum ObjectsMapSemantics: Int { case lww = 0 } -/// A partial version of `WireObjectOperation` that excludes the `objectId` property. Used for encoding initial values where the `objectId` is not yet known. +/// A partial version of `WireObjectOperation` that excludes the `action` and `objectId` property. Used for encoding initial values which don't include the `action` and where the `objectId` is not yet known. /// /// `WireObjectOperation` delegates its encoding and decoding to `PartialWireObjectOperation`. internal struct PartialWireObjectOperation { - internal var action: WireEnum // OOP3a internal var mapOp: WireObjectsMapOp? // OOP3c internal var counterOp: WireObjectsCounterOp? // OOP3d internal var map: WireObjectsMap? // OOP3e @@ -172,7 +171,6 @@ internal struct PartialWireObjectOperation { extension PartialWireObjectOperation: WireObjectCodable { internal enum WireKey: String { - case action case mapOp case counterOp case map @@ -182,7 +180,6 @@ extension PartialWireObjectOperation: WireObjectCodable { } internal init(wireObject: [String: WireValue]) throws(InternalError) { - action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) mapOp = try wireObject.optionalDecodableValueForKey(WireKey.mapOp.rawValue) counterOp = try wireObject.optionalDecodableValueForKey(WireKey.counterOp.rawValue) map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) @@ -195,9 +192,7 @@ extension PartialWireObjectOperation: WireObjectCodable { } internal var toWireObject: [String: WireValue] { - var result: [String: WireValue] = [ - WireKey.action.rawValue: .number(action.rawValue as NSNumber), - ] + var result: [String: WireValue] = [:] if let mapOp { result[WireKey.mapOp.rawValue] = .object(mapOp.toWireObject) @@ -235,18 +230,19 @@ internal struct WireObjectOperation { extension WireObjectOperation: WireObjectCodable { internal enum WireKey: String { + case action case objectId } internal init(wireObject: [String: WireValue]) throws(InternalError) { - // Decode the objectId first since it's not part of PartialWireObjectOperation + // Decode the action and objectId first since they're not part of PartialWireObjectOperation + action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) // Delegate to PartialWireObjectOperation for decoding let partialOperation = try PartialWireObjectOperation(wireObject: wireObject) // Copy the decoded values - action = partialOperation.action mapOp = partialOperation.mapOp counterOp = partialOperation.counterOp map = partialOperation.map @@ -257,7 +253,6 @@ extension WireObjectOperation: WireObjectCodable { internal var toWireObject: [String: WireValue] { var result = PartialWireObjectOperation( - action: action, mapOp: mapOp, counterOp: counterOp, map: map, @@ -267,6 +262,7 @@ extension WireObjectOperation: WireObjectCodable { ).toWireObject // Add the objectId field + result[WireKey.action.rawValue] = .number(action.rawValue as NSNumber) result[WireKey.objectId.rawValue] = .string(objectId) return result From bf05657369cf734eb39a7b32866dac2e09c12211 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 1 Aug 2025 10:28:05 +0100 Subject: [PATCH 3/6] Add Cursor rule re raw string literals --- .cursor/rules/swift.mdc | 1 + 1 file changed, 1 insertion(+) diff --git a/.cursor/rules/swift.mdc b/.cursor/rules/swift.mdc index 0ebf7370..3fa734a6 100644 --- a/.cursor/rules/swift.mdc +++ b/.cursor/rules/swift.mdc @@ -11,6 +11,7 @@ When writing Swift: - When writing initializer expressions, when the type that is being initialized can be inferred, favour using the implicit `.init(…)` form instead of explicitly writing the type name. - When writing enum value expressions, when the type that is being initialized can be inferred, favour using the implicit `.caseName` form instead of explicitly writing the type name. - When writing JSONValue or WireValue types, favour using the literal syntax enabled by their conformance to the `ExpressibleBy*Literal` protocols where possible. +- When writing a JSON string, favour using Swift raw string literals instead of escaping double quotes. - When you need to import the following modules inside the AblyLiveObjects library code (that is, in non-test code), do so in the following way: - Ably: use `import Ably` - AblyPlugin: use `internal import AblyPlugin` From dcdb3502faea6d89bae290804a677df758f2cca7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 24 Jul 2025 11:39:37 +0100 Subject: [PATCH 4/6] Implement the createMap and createCounter methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on [1] at cb11ba8. Internal interfaces by me, implementation and tests by Cursor (both with some tweaking by me). Haven't implemented: - the RTO11e etc echoMessages check — deferred to #49 - the RTO11c etc channel mode checking - same reason as 392fae3 - using server time for generating object ID — deferred to #50 [1] https://github.com/ably/specification/pull/353 --- .../InternalDefaultRealtimeObjects.swift | 84 +++++- .../Internal/InternalLiveMapValue.swift | 54 ++++ .../Internal/InternalObjectsMapEntry.swift | 2 +- .../Internal/ObjectCreationHelpers.swift | 223 +++++++++++++++ .../Internal/ObjectsPool.swift | 89 ++++++ .../PublicDefaultRealtimeObjects.swift | 43 ++- Sources/AblyLiveObjects/Utility/Errors.swift | 13 +- .../AblyLiveObjects/Utility/JSONValue.swift | 18 ++ .../InternalDefaultRealtimeObjectsTests.swift | 261 +++++++++++++++++- .../Mocks/MockCoreSDK.swift | 16 +- .../ObjectCreationHelpersTests.swift | 216 +++++++++++++++ 11 files changed, 1001 insertions(+), 18 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift create mode 100644 Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 904265f7..91486f3d 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -157,20 +157,88 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } } - internal func createMap(entries _: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { - notYetImplemented() + internal func createMap(entries: [String: InternalLiveMapValue], coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { + do throws(InternalError) { + // RTO11d + do { + try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createMap") + } catch { + throw error.toInternalError() + } + + // RTO11f + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + let timestamp = clock.now + let creationOperation = ObjectCreationHelpers.creationOperationForLiveMap( + entries: entries, + timestamp: timestamp, + ) + + // RTO11g + try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) + + // RTO11h + return mutex.withLock { + mutableState.objectsPool.getOrCreateMap( + creationOperation: creationOperation, + logger: logger, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } catch { + throw error.toARTErrorInfo() + } } - internal func createMap() async throws(ARTErrorInfo) -> any LiveMap { - notYetImplemented() + internal func createMap(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { + // RTO11f4b + try await createMap(entries: [:], coreSDK: coreSDK) } - internal func createCounter(count _: Double) async throws(ARTErrorInfo) -> any LiveCounter { - notYetImplemented() + internal func createCounter(count: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { + do throws(InternalError) { + // RTO12d + do { + try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createCounter") + } catch { + throw error.toInternalError() + } + + // RTO12f1 + if !count.isFinite { + throw LiveObjectsError.counterInitialValueInvalid(value: count).toARTErrorInfo().toInternalError() + } + + // RTO12f + + // TODO: This is a stopgap; change to use server time per RTO12f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + let timestamp = clock.now + let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter( + count: count, + timestamp: timestamp, + ) + + // RTO12g + try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) + + // RTO12h + return mutex.withLock { + mutableState.objectsPool.getOrCreateCounter( + creationOperation: creationOperation, + logger: logger, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } catch { + throw error.toARTErrorInfo() + } } - internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter { - notYetImplemented() + internal func createCounter(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { + // RTO12f2a + try await createCounter(count: 0, coreSDK: coreSDK) } internal func batch(callback _: sending BatchCallback) async throws { diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift index 0d6cb79b..657a37f3 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -6,6 +6,60 @@ 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 .primitive(primitiveValue): + self = .primitive(primitiveValue) + 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-cocoa-liveobjects-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-cocoa-liveobjects-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 RTO11f4 and RTLM20e4. + internal var toObjectData: ObjectData { + // RTO11f4c1: Create an ObjectsMapEntry for the current value + switch self { + case let .primitive(primitiveValue): + switch primitiveValue { + case let .bool(value): + .init(boolean: value) + case let .data(value): + .init(bytes: value) + case let .number(value): + .init(number: NSNumber(value: value)) + case let .string(value): + .init(string: value) + case let .jsonArray(value): + .init(json: .array(value)) + case let .jsonObject(value): + .init(json: .object(value)) + } + case let .liveMap(liveMap): + // RTO11f4c1a: If the value is of type LiveMap, set ObjectsMapEntry.data.objectId to the objectId of that object + .init(objectId: liveMap.objectID) + case let .liveCounter(liveCounter): + // RTO11f4c1a: If the value is of type LiveCounter, set ObjectsMapEntry.data.objectId to the objectId of that object + .init(objectId: liveCounter.objectID) + } + } + // MARK: - Convenience getters for associated values /// If this `InternalLiveMapValue` has case `primitive`, this returns the associated value. Else, it returns `nil`. diff --git a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift index da969c3b..40478ee3 100644 --- a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift +++ b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift @@ -1,7 +1,7 @@ import Foundation /// The entries stored in a `LiveMap`'s data. Same as an `ObjectsMapEntry` but with an additional `tombstonedAt` property, per RTLM3a. -internal struct InternalObjectsMapEntry { +internal struct InternalObjectsMapEntry: Equatable { internal var tombstonedAt: Date? // RTLM3a internal var tombstone: Bool { // TODO: Confirm that we don't need to store this (https://github.com/ably/specification/pull/350/files#r2213895661) diff --git a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift new file mode 100644 index 00000000..b82f7d84 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift @@ -0,0 +1,223 @@ +internal import AblyPlugin +import CryptoKit +import Foundation + +/// Helpers for creating a new LiveObject. +/// +/// These generate an object ID and the `ObjectMessage` needed to create the LiveObject. +internal enum ObjectCreationHelpers { + /// The metadata that `createCounter` needs in order to request that Realtime create a LiveCounter and to populate the local objects pool. + internal struct CounterCreationOperation { + /// The generated object ID. Needed for populating the local objects pool. + /// + /// 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 objectID: String + + /// The operation that should be merged into any created LiveCounter. + /// + /// 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 operation: ObjectOperation + + /// The ObjectMessage that must be sent in order for Realtime to create the object. + internal var objectMessage: OutboundObjectMessage + } + + /// The metadata that `createMap` needs in order to request that Realtime create a LiveMap and to populate the local objects pool. + internal struct MapCreationOperation { + /// The generated object ID. Needed for populating the local objects pool. + /// + /// 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 objectID: String + + /// The operation that should be merged into any 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 operation: ObjectOperation + + /// The ObjectMessage that must be sent in order for Realtime to create the object. + internal var objectMessage: 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 + } + + /// Creates a `COUNTER_CREATE` `ObjectMessage` for the `RealtimeObjects.createCounter` method per RTO12f. + /// + /// - Parameters: + /// - count: The initial count for the new LiveCounter object + /// - timestamp: The timestamp to use for the generated object ID. + internal static func creationOperationForLiveCounter( + count: Double, + timestamp: Date, + ) -> CounterCreationOperation { + // RTO12f2: Create initial value for the new LiveCounter + let initialValue = PartialObjectOperation( + counter: WireObjectsCounter(count: NSNumber(value: count)), + ) + + // RTO12f3: Create an initial value JSON string as described in RTO13 + let initialValueJSONString = createInitialValueJSONString(from: initialValue) + + // RTO12f4: Create a unique nonce as a random string + let nonce = generateNonce() + + // RTO12f5: Get the current server time (using the provided timestamp) + let serverTime = timestamp + + // RTO12f6: Create an objectId for the new LiveCounter object as described in RTO14 + let objectId = createObjectID( + type: "counter", + initialValue: initialValueJSONString, + nonce: nonce, + timestamp: serverTime, + ) + + // RTO12f7-12: Set ObjectMessage.operation fields + let operation = ObjectOperation( + action: .known(.counterCreate), + objectId: objectId, + counter: WireObjectsCounter(count: NSNumber(value: count)), + nonce: nonce, + initialValue: initialValueJSONString, + ) + + // Create the OutboundObjectMessage + let objectMessage = OutboundObjectMessage( + operation: operation, + ) + + return CounterCreationOperation( + objectID: objectId, + operation: operation, + objectMessage: objectMessage, + ) + } + + /// Creates a `MAP_CREATE` `ObjectMessage` for the `RealtimeObjects.createMap` method per RTO11f. + /// + /// - Parameters: + /// - entries: The initial entries for the new LiveMap object + /// - timestamp: The timestamp to use for the generated object ID. + internal static func creationOperationForLiveMap( + entries: [String: InternalLiveMapValue], + timestamp: Date, + ) -> MapCreationOperation { + // RTO11f4: Create initial value for the new LiveMap + let mapEntries = entries.mapValues { liveMapValue -> ObjectsMapEntry in + ObjectsMapEntry(data: liveMapValue.toObjectData) + } + + let initialValue = PartialObjectOperation( + map: ObjectsMap( + semantics: .known(.lww), + entries: mapEntries, + ), + ) + + // RTO11f5: Create an initial value JSON string as described in RTO13 + let initialValueJSONString = createInitialValueJSONString(from: initialValue) + + // RTO11f6: Create a unique nonce as a random string + let nonce = generateNonce() + + // RTO11f7: Get the current server time (using the provided timestamp) + let serverTime = timestamp + + // RTO11f8: Create an objectId for the new LiveMap object as described in RTO14 + let objectId = createObjectID( + type: "map", + initialValue: initialValueJSONString, + nonce: nonce, + timestamp: serverTime, + ) + + // RTO11f9-13: Set ObjectMessage.operation fields + let semantics = ObjectsMapSemantics.lww + let operation = ObjectOperation( + action: .known(.mapCreate), + objectId: objectId, + map: ObjectsMap( + semantics: .known(semantics), + entries: mapEntries, + ), + nonce: nonce, + initialValue: initialValueJSONString, + ) + + // Create the OutboundObjectMessage + let objectMessage = OutboundObjectMessage( + operation: operation, + ) + + return MapCreationOperation( + objectID: objectId, + operation: operation, + objectMessage: objectMessage, + semantics: semantics, + ) + } + + // MARK: - Private Helper Methods + + /// Creates an initial value JSON string from a PartialObjectOperation, per RTO13. + private static func createInitialValueJSONString(from initialValue: PartialObjectOperation) -> String { + // RTO13b: Encode the initial value using OM4 encoding + let partialWireObjectOperation = initialValue.toWire(format: .json) + let jsonObject = partialWireObjectOperation.toWireObject.mapValues { wireValue in + do { + return try wireValue.toJSONValue + } catch { + // By using `format: .json` we've requested a type that should be JSON-encodable, so if it isn't then it's a programmer error. (We can't reason about it statically though because of our choice to use a general-purpose WireValue type; maybe could improve upon this in the future.) + preconditionFailure("Failed to convert WireValue \(wireValue) to JSONValue when encoding initialValue") + } + } + + // RTO13c + return JSONObjectOrArray.object(jsonObject).toJSONString + } + + /// Creates an Object ID for a new LiveObject instance, per RTO14. + internal static func testsOnly_createObjectID( + type: String, + initialValue: String, + nonce: String, + timestamp: Date, + ) -> String { + createObjectID( + type: type, + initialValue: initialValue, + nonce: nonce, + timestamp: timestamp, + ) + } + + /// Creates an Object ID for a new LiveObject instance, per RTO14. + private static func createObjectID( + type: String, + initialValue: String, + nonce: String, + timestamp: Date, + ) -> String { + // RTO14b1: Generate a SHA-256 digest + let hash = SHA256.hash(data: Data("\(initialValue):\(nonce)".utf8)) + + // RTO14b2: Base64URL-encode the generated digest + let base64URLHash = Data(hash).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + // RTO14c: Return an Object ID in the format [type]:[hash]@[timestamp] + let timestampMillis = Int(timestamp.timeIntervalSince1970 * 1000) + return "\(type):\(base64URLHash)@\(timestampMillis)" + } + + /// Generates a unique nonce as a random string, per RTO11f6 and RTO12f4. + private static func generateNonce() -> String { + // TODO: confirm if there's any specific rules here: https://github.com/ably/specification/pull/353/files#r2228252389 + let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return String((0 ..< 16).map { _ in letters.randomElement()! }) + } +} diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 933198a5..8d2d7e4f 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -306,6 +306,95 @@ internal struct ObjectsPool { logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) } + /// Gets or creates a counter object in the pool, implementing the "find or create zero-value" behavior of RTO12h1. + /// + /// - Parameters: + /// - creationOperation: The CounterCreationOperation containing the object ID and operation to merge + /// - logger: The logger to use for any created LiveObject + /// - userCallbackQueue: The callback queue to use for any created LiveObject + /// - clock: The clock to use for any created LiveObject + /// - Returns: The existing or newly created counter object + internal mutating func getOrCreateCounter( + creationOperation: ObjectCreationHelpers.CounterCreationOperation, + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) -> InternalDefaultLiveCounter { + // RTO12h2: If an object with the ObjectMessage.operation.objectId exists in the internal ObjectsPool, return it + if let existingEntry = entries[creationOperation.objectID] { + switch existingEntry { + case let .counter(counter): + return counter + case .map: + // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/36 + preconditionFailure("Expected counter object with ID \(creationOperation.objectID) but found map object") + } + } + + // RTO12h3: Otherwise, if the object does not exist in the internal ObjectsPool: + // RTO12h3a: Create a zero-value LiveCounter, set its objectId to ObjectMessage.operation.objectId, and merge the initial value + let counter = InternalDefaultLiveCounter.createZeroValued( + objectID: creationOperation.objectID, + logger: logger, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + + // Merge the initial value from the creation operation + _ = counter.mergeInitialValue(from: creationOperation.operation) + + // RTO12h3b: Add the created LiveCounter instance to the internal ObjectsPool + entries[creationOperation.objectID] = .counter(counter) + + // RTO12h3c: Return the created LiveCounter instance + return counter + } + + /// Gets or creates a map object in the pool, implementing the "find or create zero-value" behavior of RTO11h1. + /// + /// - Parameters: + /// - creationOperation: The MapCreationOperation containing the object ID and operation to merge + /// - logger: The logger to use for any created LiveObject + /// - userCallbackQueue: The callback queue to use for any created LiveObject + /// - clock: The clock to use for any created LiveObject + /// - Returns: The existing or newly created map object + internal mutating func getOrCreateMap( + creationOperation: ObjectCreationHelpers.MapCreationOperation, + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) -> InternalDefaultLiveMap { + // RTO11h2: If an object with the ObjectMessage.operation.objectId exists in the internal ObjectsPool, return it + if let existingEntry = entries[creationOperation.objectID] { + switch existingEntry { + case let .map(map): + return map + case .counter: + // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/36 + preconditionFailure("Expected map object with ID \(creationOperation.objectID) but found counter object") + } + } + + // RTO11h3: Otherwise, if the object does not exist in the internal ObjectsPool: + // RTO11h3a: Create a zero-value LiveMap, set its objectId to ObjectMessage.operation.objectId, set its semantics to ObjectMessage.operation.map.semantics, and merge the initial value + let map = InternalDefaultLiveMap.createZeroValued( + objectID: creationOperation.objectID, + semantics: .known(creationOperation.semantics), + logger: logger, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + + // Merge the initial value from the creation operation + _ = map.mergeInitialValue(from: creationOperation.operation, objectsPool: &self) + + // RTO11h3b: Add the created LiveMap instance to the internal ObjectsPool + entries[creationOperation.objectID] = .map(map) + + // RTO11h3c: Return the created LiveMap instance + return map + } + /// Removes all entries except the root, and clears the root's data. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map, per RTO4b. internal mutating func reset() { let root = root diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index 94be8e40..34b5abf7 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -36,19 +36,54 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { } internal func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { - try await proxied.createMap(entries: entries) + 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, + delegate: proxied, + logger: logger, + ), + ) } internal func createMap() async throws(ARTErrorInfo) -> any LiveMap { - try await proxied.createMap() + let internalMap = try await proxied.createMap(coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateMap( + proxying: internalMap, + creationArgs: .init( + coreSDK: coreSDK, + delegate: proxied, + logger: logger, + ), + ) } internal func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter { - try await proxied.createCounter(count: count) + let internalCounter = try await proxied.createCounter(count: count, coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateCounter( + proxying: internalCounter, + creationArgs: .init( + coreSDK: coreSDK, + logger: logger, + ), + ) } internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter { - try await proxied.createCounter() + let internalCounter = try await proxied.createCounter(coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateCounter( + proxying: internalCounter, + creationArgs: .init( + coreSDK: coreSDK, + logger: logger, + ), + ) } internal func batch(callback: sending BatchCallback) async throws { diff --git a/Sources/AblyLiveObjects/Utility/Errors.swift b/Sources/AblyLiveObjects/Utility/Errors.swift index e44d47f9..941d3e8b 100644 --- a/Sources/AblyLiveObjects/Utility/Errors.swift +++ b/Sources/AblyLiveObjects/Utility/Errors.swift @@ -6,19 +6,26 @@ import Ably internal enum LiveObjectsError { // operationDescription should be a description of a method like "LiveCounter.value"; it will be interpolated into an error message case objectsOperationFailedInvalidChannelState(operationDescription: String, channelState: ARTRealtimeChannelState) + case counterInitialValueInvalid(value: Double) + case counterIncrementAmountInvalid(amount: Double) /// The ``ARTErrorInfo/code`` that should be returned for this error. internal var code: ARTErrorCode { switch self { case .objectsOperationFailedInvalidChannelState: .channelOperationFailedInvalidState + case .counterInitialValueInvalid, .counterIncrementAmountInvalid: + // RTO12f1, RTLC12e1 + .invalidParameterValue } } /// The ``ARTErrorInfo/statusCode`` that should be returned for this error. internal var statusCode: Int { switch self { - case .objectsOperationFailedInvalidChannelState: + case .objectsOperationFailedInvalidChannelState, + .counterInitialValueInvalid, + .counterIncrementAmountInvalid: 400 } } @@ -28,6 +35,10 @@ internal enum LiveObjectsError { switch self { case let .objectsOperationFailedInvalidChannelState(operationDescription: operationDescription, channelState: channelState): "\(operationDescription) operation failed (invalid channel state: \(channelState))" + case let .counterInitialValueInvalid(value: value): + "Invalid counter initial value (must be a finite number): \(value)" + case let .counterIncrementAmountInvalid(amount: amount): + "Invalid counter increment amount (must be a finite number): \(amount)" } } diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift index 3030dcaa..48c33f3f 100644 --- a/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -177,6 +177,24 @@ internal enum JSONObjectOrArray: Equatable { throw ConversionError.incompatibleJSONValue(jsonValue).toInternalError() } } + + // MARK: - Convenience getters for associated values + + /// If this `JSONObjectOrArray` has case `object`, this returns the associated value. Else, it returns `nil`. + internal var objectValue: [String: JSONValue]? { + if case let .object(value) = self { + return value + } + return nil + } + + /// If this `JSONObjectOrArray` has case `array`, this returns the associated value. Else, it returns `nil`. + internal var arrayValue: [JSONValue]? { + if case let .array(value) = self { + return value + } + return nil + } } extension JSONObjectOrArray: ExpressibleByDictionaryLiteral { diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 82f177cb..fee10e3c 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -8,9 +8,9 @@ struct InternalDefaultRealtimeObjectsTests { // MARK: - Test Helpers /// Creates a InternalDefaultRealtimeObjects instance for testing - static func createDefaultRealtimeObjects() -> InternalDefaultRealtimeObjects { + static func createDefaultRealtimeObjects(clock: SimpleClock = MockSimpleClock()) -> InternalDefaultRealtimeObjects { let logger = TestLogger() - return InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + return InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: .main, clock: clock) } /// Tests for `InternalDefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. @@ -1040,4 +1040,261 @@ struct InternalDefaultRealtimeObjectsTests { } } } + + /// Tests for `InternalDefaultRealtimeObjects.createMap`, covering RTO11 specification points (these are largely a smoke test, the rest being tested in ObjectCreationHelpers tests) + struct CreateMapTests { + // @spec RTO11d + @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) + func throwsIfChannelIsInInvalidState(channelState: ARTRealtimeChannelState) async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: channelState) + let entries: [String: InternalLiveMapValue] = ["testKey": .primitive(.string("testValue"))] + + await #expect { + _ = try await realtimeObjects.createMap(entries: entries, coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @spec RTO11g + // @spec RTO11h3a + // @spec RTO11h3b + @Test + func publishesObjectMessageAndCreatesMap() async throws { + let clock = MockSimpleClock(currentTime: .init(timeIntervalSince1970: 1_754_042_434)) + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(clock: clock) + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + // Call createMap + let returnedMap = try await realtimeObjects.createMap( + entries: [ + "stringKey": .primitive(.string("stringValue")), + ], + coreSDK: coreSDK, + ) + + // Verify ObjectMessage was published (RTO11g) + #expect(publishedMessages.count == 1) + let publishedMessage = publishedMessages[0] + + // Sense check of ObjectMessage structure per RTO11f9-13 + #expect(publishedMessage.operation?.action == .known(.mapCreate)) + let objectID = try #require(publishedMessage.operation?.objectId) + #expect(objectID.hasPrefix("map:")) + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + #expect(objectID.contains("1754042434000")) // check contains the mock clock's timestamp in milliseconds + #expect(publishedMessage.operation?.map?.entries == [ + "stringKey": .init(data: .init(string: "stringValue")), + ]) + + // Verify initial value was merged per RTO11h3a + #expect(returnedMap.testsOnly_data == ["stringKey": .init(data: .init(string: "stringValue"))]) + + // Verify object was added to pool per RTO11h3b + #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.mapValue === returnedMap) + } + + // @spec RTO11f4b + @Test + func withNoEntriesArgumentCreatesEmptyMap() async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + // Call createMap with no entries + let result = try await realtimeObjects.createMap(entries: [:], coreSDK: coreSDK) + + // Verify ObjectMessage was published + #expect(publishedMessages.count == 1) + let publishedMessage = publishedMessages[0] + + // Verify map operation has empty entries per RTO11f4b + let mapOperation = publishedMessage.operation?.map + #expect(mapOperation?.entries?.isEmpty == true) + + // Verify LiveMap has expected entries + #expect(result.testsOnly_data.isEmpty) + } + + // @spec RTO11h2 + @Test + func returnsExistingObjectIfAlreadyInPool() async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages and the generated objectId + var publishedMessages: [OutboundObjectMessage] = [] + var maybeGeneratedObjectID: String? + var maybeExistingObject: AnyObject? + + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + + // Extract the generated objectId from the published message + if let objectID = messages.first?.operation?.objectId { + maybeGeneratedObjectID = objectID + + // Create an object with this exact ID in the pool + // This simulates the object already existing when createMap tries to get it, before the publish operation completes (e.g. because it has been populated by receipt of an OBJECT) + maybeExistingObject = realtimeObjects.testsOnly_createZeroValueLiveObject(forObjectID: objectID)?.mapValue + } + } + + // Call createMap - the publishHandler will create the object with the generated ID + let result = try await realtimeObjects.createMap(entries: ["testKey": .primitive(.string("testValue"))], coreSDK: coreSDK) + + // Verify ObjectMessage was published + #expect(publishedMessages.count == 1) + + // Extract the variables that we populated based on the generated object ID + let generatedObjectID = try #require(maybeGeneratedObjectID) + let existingObject = try #require(maybeExistingObject) + + // Verify the returned object is the same as the existing one + #expect(result === existingObject) + + // Check that the existing object has not been replaced in the pool + #expect(realtimeObjects.testsOnly_objectsPool.entries[generatedObjectID]?.mapValue === existingObject) + } + + /// Tests for `InternalDefaultRealtimeObjects.createCounter`, covering RTO12 specification points (these are largely a smoke test, the rest being tested in ObjectCreationHelpers tests) + struct CreateCounterTests { + // @spec RTO12d + @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) + func throwsIfChannelIsInInvalidState(channelState: ARTRealtimeChannelState) async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: channelState) + + await #expect { + _ = try await realtimeObjects.createCounter(count: 10.5, coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @spec RTO12g + // @spec RTO12h3a + // @spec RTO12h3b + @Test + func publishesObjectMessageAndCreatesCounter() async throws { + let clock = MockSimpleClock(currentTime: .init(timeIntervalSince1970: 1_754_042_434)) + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(clock: clock) + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + // Call createCounter + let returnedCounter = try await realtimeObjects.createCounter(count: 10.5, coreSDK: coreSDK) + + // Verify ObjectMessage was published (RTO12g) + #expect(publishedMessages.count == 1) + let publishedMessage = publishedMessages[0] + + // Sense check of ObjectMessage structure per RTO12f7-11 + #expect(publishedMessage.operation?.action == .known(.counterCreate)) + let objectID = try #require(publishedMessage.operation?.objectId) + #expect(objectID.hasPrefix("counter:")) + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + #expect(objectID.contains("1754042434000")) // check contains the mock clock's timestamp in milliseconds + #expect(publishedMessage.operation?.counter?.count == 10.5) + + // Verify initial value was merged per RTO12h3a + #expect(try returnedCounter.value(coreSDK: coreSDK) == 10.5) + + // Verify object was added to pool per RTO12h3b + #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.counterValue === returnedCounter) + } + + // @spec RTO12f2a + @Test + func withNoEntriesArgumentCreatesWithZeroValue() async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + // Call createCounter with no count + let result = try await realtimeObjects.createCounter(coreSDK: coreSDK) + + // Verify ObjectMessage was published + #expect(publishedMessages.count == 1) + let publishedMessage = publishedMessages[0] + + // Verify counter operation has zero count per RTO12f2a + let counterOperation = publishedMessage.operation?.counter + // swiftlint:disable:next empty_count + #expect(counterOperation?.count == 0) + + // Verify LiveCounter has zero value + #expect(try result.value(coreSDK: coreSDK) == 0) + } + + // @spec RTO12h2 + @Test + func returnsExistingObjectIfAlreadyInPool() async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages and the generated objectId + var publishedMessages: [OutboundObjectMessage] = [] + var maybeGeneratedObjectID: String? + var maybeExistingObject: AnyObject? + + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + + // Extract the generated objectId from the published message + if let objectID = messages.first?.operation?.objectId { + maybeGeneratedObjectID = objectID + + // Create an object with this exact ID in the pool + // This simulates the object already existing when createMap tries to get it, before the publish operation completes (e.g. because it has been populated by receipt of an OBJECT) + maybeExistingObject = realtimeObjects.testsOnly_createZeroValueLiveObject(forObjectID: objectID)?.counterValue + } + } + + // Call createCounter - the publishHandler will create the object with the generated ID + let result = try await realtimeObjects.createCounter(count: 10.5, coreSDK: coreSDK) + + // Verify ObjectMessage was published + #expect(publishedMessages.count == 1) + + // Extract the variables that we populated based on the generated object ID + let generatedObjectID = try #require(maybeGeneratedObjectID) + let existingObject = try #require(maybeExistingObject) + + // Verify the returned object is the same as the existing one + #expect(result === existingObject) + + // Check that the existing object has not been replaced in the pool + #expect(realtimeObjects.testsOnly_objectsPool.entries[generatedObjectID]?.counterValue === existingObject) + } + } + } } diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 4883717b..f01b59ea 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -6,13 +6,18 @@ final class MockCoreSDK: CoreSDK { private let mutex = NSLock() private nonisolated(unsafe) var _channelState: ARTRealtimeChannelState + private nonisolated(unsafe) var _publishHandler: (([OutboundObjectMessage]) async throws(InternalError) -> Void)? init(channelState: ARTRealtimeChannelState) { _channelState = channelState } - func publish(objectMessages _: [AblyLiveObjects.OutboundObjectMessage]) async throws(AblyLiveObjects.InternalError) { - protocolRequirementNotImplemented() + func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + if let handler = _publishHandler { + try await handler(objectMessages) + } else { + protocolRequirementNotImplemented() + } } var channelState: ARTRealtimeChannelState { @@ -27,4 +32,11 @@ final class MockCoreSDK: CoreSDK { } } } + + /// Sets a custom publish handler for testing + func setPublishHandler(_ handler: @escaping ([OutboundObjectMessage]) async throws(InternalError) -> Void) { + mutex.withLock { + _publishHandler = handler + } + } } diff --git a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift new file mode 100644 index 00000000..126b9426 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift @@ -0,0 +1,216 @@ +@testable import AblyLiveObjects +import Foundation +import Testing + +struct ObjectCreationHelpersTests { + struct CreationOperationTests { + // @spec RTO11f4c + // @spec RTO11f4c1a + // @spec RTO11f4c1a + // @spec RTO11f4c1b + // @spec RTO11f4c1c + // @spec RTO11f4c1d + // @spec RTO11f4c1e + // @spec RTO11f4c1f + // @spec RTO11f4c2 + // @spec RTO11f9 + // @spec RTO11f11 + // @spec RTO11f12 + // @spec RTO11f13 + // @specOneOf(1/2) RTO13 + @available(iOS 16.0.0, tvOS 16.0.0, *) // because of using Regex + @Test + func map() throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_754_042_434) + let logger = TestLogger() + let clock = MockSimpleClock() + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "referencedMapID", logger: logger, userCallbackQueue: .main, clock: clock) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "referencedCounterID", logger: logger, userCallbackQueue: .main, clock: clock) + + // When + let creationOperation = ObjectCreationHelpers.creationOperationForLiveMap( + entries: [ + // RTO11f4c1a + "mapRef": .liveMap(referencedMap), + // RTO11f4c1a + "counterRef": .liveCounter(referencedCounter), + // RTO11f4c1b + "jsonArrayKey": .primitive(.jsonArray([.string("arrayItem1"), .string("arrayItem2")])), + "jsonObjectKey": .primitive(.jsonObject(["nestedKey": .string("nestedValue")])), + // RTO11f4c1c + "stringKey": .primitive(.string("stringValue")), + // RTO11f4c1d + "numberKey": .primitive(.number(42.5)), + // RTO11f4c1e + "booleanKey": .primitive(.bool(true)), + // RTO11f4c1f + "dataKey": .primitive(.data(Data([0x01, 0x02, 0x03]))), + ], + timestamp: timestamp, + ) + + // Then + + // Check that the denormalized properties match those of the ObjectMessage + #expect(creationOperation.objectMessage.operation == creationOperation.operation) + #expect(creationOperation.objectMessage.operation?.map?.semantics == .known(creationOperation.semantics)) + + // Check that the initial value JSON is correctly populated on the initialValue property per RTO11f12, using the RTO11f4 partial ObjectOperation and correctly encoded per RTO13 + let initialValueString = try #require(creationOperation.operation.initialValue) + let deserializedInitialValue = try #require(try JSONObjectOrArray(jsonString: initialValueString).objectValue) + #expect(deserializedInitialValue == [ + "map": [ + // RTO11f4a + "semantics": .number(ObjectsMapSemantics.lww.rawValue as NSNumber), + "entries": [ + // RTO11f4c1a + "mapRef": [ + "data": [ + "objectId": "referencedMapID", + ], + ], + "counterRef": [ + "data": [ + "objectId": "referencedCounterID", + ], + ], + // RTO11f4c1b + "jsonArrayKey": [ + "data": [ + "json": #"["arrayItem1","arrayItem2"]"#, + ], + ], + "jsonObjectKey": [ + "data": [ + "json": #"{"nestedKey":"nestedValue"}"#, + ], + ], + // RTO11f4c1c + "stringKey": [ + "data": [ + "string": "stringValue", + ], + ], + // RTO11f4c1d + "numberKey": [ + "data": [ + "number": 42.5, + ], + ], + // RTO11f4c1e + "booleanKey": [ + "data": [ + "boolean": true, + ], + ], + // RTO11f4c1f + "dataKey": [ + "data": [ + "bytes": .string(Data([0x01, 0x02, 0x03]).base64EncodedString()), + ], + ], + ], + ], + ]) + + // Check that the partial ObjectOperation properties are set on the ObjectMessage, per RTO11f13 + + #expect(creationOperation.objectMessage.operation?.map?.semantics == .known(.lww)) + + let expectedEntries: [String: ObjectsMapEntry] = [ + "mapRef": .init(data: .init(objectId: "referencedMapID")), + "counterRef": .init(data: .init(objectId: "referencedCounterID")), + "jsonArrayKey": .init(data: .init(json: .array(["arrayItem1", "arrayItem2"]))), + "jsonObjectKey": .init(data: .init(json: .object(["nestedKey": "nestedValue"]))), + "stringKey": .init(data: .init(string: "stringValue")), + "numberKey": .init(data: .init(number: 42.5)), + "booleanKey": .init(data: .init(boolean: true)), + "dataKey": .init(data: .init(bytes: Data([0x01, 0x02, 0x03]))), + ] + #expect(creationOperation.objectMessage.operation?.map?.entries == expectedEntries) + + // Check the other ObjectMessage properties + + // RTO11f9 + #expect(creationOperation.operation.action == .known(.mapCreate)) + + // Check that objectId has been populated on ObjectMessage per RTO11f10, and do a quick sense check that its format is what we'd expect from RTO11f8 (RTO14 is properly tested elsewhere) + #expect(try /map:.*@1754042434000/.firstMatch(in: creationOperation.operation.objectId) != nil) + + // Check that nonce has been populated per RTO11f11 (we make no assertions about its format or randomness) + #expect(creationOperation.operation.nonce != nil) + } + + // @spec RTO12f2a + // @spec RTO12f10 + // @spec RTO12f6 + // @spec RTO12f8 + // @spec RTO12f9 + // @specOneOf(2/2) RTO13 + @Test + @available(iOS 16.0.0, tvOS 16.0.0, *) // because of using Regex + func counter() throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_754_042_434) + + // When + let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter( + count: 10.5, + timestamp: timestamp, + ) + + // Then + + // Check that the denormalized properties match those of the ObjectMessage + #expect(creationOperation.objectMessage.operation == creationOperation.operation) + + // Check that the initial value JSON is correctly populated on the initialValue property per RTO12f10, using the RTO12f2 partial ObjectOperation and correctly encoded per RTO13 + let initialValueString = try #require(creationOperation.operation.initialValue) + let deserializedInitialValue = try #require(try JSONObjectOrArray(jsonString: initialValueString).objectValue) + #expect(deserializedInitialValue == [ + "counter": [ + // RTO12f2a + "count": 10.5, + ], + ]) + + // Check that the partial ObjectOperation properties are set on the ObjectMessage, per RTO12f10 + + #expect(creationOperation.objectMessage.operation?.counter?.count == 10.5) + + // Check the other ObjectMessage properties + + // RTO12f7 + #expect(creationOperation.operation.action == .known(.counterCreate)) + + // Check that objectId has been populated on ObjectMessage per RTO12f8, and do a quick sense check that its format is what we'd expect from RTO12f6 (RTO14 is properly tested elsewhere) + #expect(try /counter:.*@1754042434000/.firstMatch(in: creationOperation.operation.objectId) != nil) + + // Check that nonce has been populated per RTO12f9 (we make no assertions about its format or randomness) + #expect(creationOperation.operation.nonce != nil) + } + } + + /// Tests for the RTO14 objectID generation. + struct ObjectIDTests { + // @spec RTO14 + // @spec RTO14b1 + // @spec RTO14b2 + // @spec RTO14c + @Test + func createObjectID() { + let objectID = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", + initialValue: "arbitraryInitialValue", + nonce: "arbitraryNonceABC", // Chosen to provoke a Base64 encoding that contains Base64URL-prohibited characters +, /, and =; see below + timestamp: Date( + timeIntervalSince1970: 1_754_042_434, + ), + ) + + // (The Base64-encoded SHA-256 of "arbitraryInitialValue:arbitraryNonceABC" is X5cX5Wv32Wj84/tzyuj5XD/Qpa76E+JkjPPQMK5aouw=) + #expect(objectID == "counter:X5cX5Wv32Wj84_tzyuj5XD_Qpa76E-JkjPPQMK5aouw@1754042434000") + } + } +} From d9c327e4deb12f33bb8cd8bee3d2c5db4be08a1f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 29 Jul 2025 10:00:12 +0100 Subject: [PATCH 5/6] Implement increment / decrement / set / delete Based on [1] at cb11ba8. Code by me, tests by Cursor and tidied up by me. Channel mode checking and echoMessages check omitted as in e65643a. [1] https://github.com/ably/specification/pull/353 --- .../Internal/InternalDefaultLiveCounter.swift | 42 ++++- .../Internal/InternalDefaultLiveMap.swift | 59 +++++- .../PublicDefaultLiveCounter.swift | 4 +- .../PublicDefaultLiveMap.swift | 6 +- .../InternalDefaultLiveCounterTests.swift | 116 ++++++++++++ .../InternalDefaultLiveMapTests.swift | 177 ++++++++++++++++++ 6 files changed, 392 insertions(+), 12 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index fe9fba8a..5cd3fd27 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -89,12 +89,46 @@ internal final class InternalDefaultLiveCounter: Sendable { } } - internal func increment(amount _: Double) async throws(ARTErrorInfo) { - notYetImplemented() + internal func increment(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + do throws(InternalError) { + // RTLC12c + do { + try coreSDK.validateChannelState( + notIn: [.detached, .failed, .suspended], + operationDescription: "LiveCounter.increment", + ) + } catch { + throw error.toInternalError() + } + + // RTLC12e1 + if !amount.isFinite { + throw LiveObjectsError.counterIncrementAmountInvalid(amount: amount).toARTErrorInfo().toInternalError() + } + + let objectMessage = OutboundObjectMessage( + operation: .init( + // RTLC12e2 + action: .known(.counterInc), + // RTLC12e3 + objectId: objectID, + counterOp: .init( + // RTLC12e4 + amount: .init(value: amount), + ), + ), + ) + + // RTLC12f + try await coreSDK.publish(objectMessages: [objectMessage]) + } catch { + throw error.toARTErrorInfo() + } } - internal func decrement(amount _: Double) async throws(ARTErrorInfo) { - notYetImplemented() + internal func decrement(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + // RTLC13b + try await increment(amount: -amount, coreSDK: coreSDK) } @discardableResult diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 427a09c6..09546909 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -173,12 +173,63 @@ internal final class InternalDefaultLiveMap: Sendable { try entries(coreSDK: coreSDK, delegate: delegate).map(\.value) } - internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { - notYetImplemented() + internal func set(key: String, value: InternalLiveMapValue, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + do throws(InternalError) { + // RTLM20c + do { + try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.set") + } catch { + throw error.toInternalError() + } + + let objectMessage = OutboundObjectMessage( + operation: .init( + // RTLM20e2 + action: .known(.mapSet), + // RTLM20e3 + objectId: objectID, + mapOp: .init( + // RTLM20e4 + key: key, + // RTLM20e5 + data: value.toObjectData, + ), + ), + ) + + try await coreSDK.publish(objectMessages: [objectMessage]) + } catch { + throw error.toARTErrorInfo() + } } - internal func remove(key _: String) async throws(ARTErrorInfo) { - notYetImplemented() + internal func remove(key: String, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + do throws(InternalError) { + // RTLM21c + do { + try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.remove") + } catch { + throw error.toInternalError() + } + + let objectMessage = OutboundObjectMessage( + operation: .init( + // RTLM21e2 + action: .known(.mapRemove), + // RTLM21e3 + objectId: objectID, + mapOp: .init( + // RTLM21e4 + key: key, + ), + ), + ) + + // RTLM21f + try await coreSDK.publish(objectMessages: [objectMessage]) + } catch { + throw error.toARTErrorInfo() + } } @discardableResult diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 19c2bfc4..146d05e6 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -27,11 +27,11 @@ internal final class PublicDefaultLiveCounter: LiveCounter { } internal func increment(amount: Double) async throws(ARTErrorInfo) { - try await proxied.increment(amount: amount) + try await proxied.increment(amount: amount, coreSDK: coreSDK) } internal func decrement(amount: Double) async throws(ARTErrorInfo) { - try await proxied.decrement(amount: amount) + try await proxied.decrement(amount: amount, coreSDK: coreSDK) } internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 3569a4cc..52e207d2 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -76,11 +76,13 @@ internal final class PublicDefaultLiveMap: LiveMap { } internal func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) { - try await proxied.set(key: key, value: value) + let internalValue = InternalLiveMapValue(liveMapValue: value) + + try await proxied.set(key: key, value: internalValue, coreSDK: coreSDK) } internal func remove(key: String) async throws(ARTErrorInfo) { - try await proxied.remove(key: key) + try await proxied.remove(key: key, coreSDK: coreSDK) } internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 6ce42481..039ec517 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -422,4 +422,120 @@ struct InternalDefaultLiveCounterTests { #expect(subscriberInvocations.isEmpty) } } + + /// Tests for the `increment` method, covering RTLC12 specification points + struct IncrementTests { + // @spec RTLC12c + @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: ARTRealtimeChannelState) async throws { + let logger = TestLogger() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState) + + await #expect { + try await counter.increment(amount: 10, coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @spec RTLC12e1 - The only part that is relevant in Swift's type system is the finiteness check + @Test(arguments: [ + Double.nan, + Double.infinity, + -Double.infinity, + ] as [Double]) + func throwsErrorForInvalidAmount(amount: Double) async throws { + let logger = TestLogger() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + await #expect { + try await counter.increment(amount: amount, coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 40003 && errorInfo.statusCode == 400 + } + } + + // @spec RTLC12e2 + // @spec RTLC12e3 + // @spec RTLC12e4 + // @spec RTLC12f + func publishesCorrectObjectMessage() async throws { + let logger = TestLogger() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + try await counter.increment(amount: 10.5, coreSDK: coreSDK) + + let expectedMessage = OutboundObjectMessage( + operation: ObjectOperation( + // RTLC12e2 + action: .known(.counterInc), + // RTLC12e3 + objectId: "counter:test@123", + // RTLC12e4 + counterOp: WireObjectsCounterOp(amount: NSNumber(value: 10.5)), + ), + ) + // RTLC12f + #expect(publishedMessages.count == 1) + #expect(publishedMessages[0] == expectedMessage) + } + + @Test + func throwsErrorWhenPublishFails() async throws { + let logger = TestLogger() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + coreSDK.setPublishHandler { _ throws(InternalError) in + throw InternalError.other(.generic(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]))) + } + + await #expect { + try await counter.increment(amount: 10, coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.message.contains("Publish failed") + } + } + } + + /// Tests for the `decrement` method, covering RTLC13 specification points + struct DecrementTests { + // @spec RTLC13b + @Test + func isOppositeOfIncrement() async throws { + // This is just a smoke test; we assume that this just calls `increment`, which is tested elsewhere. + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + try await counter.decrement(amount: 10.5, coreSDK: coreSDK) + + // RTLC12f + #expect(publishedMessages.count == 1) + #expect(publishedMessages[0].operation?.counterOp?.amount == -10.5 /* i.e. assert the amount gets negated */ ) + } + } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index d68e7ccd..98982166 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -1235,4 +1235,181 @@ struct InternalDefaultLiveMapTests { #expect(subscriberInvocations.isEmpty) } } + + /// Tests for the `set` method, covering RTLM20 specification points + struct SetTests { + // @spec RTLM20c + @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: ARTRealtimeChannelState) async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState) + + await #expect { + try await map.set(key: "test", value: .primitive(.string("value")), coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @spec RTLM20e + // @specUntested RTLM20e1 - Not needed with Swift's type system + // @spec RTLM20e2 + // @spec RTLM20e3 + // @spec RTLM20e4 + // @spec RTLM20e5a + // @spec RTLM20e5b + // @spec RTLM20e5c + // @spec RTLM20e5d + // @spec RTLM20e5e + // @spec RTLM20e5f + // @spec RTLM20f + @Test(arguments: [ + // RTLM20e5a + (value: .liveMap(.createZeroValued(objectID: "map:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock())), expectedData: .init(objectId: "map:test@123")), + (value: .liveCounter(.createZeroValued(objectID: "map:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock())), expectedData: .init(objectId: "map:test@123")), + // RTLM20e5b + (value: .primitive(.jsonArray(["test"])), expectedData: .init(json: .array(["test"]))), + (value: .primitive(.jsonObject(["foo": "bar"])), expectedData: .init(json: .object(["foo": "bar"]))), + // RTLM20e5c + (value: .primitive(.string("test")), expectedData: .init(string: "test")), + // RTLM20e5d + (value: .primitive(.number(42.5)), expectedData: .init(number: NSNumber(value: 42.5))), + // RTLM20e5e + (value: .primitive(.bool(true)), expectedData: .init(boolean: true)), + // RTLM20e5f + (value: .primitive(.data(Data([0x01, 0x02]))), expectedData: .init(bytes: Data([0x01, 0x02]))), + ] as [(value: InternalLiveMapValue, expectedData: ObjectData)]) + func publishesCorrectObjectMessageForDifferentValueTypes(value: InternalLiveMapValue, expectedData: ObjectData) async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + var publishedMessage: OutboundObjectMessage? + coreSDK.setPublishHandler { messages in + publishedMessage = messages.first + } + + try await map.set(key: "testKey", value: value, coreSDK: coreSDK) + + let expectedMessage = OutboundObjectMessage( + operation: ObjectOperation( + // RTLM20e2 + action: .known(.mapSet), + // RTLM20e3 + objectId: "map:test@123", + mapOp: ObjectsMapOp( + // RTLM20e4 + key: "testKey", + // RTLM20e5 + data: expectedData, + ), + ), + ) + // RTLM20f + let message = try #require(publishedMessage) + #expect(message == expectedMessage) + } + + @Test + func throwsErrorWhenPublishFails() async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + coreSDK.setPublishHandler { _ throws(InternalError) in + throw NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]).toInternalError() + } + + await #expect { + try await map.set(key: "testKey", value: .primitive(.string("testValue")), coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.message.contains("Publish failed") + } + } + } + + /// Tests for the `remove` method, covering RTLM21 specification points + struct RemoveTests { + // @spec RTLM21c + @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: ARTRealtimeChannelState) async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState) + + await #expect { + try await map.remove(key: "test", coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @specUntested RTLM21e + // @specUntested RTLM21e1 - Not needed with Swift's type system + // @spec RTLM21e2 + // @spec RTLM21e3 + // @spec RTLM21e4 + // @spec RTLM21f + func publishesCorrectObjectMessage() async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + try await map.remove(key: "testKey", coreSDK: coreSDK) + + let expectedMessage = OutboundObjectMessage( + operation: ObjectOperation( + // RTLM21e2 + action: .known(.mapRemove), + // RTLM21e3 + objectId: "map:test@123", + mapOp: ObjectsMapOp( + // RTLM21e4 + key: "testKey", + data: nil, + ), + ), + ) + // RTLM21f + #expect(publishedMessages.count == 1) + #expect(publishedMessages[0] == expectedMessage) + } + + @Test + func throwsErrorWhenPublishFails() async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + coreSDK.setPublishHandler { _ throws(InternalError) in + throw NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]).toInternalError() + } + + await #expect { + try await map.remove(key: "testKey", coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.message.contains("Publish failed") + } + } + } } From 4e8e076373c0a0aa3090d639afffc51936f8957c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 29 Jul 2025 12:00:46 +0100 Subject: [PATCH 6/6] Add another smoke test of the public API Just to convince myself that the write API is somewhat working. Will get further confidence upon porting across more of the JS integration tests in the future. --- .../AblyLiveObjectsTests.swift | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index 1a1445d8..c9cb540b 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -122,4 +122,67 @@ struct AblyLiveObjectsTests { #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": .primitive(.bool(true)), + "numberKey": .primitive(.number(10)), + ]) + #expect( + try Dictionary(uniqueKeysWithValues: map.entries) == [ + "boolKey": .primitive(.bool(true)), + "numberKey": .primitive(.number(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": .primitive(.bool(true)), + "numberKey": .primitive(.number(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": .primitive(.number(10)), + "counterKey": .liveCounter(counter), + ], + ) + } }