diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index e7bc5b9e..252e5b0e 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -5,7 +5,8 @@ internal import AblyPlugin /// /// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to AblyPlugin's Objective-C API (i.e. boxing). internal protocol CoreSDK: AnyObject, Sendable { - func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) + /// Implements the internal `#publish` method of RTO15. + func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) /// Returns the current state of the Realtime channel that this wraps. var channelState: ARTRealtimeChannelState { get } @@ -28,7 +29,8 @@ internal final class DefaultCoreSDK: CoreSDK { // MARK: - CoreSDK conformance - internal func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + internal func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + // TODO: Implement the full spec of RTO15 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/47) try await DefaultInternalPlugin.sendObject( objectMessages: objectMessages, channel: channel, @@ -40,3 +42,28 @@ internal final class DefaultCoreSDK: CoreSDK { channel.state } } + +// MARK: - Channel State Validation + +/// Extension on CoreSDK to provide channel state validation utilities. +internal extension CoreSDK { + /// Validates that the channel is not in any of the specified invalid states. + /// + /// - Parameters: + /// - invalidStates: Array of channel states that are considered invalid for the operation + /// - operationDescription: A description of the operation being performed, used in error messages + /// - Throws: `ARTErrorInfo` with code 90001 and statusCode 400 if the channel is in any of the invalid states + func validateChannelState( + notIn invalidStates: [ARTRealtimeChannelState], + operationDescription: String, + ) throws(ARTErrorInfo) { + let currentChannelState = channelState + if invalidStates.contains(currentChannelState) { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: operationDescription, + channelState: currentChannelState, + ) + .toARTErrorInfo() + } + } +} diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 695e92cc..9dd56e0f 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -38,7 +38,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte let callbackQueue = pluginAPI.callbackQueue(for: client) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) - let liveObjects = InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: callbackQueue) + let liveObjects = InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: callbackQueue, clock: DefaultSimpleClock()) pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index de9e9696..d1149f26 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -11,24 +11,19 @@ internal final class InternalDefaultLiveCounter: Sendable { internal var testsOnly_siteTimeserials: [String: String] { mutex.withLock { - mutableState.liveObject.siteTimeserials + mutableState.liveObjectMutableState.siteTimeserials } } internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { - mutableState.liveObject.createOperationIsMerged - } - } - - internal var testsOnly_objectID: String { - mutex.withLock { - mutableState.liveObject.objectID + mutableState.liveObjectMutableState.createOperationIsMerged } } private let logger: AblyPlugin.Logger private let userCallbackQueue: DispatchQueue + private let clock: SimpleClock // MARK: - Initialization @@ -36,20 +31,23 @@ internal final class InternalDefaultLiveCounter: Sendable { testsOnly_data data: Double, objectID: String, logger: AblyPlugin.Logger, - userCallbackQueue: DispatchQueue + userCallbackQueue: DispatchQueue, + clock: SimpleClock ) { - self.init(data: data, objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue) + self.init(data: data, objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) } private init( data: Double, objectID: String, logger: AblyPlugin.Logger, - userCallbackQueue: DispatchQueue + userCallbackQueue: DispatchQueue, + clock: SimpleClock ) { - mutableState = .init(liveObject: .init(objectID: objectID), data: data) + mutableState = .init(liveObjectMutableState: .init(objectID: objectID), data: data) self.logger = logger self.userCallbackQueue = userCallbackQueue + self.clock = clock } /// Creates a "zero-value LiveCounter", per RTLC4. @@ -60,27 +58,30 @@ internal final class InternalDefaultLiveCounter: Sendable { objectID: String, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) -> Self { .init( data: 0, objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } + // MARK: - Data access + + internal var objectID: String { + mutex.withLock { + mutableState.liveObjectMutableState.objectID + } + } + // MARK: - Internal methods that back LiveCounter conformance internal func value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveCounter.value", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveCounter.value") return mutex.withLock { // RTLC5c @@ -100,13 +101,13 @@ internal final class InternalDefaultLiveCounter: Sendable { internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in // swiftlint:disable:next trailing_closure - try mutableState.liveObject.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + try mutableState.liveObjectMutableState.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in guard let self else { return } mutex.withLock { - action(&mutableState.liveObject) + action(&mutableState.liveObjectMutableState) } }) } @@ -114,7 +115,7 @@ internal final class InternalDefaultLiveCounter: Sendable { internal func unsubscribeAll() { mutex.withLock { - mutableState.liveObject.unsubscribeAll() + mutableState.liveObjectMutableState.unsubscribeAll() } } @@ -134,21 +135,27 @@ internal final class InternalDefaultLiveCounter: Sendable { /// This is used to instruct this counter to emit updates during an `OBJECT_SYNC`. internal func emit(_ update: LiveObjectUpdate) { mutex.withLock { - mutableState.liveObject.emit(update, on: userCallbackQueue) + mutableState.liveObjectMutableState.emit(update, on: userCallbackQueue) } } // MARK: - Data manipulation /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. - internal func replaceData(using state: ObjectState) -> LiveObjectUpdate { + /// + /// - Parameters: + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this counter. + internal func replaceData( + using state: ObjectState, + objectMessageSerialTimestamp: Date?, + ) -> LiveObjectUpdate { mutex.withLock { - mutableState.replaceData(using: state) + mutableState.replaceData(using: state, objectMessageSerialTimestamp: objectMessageSerialTimestamp) } } - /// Test-only method to merge initial value from an ObjectOperation, per RTLC10. - internal func testsOnly_mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { + /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC10. + internal func mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { mutex.withLock { mutableState.mergeInitialValue(from: operation) } @@ -173,6 +180,7 @@ internal final class InternalDefaultLiveCounter: Sendable { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, ) { mutex.withLock { @@ -180,6 +188,7 @@ internal final class InternalDefaultLiveCounter: Sendable { operation, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, @@ -189,20 +198,26 @@ internal final class InternalDefaultLiveCounter: Sendable { // MARK: - Mutable state and the operations that affect it - private struct MutableState { + private struct MutableState: InternalLiveObject { /// The mutable state common to all LiveObjects. - internal var liveObject: LiveObjectMutableState + internal var liveObjectMutableState: LiveObjectMutableState /// The internal data that this map holds, per RTLC3. internal var data: Double /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. - internal mutating func replaceData(using state: ObjectState) -> LiveObjectUpdate { + /// + /// - Parameters: + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this counter. + internal mutating func replaceData( + using state: ObjectState, + objectMessageSerialTimestamp: Date?, + ) -> LiveObjectUpdate { // RTLC6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials - liveObject.siteTimeserials = state.siteTimeserials + liveObjectMutableState.siteTimeserials = state.siteTimeserials // RTLC6b: Set the private flag createOperationIsMerged to false - liveObject.createOperationIsMerged = false + liveObjectMutableState.createOperationIsMerged = false // RTLC6c: Set data to the value of ObjectState.counter.count, or to 0 if it does not exist data = state.counter?.count?.doubleValue ?? 0 @@ -231,7 +246,7 @@ internal final class InternalDefaultLiveCounter: Sendable { } // RTLC10b: Set the private flag createOperationIsMerged to true - liveObject.createOperationIsMerged = true + liveObjectMutableState.createOperationIsMerged = true return update } @@ -241,18 +256,19 @@ internal final class InternalDefaultLiveCounter: Sendable { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, logger: Logger, userCallbackQueue: DispatchQueue, ) { - guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { + guard let applicableOperation = liveObjectMutableState.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { // RTLC7b logger.log("Operation \(operation) (serial: \(String(describing: objectMessageSerial)), siteCode: \(String(describing: objectMessageSiteCode))) should not be applied; discarding", level: .debug) return } // RTLC7c - liveObject.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial + liveObjectMutableState.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial switch operation.action { case .known(.counterCreate): @@ -262,12 +278,12 @@ internal final class InternalDefaultLiveCounter: Sendable { logger: logger, ) // RTLC7d1a - liveObject.emit(update, on: userCallbackQueue) + liveObjectMutableState.emit(update, on: userCallbackQueue) case .known(.counterInc): // RTLC7d2 let update = applyCounterIncOperation(operation.counterOp) // RTLC7d2a - liveObject.emit(update, on: userCallbackQueue) + liveObjectMutableState.emit(update, on: userCallbackQueue) default: // RTLC7d3 logger.log("Operation \(operation) has unsupported action for LiveCounter; discarding", level: .warn) @@ -279,7 +295,7 @@ internal final class InternalDefaultLiveCounter: Sendable { _ operation: ObjectOperation, logger: Logger, ) -> LiveObjectUpdate { - if liveObject.createOperationIsMerged { + if liveObjectMutableState.createOperationIsMerged { // RTLC8b logger.log("Not applying COUNTER_CREATE because a COUNTER_CREATE has already been applied", level: .warn) return .noop diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 8146c139..3790b57d 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -14,18 +14,12 @@ internal final class InternalDefaultLiveMap: Sendable { private nonisolated(unsafe) var mutableState: MutableState - internal var testsOnly_data: [String: ObjectsMapEntry] { + internal var testsOnly_data: [String: InternalObjectsMapEntry] { mutex.withLock { mutableState.data } } - internal var testsOnly_objectID: String { - mutex.withLock { - mutableState.liveObject.objectID - } - } - internal var testsOnly_semantics: WireEnum? { mutex.withLock { mutableState.semantics @@ -34,27 +28,29 @@ internal final class InternalDefaultLiveMap: Sendable { internal var testsOnly_siteTimeserials: [String: String] { mutex.withLock { - mutableState.liveObject.siteTimeserials + mutableState.liveObjectMutableState.siteTimeserials } } internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { - mutableState.liveObject.createOperationIsMerged + mutableState.liveObjectMutableState.createOperationIsMerged } } private let logger: AblyPlugin.Logger private let userCallbackQueue: DispatchQueue + private let clock: SimpleClock // MARK: - Initialization internal convenience init( - testsOnly_data data: [String: ObjectsMapEntry], + testsOnly_data data: [String: InternalObjectsMapEntry], objectID: String, testsOnly_semantics semantics: WireEnum? = nil, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { self.init( data: data, @@ -62,19 +58,22 @@ internal final class InternalDefaultLiveMap: Sendable { semantics: semantics, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } private init( - data: [String: ObjectsMapEntry], + data: [String: InternalObjectsMapEntry], objectID: String, semantics: WireEnum?, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { - mutableState = .init(liveObject: .init(objectID: objectID), data: data, semantics: semantics) + mutableState = .init(liveObjectMutableState: .init(objectID: objectID), data: data, semantics: semantics) self.logger = logger self.userCallbackQueue = userCallbackQueue + self.clock = clock } /// Creates a "zero-value LiveMap", per RTLM4. @@ -87,6 +86,7 @@ internal final class InternalDefaultLiveMap: Sendable { semantics: WireEnum? = nil, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) -> Self { .init( data: [:], @@ -94,22 +94,24 @@ internal final class InternalDefaultLiveMap: Sendable { semantics: semantics, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } + // MARK: - Data access + + internal var objectID: String { + mutex.withLock { + mutableState.liveObjectMutableState.objectID + } + } + // MARK: - Internal methods that back LiveMap conformance /// Returns the value associated with a given key, following RTLM5d specification. internal func get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveMap.get", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.get") let entry = mutex.withLock { mutableState.data[key] @@ -126,42 +128,26 @@ internal final class InternalDefaultLiveMap: Sendable { internal func size(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Int { // RTLM10c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveMap.size", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.size") return mutex.withLock { // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map mutableState.data.values.count { entry in - // RTLM14a: The method returns true if ObjectsMapEntry.tombstone is true - // RTLM14b: Otherwise, it returns false - entry.tombstone != true + !Self.isEntryTombstoned(entry) } } } internal func entries(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { // RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveMap.entries", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.entries") return mutex.withLock { // RTLM11d: Returns key-value pairs from the internal data map // RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned var result: [(key: String, value: InternalLiveMapValue)] = [] - for (key, entry) in mutableState.data { + for (key, entry) in mutableState.data where !Self.isEntryTombstoned(entry) { // Convert entry to LiveMapValue using the same logic as get(key:) if let value = convertEntryToLiveMapValue(entry, delegate: delegate) { result.append((key: key, value: value)) @@ -194,13 +180,13 @@ internal final class InternalDefaultLiveMap: Sendable { internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in // swiftlint:disable:next trailing_closure - try mutableState.liveObject.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + try mutableState.liveObjectMutableState.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in guard let self else { return } mutex.withLock { - action(&mutableState.liveObject) + action(&mutableState.liveObjectMutableState) } }) } @@ -208,7 +194,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal func unsubscribeAll() { mutex.withLock { - mutableState.liveObject.unsubscribeAll() + mutableState.liveObjectMutableState.unsubscribeAll() } } @@ -228,7 +214,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// This is used to instruct this map to emit updates during an `OBJECT_SYNC`. internal func emit(_ update: LiveObjectUpdate) { mutex.withLock { - mutableState.liveObject.emit(update, on: userCallbackQueue) + mutableState.liveObjectMutableState.emit(update, on: userCallbackQueue) } } @@ -238,25 +224,33 @@ internal final class InternalDefaultLiveMap: Sendable { /// /// - Parameters: /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. - internal func replaceData(using state: ObjectState, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this map. + internal func replaceData( + using state: ObjectState, + objectMessageSerialTimestamp: Date?, + objectsPool: inout ObjectsPool, + ) -> LiveObjectUpdate { mutex.withLock { mutableState.replaceData( using: state, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, logger: logger, + clock: clock, userCallbackQueue: userCallbackQueue, ) } } - /// Test-only method to merge initial value from an ObjectOperation, per RTLM17. - internal func testsOnly_mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { + /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM17. + internal func mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { mutex.withLock { mutableState.mergeInitialValue( from: operation, objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -269,6 +263,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -278,6 +273,7 @@ internal final class InternalDefaultLiveMap: Sendable { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, ) { mutex.withLock { @@ -285,9 +281,11 @@ internal final class InternalDefaultLiveMap: Sendable { operation, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -309,6 +307,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -334,12 +333,12 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - Mutable state and the operations that affect it - private struct MutableState { + private struct MutableState: InternalLiveObject { /// The mutable state common to all LiveObjects. - internal var liveObject: LiveObjectMutableState + internal var liveObjectMutableState: LiveObjectMutableState /// The internal data that this map holds, per RTLM3. - internal var data: [String: ObjectsMapEntry] + internal var data: [String: InternalObjectsMapEntry] /// The "private `semantics` field" of RTO5c1b1b. internal var semantics: WireEnum? @@ -348,20 +347,23 @@ internal final class InternalDefaultLiveMap: Sendable { /// /// - Parameters: /// - 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, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, + clock: SimpleClock, userCallbackQueue: DispatchQueue, ) -> LiveObjectUpdate { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials - liveObject.siteTimeserials = state.siteTimeserials + liveObjectMutableState.siteTimeserials = state.siteTimeserials // RTLM6b: Set the private flag createOperationIsMerged to false - liveObject.createOperationIsMerged = false + liveObjectMutableState.createOperationIsMerged = false // RTLM6c: Set data to ObjectState.map.entries, or to an empty map if it does not exist - data = state.map?.entries ?? [:] + data = state.map?.entries?.mapValues { .init(objectsMapEntry: $0) } ?? [:] // RTLM6d: If ObjectState.createOp is present, merge the initial value into the LiveMap as described in RTLM17 return if let createOp = state.createOp { @@ -370,6 +372,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } else { // TODO: I assume this is what to do, clarify in https://github.com/ably/specification/pull/346/files#r2201363446 @@ -383,6 +386,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) -> LiveObjectUpdate { // RTLM17a: For each key–ObjectsMapEntry pair in ObjectOperation.map.entries let perKeyUpdates: [LiveObjectUpdate] = if let entries = operation.map?.entries { @@ -404,6 +408,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -412,7 +417,7 @@ internal final class InternalDefaultLiveMap: Sendable { } // RTLM17b: Set the private flag createOperationIsMerged to true - liveObject.createOperationIsMerged = true + liveObjectMutableState.createOperationIsMerged = true // RTLM17c: Merge the updates, skipping no-ops // I don't love having to use uniqueKeysWithValues, when I shouldn't have to. I should be able to reason _statically_ that there are no overlapping keys. The problem that we're trying to use LiveMapUpdate throughout instead of something more communicative. But I don't know what's to come in the spec so I don't want to mess with this internal interface. @@ -436,18 +441,20 @@ internal final class InternalDefaultLiveMap: Sendable { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, logger: Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { - guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { + guard let applicableOperation = liveObjectMutableState.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { // RTLM15b logger.log("Operation \(operation) (serial: \(String(describing: objectMessageSerial)), siteCode: \(String(describing: objectMessageSiteCode))) should not be applied; discarding", level: .debug) return } // RTLM15c - liveObject.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial + liveObjectMutableState.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial switch operation.action { case .known(.mapCreate): @@ -457,9 +464,10 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) // RTLM15d1a - liveObject.emit(update, on: userCallbackQueue) + liveObjectMutableState.emit(update, on: userCallbackQueue) case .known(.mapSet): guard let mapOp = operation.mapOp else { logger.log("Could not apply MAP_SET since operation.mapOp is missing", level: .warn) @@ -478,9 +486,10 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) // RTLM15d2a - liveObject.emit(update, on: userCallbackQueue) + liveObjectMutableState.emit(update, on: userCallbackQueue) case .known(.mapRemove): guard let mapOp = operation.mapOp else { return @@ -492,7 +501,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationTimeserial: applicableOperation.objectMessageSerial, ) // RTLM15d3a - liveObject.emit(update, on: userCallbackQueue) + liveObjectMutableState.emit(update, on: userCallbackQueue) default: // RTLM15d4 logger.log("Operation \(operation) has unsupported action for LiveMap; discarding", level: .warn) @@ -507,6 +516,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) -> LiveObjectUpdate { // RTLM7a: If an entry exists in the private data for the specified key if let existingEntry = data[key] { @@ -527,13 +537,13 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM7b: If an entry does not exist in the private data for the specified key // RTLM7b1: Create a new entry in data for the specified key with the provided ObjectData and the operation's serial // RTLM7b2: Set ObjectsMapEntry.tombstone for the new entry to false - data[key] = ObjectsMapEntry(tombstone: false, timeserial: operationTimeserial, data: operationData) + data[key] = InternalObjectsMapEntry(tombstone: false, timeserial: operationTimeserial, data: operationData) } // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute if let objectId = operationData.objectId, !objectId.isEmpty { // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 - _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger, userCallbackQueue: userCallbackQueue) + _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) } // RTLM7f @@ -563,7 +573,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM8b: If an entry does not exist in the private data for the specified key // RTLM8b1: Create a new entry in data for the specified key, with ObjectsMapEntry.data set to undefined/null and the operation's serial // RTLM8b2: Set ObjectsMapEntry.tombstone for the new entry to true - data[key] = ObjectsMapEntry(tombstone: true, timeserial: operationTimeserial, data: ObjectData()) + data[key] = InternalObjectsMapEntry(tombstone: true, timeserial: operationTimeserial, data: ObjectData()) } return .update(DefaultLiveMapUpdate(update: [key: .removed])) @@ -615,8 +625,9 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) -> LiveObjectUpdate { - if liveObject.createOperationIsMerged { + if liveObjectMutableState.createOperationIsMerged { // RTLM16b logger.log("Not applying MAP_CREATE because a MAP_CREATE has already been applied", level: .warn) return .noop @@ -630,6 +641,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } @@ -641,17 +653,22 @@ internal final class InternalDefaultLiveMap: Sendable { // RTO4b2a let mapUpdate = DefaultLiveMapUpdate(update: previousData.mapValues { _ in .removed }) - liveObject.emit(.update(mapUpdate), on: userCallbackQueue) + liveObjectMutableState.emit(.update(mapUpdate), on: userCallbackQueue) } } // MARK: - Helper Methods - /// Converts an ObjectsMapEntry to LiveMapValue using the same logic as get(key:) + /// Returns whether a map entry should be considered tombstoned, per the check described in RTLM14. + private static func isEntryTombstoned(_ entry: InternalObjectsMapEntry) -> Bool { + // RTLM14a, RTLM14b + entry.tombstone == true + } + + /// Converts an InternalObjectsMapEntry to LiveMapValue using the same logic as get(key:) /// This is used by entries to ensure consistent value conversion - private func convertEntryToLiveMapValue(_ entry: ObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> InternalLiveMapValue? { + private func convertEntryToLiveMapValue(_ entry: InternalObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> InternalLiveMapValue? { // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null - // This is also equivalent to the RTLM14 check if entry.tombstone == true { return nil } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 3bc22e6a..6d413353 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -10,6 +10,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool private let logger: AblyPlugin.Logger private let userCallbackQueue: DispatchQueue + private let clock: SimpleClock // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. private let receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> @@ -44,7 +45,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool internal var id: String /// The `ObjectMessage`s gathered during this sync sequence. - internal var syncObjectsPool: [ObjectState] + internal var syncObjectsPool: [SyncObjectsPoolEntry] /// `OBJECT` ProtocolMessages that were received during this sync sequence, to be applied once the sync sequence is complete, per RTO7a. internal var bufferedObjectOperations: [InboundObjectMessage] @@ -70,13 +71,14 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } } - internal init(logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue) { + internal init(logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock) { self.logger = logger self.userCallbackQueue = userCallbackQueue + self.clock = clock (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() - mutableState = .init(objectsPool: .init(logger: logger, userCallbackQueue: userCallbackQueue)) + mutableState = .init(objectsPool: .init(logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) } // MARK: - LiveMapObjectPoolDelegate @@ -91,14 +93,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool internal func getRoot(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { // RTO1b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "getRoot", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "getRoot") let syncStatus = mutex.withLock { mutableState.syncStatus @@ -175,6 +170,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessages: objectMessages, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, receivedObjectProtocolMessagesContinuation: receivedObjectProtocolMessagesContinuation, ) } @@ -192,6 +188,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool protocolMessageChannelSerial: protocolMessageChannelSerial, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, receivedObjectSyncProtocolMessagesContinuation: receivedObjectSyncProtocolMessagesContinuation, ) } @@ -202,15 +199,15 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool /// Intended as a way for tests to populate the object pool. internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String) -> ObjectsPool.Entry? { mutex.withLock { - mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue) + mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) } } // 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_sendObject(objectMessages: [OutboundObjectMessage], coreSDK: CoreSDK) async throws(InternalError) { - try await coreSDK.sendObject(objectMessages: objectMessages) + internal func testsOnly_publish(objectMessages: [OutboundObjectMessage], coreSDK: CoreSDK) async throws(InternalError) { + try await coreSDK.publish(objectMessages: objectMessages) } // MARK: - Testing @@ -264,6 +261,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool protocolMessageChannelSerial: String?, logger: Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, ) { logger.log("handleObjectSyncProtocolMessage(objectMessages: \(objectMessages), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) @@ -271,7 +269,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) // If populated, this contains a full set of sync data for the channel, and should be applied to the ObjectsPool. - let completedSyncObjectsPool: [ObjectState]? + let completedSyncObjectsPool: [SyncObjectsPoolEntry]? // If populated, this contains a set of buffered inbound OBJECT messages that should be applied. let completedSyncBufferedObjectOperations: [InboundObjectMessage]? @@ -300,7 +298,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } // RTO5b - updatedSyncSequence.syncObjectsPool.append(contentsOf: objectMessages.compactMap(\.object)) + updatedSyncSequence.syncObjectsPool.append(contentsOf: objectMessages.compactMap { objectMessage in + if let object = objectMessage.object { + .init(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) + } else { + nil + } + }) syncSequence = updatedSyncSequence @@ -311,7 +315,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } } else { // RTO5a5: The sync data is contained entirely within this single OBJECT_SYNC - completedSyncObjectsPool = objectMessages.compactMap(\.object) + completedSyncObjectsPool = objectMessages.compactMap { objectMessage in + if let object = objectMessage.object { + .init(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) + } else { + nil + } + } completedSyncBufferedObjectOperations = nil } @@ -321,6 +331,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool completedSyncObjectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) // RTO5c6 @@ -331,6 +342,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessage, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -347,6 +359,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessages: [InboundObjectMessage], logger: Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, ) { receivedObjectProtocolMessagesContinuation.yield(objectMessages) @@ -366,6 +379,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessage, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -376,6 +390,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool _ objectMessage: InboundObjectMessage, logger: Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { guard let operation = objectMessage.operation else { // RTO9a1 @@ -392,6 +407,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool forObjectID: operation.objectId, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) else { logger.log("Unable to create zero-value object for \(operation.objectId) when processing OBJECT message; dropping", level: .warn) return @@ -409,6 +425,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool operation, objectMessageSerial: objectMessage.serial, objectMessageSiteCode: objectMessage.siteCode, + objectMessageSerialTimestamp: objectMessage.serialTimestamp, objectsPool: &objectsPool, ) } diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift index f63d2713..95b36fe3 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -1,7 +1,7 @@ import Foundation /// Same as the public ``LiveMapValue`` type but with associated values of internal type. -internal enum InternalLiveMapValue: Sendable { +internal enum InternalLiveMapValue: Sendable, Equatable { case primitive(PrimitiveObjectValue) case liveMap(InternalDefaultLiveMap) case liveCounter(InternalDefaultLiveCounter) @@ -51,4 +51,25 @@ internal enum InternalLiveMapValue: Sendable { internal var dataValue: Data? { primitiveValue?.dataValue } + + // MARK: - Equatable Implementation + + internal static func == (lhs: InternalLiveMapValue, rhs: InternalLiveMapValue) -> Bool { + switch lhs { + case let .primitive(lhsValue): + if case let .primitive(rhsValue) = rhs, lhsValue == rhsValue { + return true + } + case let .liveMap(lhsMap): + if case let .liveMap(rhsMap) = rhs, lhsMap === rhsMap { + return true + } + case let .liveCounter(lhsCounter): + if case let .liveCounter(rhsCounter) = rhs, lhsCounter === rhsCounter { + return true + } + } + + return false + } } diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift b/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift new file mode 100644 index 00000000..e36dce1c --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift @@ -0,0 +1,8 @@ +/// Provides RTLO spec point functionality common to all LiveObjects. +/// +/// This exists in addition to ``LiveObjectMutableState`` to enable polymorphism. +internal protocol InternalLiveObject { + associatedtype Update: Sendable + + var liveObjectMutableState: LiveObjectMutableState { get set } +} diff --git a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift new file mode 100644 index 00000000..435dec23 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift @@ -0,0 +1,14 @@ +/// The entries stored in a `LiveMap`'s data. Same as an `ObjectsMapEntry` but with an additional `tombstonedAt` property, per RTLM3a. (This property will be added in an upcoming commit.) +internal struct InternalObjectsMapEntry { + internal var tombstone: Bool? // OME2a + internal var timeserial: String? // OME2b + internal var data: ObjectData // OME2c +} + +internal extension InternalObjectsMapEntry { + init(objectsMapEntry: ObjectsMapEntry) { + tombstone = objectsMapEntry.tombstone + timeserial = objectsMapEntry.timeserial + data = objectsMapEntry.data + } +} diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index 4c608fc5..f35fc9ba 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -88,14 +88,7 @@ internal struct LiveObjectMutableState { @discardableResult internal mutating func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK, updateSelfLater: @escaping UpdateLiveObject) throws(ARTErrorInfo) -> any AblyLiveObjects.SubscribeResponse { // RTLO4b2 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "subscribe", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "subscribe") let subscription = Subscription(listener: listener, updateLiveObject: updateSelfLater) subscriptionsByID[subscription.id] = subscription diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 135ea792..f047e9da 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -34,6 +34,7 @@ internal struct ObjectsPool { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, ) { switch self { @@ -42,6 +43,7 @@ internal struct ObjectsPool { operation, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, ) case let .counter(counter): @@ -49,6 +51,7 @@ internal struct ObjectsPool { operation, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, ) } @@ -73,12 +76,32 @@ internal struct ObjectsPool { /// Overrides the internal data for the object as per RTLC6, RTLM6. /// /// Returns a ``DeferredUpdate`` which contains the object plus an update that should be emitted on this object once the `SyncObjectsPool` has been applied. - fileprivate func replaceData(using state: ObjectState, objectsPool: inout ObjectsPool) -> DeferredUpdate { + /// + /// - Parameters: + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone the object. + fileprivate func replaceData( + using state: ObjectState, + objectMessageSerialTimestamp: Date?, + objectsPool: inout ObjectsPool, + ) -> DeferredUpdate { switch self { case let .map(map): - .map(map, map.replaceData(using: state, objectsPool: &objectsPool)) + .map( + map, + map.replaceData( + using: state, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + objectsPool: &objectsPool, + ), + ) case let .counter(counter): - .counter(counter, counter.replaceData(using: state)) + .counter( + counter, + counter.replaceData( + using: state, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + ), + ) } } } @@ -97,11 +120,13 @@ internal struct ObjectsPool { internal init( logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, testsOnly_otherEntries otherEntries: [String: Entry]? = nil, ) { self.init( logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, otherEntries: otherEntries, ) } @@ -109,11 +134,12 @@ internal struct ObjectsPool { private init( logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, otherEntries: [String: Entry]? ) { entries = otherEntries ?? [:] // TODO: What initial root entry to use? https://github.com/ably/specification/pull/333/files#r2152312933 - entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, logger: logger, userCallbackQueue: userCallbackQueue)) + entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) } // MARK: - Typed root @@ -140,8 +166,9 @@ internal struct ObjectsPool { /// - objectID: The ID of the object to create /// - logger: The logger to use for any created LiveObject /// - userCallbackQueue: The callback queue to use for any created LiveObject + /// - clock: The clock to use for any created LiveObject /// - Returns: The existing or newly created object - internal mutating func createZeroValueObject(forObjectID objectID: String, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue) -> Entry? { + internal mutating func createZeroValueObject(forObjectID objectID: String, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock) -> Entry? { // RTO6a: If an object with objectId exists in ObjectsPool, do not create a new object if let existingEntry = entries[objectID] { return existingEntry @@ -159,9 +186,9 @@ internal struct ObjectsPool { let entry: Entry switch typeString { case "map": - entry = .map(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue)) + entry = .map(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) case "counter": - entry = .counter(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue)) + entry = .counter(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) default: return nil } @@ -173,9 +200,10 @@ internal struct ObjectsPool { /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1 and RTO5c2. internal mutating func applySyncObjectsPool( - _ syncObjectsPool: [ObjectState], + _ syncObjectsPool: [SyncObjectsPoolEntry], logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { logger.log("applySyncObjectsPool called with \(syncObjectsPool.count) objects", level: .debug) @@ -186,46 +214,57 @@ internal struct ObjectsPool { var updatesToExistingObjects: [ObjectsPool.Entry.DeferredUpdate] = [] // RTO5c1: For each ObjectState member in the SyncObjectsPool list - for objectState in syncObjectsPool { - receivedObjectIds.insert(objectState.objectId) + for syncObjectsPoolEntry in syncObjectsPool { + receivedObjectIds.insert(syncObjectsPoolEntry.state.objectId) // RTO5c1a: If an object with ObjectState.objectId exists in the internal ObjectsPool - if let existingEntry = entries[objectState.objectId] { - logger.log("Updating existing object with ID: \(objectState.objectId)", level: .debug) + if let existingEntry = entries[syncObjectsPoolEntry.state.objectId] { + logger.log("Updating existing object with ID: \(syncObjectsPoolEntry.state.objectId)", level: .debug) // RTO5c1a1: Override the internal data for the object as per RTLC6, RTLM6 - let deferredUpdate = existingEntry.replaceData(using: objectState, objectsPool: &self) + let deferredUpdate = existingEntry.replaceData( + using: syncObjectsPoolEntry.state, + objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, + objectsPool: &self, + ) // RTO5c1a2: Store this update to emit at end updatesToExistingObjects.append(deferredUpdate) } else { // RTO5c1b: If an object with ObjectState.objectId does not exist in the internal ObjectsPool - logger.log("Creating new object with ID: \(objectState.objectId)", level: .debug) + logger.log("Creating new object with ID: \(syncObjectsPoolEntry.state.objectId)", level: .debug) // RTO5c1b1: Create a new LiveObject using the data from ObjectState and add it to the internal ObjectsPool: let newEntry: Entry? - if objectState.counter != nil { + if syncObjectsPoolEntry.state.counter != nil { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger, userCallbackQueue: userCallbackQueue) - _ = counter.replaceData(using: objectState) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: syncObjectsPoolEntry.state.objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) + _ = counter.replaceData( + using: syncObjectsPoolEntry.state, + objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, + ) newEntry = .counter(counter) - } else if let objectsMap = objectState.map { + } else if let objectsMap = syncObjectsPoolEntry.state.map { // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, // set its private objectId equal to ObjectState.objectId, set its private semantics // equal to ObjectState.map.semantics and override its internal data per RTLM6 - let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue) - _ = map.replaceData(using: objectState, objectsPool: &self) + let map = InternalDefaultLiveMap.createZeroValued(objectID: syncObjectsPoolEntry.state.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) + _ = map.replaceData( + using: syncObjectsPoolEntry.state, + objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, + objectsPool: &self, + ) newEntry = .map(map) } else { // RTO5c1b1c: Otherwise, log a warning that an unsupported object state message has been received, and discard the current ObjectState without taking any action - logger.log("Unsupported object state message received for objectId: \(objectState.objectId)", level: .warn) + logger.log("Unsupported object state message received for objectId: \(syncObjectsPoolEntry.state.objectId)", level: .warn) newEntry = nil } if let newEntry { // Note that we will never replace the root object here, and thus never break the RTO3b invariant that the root object is always a map. This is because the pool always contains a root object and thus we always go through the RTO5c1a branch of the `if` above. - entries[objectState.objectId] = newEntry + entries[syncObjectsPoolEntry.state.objectId] = newEntry } } } diff --git a/Sources/AblyLiveObjects/Internal/SimpleClock.swift b/Sources/AblyLiveObjects/Internal/SimpleClock.swift new file mode 100644 index 00000000..bc6c0faa --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/SimpleClock.swift @@ -0,0 +1,19 @@ +import Foundation + +/// A simple clock interface for getting the current time. +/// +/// This protocol allows for dependency injection of time-related functionality, +/// making it easier to test time-dependent code. +internal protocol SimpleClock: Sendable { + /// Returns the current time as a Date. + var now: Date { get } +} + +/// The default implementation of SimpleClock that uses the system clock. +internal final class DefaultSimpleClock: SimpleClock { + internal init() {} + + internal var now: Date { + Date() + } +} diff --git a/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift b/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift new file mode 100644 index 00000000..3a702137 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift @@ -0,0 +1,15 @@ +import Foundation + +/// The contents of the spec's `SyncObjectsPool` that is built during an `OBJECT_SYNC` sync sequence. +internal struct SyncObjectsPoolEntry { + internal var state: ObjectState + /// The `serialTimestamp` of the `ObjectMessage` that generated this entry. + internal var objectMessageSerialTimestamp: Date? + + // We replace the default memberwise initializer because we don't want a default argument for objectMessageSerialTimestamp (want to make sure we don't forget to set it whenever we create an entry). + // swiftlint:disable:next unneeded_synthesized_initializer + internal init(state: ObjectState, objectMessageSerialTimestamp: Date?) { + self.state = state + self.objectMessageSerialTimestamp = objectMessageSerialTimestamp + } +} diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index a15ea0db..caf21cd7 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -14,6 +14,7 @@ internal struct InboundObjectMessage { 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`. @@ -27,6 +28,20 @@ internal struct OutboundObjectMessage { internal var object: ObjectState? // OM2g internal var serial: String? // OM2h internal var siteCode: String? // OM2i + 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. +/// +/// `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 + internal var counter: WireObjectsCounter? // OOP3f + internal var nonce: String? // OOP3g + internal var initialValue: String? // OOP3h } internal struct ObjectOperation { @@ -37,8 +52,7 @@ internal struct ObjectOperation { internal var map: ObjectsMap? // OOP3e internal var counter: WireObjectsCounter? // OOP3f internal var nonce: String? // OOP3g - internal var initialValue: Data? // OOP3h - internal var initialValueEncoding: String? // OOP3i + internal var initialValue: String? // OOP3h } internal struct ObjectData { @@ -65,6 +79,7 @@ internal struct ObjectsMapEntry { internal var tombstone: Bool? // OME2a internal var timeserial: String? // OME2b internal var data: ObjectData // OME2c + internal var serialTimestamp: Date? // OME2d } internal struct ObjectsMap { @@ -104,6 +119,7 @@ internal extension InboundObjectMessage { } serial = wireObjectMessage.serial siteCode = wireObjectMessage.siteCode + serialTimestamp = wireObjectMessage.serialTimestamp } } @@ -123,6 +139,7 @@ internal extension OutboundObjectMessage { object: object?.toWire(format: format), serial: serial, siteCode: siteCode, + serialTimestamp: serialTimestamp, ) } } @@ -137,65 +154,101 @@ internal extension ObjectOperation { wireObjectOperation: WireObjectOperation, format: AblyPlugin.EncodingFormat ) throws(InternalError) { - action = wireObjectOperation.action + // Decode the objectId first since it's not part of PartialObjectOperation objectId = wireObjectOperation.objectId - mapOp = try wireObjectOperation.mapOp.map { wireObjectsMapOp throws(InternalError) in + + // Delegate to PartialObjectOperation for decoding + let partialOperation = try PartialObjectOperation( + partialWireObjectOperation: PartialWireObjectOperation( + action: wireObjectOperation.action, + mapOp: wireObjectOperation.mapOp, + counterOp: wireObjectOperation.counterOp, + map: wireObjectOperation.map, + counter: wireObjectOperation.counter, + nonce: wireObjectOperation.nonce, + initialValue: wireObjectOperation.initialValue, + ), + format: format, + ) + + // Copy the decoded values + action = partialOperation.action + mapOp = partialOperation.mapOp + counterOp = partialOperation.counterOp + map = partialOperation.map + counter = partialOperation.counter + nonce = partialOperation.nonce + initialValue = partialOperation.initialValue + } + + /// Converts this `ObjectOperation` to a `WireObjectOperation`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - 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, + counter: counter, + nonce: nonce, + initialValue: initialValue, + ).toWire(format: format) + + // Create WireObjectOperation from PartialWireObjectOperation and add objectId + return WireObjectOperation( + action: partialWireOperation.action, + objectId: objectId, + mapOp: partialWireOperation.mapOp, + counterOp: partialWireOperation.counterOp, + map: partialWireOperation.map, + counter: partialWireOperation.counter, + nonce: partialWireOperation.nonce, + initialValue: partialWireOperation.initialValue, + ) + } +} + +internal extension PartialObjectOperation { + /// Initializes a `PartialObjectOperation` from a `PartialWireObjectOperation`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `InternalError` if JSON or Base64 decoding fails. + init( + 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) } - counterOp = wireObjectOperation.counterOp - map = try wireObjectOperation.map.map { wireMap throws(InternalError) in + counterOp = partialWireObjectOperation.counterOp + map = try partialWireObjectOperation.map.map { wireMap throws(InternalError) in try .init(wireObjectsMap: wireMap, format: format) } - counter = wireObjectOperation.counter + counter = partialWireObjectOperation.counter // Do not access on inbound data, per OOP3g nonce = nil // Do not access on inbound data, per OOP3h initialValue = nil - // Do not access on inbound data, per OOP3i - initialValueEncoding = nil } - /// Converts this `ObjectOperation` to a `WireObjectOperation`, applying the data encoding rules of OD4 and OOP5. + /// Converts this `PartialObjectOperation` to a `PartialWireObjectOperation`, applying the data encoding rules of OD4. /// /// - Parameters: - /// - format: The format to use when applying the encoding rules of OD4 and OOP5. - func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectOperation { - // OOP5: Encode initialValue based on format - let wireInitialValue: StringOrData? - let wireInitialValueEncoding: String? - - if let initialValue { - switch format { - case .messagePack: - // OOP5a: When the MessagePack protocol is used - // OOP5a1: A binary ObjectOperation.initialValue is encoded as a MessagePack binary type - wireInitialValue = .data(initialValue) - // OOP5a2: Set ObjectOperation.initialValueEncoding to msgpack - wireInitialValueEncoding = "msgpack" - - case .json: - // OOP5b: When the JSON protocol is used - // OOP5b1: A binary ObjectOperation.initialValue is Base64-encoded and represented as a JSON string - wireInitialValue = .string(initialValue.base64EncodedString()) - // OOP5b2: Set ObjectOperation.initialValueEncoding to json - wireInitialValueEncoding = "json" - } - } else { - wireInitialValue = nil - wireInitialValueEncoding = nil - } - - return .init( + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: AblyPlugin.EncodingFormat) -> PartialWireObjectOperation { + .init( action: action, - objectId: objectId, mapOp: mapOp?.toWire(format: format), counterOp: counterOp, map: map?.toWire(format: format), counter: counter, nonce: nonce, - initialValue: wireInitialValue, - initialValueEncoding: wireInitialValueEncoding, + initialValue: initialValue, ) } } @@ -360,6 +413,7 @@ internal extension ObjectsMapEntry { tombstone = wireObjectsMapEntry.tombstone timeserial = wireObjectsMapEntry.timeserial data = try .init(wireObjectData: wireObjectsMapEntry.data, format: format) + serialTimestamp = wireObjectsMapEntry.serialTimestamp } /// Converts this `ObjectsMapEntry` to a `WireObjectsMapEntry`, applying the data encoding rules of OD4. diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 348cc483..163cde4d 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -15,6 +15,7 @@ internal struct InboundWireObjectMessage { internal var object: WireObjectState? // 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`. @@ -28,6 +29,7 @@ internal struct OutboundWireObjectMessage { internal var object: WireObjectState? // OM2g internal var serial: String? // OM2h internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j } /// The keys for decoding an `InboundWireObjectMessage` or encoding an `OutboundWireObjectMessage`. @@ -41,6 +43,7 @@ internal enum WireObjectMessageWireKey: String { case object case serial case siteCode + case serialTimestamp } internal extension InboundWireObjectMessage { @@ -96,6 +99,7 @@ internal extension InboundWireObjectMessage { object = try wireObject.optionalDecodableValueForKey(WireObjectMessageWireKey.object.rawValue) serial = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.serial.rawValue) siteCode = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.siteCode.rawValue) + serialTimestamp = try wireObject.optionalAblyProtocolDateValueForKey(WireObjectMessageWireKey.serialTimestamp.rawValue) } } @@ -131,6 +135,9 @@ extension OutboundWireObjectMessage: WireObjectEncodable { if let object { result[WireObjectMessageWireKey.object.rawValue] = .object(object.toWireObject) } + if let serialTimestamp { + result[WireObjectMessageWireKey.serialTimestamp.rawValue] = .number(NSNumber(value: serialTimestamp.timeIntervalSince1970 * 1000)) + } return result } } @@ -150,34 +157,32 @@ internal enum ObjectsMapSemantics: Int { case lww = 0 } -internal struct WireObjectOperation { +/// A partial version of `WireObjectOperation` that excludes the `objectId` property. Used for encoding initial values 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 objectId: String // OOP3b internal var mapOp: WireObjectsMapOp? // OOP3c internal var counterOp: WireObjectsCounterOp? // OOP3d internal var map: WireObjectsMap? // OOP3e internal var counter: WireObjectsCounter? // OOP3f internal var nonce: String? // OOP3g - internal var initialValue: StringOrData? // OOP3h - internal var initialValueEncoding: String? // OOP3i + internal var initialValue: String? // OOP3h } -extension WireObjectOperation: WireObjectCodable { +extension PartialWireObjectOperation: WireObjectCodable { internal enum WireKey: String { case action - case objectId case mapOp case counterOp case map case counter case nonce case initialValue - case initialValueEncoding } internal init(wireObject: [String: WireValue]) throws(InternalError) { action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) - objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) mapOp = try wireObject.optionalDecodableValueForKey(WireKey.mapOp.rawValue) counterOp = try wireObject.optionalDecodableValueForKey(WireKey.counterOp.rawValue) map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) @@ -187,14 +192,11 @@ extension WireObjectOperation: WireObjectCodable { nonce = nil // Do not access on inbound data, per OOP3h initialValue = nil - // Do not access on inbound data, per OOP3i - initialValueEncoding = nil } internal var toWireObject: [String: WireValue] { var result: [String: WireValue] = [ WireKey.action.rawValue: .number(action.rawValue as NSNumber), - WireKey.objectId.rawValue: .string(objectId), ] if let mapOp { @@ -213,16 +215,64 @@ extension WireObjectOperation: WireObjectCodable { result[WireKey.nonce.rawValue] = .string(nonce) } if let initialValue { - result[WireKey.initialValue.rawValue] = initialValue.toWireValue - } - if let initialValueEncoding { - result[WireKey.initialValueEncoding.rawValue] = .string(initialValueEncoding) + result[WireKey.initialValue.rawValue] = .string(initialValue) } return result } } +internal struct WireObjectOperation { + internal var action: WireEnum // OOP3a + internal var objectId: String // OOP3b + internal var mapOp: WireObjectsMapOp? // OOP3c + internal var counterOp: WireObjectsCounterOp? // OOP3d + internal var map: WireObjectsMap? // OOP3e + internal var counter: WireObjectsCounter? // OOP3f + internal var nonce: String? // OOP3g + internal var initialValue: String? // OOP3h +} + +extension WireObjectOperation: WireObjectCodable { + internal enum WireKey: String { + case objectId + } + + internal init(wireObject: [String: WireValue]) throws(InternalError) { + // Decode the objectId first since it's not part of PartialWireObjectOperation + 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 + counter = partialOperation.counter + nonce = partialOperation.nonce + initialValue = partialOperation.initialValue + } + + internal var toWireObject: [String: WireValue] { + var result = PartialWireObjectOperation( + action: action, + mapOp: mapOp, + counterOp: counterOp, + map: map, + counter: counter, + nonce: nonce, + initialValue: initialValue, + ).toWireObject + + // Add the objectId field + result[WireKey.objectId.rawValue] = .string(objectId) + + return result + } +} + internal struct WireObjectState { internal var objectId: String // OST2a internal var siteTimeserials: [String: String] // OST2b @@ -386,6 +436,7 @@ internal struct WireObjectsMapEntry { internal var tombstone: Bool? // OME2a internal var timeserial: String? // OME2b internal var data: WireObjectData // OME2c + internal var serialTimestamp: Date? // OME2d } extension WireObjectsMapEntry: WireObjectCodable { @@ -393,12 +444,14 @@ extension WireObjectsMapEntry: WireObjectCodable { case tombstone case timeserial case data + case serialTimestamp } internal init(wireObject: [String: WireValue]) throws(InternalError) { tombstone = try wireObject.optionalBoolValueForKey(WireKey.tombstone.rawValue) timeserial = try wireObject.optionalStringValueForKey(WireKey.timeserial.rawValue) data = try wireObject.decodableValueForKey(WireKey.data.rawValue) + serialTimestamp = try wireObject.optionalAblyProtocolDateValueForKey(WireKey.serialTimestamp.rawValue) } internal var toWireObject: [String: WireValue] { @@ -412,6 +465,9 @@ extension WireObjectsMapEntry: WireObjectCodable { if let timeserial { result[WireKey.timeserial.rawValue] = .string(timeserial) } + if let serialTimestamp { + result[WireKey.serialTimestamp.rawValue] = .number(NSNumber(value: serialTimestamp.timeIntervalSince1970 * 1000)) + } return result } @@ -473,10 +529,7 @@ extension WireObjectData: WireObjectCodable { /// A type that can be either a string or binary data. /// -/// Used to represent: -/// -/// - the values that `WireObjectData.bytes` might hold, after being encoded per OD4 or before being decoded per OD5 -/// - the values that `WireObjectOperation.initialValue` might hold, after being encoded per OOP5 +/// Used to represent the values that `WireObjectData.bytes` might hold, after being encoded per OD4 or before being decoded per OD5. internal enum StringOrData: WireCodable { case string(String) case data(Data) diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index 964baaba..d36b5ccf 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -18,10 +18,13 @@ public extension ARTRealtimeChannel { pluginAPI: Plugin.defaultPluginAPI, ) + let logger = pluginAPI.logger(for: underlyingObjects.channel) + return PublicObjectsStore.shared.getOrCreateRealtimeObjects( proxying: internalObjects, creationArgs: .init( coreSDK: coreSDK, + logger: logger, ), ) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift index 92acbe1a..e5b599d0 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift @@ -1,16 +1,19 @@ +internal import AblyPlugin + internal extension InternalLiveMapValue { // MARK: - Mapping to public types struct PublicValueCreationArgs { internal var coreSDK: CoreSDK internal var mapDelegate: LiveMapObjectPoolDelegate + internal var logger: AblyPlugin.Logger internal var toCounterCreationArgs: PublicObjectsStore.CounterCreationArgs { - .init(coreSDK: coreSDK) + .init(coreSDK: coreSDK, logger: logger) } internal var toMapCreationArgs: PublicObjectsStore.MapCreationArgs { - .init(coreSDK: coreSDK, delegate: mapDelegate) + .init(coreSDK: coreSDK, delegate: mapDelegate, logger: logger) } } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 7b6c0795..19c2bfc4 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -1,21 +1,21 @@ import Ably +internal import AblyPlugin /// Our default implementation of ``LiveCounter``. /// /// This is largely a wrapper around ``InternalDefaultLiveCounter``. internal final class PublicDefaultLiveCounter: LiveCounter { - private let proxied: InternalDefaultLiveCounter - internal var testsOnly_proxied: InternalDefaultLiveCounter { - proxied - } + internal let proxied: InternalDefaultLiveCounter // MARK: - Dependencies that hold a strong reference to `proxied` private let coreSDK: CoreSDK + private let logger: AblyPlugin.Logger - internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK) { + internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK, logger: AblyPlugin.Logger) { self.proxied = proxied self.coreSDK = coreSDK + self.logger = logger } // MARK: - `LiveCounter` protocol diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index a49cbfd2..f3a9c9ef 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -1,23 +1,23 @@ import Ably +internal import AblyPlugin /// Our default implementation of ``LiveMap``. /// /// This is largely a wrapper around ``InternalDefaultLiveMap``. internal final class PublicDefaultLiveMap: LiveMap { - private let proxied: InternalDefaultLiveMap - internal var testsOnly_proxied: InternalDefaultLiveMap { - proxied - } + internal let proxied: InternalDefaultLiveMap // MARK: - Dependencies that hold a strong reference to `proxied` private let coreSDK: CoreSDK private let delegate: LiveMapObjectPoolDelegate + private let logger: AblyPlugin.Logger - internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) { + internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate, logger: AblyPlugin.Logger) { self.proxied = proxied self.coreSDK = coreSDK self.delegate = delegate + self.logger = logger } // MARK: - `LiveMap` protocol @@ -27,6 +27,7 @@ internal final class PublicDefaultLiveMap: LiveMap { creationArgs: .init( coreSDK: coreSDK, mapDelegate: delegate, + logger: logger, ), ) } @@ -46,6 +47,7 @@ internal final class PublicDefaultLiveMap: LiveMap { creationArgs: .init( coreSDK: coreSDK, mapDelegate: delegate, + logger: logger, ), ) ) @@ -66,6 +68,7 @@ internal final class PublicDefaultLiveMap: LiveMap { creationArgs: .init( coreSDK: coreSDK, mapDelegate: delegate, + logger: logger, ), ) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index 09828035..94be8e40 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -1,4 +1,5 @@ import Ably +internal import AblyPlugin /// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. /// @@ -12,10 +13,12 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { // MARK: - Dependencies that hold a strong reference to `proxied` private let coreSDK: CoreSDK + private let logger: AblyPlugin.Logger - internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK) { + internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK, logger: AblyPlugin.Logger) { self.proxied = proxied self.coreSDK = coreSDK + self.logger = logger } // MARK: - `RealtimeObjects` protocol @@ -27,6 +30,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { creationArgs: .init( coreSDK: coreSDK, delegate: proxied, + logger: logger, ), ) } @@ -71,8 +75,8 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { proxied.testsOnly_receivedObjectProtocolMessages } - internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { - try await proxied.testsOnly_sendObject(objectMessages: objectMessages, coreSDK: coreSDK) + internal func testsOnly_publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + try await proxied.testsOnly_publish(objectMessages: objectMessages, coreSDK: coreSDK) } internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift index 39e7f907..4be07534 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift @@ -1,3 +1,4 @@ +internal import AblyPlugin import Foundation /// Stores the public objects that wrap the SDK's internal components. @@ -20,6 +21,7 @@ internal final class PublicObjectsStore: Sendable { internal struct RealtimeObjectsCreationArgs { internal var coreSDK: CoreSDK + internal var logger: AblyPlugin.Logger } /// Fetches the cached `PublicDefaultRealtimeObjects` that wraps a given `InternalDefaultRealtimeObjects`, creating a new public object if there isn't already one. @@ -31,6 +33,7 @@ internal final class PublicObjectsStore: Sendable { internal struct CounterCreationArgs { internal var coreSDK: CoreSDK + internal var logger: AblyPlugin.Logger } /// Fetches the cached `PublicDefaultLiveCounter` that wraps a given `InternalDefaultLiveCounter`, creating a new public object if there isn't already one. @@ -43,6 +46,7 @@ internal final class PublicObjectsStore: Sendable { internal struct MapCreationArgs { internal var coreSDK: CoreSDK internal var delegate: LiveMapObjectPoolDelegate + internal var logger: AblyPlugin.Logger } /// Fetches the cached `PublicDefaultLiveMap` that wraps a given `InternalDefaultLiveMap`, creating a new public object if there isn't already one. @@ -64,27 +68,37 @@ internal final class PublicObjectsStore: Sendable { /// Fetches the proxy that wraps `proxied`, creating a new proxy if there isn't already one. Stores a weak reference to the proxy. mutating func getOrCreate( proxying proxied: some AnyObject, + logger: AblyPlugin.Logger, + logObjectType: String, createProxy: () -> Proxy, ) -> Proxy { // Remove any entries that are no longer useful - removeDeallocatedEntries() + removeDeallocatedEntries(logger: logger, logObjectType: logObjectType) // Do the get-or-create let proxiedObjectIdentifier = ObjectIdentifier(proxied) if let existing = proxiesByProxiedObjectIdentifier[proxiedObjectIdentifier]?.referenced { + logger.log("Reusing existing \(logObjectType) proxy (proxy: \(ObjectIdentifier(existing)), proxied: \(proxiedObjectIdentifier))", level: .debug) return existing } let created = createProxy() proxiesByProxiedObjectIdentifier[proxiedObjectIdentifier] = .init(referenced: created) + logger.log("Creating new \(logObjectType) proxy (proxy: \(ObjectIdentifier(created)), proxied: \(proxiedObjectIdentifier))", level: .debug) return created } - private mutating func removeDeallocatedEntries() { - proxiesByProxiedObjectIdentifier = proxiesByProxiedObjectIdentifier.filter { entry in - entry.value.referenced != nil + private mutating func removeDeallocatedEntries(logger: AblyPlugin.Logger, logObjectType: String) { + var keysToRemove: Set = [] + for (proxiedObjectIdentifier, weakProxyRef) in proxiesByProxiedObjectIdentifier where weakProxyRef.referenced == nil { + logger.log("Clearing unused \(logObjectType) proxy from cache (proxied: \(proxiedObjectIdentifier))", level: .debug) + keysToRemove.insert(proxiedObjectIdentifier) + } + + for key in keysToRemove { + proxiesByProxiedObjectIdentifier.removeValue(forKey: key) } } } @@ -93,10 +107,15 @@ internal final class PublicObjectsStore: Sendable { proxying proxied: InternalDefaultRealtimeObjects, creationArgs: RealtimeObjectsCreationArgs, ) -> PublicDefaultRealtimeObjects { - realtimeObjectsProxies.getOrCreate(proxying: proxied) { + realtimeObjectsProxies.getOrCreate( + proxying: proxied, + logger: creationArgs.logger, + logObjectType: "RealtimeObjects", + ) { .init( proxied: proxied, coreSDK: creationArgs.coreSDK, + logger: creationArgs.logger, ) } } @@ -105,10 +124,15 @@ internal final class PublicObjectsStore: Sendable { proxying proxied: InternalDefaultLiveCounter, creationArgs: CounterCreationArgs, ) -> PublicDefaultLiveCounter { - counterProxies.getOrCreate(proxying: proxied) { + counterProxies.getOrCreate( + proxying: proxied, + logger: creationArgs.logger, + logObjectType: "LiveCounter", + ) { .init( proxied: proxied, coreSDK: creationArgs.coreSDK, + logger: creationArgs.logger, ) } } @@ -117,11 +141,16 @@ internal final class PublicObjectsStore: Sendable { proxying proxied: InternalDefaultLiveMap, creationArgs: MapCreationArgs, ) -> PublicDefaultLiveMap { - mapProxies.getOrCreate(proxying: proxied) { + mapProxies.getOrCreate( + proxying: proxied, + logger: creationArgs.logger, + logObjectType: "LiveMap", + ) { .init( proxied: proxied, coreSDK: creationArgs.coreSDK, delegate: creationArgs.delegate, + logger: creationArgs.logger, ) } } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 34e67f2b..14019129 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -83,7 +83,7 @@ public protocol RealtimeObjects: Sendable { /// Represents the type of data stored for a given key in a ``LiveMap``. /// It may be a primitive value (``PrimitiveObjectValue``), or another ``LiveObject``. -public enum LiveMapValue: Sendable { +public enum LiveMapValue: Sendable, Equatable { case primitive(PrimitiveObjectValue) case liveMap(any LiveMap) case liveCounter(any LiveCounter) @@ -133,6 +133,27 @@ public enum LiveMapValue: Sendable { public var dataValue: Data? { primitiveValue?.dataValue } + + // MARK: - Equatable Implementation + + public static func == (lhs: LiveMapValue, rhs: LiveMapValue) -> Bool { + switch lhs { + case let .primitive(lhsValue): + if case let .primitive(rhsValue) = rhs, lhsValue == rhsValue { + return true + } + case let .liveMap(lhsMap): + if case let .liveMap(rhsMap) = rhs, lhsMap === rhsMap { + return true + } + case let .liveCounter(lhsCounter): + if case let .liveCounter(rhsCounter) = rhs, lhsCounter === rhsCounter { + return true + } + } + + return false + } } /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. @@ -265,7 +286,7 @@ public protocol LiveMapUpdate: Sendable { } /// Represents a primitive value that can be stored in a ``LiveMap``. -public enum PrimitiveObjectValue: Sendable { +public enum PrimitiveObjectValue: Sendable, Equatable { case string(String) case number(Double) case bool(Bool) @@ -371,3 +392,29 @@ 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 d33ca22d..1a1445d8 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -90,7 +90,7 @@ struct AblyLiveObjectsTests { // (This objectId comes from copying that which was given in an expected value in an error message from Realtime) let realtimeCreatedMapObjectID = "map:iC4Nq8EbTSEmw-_tDJdVV8HfiBvJGpZmO_WbGbh0_-4@\(currentAblyTimestamp)" - try await channel.testsOnly_nonTypeErasedObjects.testsOnly_sendObject(objectMessages: [ + try await channel.testsOnly_nonTypeErasedObjects.testsOnly_publish(objectMessages: [ OutboundObjectMessage( operation: .init( action: .known(.mapCreate), @@ -110,7 +110,7 @@ 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) { do throws(InternalError) { - try await channel.testsOnly_nonTypeErasedObjects.testsOnly_sendObject(objectMessages: [ + try await channel.testsOnly_nonTypeErasedObjects.testsOnly_publish(objectMessages: [ .init(), ]) } catch { diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index c82df9e9..362e24ed 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -341,8 +341,7 @@ struct TestFactories { map: ObjectsMap? = nil, counter: WireObjectsCounter? = nil, nonce: String? = nil, - initialValue: Data? = nil, - initialValueEncoding: String? = nil, + initialValue: String? = nil, ) -> ObjectOperation { ObjectOperation( action: action, @@ -353,7 +352,6 @@ struct TestFactories { counter: counter, nonce: nonce, initialValue: initialValue, - initialValueEncoding: initialValueEncoding, ) } @@ -404,6 +402,21 @@ struct TestFactories { ) } + /// Creates an InternalObjectsMapEntry with sensible defaults + /// + /// This should be kept in sync with ``mapEntry``. + static func internalMapEntry( + tombstone: Bool? = false, + timeserial: String? = "ts1", + data: ObjectData, + ) -> InternalObjectsMapEntry { + InternalObjectsMapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: data, + ) + } + /// Creates a map entry with string data static func stringMapEntry( key: String = "testKey", @@ -421,6 +434,25 @@ struct TestFactories { ) } + /// Creates an internal map entry with string data + /// + /// This should be kept in sync with ``stringMapEntry``. + static func internalStringMapEntry( + key: String = "testKey", + value: String = "testValue", + tombstone: Bool? = false, + timeserial: String? = "ts1", + ) -> (key: String, entry: InternalObjectsMapEntry) { + ( + key: key, + entry: internalMapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: ObjectData(string: .string(value)), + ), + ) + } + /// Creates a map entry with number data static func numberMapEntry( key: String = "testKey", diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 83e85728..6ce42481 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -10,7 +10,7 @@ struct InternalDefaultLiveCounterTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func valueThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: channelState) #expect { @@ -28,11 +28,11 @@ struct InternalDefaultLiveCounterTests { @Test func valueReturnsCurrentDataWhenChannelIsValid() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attached) // Set some test data - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 42)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 42), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 42) } @@ -44,11 +44,11 @@ struct InternalDefaultLiveCounterTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) - _ = counter.replaceData(using: state) + _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) } @@ -60,14 +60,14 @@ struct InternalDefaultLiveCounterTests { // Given: A counter whose createOperationIsMerged is true let logger = TestLogger() let counter = { - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Test setup: Manipulate counter so that its createOperationIsMerged gets set to true (we need to do this since we want to later assert that it gets set to false, but the default is false). let state = TestFactories.counterObjectState( createOp: TestFactories.objectOperation( action: .known(.counterCreate), ), ) - _ = counter.replaceData(using: state) + _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) #expect(counter.testsOnly_createOperationIsMerged) return counter @@ -77,7 +77,7 @@ struct InternalDefaultLiveCounterTests { let state = TestFactories.counterObjectState( createOp: nil, // Test value - must be nil to test RTLC6b ) - _ = counter.replaceData(using: state) + _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) // Then: #expect(!counter.testsOnly_createOperationIsMerged) @@ -87,12 +87,12 @@ struct InternalDefaultLiveCounterTests { @Test func setsDataToCounterCount() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) let state = TestFactories.counterObjectState( count: 42, // Test value ) - _ = counter.replaceData(using: state) + _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 42) } @@ -100,11 +100,12 @@ struct InternalDefaultLiveCounterTests { @Test func setsDataToZeroWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) _ = counter.replaceData(using: TestFactories.counterObjectState( count: nil, // Test value - must be nil - )) + ), objectMessageSerialTimestamp: nil) + #expect(try counter.value(coreSDK: coreSDK) == 0) } } @@ -115,36 +116,36 @@ struct InternalDefaultLiveCounterTests { @Test func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) let state = TestFactories.counterObjectState( createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist count: 5, // Test value - must exist ) - _ = counter.replaceData(using: state) + _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC10a) #expect(counter.testsOnly_createOperationIsMerged) } } } - /// Tests for the `testsOnly_mergeInitialValue` method, covering RTLC10 specification points + /// Tests for the `mergeInitialValue` method, covering RTLC10 specification points struct MergeInitialValueTests { // @specOneOf(1/2) RTLC10a - with count // @spec RTLC10c @Test func addsCounterCountToData() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist - let update = counter.testsOnly_mergeInitialValue(from: operation) + let update = counter.mergeInitialValue(from: operation) #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 @@ -157,11 +158,11 @@ struct InternalDefaultLiveCounterTests { @Test func doesNotModifyDataWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation with no count @@ -169,7 +170,7 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterCreate), counter: nil, // Test value - must be nil ) - let update = counter.testsOnly_mergeInitialValue(from: operation) + let update = counter.mergeInitialValue(from: operation) #expect(try counter.value(coreSDK: coreSDK) == 5) // Unchanged @@ -181,11 +182,11 @@ struct InternalDefaultLiveCounterTests { @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist - _ = counter.testsOnly_mergeInitialValue(from: operation) + _ = counter.mergeInitialValue(from: operation) #expect(counter.testsOnly_createOperationIsMerged) } @@ -197,12 +198,12 @@ struct InternalDefaultLiveCounterTests { @Test func discardsOperationWhenCreateOperationIsMerged() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data and mark create operation as merged - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) - _ = counter.testsOnly_mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) + _ = counter.mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) #expect(counter.testsOnly_createOperationIsMerged) // Try to apply another COUNTER_CREATE operation @@ -221,11 +222,11 @@ struct InternalDefaultLiveCounterTests { @Test func mergesInitialValue() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data but don't mark create operation as merged - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) #expect(!counter.testsOnly_createOperationIsMerged) // Apply COUNTER_CREATE operation @@ -262,11 +263,11 @@ struct InternalDefaultLiveCounterTests { ) func addsAmountToData(operation: WireObjectsCounterOp?, expectedValue: Double, expectedUpdate: LiveObjectUpdate) throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply COUNTER_INC operation @@ -286,26 +287,27 @@ struct InternalDefaultLiveCounterTests { @Test func discardsOperationWhenCannotBeApplied() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) // Set up the counter with an existing site timeserial that will cause the operation to be discarded _ = counter.replaceData(using: TestFactories.counterObjectState( siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" count: 5, - )) + ), objectMessageSerialTimestamp: nil) let operation = TestFactories.objectOperation( action: .known(.counterInc), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply operation with serial "ts1" which is lexicographically less than existing "ts2" and thus will be applied per RTLO4a (this is a non-pathological case of RTOL4a, that spec point being fully tested elsewhere) counter.apply( operation, objectMessageSerial: "ts1", // Less than existing "ts2" objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -323,20 +325,21 @@ struct InternalDefaultLiveCounterTests { @Test func appliesCounterCreateOperation() async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) let subscriber = Subscriber(callbackQueue: .main) try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) let operation = TestFactories.counterCreateOperation(count: 15) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply COUNTER_CREATE operation counter.apply( operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -358,27 +361,28 @@ struct InternalDefaultLiveCounterTests { @Test func appliesCounterIncOperation() async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) let subscriber = Subscriber(callbackQueue: .main) try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) // Set initial data - _ = counter.replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 5) let operation = TestFactories.objectOperation( action: .known(.counterInc), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply COUNTER_INC operation counter.apply( operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -397,18 +401,19 @@ struct InternalDefaultLiveCounterTests { @Test func noOpForOtherOperation() async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) let subscriber = Subscriber(callbackQueue: .main) try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) // Try to apply a MAP_CREATE to the counter (not supported) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) counter.apply( TestFactories.mapCreateOperation(), objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 2cdefbf8..4a67d2e4 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -10,7 +10,7 @@ struct InternalDefaultLiveMapTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func getThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect { _ = try map.get(key: "test", coreSDK: MockCoreSDK(channelState: channelState), delegate: MockLiveMapObjectPoolDelegate()) @@ -30,7 +30,7 @@ struct InternalDefaultLiveMapTests { func returnsNilWhenNoEntryExists() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect(try map.get(key: "nonexistent", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) } @@ -38,12 +38,12 @@ struct InternalDefaultLiveMapTests { @Test func returnsNilWhenEntryIsTombstoned() throws { let logger = TestLogger() - let entry = TestFactories.mapEntry( + let entry = TestFactories.internalMapEntry( tombstone: true, data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) } @@ -51,9 +51,9 @@ struct InternalDefaultLiveMapTests { @Test func returnsBooleanValue() throws { let logger = TestLogger() - let entry = TestFactories.mapEntry(data: ObjectData(boolean: true)) + let entry = TestFactories.internalMapEntry(data: ObjectData(boolean: true)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.boolValue == true) } @@ -63,9 +63,9 @@ struct InternalDefaultLiveMapTests { func returnsBytesValue() throws { let logger = TestLogger() let bytes = Data([0x01, 0x02, 0x03]) - let entry = TestFactories.mapEntry(data: ObjectData(bytes: bytes)) + let entry = TestFactories.internalMapEntry(data: ObjectData(bytes: bytes)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.dataValue == bytes) } @@ -74,9 +74,9 @@ struct InternalDefaultLiveMapTests { @Test func returnsNumberValue() throws { let logger = TestLogger() - let entry = TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 123.456))) + let entry = TestFactories.internalMapEntry(data: ObjectData(number: NSNumber(value: 123.456))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.numberValue == 123.456) } @@ -85,9 +85,9 @@ struct InternalDefaultLiveMapTests { @Test func returnsStringValue() throws { let logger = TestLogger() - let entry = TestFactories.mapEntry(data: ObjectData(string: .string("test"))) + let entry = TestFactories.internalMapEntry(data: ObjectData(string: .string("test"))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.stringValue == "test") } @@ -96,10 +96,10 @@ struct InternalDefaultLiveMapTests { @Test func returnsNilWhenReferencedObjectDoesNotExist() throws { let logger = TestLogger() - let entry = TestFactories.mapEntry(data: ObjectData(objectId: "missing")) + let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: "missing")) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) } @@ -108,12 +108,12 @@ struct InternalDefaultLiveMapTests { func returnsReferencedMap() throws { let logger = TestLogger() let objectId = "map1" - let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) + let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) delegate.objects[objectId] = .map(referencedMap) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) let returnedMap = result?.liveMapValue #expect(returnedMap as AnyObject === referencedMap as AnyObject) @@ -124,12 +124,12 @@ struct InternalDefaultLiveMapTests { func returnsReferencedCounter() throws { let logger = TestLogger() let objectId = "counter1" - let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) + let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) delegate.objects[objectId] = .counter(referencedCounter) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) let returnedCounter = result?.liveCounterValue #expect(returnedCounter as AnyObject === referencedCounter as AnyObject) @@ -139,10 +139,10 @@ struct InternalDefaultLiveMapTests { @Test func returnsNullOtherwise() throws { let logger = TestLogger() - let entry = TestFactories.mapEntry(data: ObjectData()) + let entry = TestFactories.internalMapEntry(data: ObjectData()) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) } } @@ -153,13 +153,13 @@ struct InternalDefaultLiveMapTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let state = TestFactories.objectState( objectId: "arbitrary-id", siteTimeserials: ["site1": "ts1", "site2": "ts2"], ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - _ = map.replaceData(using: state, objectsPool: &pool) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) } @@ -168,15 +168,15 @@ struct InternalDefaultLiveMapTests { func setsCreateOperationIsMergedToFalseWhenCreateOpAbsent() { // Given: let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let map = { - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Test setup: Manipulate map so that its createOperationIsMerged gets set to true (we need to do this since we want to later assert that it gets set to false, but the default is false). let state = TestFactories.objectState( createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), ) - _ = map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) return map @@ -184,7 +184,7 @@ struct InternalDefaultLiveMapTests { // When: let state = TestFactories.objectState(objectId: "arbitrary-id", createOp: nil) - _ = map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) // Then: #expect(!map.testsOnly_createOperationIsMerged) @@ -196,14 +196,14 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "test") let state = TestFactories.mapObjectState( objectId: "arbitrary-id", entries: [key: entry], ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - _ = map.replaceData(using: state, objectsPool: &pool) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) let newData = map.testsOnly_data #expect(newData.count == 1) #expect(Set(newData.keys) == ["key1"]) @@ -217,7 +217,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation( @@ -233,8 +233,8 @@ struct InternalDefaultLiveMapTests { ], ), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - _ = map.replaceData(using: state, objectsPool: &pool) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) // Note that we just check for some basic expected side effects of merging the initial value; RTLM17 is tested in more detail elsewhere // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d) #expect(try map.get(key: "keyFromMapEntries", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromMapEntries") @@ -254,7 +254,7 @@ struct InternalDefaultLiveMapTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: channelState) let delegate = MockLiveMapObjectPoolDelegate() @@ -294,15 +294,16 @@ struct InternalDefaultLiveMapTests { let map = InternalDefaultLiveMap( testsOnly_data: [ // tombstone is nil, so not considered tombstoned - "active1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))), + "active1": TestFactories.internalMapEntry(data: ObjectData(string: .string("value1"))), // tombstone is false, so not considered tombstoned[ - "active2": TestFactories.mapEntry(tombstone: false, data: ObjectData(string: .string("value2"))), - "tombstoned": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned"))), - "tombstoned2": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned2"))), + "active2": TestFactories.internalMapEntry(tombstone: false, data: ObjectData(string: .string("value2"))), + "tombstoned": TestFactories.internalMapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned"))), + "tombstoned2": TestFactories.internalMapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned2"))), ], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) // Test size - should only count non-tombstoned entries @@ -339,13 +340,14 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let map = InternalDefaultLiveMap( testsOnly_data: [ - "key1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))), - "key2": TestFactories.mapEntry(data: ObjectData(string: .string("value2"))), - "key3": TestFactories.mapEntry(data: ObjectData(string: .string("value3"))), + "key1": TestFactories.internalMapEntry(data: ObjectData(string: .string("value1"))), + "key2": TestFactories.internalMapEntry(data: ObjectData(string: .string("value2"))), + "key3": TestFactories.internalMapEntry(data: ObjectData(string: .string("value3"))), ], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) let size = try map.size(coreSDK: coreSDK) @@ -376,23 +378,24 @@ struct InternalDefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Create referenced objects for testing - let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) delegate.objects["map:ref@123"] = .map(referencedMap) delegate.objects["counter:ref@456"] = .counter(referencedCounter) let map = InternalDefaultLiveMap( testsOnly_data: [ - "boolean": TestFactories.mapEntry(data: ObjectData(boolean: true)), // RTLM5d2b - "bytes": TestFactories.mapEntry(data: ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c - "number": TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 42))), // RTLM5d2d - "string": TestFactories.mapEntry(data: ObjectData(string: .string("hello"))), // RTLM5d2e - "mapRef": TestFactories.mapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 - "counterRef": TestFactories.mapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 + "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: .string("hello"))), // RTLM5d2e + "mapRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 + "counterRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 ], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) let size = try map.size(coreSDK: coreSDK) @@ -434,12 +437,13 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Try to apply operation with lower timeserial (ts1 < ts2) let update = map.testsOnly_applyMapSetOperation( @@ -473,12 +477,13 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let update = map.testsOnly_applyMapSetOperation( key: "key1", @@ -544,8 +549,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let update = map.testsOnly_applyMapSetOperation( key: "newKey", @@ -594,7 +599,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Create an existing object in the pool with some data let existingObjectId = "map:existing@123" @@ -603,10 +608,12 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) var pool = ObjectsPool( logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), testsOnly_otherEntries: [existingObjectId: .map(existingObject)], ) // Populate the delegate so that when we "verify the MAP_SET operation was applied correctly" using map.get below it returns the referenced object @@ -642,10 +649,11 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 @@ -667,10 +675,11 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 @@ -706,7 +715,7 @@ struct InternalDefaultLiveMapTests { @Test func createsNewEntryWhenNoExistingEntry() throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let update = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -728,7 +737,7 @@ struct InternalDefaultLiveMapTests { @Test func setsNewEntryTombstoneToTrue() throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) _ = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -791,12 +800,13 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) _ = map.testsOnly_applyMapSetOperation( key: "key1", @@ -817,7 +827,7 @@ struct InternalDefaultLiveMapTests { } } - /// Tests for the `testsOnly_mergeInitialValue` method, covering RTLM17 specification points + /// Tests for the `mergeInitialValue` method, covering RTLM17 specification points struct MergeInitialValueTests { // @spec RTLM17a1 @Test @@ -825,8 +835,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply merge operation with MAP_SET entries let operation = TestFactories.mapCreateOperation( @@ -835,7 +845,7 @@ struct InternalDefaultLiveMapTests { "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, ], ) - _ = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + _ = map.mergeInitialValue(from: operation, objectsPool: &pool) // Note that we just check for some basic expected side effects of applying MAP_SET; RTLM7 is tested in more detail elsewhere // Check that it contains the data from the operation (per RTLM17a1) @@ -849,12 +859,13 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], + testsOnly_data: ["key1": TestFactories.internalStringMapEntry().entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Confirm that the initial data is there #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) != nil) @@ -869,7 +880,7 @@ struct InternalDefaultLiveMapTests { objectId: "arbitrary-id", entries: ["key1": entry], ) - _ = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + _ = map.mergeInitialValue(from: operation, objectsPool: &pool) // Verify the MAP_REMOVE operation was applied #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) @@ -881,14 +892,15 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let map = InternalDefaultLiveMap( testsOnly_data: [ - "keyThatWillBeRemoved": TestFactories.stringMapEntry(timeserial: "ts1").entry, - "keyThatWillNotBeRemoved": TestFactories.stringMapEntry(timeserial: "ts1").entry, + "keyThatWillBeRemoved": TestFactories.internalStringMapEntry(timeserial: "ts1").entry, + "keyThatWillNotBeRemoved": TestFactories.internalStringMapEntry(timeserial: "ts1").entry, ], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply merge operation with MAP_CREATE and MAP_REMOVE entries (copied from RTLM17a1 and RTLM17a2 test cases) let operation = TestFactories.mapCreateOperation( @@ -907,7 +919,7 @@ struct InternalDefaultLiveMapTests { "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, ], ) - let update = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + let update = map.mergeInitialValue(from: operation, objectsPool: &pool) // Verify merged return value per RTLM17c #expect(try #require(update.update).update == ["keyThatWillBeRemoved": .removed, "keyFromCreateOp": .updated]) @@ -917,12 +929,12 @@ struct InternalDefaultLiveMapTests { @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply merge operation let operation = TestFactories.mapCreateOperation(objectId: "arbitrary-id") - _ = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + _ = map.mergeInitialValue(from: operation, objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) } @@ -936,12 +948,12 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Set initial data and mark create operation as merged - _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) - _ = map.testsOnly_mergeInitialValue(from: TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]), objectsPool: &pool) + _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectMessageSerialTimestamp: nil, objectsPool: &pool) + _ = map.mergeInitialValue(from: TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]), objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) // Try to apply another MAP_CREATE operation @@ -964,11 +976,11 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Set initial data but don't mark create operation as merged - _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) + _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectMessageSerialTimestamp: nil, objectsPool: &pool) #expect(!map.testsOnly_createOperationIsMerged) // Apply MAP_CREATE operation @@ -993,15 +1005,19 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Set up the map with an existing site timeserial that will cause the operation to be discarded - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - _ = map.replaceData(using: TestFactories.mapObjectState( - siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" - entries: [key1: entry1], - ), objectsPool: &pool) + _ = map.replaceData( + using: TestFactories.mapObjectState( + siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" + entries: [key1: entry1], + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) let operation = TestFactories.objectOperation( action: .known(.mapSet), @@ -1013,6 +1029,7 @@ struct InternalDefaultLiveMapTests { operation, objectMessageSerial: "ts1", // Less than existing "ts2" objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -1032,7 +1049,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let subscriber = Subscriber(callbackQueue: .main) try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) @@ -1040,13 +1057,14 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.mapCreateOperation( entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "value1").entry], ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply MAP_CREATE operation map.apply( operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -1070,18 +1088,22 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let subscriber = Subscriber(callbackQueue: .main) try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) // Set initial data - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - _ = map.replaceData(using: TestFactories.mapObjectState( - siteTimeserials: [:], - entries: [key1: entry1], - ), objectsPool: &pool) + _ = map.replaceData( + using: TestFactories.mapObjectState( + siteTimeserials: [:], + entries: [key1: entry1], + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") let operation = TestFactories.objectOperation( @@ -1094,6 +1116,7 @@ struct InternalDefaultLiveMapTests { operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -1116,18 +1139,22 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let subscriber = Subscriber(callbackQueue: .main) try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) // Set initial data - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - _ = map.replaceData(using: TestFactories.mapObjectState( - siteTimeserials: [:], - entries: [key1: entry1], - ), objectsPool: &pool) + _ = map.replaceData( + using: TestFactories.mapObjectState( + siteTimeserials: [:], + entries: [key1: entry1], + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") let operation = TestFactories.objectOperation( @@ -1140,6 +1167,7 @@ struct InternalDefaultLiveMapTests { operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -1158,18 +1186,19 @@ struct InternalDefaultLiveMapTests { @Test func noOpForOtherOperation() async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) let subscriber = Subscriber(callbackQueue: .main) try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) // Try to apply a COUNTER_CREATE to the map (not supported) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) map.apply( TestFactories.counterCreateOperation(), objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 38e40444..82f177cb 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -10,7 +10,7 @@ struct InternalDefaultRealtimeObjectsTests { /// Creates a InternalDefaultRealtimeObjects instance for testing static func createDefaultRealtimeObjects() -> InternalDefaultRealtimeObjects { let logger = TestLogger() - return InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: .main) + return InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) } /// Tests for `InternalDefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 6893a5f7..4883717b 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -11,7 +11,7 @@ final class MockCoreSDK: CoreSDK { _channelState = channelState } - func sendObject(objectMessages _: [AblyLiveObjects.OutboundObjectMessage]) async throws(AblyLiveObjects.InternalError) { + func publish(objectMessages _: [AblyLiveObjects.OutboundObjectMessage]) async throws(AblyLiveObjects.InternalError) { protocolRequirementNotImplemented() } diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift b/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift new file mode 100644 index 00000000..8e1fc1ef --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift @@ -0,0 +1,39 @@ +@testable import AblyLiveObjects +import Foundation + +/// A mock implementation of SimpleClock for testing purposes. +final class MockSimpleClock: SimpleClock { + private let mutex = NSLock() + + /// The current time that this mock clock will return. + private nonisolated(unsafe) var currentTime: Date + + /// Creates a new MockSimpleClock with the specified current time. + /// - Parameter currentTime: The time that this clock should return. Defaults to the current system time. + init(currentTime: Date = Date()) { + self.currentTime = currentTime + } + + /// Returns the current time set for this mock clock. + var now: Date { + mutex.withLock { + currentTime + } + } + + /// Updates the current time of this mock clock. + /// - Parameter newTime: The new time to set. + func setTime(_ newTime: Date) { + mutex.withLock { + currentTime = newTime + } + } + + /// Advances the clock by the specified time interval. + /// - Parameter interval: The time interval to advance by. + func advance(by interval: TimeInterval) { + mutex.withLock { + currentTime = currentTime.addingTimeInterval(interval) + } + } +} diff --git a/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift index 067f2f19..23c84980 100644 --- a/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift @@ -162,7 +162,7 @@ struct ObjectLifetimesTests { weakPublicRealtimeObjects: objects, weakInternalRealtimeObjects: objects.testsOnly_proxied, strongPublicLiveObject: root, - weakInternalLiveObject: root.testsOnly_proxied, + weakInternalLiveObject: root.proxied, ) } diff --git a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift index 7a614f47..12182b65 100644 --- a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift @@ -498,71 +498,4 @@ struct ObjectMessageTests { } } } - - struct ObjectOperationTests { - struct EncodingTests { - @Test(arguments: [EncodingFormat.json.rawValue, EncodingFormat.messagePack.rawValue]) - func noInitialValue(formatRawValue: EncodingFormat.RawValue) throws { - let format = try #require(EncodingFormat(rawValue: formatRawValue)) - let operation = ObjectOperation( - action: .known(.mapSet), - objectId: "test-id", - ) - let wireOperation = operation.toWire(format: format) - - #expect(wireOperation.initialValue == nil) - #expect(wireOperation.initialValueEncoding == nil) - } - - struct MessagePackTests { - // @spec OOP5a1 - // @spec OOP5a2 - @Test - func initialValue() { - let testData = Data([1, 2, 3, 4]) - let operation = ObjectOperation( - action: .known(.mapSet), - objectId: "test-id", - initialValue: testData, - ) - let wireOperation = operation.toWire(format: .messagePack) - - // OOP5a1: A binary ObjectOperation.initialValue is encoded as a MessagePack binary type - switch wireOperation.initialValue { - case let .data(data): - #expect(data == testData) - default: - Issue.record("Expected .data case") - } - // OOP5a2: Set ObjectOperation.initialValueEncoding to msgpack - #expect(wireOperation.initialValueEncoding == "msgpack") - } - } - - struct JSONTests { - // @spec OOP5b1 - // @spec OOP5b2 - @Test - func initialValue() { - let testData = Data([1, 2, 3, 4]) - let operation = ObjectOperation( - action: .known(.mapSet), - objectId: "test-id", - initialValue: testData, - ) - let wireOperation = operation.toWire(format: .json) - - // OOP5b1: A binary ObjectOperation.initialValue is Base64-encoded and represented as a JSON string - switch wireOperation.initialValue { - case let .string(base64String): - #expect(base64String == testData.base64EncodedString()) - default: - Issue.record("Expected .string case") - } - // OOP5b2: Set ObjectOperation.initialValueEncoding to json - #expect(wireOperation.initialValueEncoding == "json") - } - } - } - } } diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 8b3d14d5..2609d9f4 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -9,10 +9,10 @@ struct ObjectsPoolTests { @Test func returnsExistingObject() throws { let logger = TestLogger() - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, userCallbackQueue: .main) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let map = try #require(result?.mapValue) #expect(map as AnyObject === existingMap as AnyObject) } @@ -21,16 +21,16 @@ struct ObjectsPoolTests { @Test func createsZeroValueMap() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, userCallbackQueue: .main) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let map = try #require(result?.mapValue) // Verify it was added to the pool #expect(pool.entries["map:123@456"]?.mapValue != nil) // Verify the objectID is correctly set - #expect(map.testsOnly_objectID == "map:123@456") + #expect(map.objectID == "map:123@456") } // @spec RTO6b3 @@ -38,25 +38,25 @@ struct ObjectsPoolTests { func createsZeroValueCounter() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = pool.createZeroValueObject(forObjectID: "counter:123@456", logger: logger, userCallbackQueue: .main) + let result = pool.createZeroValueObject(forObjectID: "counter:123@456", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let counter = try #require(result?.counterValue) #expect(try counter.value(coreSDK: coreSDK) == 0) // Verify it was added to the pool #expect(pool.entries["counter:123@456"]?.counterValue != nil) // Verify the objectID is correctly set - #expect(counter.testsOnly_objectID == "counter:123@456") + #expect(counter.objectID == "counter:123@456") } // Sense check to see how it behaves when given an object ID not in the format of RTO6b1 (spec isn't prescriptive here) @Test func returnsNilForInvalidObjectId() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = pool.createZeroValueObject(forObjectID: "invalid", logger: logger, userCallbackQueue: .main) + let result = pool.createZeroValueObject(forObjectID: "invalid", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect(result == nil) } @@ -64,9 +64,9 @@ struct ObjectsPoolTests { @Test func returnsNilForUnknownType() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", logger: logger, userCallbackQueue: .main) + let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect(result == nil) #expect(pool.entries["unknown:123@456"] == nil) } @@ -85,10 +85,10 @@ struct ObjectsPoolTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let existingMapSubscriber = Subscriber(callbackQueue: .main) try existingMap.subscribe(listener: existingMapSubscriber.createListener(), coreSDK: coreSDK) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "updated_value") let objectState = TestFactories.mapObjectState( @@ -100,7 +100,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify the existing map was updated by checking side effects of InternalDefaultLiveMap.replaceData(using:) let updatedMap = try #require(pool.entries["map:hash@123"]?.mapValue) @@ -123,10 +123,10 @@ struct ObjectsPoolTests { func updatesExistingCounterObject() async throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let existingCounterSubscriber = Subscriber(callbackQueue: .main) try existingCounter.subscribe(listener: existingCounterSubscriber.createListener(), coreSDK: coreSDK) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@123", @@ -135,7 +135,7 @@ struct ObjectsPoolTests { count: 10, ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify the existing counter was updated by checking side effects of InternalDefaultLiveCounter.replaceData(using:) let updatedCounter = try #require(pool.entries["counter:hash@123"]?.counterValue) @@ -155,7 +155,7 @@ struct ObjectsPoolTests { func createsNewCounterObject() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -163,7 +163,7 @@ struct ObjectsPoolTests { count: 100, ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify a new counter was created and data was set by checking side effects of InternalDefaultLiveCounter.replaceData(using:) let newCounter = try #require(pool.entries["counter:hash@456"]?.counterValue) @@ -172,7 +172,7 @@ struct ObjectsPoolTests { // Checking site timeserials to verify they were set by replaceData #expect(newCounter.testsOnly_siteTimeserials == ["site2": "ts2"]) // Verify the objectID is correctly set per RTO5c1b1a - #expect(newCounter.testsOnly_objectID == "counter:hash@456") + #expect(newCounter.objectID == "counter:hash@456") } // @spec RTO5c1b1b @@ -182,7 +182,7 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let (key, entry) = TestFactories.stringMapEntry(key: "key2", value: "new_value") let objectState = TestFactories.mapObjectState( @@ -191,7 +191,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify a new map was created and data was set by checking side effects of InternalDefaultLiveMap.replaceData(using:) let newMap = try #require(pool.entries["map:hash@789"]?.mapValue) @@ -200,7 +200,7 @@ struct ObjectsPoolTests { // Checking site timeserials to verify they were set by replaceData #expect(newMap.testsOnly_siteTimeserials == ["site3": "ts3"]) // Verify the objectID and semantics are correctly set per RTO5c1b1b - #expect(newMap.testsOnly_objectID == "map:hash@789") + #expect(newMap.objectID == "map:hash@789") #expect(newMap.testsOnly_semantics == .known(.lww)) } @@ -208,7 +208,7 @@ struct ObjectsPoolTests { @Test func ignoresNonMapOrCounterObject() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let validObjectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -218,7 +218,7 @@ struct ObjectsPoolTests { let invalidObjectState = TestFactories.objectState(objectId: "invalid") - pool.applySyncObjectsPool([invalidObjectState, validObjectState], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([invalidObjectState, validObjectState].map { .init(state: $0, objectMessageSerialTimestamp: nil) }, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Check that there's no entry for the key that we don't know how to handle, and that it didn't interfere with the insertion of the we one that we do know how to handle #expect(Set(pool.entries.keys) == ["root", "counter:hash@456"]) @@ -230,11 +230,11 @@ struct ObjectsPoolTests { @Test func removesObjectsNotInSync() throws { let logger = TestLogger() - let existingMap1 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - let existingMap2 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingMap1 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let existingMap2 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: [ + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: [ "map:hash@1": .map(existingMap1), "map:hash@2": .map(existingMap2), "counter:hash@1": .counter(existingCounter), @@ -243,7 +243,7 @@ struct ObjectsPoolTests { // Only sync one of the existing objects let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify only synced object and root remain #expect(pool.entries.count == 2) // root + map:hash@1 @@ -257,11 +257,11 @@ struct ObjectsPoolTests { @Test func doesNotRemoveRootObject() throws { let logger = TestLogger() - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) // Sync with empty list (no objects) - pool.applySyncObjectsPool([], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify root is preserved but other objects are removed #expect(pool.entries.count == 1) // Only root @@ -277,16 +277,16 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let existingMapSubscriber = Subscriber(callbackQueue: .main) try existingMap.subscribe(listener: existingMapSubscriber.createListener(), coreSDK: coreSDK) let existingCounterSubscriber = Subscriber(callbackQueue: .main) try existingCounter.subscribe(listener: existingCounterSubscriber.createListener(), coreSDK: coreSDK) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: [ + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: [ "map:existing@1": .map(existingMap), "counter:existing@1": .counter(existingCounter), "map:toremove@1": .map(toBeRemovedMap), @@ -324,7 +324,7 @@ struct ObjectsPoolTests { // Note: "map:toremove@1" is not in sync, so it should be removed ] - pool.applySyncObjectsPool(syncObjects, logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool(syncObjects.map { .init(state: $0, objectMessageSerialTimestamp: nil) }, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify final state #expect(pool.entries.count == 5) // root + 4 synced objects @@ -354,14 +354,14 @@ struct ObjectsPoolTests { // Checking map data to verify the new map was created and replaceData was called #expect(try newMap.get(key: "new", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") // Verify the objectID and semantics are correctly set per RTO5c1b1b - #expect(newMap.testsOnly_objectID == "map:new@1") + #expect(newMap.objectID == "map:new@1") #expect(newMap.testsOnly_semantics == .known(.lww)) let newCounter = try #require(pool.entries["counter:new@1"]?.counterValue) // Checking counter value to verify the new counter was created and replaceData was called #expect(try newCounter.value(coreSDK: coreSDK) == 50) // Verify the objectID is correctly set per RTO5c1b1a - #expect(newCounter.testsOnly_objectID == "counter:new@1") + #expect(newCounter.objectID == "counter:new@1") // Removed object #expect(pool.entries["map:toremove@1"] == nil) diff --git a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift index 4b77924b..c5503f29 100644 --- a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -118,7 +118,6 @@ enum WireObjectMessageTests { counter: nil, nonce: nil, initialValue: nil, - initialValueEncoding: nil, ), object: nil, serial: "s1", @@ -173,7 +172,6 @@ enum WireObjectMessageTests { "map": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], "counter": ["count": 42], "nonce": "nonce1", - "initialValueEncoding": "utf8", ] let op = try WireObjectOperation(wireObject: wire) #expect(op.action == .known(.mapCreate)) @@ -189,8 +187,6 @@ enum WireObjectMessageTests { // Per OOP3g we should not try and extract this #expect(op.nonce == nil) // Per OOP3h we should not try and extract this - #expect(op.initialValueEncoding == nil) - // Per OOP3i we should not try and extract this #expect(op.initialValue == nil) } @@ -209,7 +205,6 @@ enum WireObjectMessageTests { #expect(op.counter == nil) #expect(op.nonce == nil) #expect(op.initialValue == nil) - #expect(op.initialValueEncoding == nil) } @Test @@ -236,7 +231,6 @@ enum WireObjectMessageTests { counter: WireObjectsCounter(count: 42), nonce: "nonce1", initialValue: nil, - initialValueEncoding: "utf8", ) let wire = op.toWireObject #expect(wire == [ @@ -247,7 +241,6 @@ enum WireObjectMessageTests { "map": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], "counter": ["count": 42], "nonce": "nonce1", - "initialValueEncoding": "utf8", ]) } @@ -262,7 +255,6 @@ enum WireObjectMessageTests { counter: nil, nonce: nil, initialValue: nil, - initialValueEncoding: nil, ) let wire = op.toWireObject #expect(wire == [ @@ -326,7 +318,6 @@ enum WireObjectMessageTests { counter: nil, nonce: nil, initialValue: nil, - initialValueEncoding: nil, ), map: WireObjectsMap( semantics: .known(.lww), diff --git a/ably-cocoa b/ably-cocoa index 4e0601a4..c72e1d74 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 4e0601a4567235a2e5afd36eb19d363057b642da +Subproject commit c72e1d7498bfcd0562f67442601e5056c2592631