From 51e3f9fe0dbe33fe2b0d6c8c8ab1aa295807f416 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 18 Jul 2025 11:10:54 -0300 Subject: [PATCH 01/18] Switch to a new internal type for LiveMap data values This is preparation for adding additional fields (e.g. tombstonedAt) per RTLM3a in [1]. [1] https://github.com/ably/specification/pull/350 --- .../Internal/InternalDefaultLiveMap.swift | 18 +++--- .../Internal/InternalObjectsMapEntry.swift | 14 +++++ .../Helpers/TestFactories.swift | 34 +++++++++++ .../InternalDefaultLiveMapTests.swift | 60 +++++++++---------- 4 files changed, 87 insertions(+), 39 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 8146c139..59a72f46 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -14,7 +14,7 @@ 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 } @@ -50,7 +50,7 @@ internal final class InternalDefaultLiveMap: Sendable { // 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, @@ -66,7 +66,7 @@ internal final class InternalDefaultLiveMap: Sendable { } private init( - data: [String: ObjectsMapEntry], + data: [String: InternalObjectsMapEntry], objectID: String, semantics: WireEnum?, logger: AblyPlugin.Logger, @@ -339,7 +339,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal var liveObject: 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? @@ -361,7 +361,7 @@ internal final class InternalDefaultLiveMap: Sendable { liveObject.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 { @@ -527,7 +527,7 @@ 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 @@ -563,7 +563,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])) @@ -647,9 +647,9 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - Helper Methods - /// Converts an ObjectsMapEntry to LiveMapValue using the same logic as get(key:) + /// 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 { 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/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index c82df9e9..424e06ef 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -404,6 +404,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 +436,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/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 2cdefbf8..081e13f9 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -38,7 +38,7 @@ 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 ) @@ -51,7 +51,7 @@ 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 result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) @@ -63,7 +63,7 @@ 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 result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) @@ -74,7 +74,7 @@ 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 result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) @@ -85,7 +85,7 @@ 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 result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) @@ -96,7 +96,7 @@ 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) @@ -108,7 +108,7 @@ 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) @@ -124,7 +124,7 @@ 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) @@ -139,7 +139,7 @@ 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) @@ -294,11 +294,11 @@ 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, @@ -339,9 +339,9 @@ 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, @@ -383,12 +383,12 @@ struct InternalDefaultLiveMapTests { 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, @@ -434,7 +434,7 @@ 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, @@ -473,7 +473,7 @@ 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, @@ -642,7 +642,7 @@ 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, @@ -667,7 +667,7 @@ 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, @@ -791,7 +791,7 @@ 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, @@ -849,7 +849,7 @@ 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, @@ -881,8 +881,8 @@ 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, From 19980574317d596cabe3f6ce4eaf48f86af3309b Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 18 Jul 2025 14:35:47 -0300 Subject: [PATCH 02/18] Inject a clock into our LiveObjects We'll use this when setting the upcoming tombstonedAt value for objects and map entries. This was all generated by Cursor; my only change was to add some locking into the mock class. --- .../Internal/DefaultInternalPlugin.swift | 2 +- .../Internal/InternalDefaultLiveCounter.swift | 12 +- .../Internal/InternalDefaultLiveMap.swift | 24 +++- .../InternalDefaultRealtimeObjects.swift | 17 ++- .../Internal/ObjectsPool.swift | 17 ++- .../Internal/SimpleClock.swift | 19 +++ .../InternalDefaultLiveCounterTests.swift | 42 +++---- .../InternalDefaultLiveMapTests.swift | 116 ++++++++++-------- .../InternalDefaultRealtimeObjectsTests.swift | 2 +- .../Mocks/MockSimpleClock.swift | 39 ++++++ .../ObjectsPoolTests.swift | 72 +++++------ 11 files changed, 238 insertions(+), 124 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/SimpleClock.swift create mode 100644 Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift 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..911022dd 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -29,6 +29,7 @@ internal final class InternalDefaultLiveCounter: Sendable { private let logger: AblyPlugin.Logger private let userCallbackQueue: DispatchQueue + private let clock: SimpleClock // MARK: - Initialization @@ -36,20 +37,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) self.logger = logger self.userCallbackQueue = userCallbackQueue + self.clock = clock } /// Creates a "zero-value LiveCounter", per RTLC4. @@ -60,12 +64,14 @@ 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, ) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 59a72f46..d31383d0 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -46,6 +46,7 @@ internal final class InternalDefaultLiveMap: Sendable { private let logger: AblyPlugin.Logger private let userCallbackQueue: DispatchQueue + private let clock: SimpleClock // MARK: - Initialization @@ -55,6 +56,7 @@ internal final class InternalDefaultLiveMap: Sendable { testsOnly_semantics semantics: WireEnum? = nil, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { self.init( data: data, @@ -62,6 +64,7 @@ internal final class InternalDefaultLiveMap: Sendable { semantics: semantics, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } @@ -71,10 +74,12 @@ internal final class InternalDefaultLiveMap: Sendable { semantics: WireEnum?, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { mutableState = .init(liveObject: .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 +92,7 @@ internal final class InternalDefaultLiveMap: Sendable { semantics: WireEnum? = nil, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) -> Self { .init( data: [:], @@ -94,6 +100,7 @@ internal final class InternalDefaultLiveMap: Sendable { semantics: semantics, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } @@ -244,6 +251,7 @@ internal final class InternalDefaultLiveMap: Sendable { using: state, objectsPool: &objectsPool, logger: logger, + clock: clock, userCallbackQueue: userCallbackQueue, ) } @@ -257,6 +265,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -269,6 +278,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -288,6 +298,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -309,6 +320,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -352,6 +364,7 @@ internal final class InternalDefaultLiveMap: Sendable { using state: ObjectState, objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, + clock: SimpleClock, userCallbackQueue: DispatchQueue, ) -> LiveObjectUpdate { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials @@ -370,6 +383,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 +397,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 +419,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -439,6 +455,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { // RTLM15b @@ -457,6 +474,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) // RTLM15d1a liveObject.emit(update, on: userCallbackQueue) @@ -478,6 +496,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) // RTLM15d2a liveObject.emit(update, on: userCallbackQueue) @@ -507,6 +526,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] { @@ -533,7 +553,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute if let objectId = operationData.objectId, !objectId.isEmpty { // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 - _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger, userCallbackQueue: userCallbackQueue) + _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) } // RTLM7f @@ -615,6 +635,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) -> LiveObjectUpdate { if liveObject.createOperationIsMerged { // RTLM16b @@ -630,6 +651,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 3bc22e6a..2b5439aa 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]> @@ -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 @@ -175,6 +177,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessages: objectMessages, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, receivedObjectProtocolMessagesContinuation: receivedObjectProtocolMessagesContinuation, ) } @@ -192,6 +195,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool protocolMessageChannelSerial: protocolMessageChannelSerial, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, receivedObjectSyncProtocolMessagesContinuation: receivedObjectSyncProtocolMessagesContinuation, ) } @@ -202,7 +206,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool /// Intended as a way for tests to populate the object pool. internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String) -> ObjectsPool.Entry? { mutex.withLock { - mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue) + mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) } } @@ -264,6 +268,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) @@ -321,6 +326,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool completedSyncObjectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) // RTO5c6 @@ -331,6 +337,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessage, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -347,6 +354,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessages: [InboundObjectMessage], logger: Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, ) { receivedObjectProtocolMessagesContinuation.yield(objectMessages) @@ -366,6 +374,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessage, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -376,6 +385,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool _ objectMessage: InboundObjectMessage, logger: Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { guard let operation = objectMessage.operation else { // RTO9a1 @@ -392,6 +402,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 diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 135ea792..f45ac9a1 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -97,11 +97,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 +111,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 +143,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 +163,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 } @@ -176,6 +180,7 @@ internal struct ObjectsPool { _ syncObjectsPool: [ObjectState], logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { logger.log("applySyncObjectsPool called with \(syncObjectsPool.count) objects", level: .debug) @@ -207,14 +212,14 @@ internal struct ObjectsPool { if objectState.counter != nil { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger, userCallbackQueue: userCallbackQueue) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) _ = counter.replaceData(using: objectState) newEntry = .counter(counter) } else if let objectsMap = objectState.map { // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, // set its private objectId equal to ObjectState.objectId, set its private semantics // equal to ObjectState.map.semantics and override its internal data per RTLM6 - let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) _ = map.replaceData(using: objectState, objectsPool: &self) newEntry = .map(map) } else { 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/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 83e85728..4b0830a5 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,7 +28,7 @@ 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 @@ -44,7 +44,7 @@ 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 ) @@ -60,7 +60,7 @@ struct InternalDefaultLiveCounterTests { // Given: A counter whose createOperationIsMerged is true let logger = TestLogger() let counter = { - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, 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( @@ -87,7 +87,7 @@ 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 @@ -100,7 +100,7 @@ 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 @@ -115,7 +115,7 @@ 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 @@ -135,7 +135,7 @@ struct InternalDefaultLiveCounterTests { @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 @@ -157,7 +157,7 @@ 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 @@ -181,7 +181,7 @@ 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 @@ -197,7 +197,7 @@ 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 @@ -221,7 +221,7 @@ 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 @@ -262,7 +262,7 @@ 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 @@ -286,7 +286,7 @@ 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 @@ -299,7 +299,7 @@ struct InternalDefaultLiveCounterTests { 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( @@ -323,14 +323,14 @@ 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( @@ -358,7 +358,7 @@ 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) @@ -372,7 +372,7 @@ struct InternalDefaultLiveCounterTests { 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( @@ -397,14 +397,14 @@ 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", diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 081e13f9..22828ca0 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) } @@ -43,7 +43,7 @@ struct InternalDefaultLiveMapTests { data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, 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) } @@ -53,7 +53,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() 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) } @@ -65,7 +65,7 @@ struct InternalDefaultLiveMapTests { let bytes = Data([0x01, 0x02, 0x03]) 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) } @@ -76,7 +76,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() 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) } @@ -87,7 +87,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() 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") } @@ -99,7 +99,7 @@ struct InternalDefaultLiveMapTests { 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) } @@ -111,9 +111,9 @@ struct InternalDefaultLiveMapTests { 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) @@ -127,9 +127,9 @@ struct InternalDefaultLiveMapTests { 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) @@ -142,7 +142,7 @@ struct InternalDefaultLiveMapTests { 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,12 +153,12 @@ 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) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) _ = map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) } @@ -168,9 +168,9 @@ 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( @@ -196,13 +196,13 @@ 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) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) _ = map.replaceData(using: state, objectsPool: &pool) let newData = map.testsOnly_data #expect(newData.count == 1) @@ -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,7 +233,7 @@ struct InternalDefaultLiveMapTests { ], ), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) _ = map.replaceData(using: state, objectsPool: &pool) // Note that we just check for some basic expected side effects of merging the initial value; RTLM17 is tested in more detail elsewhere // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d) @@ -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() @@ -303,6 +303,7 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) // Test size - should only count non-tombstoned entries @@ -346,6 +347,7 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) let size = try map.size(coreSDK: coreSDK) @@ -376,8 +378,8 @@ 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) @@ -393,6 +395,7 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) let size = try map.size(coreSDK: coreSDK) @@ -438,8 +441,9 @@ struct InternalDefaultLiveMapTests { 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( @@ -477,8 +481,9 @@ struct InternalDefaultLiveMapTests { 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 @@ -646,6 +653,7 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 @@ -671,6 +679,7 @@ struct InternalDefaultLiveMapTests { 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") @@ -795,8 +804,9 @@ struct InternalDefaultLiveMapTests { 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", @@ -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( @@ -853,8 +863,9 @@ struct InternalDefaultLiveMapTests { 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) @@ -887,8 +898,9 @@ struct InternalDefaultLiveMapTests { 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( @@ -917,8 +929,8 @@ 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") @@ -936,8 +948,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()) // Set initial data and mark create operation as merged _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) @@ -964,8 +976,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()) // Set initial data but don't mark create operation as merged _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) @@ -993,10 +1005,10 @@ 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" @@ -1032,7 +1044,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,7 +1052,7 @@ 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( @@ -1070,13 +1082,13 @@ 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: [:], @@ -1116,13 +1128,13 @@ 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: [:], @@ -1158,14 +1170,14 @@ 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", 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/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/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 8b3d14d5..eb3b8321 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,9 +21,9 @@ 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 @@ -38,9 +38,9 @@ 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) @@ -54,9 +54,9 @@ struct ObjectsPoolTests { @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([objectState], 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([objectState], 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([objectState], 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) @@ -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([objectState], 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) @@ -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], 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([objectState], 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, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify final state #expect(pool.entries.count == 5) // root + 4 synced objects From 94efe5a842d83d974e343d7beeeb25ff1d32a088 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 18 Jul 2025 16:01:16 -0300 Subject: [PATCH 03/18] Add new serialTimestamp fields Per [1] at 488e932. Preparation for implementing this tombstoning spec. [1] https://github.com/ably/specification/pull/350 --- .../AblyLiveObjects/Protocol/ObjectMessage.swift | 6 ++++++ .../Protocol/WireObjectMessage.swift | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index a15ea0db..d83df1fa 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,7 @@ internal struct OutboundObjectMessage { internal var object: ObjectState? // OM2g internal var serial: String? // OM2h internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j } internal struct ObjectOperation { @@ -65,6 +67,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 +107,7 @@ internal extension InboundObjectMessage { } serial = wireObjectMessage.serial siteCode = wireObjectMessage.siteCode + serialTimestamp = wireObjectMessage.serialTimestamp } } @@ -123,6 +127,7 @@ internal extension OutboundObjectMessage { object: object?.toWire(format: format), serial: serial, siteCode: siteCode, + serialTimestamp: serialTimestamp, ) } } @@ -360,6 +365,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..a562af2b 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 } } @@ -386,6 +393,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 +401,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 +422,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 } From 4724a0582d233b51e4abc7b9244f25ed49df11c9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 18 Jul 2025 16:50:06 -0300 Subject: [PATCH 04/18] Add placeholder protocol for polymorphic LiveObject functionality Preparation for adding the `tombstone` method from [1]. (This approach is a _bit_ weird but it's what I could think of that's compatible with the existing LiveObjectMutableState approach.) [1] https://github.com/ably/specification/pull/350 --- .../Internal/InternalDefaultLiveCounter.swift | 36 ++++++++--------- .../Internal/InternalDefaultLiveMap.swift | 40 +++++++++---------- .../Internal/InternalLiveObject.swift | 8 ++++ 3 files changed, 46 insertions(+), 38 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/InternalLiveObject.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 911022dd..0c311272 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -11,19 +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 + mutableState.liveObjectMutableState.createOperationIsMerged } } internal var testsOnly_objectID: String { mutex.withLock { - mutableState.liveObject.objectID + mutableState.liveObjectMutableState.objectID } } @@ -50,7 +50,7 @@ internal final class InternalDefaultLiveCounter: Sendable { 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 @@ -106,13 +106,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) } }) } @@ -120,7 +120,7 @@ internal final class InternalDefaultLiveCounter: Sendable { internal func unsubscribeAll() { mutex.withLock { - mutableState.liveObject.unsubscribeAll() + mutableState.liveObjectMutableState.unsubscribeAll() } } @@ -140,7 +140,7 @@ 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) } } @@ -195,9 +195,9 @@ 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 @@ -205,10 +205,10 @@ internal final class InternalDefaultLiveCounter: Sendable { /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. internal mutating func replaceData(using state: ObjectState) -> 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 @@ -237,7 +237,7 @@ internal final class InternalDefaultLiveCounter: Sendable { } // RTLC10b: Set the private flag createOperationIsMerged to true - liveObject.createOperationIsMerged = true + liveObjectMutableState.createOperationIsMerged = true return update } @@ -251,14 +251,14 @@ internal final class InternalDefaultLiveCounter: Sendable { 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): @@ -268,12 +268,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) @@ -285,7 +285,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 d31383d0..3db67a9d 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -22,7 +22,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal var testsOnly_objectID: String { mutex.withLock { - mutableState.liveObject.objectID + mutableState.liveObjectMutableState.objectID } } @@ -34,13 +34,13 @@ 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 } } @@ -76,7 +76,7 @@ internal final class InternalDefaultLiveMap: Sendable { 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 @@ -201,13 +201,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) } }) } @@ -215,7 +215,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal func unsubscribeAll() { mutex.withLock { - mutableState.liveObject.unsubscribeAll() + mutableState.liveObjectMutableState.unsubscribeAll() } } @@ -235,7 +235,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) } } @@ -346,9 +346,9 @@ 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: InternalObjectsMapEntry] @@ -368,10 +368,10 @@ internal final class InternalDefaultLiveMap: Sendable { 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?.mapValues { .init(objectsMapEntry: $0) } ?? [:] @@ -428,7 +428,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. @@ -457,14 +457,14 @@ internal final class InternalDefaultLiveMap: Sendable { 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): @@ -477,7 +477,7 @@ internal final class InternalDefaultLiveMap: Sendable { 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) @@ -499,7 +499,7 @@ internal final class InternalDefaultLiveMap: Sendable { clock: clock, ) // RTLM15d2a - liveObject.emit(update, on: userCallbackQueue) + liveObjectMutableState.emit(update, on: userCallbackQueue) case .known(.mapRemove): guard let mapOp = operation.mapOp else { return @@ -511,7 +511,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) @@ -637,7 +637,7 @@ internal final class InternalDefaultLiveMap: Sendable { 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 @@ -663,7 +663,7 @@ 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) } } 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 } +} From ec37ccaebab8d8ea931a4957845060f294cd34f2 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 24 Jul 2025 14:11:32 +0100 Subject: [PATCH 05/18] Make initialValue a string There isn't a spec PR for this yet, but it's needed in order to implement the write spec [1]. Asked about it in [2]. It's implemented in JS in [3]. Got Cursor to do this. [1] https://github.com/ably/specification/pull/353 [2] https://github.com/ably/specification/pull/353/files#r2228017382 [3] https://github.com/ably/ably-js/pull/2065 --- .../Protocol/ObjectMessage.swift | 39 ++--------- .../Protocol/WireObjectMessage.swift | 16 +---- .../Helpers/TestFactories.swift | 4 +- .../ObjectMessageTests.swift | 67 ------------------- .../WireObjectMessageTests.swift | 9 --- 5 files changed, 9 insertions(+), 126 deletions(-) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index d83df1fa..32201b1e 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -39,8 +39,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 { @@ -157,41 +156,14 @@ internal extension ObjectOperation { 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 `ObjectOperation` to a `WireObjectOperation`, applying the data encoding rules of OD4. /// /// - Parameters: - /// - format: The format to use when applying the encoding rules of OD4 and OOP5. + /// - format: The format to use when applying the encoding rules of OD4. 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( + .init( action: action, objectId: objectId, mapOp: mapOp?.toWire(format: format), @@ -199,8 +171,7 @@ internal extension ObjectOperation { map: map?.toWire(format: format), counter: counter, nonce: nonce, - initialValue: wireInitialValue, - initialValueEncoding: wireInitialValueEncoding, + initialValue: initialValue, ) } } diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index a562af2b..d9a26350 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -165,8 +165,7 @@ internal struct WireObjectOperation { 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 { @@ -179,7 +178,6 @@ extension WireObjectOperation: WireObjectCodable { case counter case nonce case initialValue - case initialValueEncoding } internal init(wireObject: [String: WireValue]) throws(InternalError) { @@ -194,8 +192,6 @@ 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] { @@ -220,10 +216,7 @@ 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 @@ -486,10 +479,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/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index 424e06ef..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, ) } 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/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), From 842d9e4dd324db602ba65d5b8cadaa978c4ace13 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 24 Jul 2025 14:45:30 +0100 Subject: [PATCH 06/18] Expose LiveObjects' ID internally This will be needed for the write API (to extract object IDs from the entries that the user supplies when creating a LiveMap). --- .../Internal/InternalDefaultLiveCounter.swift | 14 ++++++++------ .../Internal/InternalDefaultLiveMap.swift | 14 ++++++++------ Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift | 12 ++++++------ 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 0c311272..25158edd 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -21,12 +21,6 @@ internal final class InternalDefaultLiveCounter: Sendable { } } - internal var testsOnly_objectID: String { - mutex.withLock { - mutableState.liveObjectMutableState.objectID - } - } - private let logger: AblyPlugin.Logger private let userCallbackQueue: DispatchQueue private let clock: SimpleClock @@ -75,6 +69,14 @@ internal final class InternalDefaultLiveCounter: Sendable { ) } + // 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 { diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 3db67a9d..fb5b896f 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -20,12 +20,6 @@ internal final class InternalDefaultLiveMap: Sendable { } } - internal var testsOnly_objectID: String { - mutex.withLock { - mutableState.liveObjectMutableState.objectID - } - } - internal var testsOnly_semantics: WireEnum? { mutex.withLock { mutableState.semantics @@ -104,6 +98,14 @@ internal final class InternalDefaultLiveMap: Sendable { ) } + // 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. diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index eb3b8321..505a2f87 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -30,7 +30,7 @@ struct ObjectsPoolTests { #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 @@ -47,7 +47,7 @@ struct ObjectsPoolTests { // 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) @@ -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 @@ -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)) } @@ -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) From c74a23b7c69a5aa9c75103db1290a05229844714 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 24 Jul 2025 17:09:59 +0100 Subject: [PATCH 07/18] Expose mergeInitialValue methods internally This will be needed for the write API (to incorporate the values that the user supplies when creating a LiveObject). --- .../Internal/InternalDefaultLiveCounter.swift | 4 ++-- .../Internal/InternalDefaultLiveMap.swift | 4 ++-- .../InternalDefaultLiveCounterTests.swift | 10 +++++----- .../InternalDefaultLiveMapTests.swift | 12 ++++++------ 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 25158edd..2e6e1461 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -155,8 +155,8 @@ internal final class InternalDefaultLiveCounter: Sendable { } } - /// 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) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index fb5b896f..9e4763fc 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -259,8 +259,8 @@ internal final class InternalDefaultLiveMap: Sendable { } } - /// Test-only method to merge initial value from an ObjectOperation, per RTLM17. - internal func testsOnly_mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) -> 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, diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 4b0830a5..2c6fb5ee 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -128,7 +128,7 @@ struct InternalDefaultLiveCounterTests { } } - /// 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 @@ -144,7 +144,7 @@ struct InternalDefaultLiveCounterTests { // 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 @@ -169,7 +169,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 @@ -185,7 +185,7 @@ struct InternalDefaultLiveCounterTests { // 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) } @@ -202,7 +202,7 @@ struct InternalDefaultLiveCounterTests { // 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.mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) #expect(counter.testsOnly_createOperationIsMerged) // Try to apply another COUNTER_CREATE operation diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 22828ca0..c6cc8613 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -827,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 @@ -845,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) @@ -880,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) @@ -919,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]) @@ -934,7 +934,7 @@ struct InternalDefaultLiveMapTests { // 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) } @@ -953,7 +953,7 @@ struct InternalDefaultLiveMapTests { // 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.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 From f62a45792d4728253b702319f1102b1444c9d59e Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 24 Jul 2025 17:59:23 +0100 Subject: [PATCH 08/18] Expose public types' proxied objects internally This will be needed for the write API (to extract object IDs from the entries that the user supplies when creating a LiveMap). --- .../Public Proxy Objects/PublicDefaultLiveCounter.swift | 5 +---- .../Public/Public Proxy Objects/PublicDefaultLiveMap.swift | 5 +---- Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 7b6c0795..b769317d 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -4,10 +4,7 @@ import Ably /// /// 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` diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index a49cbfd2..b29d695c 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -4,10 +4,7 @@ import Ably /// /// 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` 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, ) } From 25455fd77774f33c51b77ed2f85efa6643e0caad Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 29 Jul 2025 11:45:12 +0100 Subject: [PATCH 09/18] Add an AsyncSequence API for subscribing to LiveObject updates Generated by Cursor at my request. Useful for tests. Will refine this (e.g. to hide the usage of AsyncStream, and maybe tweak the name) in #4. --- .../AblyLiveObjects/Public/PublicTypes.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 34e67f2b..68fa6638 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -371,3 +371,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 + } +} From 37e14d74eb6298795e278bc1d5abe1ef8bd4e60e Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 29 Jul 2025 13:53:11 +0100 Subject: [PATCH 10/18] Add Equatable conformance to (Internal)LiveMapValue Generated by Cursor at my request (although I largely replaced its code). Useful for tests. --- .../Internal/InternalLiveMapValue.swift | 23 ++++++++++++++++- .../AblyLiveObjects/Public/PublicTypes.swift | 25 +++++++++++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) 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/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 68fa6638..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) From 76ca4982ae58f39324e115cdfcf9f142f3c43442 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 29 Jul 2025 15:16:51 +0100 Subject: [PATCH 11/18] Add logging to PublicObjectsStore I'm trying to debug an issue and this will be helpful. Code largely generated by Cursor at my instruction. --- .../Public/ARTRealtimeChannel+Objects.swift | 3 ++ .../InternalLiveMapValue+ToPublic.swift | 7 ++- .../PublicDefaultLiveCounter.swift | 5 ++- .../PublicDefaultLiveMap.swift | 8 +++- .../PublicDefaultRealtimeObjects.swift | 6 ++- .../PublicObjectsStore.swift | 43 ++++++++++++++++--- 6 files changed, 60 insertions(+), 12 deletions(-) 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 b769317d..19c2bfc4 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -1,4 +1,5 @@ import Ably +internal import AblyPlugin /// Our default implementation of ``LiveCounter``. /// @@ -9,10 +10,12 @@ internal final class PublicDefaultLiveCounter: LiveCounter { // 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 b29d695c..f3a9c9ef 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -1,4 +1,5 @@ import Ably +internal import AblyPlugin /// Our default implementation of ``LiveMap``. /// @@ -10,11 +11,13 @@ internal final class PublicDefaultLiveMap: LiveMap { 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 @@ -24,6 +27,7 @@ internal final class PublicDefaultLiveMap: LiveMap { creationArgs: .init( coreSDK: coreSDK, mapDelegate: delegate, + logger: logger, ), ) } @@ -43,6 +47,7 @@ internal final class PublicDefaultLiveMap: LiveMap { creationArgs: .init( coreSDK: coreSDK, mapDelegate: delegate, + logger: logger, ), ) ) @@ -63,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..4a0f9ffe 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, ), ) } 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, ) } } From 116ee63821732fa8d6a3a720790ee304d9e0fd12 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 21 Jul 2025 14:31:59 +0100 Subject: [PATCH 12/18] Allow SyncObjectsPool entries to contain additional data This is preparation for tombstoning an object per [1]; we'll need the ObjectMessage's serialTimestamp. [1] https://github.com/ably/specification/pull/350 --- .../InternalDefaultRealtimeObjects.swift | 20 ++++++++++--- .../Internal/ObjectsPool.swift | 30 +++++++++---------- .../Internal/SyncObjectsPoolEntry.swift | 6 ++++ .../ObjectsPoolTests.swift | 14 ++++----- 4 files changed, 44 insertions(+), 26 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 2b5439aa..9bc9e763 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -45,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] @@ -276,7 +276,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]? @@ -305,7 +305,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) + } else { + nil + } + }) syncSequence = updatedSyncSequence @@ -316,7 +322,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) + } else { + nil + } + } completedSyncBufferedObjectOperations = nil } diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index f45ac9a1..da52da34 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -177,7 +177,7 @@ 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, @@ -191,46 +191,46 @@ 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, 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, clock: clock) - _ = counter.replaceData(using: objectState) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: syncObjectsPoolEntry.state.objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) + _ = counter.replaceData(using: syncObjectsPoolEntry.state) 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, clock: clock) - _ = 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, 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/SyncObjectsPoolEntry.swift b/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift new file mode 100644 index 00000000..40337b14 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift @@ -0,0 +1,6 @@ +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 +} diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 505a2f87..65e67247 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -100,7 +100,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState)], 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) @@ -135,7 +135,7 @@ struct ObjectsPoolTests { count: 10, ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState)], 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) @@ -163,7 +163,7 @@ struct ObjectsPoolTests { count: 100, ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState)], 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) @@ -191,7 +191,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState)], 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) @@ -218,7 +218,7 @@ struct ObjectsPoolTests { let invalidObjectState = TestFactories.objectState(objectId: "invalid") - pool.applySyncObjectsPool([invalidObjectState, validObjectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([invalidObjectState, validObjectState].map { .init(state: $0) }, 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"]) @@ -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, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify only synced object and root remain #expect(pool.entries.count == 2) // root + map:hash@1 @@ -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, clock: MockSimpleClock()) + pool.applySyncObjectsPool(syncObjects.map { .init(state: $0) }, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify final state #expect(pool.entries.count == 5) // root + 4 synced objects From 29aa9fc01f86ee713c722530cf4485c4c3fc9f03 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 21 Jul 2025 12:00:41 +0100 Subject: [PATCH 13/18] Accept a serialTimestamp in replaceData and applySyncObjectsPool Preparation for making these methods perform tombstoning per [1] (not implemented yet). [1] https://github.com/ably/specification/pull/350 --- .../Internal/InternalDefaultLiveCounter.swift | 21 ++++++- .../Internal/InternalDefaultLiveMap.swift | 13 ++++- .../InternalDefaultRealtimeObjects.swift | 5 +- .../Internal/ObjectsPool.swift | 46 ++++++++++++++-- .../Internal/SyncObjectsPoolEntry.swift | 9 +++ .../InternalDefaultLiveCounterTests.swift | 33 ++++++----- .../InternalDefaultLiveMapTests.swift | 55 ++++++++++++------- .../ObjectsPoolTests.swift | 14 ++--- 8 files changed, 144 insertions(+), 52 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 2e6e1461..635783fe 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -149,9 +149,15 @@ internal final class InternalDefaultLiveCounter: Sendable { // 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) } } @@ -181,6 +187,7 @@ internal final class InternalDefaultLiveCounter: Sendable { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, ) { mutex.withLock { @@ -188,6 +195,7 @@ internal final class InternalDefaultLiveCounter: Sendable { operation, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, @@ -205,7 +213,13 @@ internal final class InternalDefaultLiveCounter: Sendable { 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 liveObjectMutableState.siteTimeserials = state.siteTimeserials @@ -249,6 +263,7 @@ internal final class InternalDefaultLiveCounter: Sendable { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, logger: Logger, userCallbackQueue: DispatchQueue, diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 9e4763fc..e7880e77 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -247,10 +247,16 @@ 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, @@ -290,6 +296,7 @@ internal final class InternalDefaultLiveMap: Sendable { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, ) { mutex.withLock { @@ -297,6 +304,7 @@ internal final class InternalDefaultLiveMap: Sendable { operation, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, @@ -362,8 +370,10 @@ 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, @@ -454,6 +464,7 @@ internal final class InternalDefaultLiveMap: Sendable { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, logger: Logger, userCallbackQueue: DispatchQueue, diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 9bc9e763..ba5741d4 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -307,7 +307,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool // RTO5b updatedSyncSequence.syncObjectsPool.append(contentsOf: objectMessages.compactMap { objectMessage in if let object = objectMessage.object { - .init(state: object) + .init(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) } else { nil } @@ -324,7 +324,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool // RTO5a5: The sync data is contained entirely within this single OBJECT_SYNC completedSyncObjectsPool = objectMessages.compactMap { objectMessage in if let object = objectMessage.object { - .init(state: object) + .init(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) } else { nil } @@ -432,6 +432,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/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index da52da34..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, + ), + ) } } } @@ -199,7 +222,11 @@ internal struct ObjectsPool { 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: syncObjectsPoolEntry.state, 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 { @@ -213,14 +240,21 @@ internal struct ObjectsPool { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 let counter = InternalDefaultLiveCounter.createZeroValued(objectID: syncObjectsPoolEntry.state.objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) - _ = counter.replaceData(using: syncObjectsPoolEntry.state) + _ = counter.replaceData( + using: syncObjectsPoolEntry.state, + objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, + ) newEntry = .counter(counter) } 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: syncObjectsPoolEntry.state.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) - _ = map.replaceData(using: syncObjectsPoolEntry.state, objectsPool: &self) + _ = 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 diff --git a/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift b/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift index 40337b14..3a702137 100644 --- a/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift +++ b/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift @@ -3,4 +3,13 @@ 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/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 2c6fb5ee..6ce42481 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -32,7 +32,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attached) // Set some test data - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 42)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 42), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 42) } @@ -48,7 +48,7 @@ struct InternalDefaultLiveCounterTests { let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) - _ = counter.replaceData(using: state) + _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) } @@ -67,7 +67,7 @@ struct InternalDefaultLiveCounterTests { 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) @@ -92,7 +92,7 @@ struct InternalDefaultLiveCounterTests { 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) } @@ -104,7 +104,8 @@ struct InternalDefaultLiveCounterTests { 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) } } @@ -121,7 +122,7 @@ struct InternalDefaultLiveCounterTests { createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist count: 5, // Test value - must exist ) - _ = counter.replaceData(using: state) + _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC10a) #expect(counter.testsOnly_createOperationIsMerged) } @@ -139,7 +140,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation @@ -161,7 +162,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation with no count @@ -201,7 +202,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data and mark create operation as merged - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) _ = counter.mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) #expect(counter.testsOnly_createOperationIsMerged) @@ -225,7 +226,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data but don't mark create operation as merged - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) #expect(!counter.testsOnly_createOperationIsMerged) // Apply COUNTER_CREATE operation @@ -266,7 +267,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply COUNTER_INC operation @@ -293,7 +294,7 @@ struct InternalDefaultLiveCounterTests { _ = counter.replaceData(using: TestFactories.counterObjectState( siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" count: 5, - )) + ), objectMessageSerialTimestamp: nil) let operation = TestFactories.objectOperation( action: .known(.counterInc), @@ -306,6 +307,7 @@ struct InternalDefaultLiveCounterTests { operation, objectMessageSerial: "ts1", // Less than existing "ts2" objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -337,6 +339,7 @@ struct InternalDefaultLiveCounterTests { operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -365,7 +368,7 @@ struct InternalDefaultLiveCounterTests { 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( @@ -379,6 +382,7 @@ struct InternalDefaultLiveCounterTests { operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -409,6 +413,7 @@ struct InternalDefaultLiveCounterTests { TestFactories.mapCreateOperation(), objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index c6cc8613..4a67d2e4 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -159,7 +159,7 @@ struct InternalDefaultLiveMapTests { siteTimeserials: ["site1": "ts1", "site2": "ts2"], ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - _ = map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) } @@ -176,7 +176,7 @@ struct InternalDefaultLiveMapTests { let state = TestFactories.objectState( createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), ) - _ = map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, 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) @@ -203,7 +203,7 @@ struct InternalDefaultLiveMapTests { entries: [key: entry], ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - _ = map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) let newData = map.testsOnly_data #expect(newData.count == 1) #expect(Set(newData.keys) == ["key1"]) @@ -234,7 +234,7 @@ struct InternalDefaultLiveMapTests { ), ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - _ = map.replaceData(using: state, objectsPool: &pool) + _ = 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") @@ -952,7 +952,7 @@ struct InternalDefaultLiveMapTests { 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.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) @@ -980,7 +980,7 @@ struct InternalDefaultLiveMapTests { 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 @@ -1010,10 +1010,14 @@ struct InternalDefaultLiveMapTests { // Set up the map with an existing site timeserial that will cause the operation to be discarded var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, 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), @@ -1025,6 +1029,7 @@ struct InternalDefaultLiveMapTests { operation, objectMessageSerial: "ts1", // Less than existing "ts2" objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -1059,6 +1064,7 @@ struct InternalDefaultLiveMapTests { operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -1090,10 +1096,14 @@ struct InternalDefaultLiveMapTests { // Set initial data 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( @@ -1106,6 +1116,7 @@ struct InternalDefaultLiveMapTests { operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -1136,10 +1147,14 @@ struct InternalDefaultLiveMapTests { // Set initial data 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( @@ -1152,6 +1167,7 @@ struct InternalDefaultLiveMapTests { operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -1182,6 +1198,7 @@ struct InternalDefaultLiveMapTests { TestFactories.counterCreateOperation(), objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 65e67247..2609d9f4 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -100,7 +100,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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) @@ -135,7 +135,7 @@ struct ObjectsPoolTests { count: 10, ) - pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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) @@ -163,7 +163,7 @@ struct ObjectsPoolTests { count: 100, ) - pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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) @@ -191,7 +191,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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) @@ -218,7 +218,7 @@ struct ObjectsPoolTests { let invalidObjectState = TestFactories.objectState(objectId: "invalid") - pool.applySyncObjectsPool([invalidObjectState, validObjectState].map { .init(state: $0) }, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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"]) @@ -243,7 +243,7 @@ struct ObjectsPoolTests { // Only sync one of the existing objects let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") - pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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 @@ -324,7 +324,7 @@ struct ObjectsPoolTests { // Note: "map:toremove@1" is not in sync, so it should be removed ] - pool.applySyncObjectsPool(syncObjects.map { .init(state: $0) }, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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 From 951473c176b8c48a38d8a239fdf8e49e5db888ab Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 22 Jul 2025 11:46:53 +0100 Subject: [PATCH 14/18] Pull the RTLM14d "is map entry tombstoned?" check into method The logic for this check is going to expand when we implement the tombstoning spec [1], so let's DRY it up. [1] https://github.com/ably/specification/pull/350 --- .../Internal/InternalDefaultLiveMap.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index e7880e77..6b779ddf 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -147,9 +147,7 @@ internal final class InternalDefaultLiveMap: Sendable { 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) } } } @@ -170,7 +168,7 @@ internal final class InternalDefaultLiveMap: Sendable { // 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)) @@ -682,11 +680,16 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - Helper Methods + /// 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: 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 } From 83de58af4952099242bafd17d8b90079bdd28ab8 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 29 Jul 2025 16:52:54 +0100 Subject: [PATCH 15/18] Bump ably-cocoa to latest commit on LiveObjects integration branch The commit that we're currently using no longer exists due to rebases of feature branches (which have now been merged). --- ably-cocoa | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From b701b4646a3f5a29e3be12f7731d3cd1e4e6fe22 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 30 Jul 2025 14:18:17 +0100 Subject: [PATCH 16/18] Create a PartialObjectOperation type We need a type that represents (when considering [1] at cb11ba8) RTO13's "partial ObjectOperation"; namely an ObjectOperation without an objectId property. This is something that is easy to represent in TypeScript but a bit of a faff in Swift; we have to create a new type for it. Code largely generated by Cursor at my instruction. [1] https://github.com/ably/specification/pull/353 --- .../Protocol/ObjectMessage.swift | 93 +++++++++++++++++-- .../Protocol/WireObjectMessage.swift | 62 +++++++++++-- 2 files changed, 141 insertions(+), 14 deletions(-) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 32201b1e..caf21cd7 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -31,6 +31,19 @@ internal struct OutboundObjectMessage { 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 { internal var action: WireEnum // OOP3a internal var objectId: String // OOP3b @@ -141,16 +154,81 @@ 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 @@ -158,14 +236,13 @@ internal extension ObjectOperation { initialValue = nil } - /// Converts this `ObjectOperation` to a `WireObjectOperation`, applying the data encoding rules of OD4. + /// 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. - func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectOperation { + func toWire(format: AblyPlugin.EncodingFormat) -> PartialWireObjectOperation { .init( action: action, - objectId: objectId, mapOp: mapOp?.toWire(format: format), counterOp: counterOp, map: map?.toWire(format: format), diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index d9a26350..163cde4d 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -157,9 +157,11 @@ 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 @@ -168,10 +170,9 @@ internal struct WireObjectOperation { 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 @@ -182,7 +183,6 @@ extension WireObjectOperation: WireObjectCodable { 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) @@ -197,7 +197,6 @@ extension WireObjectOperation: WireObjectCodable { 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 { @@ -223,6 +222,57 @@ extension WireObjectOperation: WireObjectCodable { } } +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 From 2d132ef4b9eb39dc0befc8454a51b637d5c5944d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 30 Jul 2025 14:48:47 +0100 Subject: [PATCH 17/18] Extract operations' channel state validation to helper Code largely generated by Cursor at my request. --- .../AblyLiveObjects/Internal/CoreSDK.swift | 25 +++++++++++++++++ .../Internal/InternalDefaultLiveCounter.swift | 9 +------ .../Internal/InternalDefaultLiveMap.swift | 27 +++---------------- .../InternalDefaultRealtimeObjects.swift | 9 +------ .../Internal/LiveObjectMutableState.swift | 9 +------ 5 files changed, 31 insertions(+), 48 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index e7bc5b9e..00695c18 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -40,3 +40,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/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 635783fe..d1149f26 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -81,14 +81,7 @@ internal final class InternalDefaultLiveCounter: Sendable { 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 diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 6b779ddf..3790b57d 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -111,14 +111,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// 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] @@ -135,14 +128,7 @@ 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 @@ -154,14 +140,7 @@ internal final class InternalDefaultLiveMap: Sendable { 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 diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index ba5741d4..81c9a86f 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -93,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 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 From bbf82295ed4cc5b144b1500d1a7afca10ba3912b Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 30 Jul 2025 15:11:17 +0100 Subject: [PATCH 18/18] Add placeholder for spec's #publish method Based on [1] at cb11ba8. Implementation deferred to #47. [1] https://github.com/ably/specification/pull/353 --- Sources/AblyLiveObjects/Internal/CoreSDK.swift | 6 ++++-- .../Internal/InternalDefaultRealtimeObjects.swift | 4 ++-- .../Public Proxy Objects/PublicDefaultRealtimeObjects.swift | 4 ++-- Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift | 4 ++-- Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 00695c18..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, diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 81c9a86f..6d413353 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -206,8 +206,8 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool // 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 diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index 4a0f9ffe..94be8e40 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -75,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/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/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() }