From 71b7d2041d47f7b219689e15ec53ef1de867439e Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 31 Jul 2026 15:48:14 +0530 Subject: [PATCH 1/2] liveobjects: implement path-based API end-to-end and port all UTS unit specs Completes the LiveObjects implementation behind the shipped public API (which is unchanged), ported from ably-java as the reference: - Instance and PathObject layers (typed instances, per-call path resolution, compactJson with cycle markers, as* casts) - Path subscriptions with RTO24 depth-windowed coverage, wired to getFullPaths over the new parent-reference graph (RTLO3f/4f/4g/4h, RTO5c10 post-sync rebuild) - RealtimeObject.get()/on()/dispose (RTO23, RTO17/18, RTO20e waiters) - Update enrichment: objectMessage + tombstone on update payloads, PAOM3 wire-to-public conversion, tombstone subscription teardown - RTO27 channel-state data lifecycle; OM3 message-size publish gate; RTO5a sync-cursor reconciliation; channel-config guards + error model - 14 spec-compliance bug fixes found by the ported spec tests (RTLO4e10 root-tombstone protection, exclusive-access crash guards, RTLM24c tie-break, RTLM22 teardown diffs, RTO9a2b check ordering, ...) All 15 UTS objects/unit specs are ported to LiveObjects/Tests/AblyLiveObjectsTests/UTS/ (matching ably-java's uts/unit layout) with @UTS traceability tags and a deviations register. 653 unit tests passing; lint clean; public API surface byte-identical to the previously shipped commit. --- .../Internal/ChannelConfigGuards.swift | 117 +++ .../Internal/DefaultInternalPlugin.swift | 11 + .../Internal/DefaultLiveCounterUpdate.swift | 10 + .../Internal/DefaultLiveMapUpdate.swift | 10 + .../Internal/InternalDefaultLiveCounter.swift | 154 ++- .../Internal/InternalDefaultLiveMap.swift | 370 ++++++- .../InternalDefaultRealtimeObjects.swift | 361 ++++++- .../Internal/InternalLiveObject.swift | 61 ++ .../Internal/InternalTypes.swift | 18 +- .../Internal/LiveObjectMutableState.swift | 6 + .../Internal/LiveObjectUpdate.swift | 27 + .../Internal/ObjectDiffHelpers.swift | 20 +- .../Internal/ObjectsPool.swift | 275 +++++- .../PathObjectSubscriptionRegister.swift | 150 +++ .../Default/DefaultLiveCounterInstance.swift | 68 +- .../DefaultLiveCounterPathObject.swift | 98 +- .../Default/DefaultLiveMapInstance.swift | 276 +++++- .../Default/DefaultLiveMapPathObject.swift | 146 +-- .../Default/DefaultPathObject.swift | 183 ++++ .../Default/DefaultPrimitiveInstance.swift | 72 +- .../Default/DefaultPrimitivePathObject.swift | 76 +- .../Default/DefaultStatusSubscription.swift | 11 +- .../Default/DefaultSubscription.swift | 11 +- .../Path Based API/Public/PathObject.swift | 5 +- .../Protocol/ObjectMessage.swift | 285 ++++++ .../AblyLiveObjects/Protocol/SyncCursor.swift | 36 +- .../PublicDefaultRealtimeObject.swift | 29 +- .../AblyLiveObjects/Utility/Errors.swift | 93 +- .../Utility/PathSegments.swift | 87 ++ .../DefaultInstanceTests.swift | 481 ++++++++++ .../DefaultPathObjectTests.swift | 396 ++++++++ .../Helpers/UTSTestPoolFactories.swift | 146 +++ .../InternalDefaultLiveCounterTests.swift | 10 +- .../InternalDefaultLiveMapTests.swift | 389 +++++++- .../InternalDefaultRealtimeObjectsTests.swift | 303 ++++-- .../Mocks/MockRealtimeObjects.swift | 11 + .../ObjectDiffHelpersTests.swift | 27 +- .../ObjectsPoolTests.swift | 58 +- .../ParentReferencesTests.swift | 203 ++++ .../PathObjectSubscriptionTests.swift | 451 +++++++++ .../PublicRealtimeObjectTests.swift | 284 ++++++ .../SyncCursorTests.swift | 108 ++- .../TestsOnlySeamsTests.swift | 393 ++++++++ .../UTS/InstanceUTSTests.swift | 478 ++++++++++ .../UTS/InternalLiveCounterApiUTSTests.swift | 171 ++++ .../UTS/InternalLiveCounterUTSTests.swift | 593 ++++++++++++ .../UTS/InternalLiveMapApiUTSTests.swift | 170 ++++ ...ernalLiveMapParentReferencesUTSTests.swift | 282 ++++++ .../UTS/InternalLiveMapUTSTests.swift | 899 ++++++++++++++++++ .../UTS/LiveObjectSubscribeUTSTests.swift | 372 ++++++++ .../UTS/ObjectIdUTSTests.swift | 142 +++ .../UTS/ObjectsPoolUTSTests.swift | 706 ++++++++++++++ .../UTS/ObjectsUTSHelpers.swift | 521 ++++++++++ .../UTS/ParentReferencesUTSTests.swift | 523 ++++++++++ .../UTS/PathObjectMutationsUTSTests.swift | 206 ++++ .../UTS/PathObjectSubscribeUTSTests.swift | 541 +++++++++++ .../UTS/PathObjectUTSTests.swift | 317 ++++++ .../UTS/PublicObjectMessageUTSTests.swift | 625 ++++++++++++ .../Tests/AblyLiveObjectsTests/UTS/README.md | 32 + .../UTS/RealtimeObjectUTSTests.swift | 365 +++++++ .../UTS/ValueTypesUTSTests.swift | 195 ++++ .../AblyLiveObjectsTests/UTS/deviations.md | 263 +++++ .../WireObjectMessageSizeTests.swift | 247 +++++ Package.swift | 6 +- Test/UTS/deviations.md | 20 +- 65 files changed, 13503 insertions(+), 497 deletions(-) create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/ChannelConfigGuards.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/PathObjectSubscriptionRegister.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPathObject.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/PathSegments.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/DefaultInstanceTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/DefaultPathObjectTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/Helpers/UTSTestPoolFactories.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/ParentReferencesTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/PathObjectSubscriptionTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/PublicRealtimeObjectTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/TestsOnlySeamsTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/InstanceUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveCounterApiUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveCounterUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveMapApiUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveMapParentReferencesUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveMapUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/LiveObjectSubscribeUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectIdUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectsPoolUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectsUTSHelpers.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/ParentReferencesUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/PathObjectMutationsUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/PathObjectSubscribeUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/PathObjectUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/PublicObjectMessageUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/README.md create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/RealtimeObjectUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/ValueTypesUTSTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/UTS/deviations.md create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/WireObjectMessageSizeTests.swift diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ChannelConfigGuards.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ChannelConfigGuards.swift new file mode 100644 index 000000000..7e931dc7d --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ChannelConfigGuards.swift @@ -0,0 +1,117 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// The channel-configuration precondition guards for the path-based public API (Kotlin +/// `Helpers.kt`'s `throwIf*` family). Each public read/write entry point runs the relevant guard +/// before touching the object graph, per RTO23a/RTO25/RTO26. +/// +/// ## Implementability against the plugin API +/// +/// Only some of ably-java's guard checks are expressible through `_AblyPluginSupportPrivate` +/// (`PluginAPIProtocol`) as it stands today: +/// +/// | Check | Spec | Implemented? | +/// |---|---|---| +/// | Channel state (DETACHED/FAILED, +SUSPENDED for writes) | RTO25/RTO26 | ✅ via `CoreSDK.nosync_channelState` | +/// | `object_subscribe` / `object_publish` channel mode | RTO2a2/RTO2b2 (40024) | ❌ no plugin accessor — stubbed | +/// | `echoMessages` client option | RTO26 | ❌ no plugin accessor — stubbed | +/// | Connection `isActive` (publishable state) | RTO26 | ❌ no plugin accessor — stubbed | +/// +/// The unimplementable checks carry a `// TODO(core accessor):` marker; landing them needs a +/// `PluginAPI`/core-SDK accessor (a user-gated core change, out of scope per plan §6.6 — the DEV-23 +/// precedent). The channel-state check reuses the exact `CoreSDK.nosync_validateChannelState` the +/// internal engine's node accessors already run (same 90001 code), so no new state check is invented. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal enum ChannelConfigGuards { + /// Validates the access (read/subscribe) API preconditions: the channel must be attachable (not + /// DETACHED/FAILED) and configured with the `object_subscribe` mode. Spec: RTO25. + internal static func throwIfInvalidAccessApiConfiguration(coreSDK: CoreSDK, internalQueue: DispatchQueue) throws(ARTErrorInfo) { + // RTO25b — channel-state check (implementable). Reuses the engine's node-accessor check. + try validateChannelState(coreSDK: coreSDK, internalQueue: internalQueue, notIn: [.detached, .failed], operationDescription: "access API") + // TODO(core accessor): RTO25a / RTO2a2 — throw `channelModeRequired("object_subscribe")` (40024) + // when the channel is not configured with the `object_subscribe` mode. The plugin API exposes + // no channel modes; see the guard-implementability table in this file's doc comment. + } + + /// Validates only the `object_subscribe` channel mode (no channel-state check), for + /// `RealtimeObject.get()` (RTO23a). Unlike the access methods, `get()` delegates channel-state + /// handling to the ensure-attached procedure (RTL33), so it must not pre-empt that with a state + /// gate. Spec: RTO23a. (Wired in P5's `get()`.) + internal static func throwIfMissingObjectSubscribeMode(coreSDK _: CoreSDK, internalQueue _: DispatchQueue) throws(ARTErrorInfo) { + // TODO(core accessor): RTO2a2 — throw `channelModeRequired("object_subscribe")` (40024) when the + // channel is not configured with the `object_subscribe` mode. No plugin channel-modes accessor. + } + + /// Validates the write (mutation) API preconditions: message echo must be enabled, the channel + /// must be usable (not DETACHED/FAILED/SUSPENDED) and configured with the `object_publish` mode. + /// Spec: RTO26. + internal static func throwIfInvalidWriteApiConfiguration(coreSDK: CoreSDK, internalQueue: DispatchQueue) throws(ARTErrorInfo) { + // TODO(core accessor): RTO26 — throw a bad-request error when `echoMessages` is disabled. The + // plugin API exposes client options only as an opaque marker protocol (no `echoMessages`). + // RTO26b — channel-state check (implementable). Reuses the engine's node-accessor check. + try validateChannelState(coreSDK: coreSDK, internalQueue: internalQueue, notIn: [.detached, .failed, .suspended], operationDescription: "write API") + // TODO(core accessor): RTO2b2 — throw `channelModeRequired("object_publish")` (40024) when the + // channel is not configured with the `object_publish` mode. No plugin channel-modes accessor. + } + + /// RTO23e / RTL33 — the *ensure-active-channel* procedure that `RealtimeObject.get()` runs before + /// waiting for sync. ably-java implicitly attaches a DETACHED/INITIALIZED channel (RTL33b) and + /// rejects only FAILED (RTL33c, code 90001). + /// + /// The plugin API (`PluginAPIProtocol` / `_AblyPluginSupportPrivate`) exposes **no way to + /// initiate a channel attach** — only `nosync_stateForChannel` (state read) and the inbound + /// `nosync_onChannelAttached` callback. So the implicit-attach half of RTL33 is not implementable + /// here; we implement the implementable half — reject a FAILED channel (RTL33c) — and leave the + /// attach to the application. When the channel is not yet attached, `get()`'s subsequent + /// `ensureSynced` simply waits until it becomes synced. + /// + /// Spec: RTO23e, RTL33. + internal static func ensureActiveChannel(coreSDK: CoreSDK, internalQueue: DispatchQueue) throws(ARTErrorInfo) { + // TODO(core accessor): RTL33b — implicitly attach a DETACHED/INITIALIZED channel. The plugin + // API can only read channel state, not initiate an attach; landing this needs a + // PluginAPI/core-SDK accessor (user-gated core change, plan §6.6; DEV-38 precedent). + // RTL33c — reject a FAILED channel (implementable; code 90001). + try validateChannelState(coreSDK: coreSDK, internalQueue: internalQueue, notIn: [.failed], operationDescription: "get") + } + + /// Validates that the channel is in a publishable state (connection active, channel not + /// FAILED/SUSPENDED). Spec: RTO26 (publishable-state variant). (Reserved for P5.) + internal static func throwIfUnpublishableState(coreSDK: CoreSDK, internalQueue: DispatchQueue) throws(ARTErrorInfo) { + // TODO(core accessor): the connection `isActive` check — the plugin API exposes no connection + // state / manager. Only the channel-state portion below is implementable. + try validateChannelState(coreSDK: coreSDK, internalQueue: internalQueue, notIn: [.failed, .suspended], operationDescription: "publish") + } + + /// RTPO19c1a / DEV-9 — validates a subscription `depth`. ably-java throws 40003 from the + /// `PathObjectSubscriptionOptions(int)` constructor; the shipped Swift `init(depth:)` is + /// non-throwing and frozen, so the check moves here, to be called from `subscribe(options:listener:)` + /// once path subscriptions land (part 2). + internal static func validateSubscriptionDepth(_ depth: Int?) throws(ARTErrorInfo) { + if let depth, depth <= 0 { + throw LiveObjectsError.invalidInput(message: "Subscription depth must be a positive integer, got \(depth)").toARTErrorInfo() + } + } + + // MARK: - Private helpers + + /// Runs `CoreSDK.nosync_validateChannelState` on the internal queue (the `nosync_` accessor must be + /// invoked there), re-throwing its typed `ARTErrorInfo` to the caller. + private static func validateChannelState( + coreSDK: CoreSDK, + internalQueue: DispatchQueue, + notIn invalidStates: [_AblyPluginSupportPrivate.RealtimeChannelState], + operationDescription: String, + ) throws(ARTErrorInfo) { + let failure: ARTErrorInfo? = internalQueue.ably_syncNoDeadlock { + do throws(ARTErrorInfo) { + try coreSDK.nosync_validateChannelState(notIn: invalidStates, operationDescription: operationDescription) + return nil + } catch { + return error + } + } + if let failure { + throw failure + } + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 01c3c39be..28d9529c5 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -20,6 +20,17 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. self.pluginAPI = pluginAPI } + // Dispose lifecycle (matrix #18): Kotlin's `DefaultLiveObjectsPlugin` exposes + // `dispose(channelName)` / `dispose()`, which ably-java calls from its channel/client teardown to + // dispose the per-channel `DefaultRealtimeObject`. ably-cocoa's plugin SPI + // (`LiveObjectsInternalPluginProtocol`) has **no dispose / channel-release callback** — the core + // SDK never notifies the plugin that a channel or client is being released. So there is no + // analogue to wire here: the per-channel `InternalDefaultRealtimeObjects` is stored as the + // channel's plugin-data value, and its teardown (`InternalDefaultRealtimeObjects.dispose()`) runs + // from its `deinit` when the channel releases that value (ARC). Landing an explicit, + // deterministic plugin-level dispose would need a new plugin-protocol method (a user-gated core + // change; DEV-38 precedent). + // MARK: - Channel `objects` property /// The `pluginDataValue(forKey:channel:)` key that we use to store the value of the `ARTRealtimeChannel.objects` property. diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift index 32f25b28e..473ca97f6 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift @@ -1,4 +1,14 @@ @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct DefaultLiveCounterUpdate: LiveCounterUpdate, Equatable { internal var amount: Double + /// The source public object message (op-bearing only), or `nil` for sync-originated updates (RTO4b2a). + internal var objectMessage: ObjectMessage? + /// Whether this update tombstones the counter (RTLO4b4e). + internal var tombstone: Bool + + internal init(amount: Double, objectMessage: ObjectMessage? = nil, tombstone: Bool = false) { + self.amount = amount + self.objectMessage = objectMessage + self.tombstone = tombstone + } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift index 28ece0b52..c2cd1256a 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift @@ -1,4 +1,14 @@ @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct DefaultLiveMapUpdate: LiveMapUpdate, Equatable { internal var update: [String: LiveMapUpdateAction] + /// The source public object message (op-bearing only), or `nil` for sync-originated updates (RTO4b2a). + internal var objectMessage: ObjectMessage? + /// Whether this update tombstones the map (RTLO4b4e). + internal var tombstone: Bool + + internal init(update: [String: LiveMapUpdateAction], objectMessage: ObjectMessage? = nil, tombstone: Bool = false) { + self.update = update + self.objectMessage = objectMessage + self.tombstone = tombstone + } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 13de2fc93..a178c882f 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -19,6 +19,64 @@ internal final class InternalDefaultLiveCounter: Sendable { } } + // MARK: - Test-only setters + + /// Test-only setter for `siteTimeserials`, executing on the internal queue. + internal func testsOnly_setSiteTimeserials(_ siteTimeserials: [String: String]) { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.siteTimeserials = siteTimeserials + } + } + + /// Test-only setter for `tombstonedAt`, executing on the internal queue. + internal func testsOnly_setTombstonedAt(_ tombstonedAt: Date?) { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.tombstonedAt = tombstonedAt + } + } + + /// Test-only setter for `createOperationIsMerged`, executing on the internal queue. + internal func testsOnly_setCreateOperationIsMerged(_ createOperationIsMerged: Bool) { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.createOperationIsMerged = createOperationIsMerged + } + } + + /// Test-only setter for the counter's `data` (the count), executing on the internal queue. + internal func testsOnly_setData(_ data: Double) { + mutableStateMutex.withSync { mutableState in + mutableState.data = data + } + } + + internal var testsOnly_data: Double { + mutableStateMutex.withSync { mutableState in + mutableState.data + } + } + + internal var testsOnly_parentReferences: [String: Set] { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.parentReferences + } + } + + /// Test-only setter for `parentReferences`, executing on the internal queue. + internal func testsOnly_setParentReferences(_ parentReferences: [String: Set]) { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.parentReferences = parentReferences + } + } + + /// Test-only accessor for `getFullPaths` that hops onto the internal queue. It dispatches to the + /// queue (rather than holding this object's mutex) so the pool DFS can read every object's + /// `parentReferences` independently. + internal func testsOnly_getFullPaths(objectsPool: ObjectsPool) -> [[String]] { + mutableStateMutex.dispatchQueue.ably_syncNoDeadlock { + objectsPool.nosync_getFullPaths(forObjectID: nosync_objectID) + } + } + private let logger: Logger private let userCallbackQueue: DispatchQueue private let clock: SimpleClock @@ -224,6 +282,16 @@ internal final class InternalDefaultLiveCounter: Sendable { } } + /// RTO27a1: Clears this counter's data to its zero value (`0`) **without emitting any update + /// event**. Used by the RTO27a DETACHED/FAILED channel-state clear; the object itself remains + /// in the pool. + internal func nosync_clearDataToZeroValue() { + mutableStateMutex.withoutSync { mutableState in + // RTLC4: reset the counter's data to zero. + mutableState.resetDataToZeroValued() + } + } + /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC16. internal func nosync_mergeInitialValue(from operation: ProtocolTypes.ObjectOperation) -> LiveObjectUpdate { mutableStateMutex.withoutSync { mutableState in @@ -247,15 +315,17 @@ internal final class InternalDefaultLiveCounter: Sendable { /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. /// - /// - Returns: `true` if the operation was applied, `false` if it was skipped (RTLC7g). + /// - Returns: The update that was emitted if the operation was applied (which may be `.noop`), or `nil` if the operation was skipped (RTLC7g). + @discardableResult internal func nosync_apply( _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, objectMessageSerialTimestamp: Date?, + sourceObjectMessage: ObjectMessage? = nil, objectsPool: inout ObjectsPool, - ) -> Bool { + ) -> LiveObjectUpdate? { mutableStateMutex.withoutSync { mutableState in mutableState.apply( operation, @@ -263,6 +333,7 @@ internal final class InternalDefaultLiveCounter: Sendable { objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, objectMessageSerialTimestamp: objectMessageSerialTimestamp, + sourceObjectMessage: sourceObjectMessage, objectsPool: &objectsPool, logger: logger, clock: clock, @@ -271,6 +342,14 @@ internal final class InternalDefaultLiveCounter: Sendable { } } + /// Deregisters all of this counter's subscriptions, per the RTLO4b4c3c tombstone teardown. + /// Used by the deferred (sync) emission path after emitting a tombstone update. + internal func nosync_deregisterSubscriptionsForTombstone() { + mutableStateMutex.withoutSync { mutableState in + mutableState.liveObjectMutableState.unsubscribeAll() + } + } + // MARK: - LiveObject /// Returns the object's RTLO3d `isTombstone` property. @@ -301,6 +380,42 @@ internal final class InternalDefaultLiveCounter: Sendable { } } + // MARK: - Parent-reference graph (RTLO3f) + + /// The object's RTLO3f `parentReferences`, accessed on the internal queue. + internal var nosync_parentReferences: [String: Set] { + mutableStateMutex.withoutSync { mutableState in + mutableState.liveObjectMutableState.parentReferences + } + } + + /// Records that the map identified by `parentObjectID` references this object at `key`, per RTLO4g. + internal func nosync_addParentReference(parentObjectID: String, key: String) { + mutableStateMutex.withoutSync { mutableState in + mutableState.nosync_addParentReference(parentObjectID: parentObjectID, key: key) + } + } + + /// Removes the recorded reference from the map identified by `parentObjectID` at `key`, per RTLO4h. + internal func nosync_removeParentReference(parentObjectID: String, key: String) { + mutableStateMutex.withoutSync { mutableState in + mutableState.nosync_removeParentReference(parentObjectID: parentObjectID, key: key) + } + } + + /// Resets `parentReferences` to an empty map, per RTO5c10a. + internal func nosync_clearParentReferences() { + mutableStateMutex.withoutSync { mutableState in + mutableState.nosync_clearParentReferences() + } + } + + /// Computes all key-paths from root to this object, per RTLO4f. Delegates to the pool DFS, + /// reading only this object's `objectID` up front so no mutex is held across the traversal. + internal func nosync_getFullPaths(objectsPool: ObjectsPool) -> [[String]] { + objectsPool.nosync_getFullPaths(forObjectID: nosync_objectID) + } + // MARK: - Mutable state and the operations that affect it private struct MutableState: InternalLiveObject { @@ -340,8 +455,9 @@ internal final class InternalDefaultLiveCounter: Sendable { userCallbackQueue: userCallbackQueue, ) - // RTLC6f1 - return .update(.init(amount: -dataBeforeTombstoning)) + // RTLC6f1. Sync-originated (RTO4b2a) so objectMessage stays nil; tombstone drives + // the RTLO4b4c3c teardown when the deferred update is emitted. + return .update(.init(amount: -dataBeforeTombstoning, tombstone: true)) } // RTLC6g: Store the current data value as previousData for use in RTLC6h @@ -389,22 +505,23 @@ internal final class InternalDefaultLiveCounter: Sendable { /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. /// - /// - Returns: `true` if the operation was applied, `false` if skipped (RTLC7g). + /// - Returns: The update that was emitted if the operation was applied (which may be `.noop`), or `nil` if the operation was skipped (RTLC7g). internal mutating func apply( _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, objectMessageSerialTimestamp: Date?, + sourceObjectMessage: ObjectMessage?, objectsPool: inout ObjectsPool, logger: Logger, clock: SimpleClock, userCallbackQueue: DispatchQueue, - ) -> Bool { + ) -> LiveObjectUpdate? { 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 false + return nil } // RTLC7c @@ -415,7 +532,7 @@ internal final class InternalDefaultLiveCounter: Sendable { // RTLC7e // TODO: are we still meant to update siteTimeserials? https://github.com/ably/specification/pull/350/files#r2218718854 if liveObjectMutableState.isTombstone { - return false + return nil } switch operation.action { @@ -425,17 +542,13 @@ internal final class InternalDefaultLiveCounter: Sendable { operation, logger: logger, ) - // RTLC7d1a - liveObjectMutableState.emit(update, on: userCallbackQueue) - // RTLC7d1b - return true + // RTLC7d1a, RTLC7d1b: emit the enriched update (carrying the source message) + return nosync_emitAndTearDown(update, sourceObjectMessage: sourceObjectMessage, userCallbackQueue: userCallbackQueue) case .known(.counterInc): // RTLC7d5 let update = applyCounterIncOperation(operation.counterInc) - // RTLC7d5a - liveObjectMutableState.emit(update, on: userCallbackQueue) - // RTLC7d5b - return true + // RTLC7d5a, RTLC7d5b + return nosync_emitAndTearDown(update, sourceObjectMessage: sourceObjectMessage, userCallbackQueue: userCallbackQueue) case .known(.objectDelete): let dataBeforeApplyingOperation = data @@ -447,14 +560,13 @@ internal final class InternalDefaultLiveCounter: Sendable { userCallbackQueue: userCallbackQueue, ) - // RTLC7d4a - liveObjectMutableState.emit(.update(.init(amount: -dataBeforeApplyingOperation)), on: userCallbackQueue) - // RTLC7d4b - return true + // RTLC7d4a, RTLC7d4b: tombstone update drives the RTLO4b4c3c teardown + let update: LiveObjectUpdate = .update(.init(amount: -dataBeforeApplyingOperation, tombstone: true)) + return nosync_emitAndTearDown(update, sourceObjectMessage: sourceObjectMessage, userCallbackQueue: userCallbackQueue) default: // RTLC7d3 logger.log("Operation \(operation) has unsupported action for LiveCounter; discarding", level: .warn) - return false + return nil } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 8fdd59d82..1861cd5ed 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -43,6 +43,58 @@ internal final class InternalDefaultLiveMap: Sendable { } } + // MARK: - Test-only setters + + /// Test-only setter for `siteTimeserials`, executing on the internal queue. + internal func testsOnly_setSiteTimeserials(_ siteTimeserials: [String: String]) { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.siteTimeserials = siteTimeserials + } + } + + /// Test-only setter for `tombstonedAt`, executing on the internal queue. + internal func testsOnly_setTombstonedAt(_ tombstonedAt: Date?) { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.tombstonedAt = tombstonedAt + } + } + + /// Test-only setter for `createOperationIsMerged`, executing on the internal queue. + internal func testsOnly_setCreateOperationIsMerged(_ createOperationIsMerged: Bool) { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.createOperationIsMerged = createOperationIsMerged + } + } + + /// Test-only setter for `clearTimeserial`, executing on the internal queue. + internal func testsOnly_setClearTimeserial(_ clearTimeserial: String?) { + mutableStateMutex.withSync { mutableState in + mutableState.clearTimeserial = clearTimeserial + } + } + + internal var testsOnly_parentReferences: [String: Set] { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.parentReferences + } + } + + /// Test-only setter for `parentReferences`, executing on the internal queue. + internal func testsOnly_setParentReferences(_ parentReferences: [String: Set]) { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.parentReferences = parentReferences + } + } + + /// Test-only accessor for `getFullPaths` that hops onto the internal queue. It dispatches to the + /// queue (rather than holding this object's mutex) so the pool DFS can read every object's + /// `parentReferences` independently. + internal func testsOnly_getFullPaths(objectsPool: ObjectsPool) -> [[String]] { + mutableStateMutex.dispatchQueue.ably_syncNoDeadlock { + objectsPool.nosync_getFullPaths(forObjectID: nosync_objectID) + } + } + private let logger: Logger private let userCallbackQueue: DispatchQueue private let clock: SimpleClock @@ -341,15 +393,17 @@ internal final class InternalDefaultLiveMap: Sendable { /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLM15. /// - /// - Returns: `true` if the operation was applied, `false` if it was skipped (RTLM15g). + /// - Returns: The update that was emitted if the operation was applied (which may be `.noop`), or `nil` if the operation was skipped (RTLM15g). + @discardableResult internal func nosync_apply( _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, objectMessageSerialTimestamp: Date?, + sourceObjectMessage: ObjectMessage? = nil, objectsPool: inout ObjectsPool, - ) -> Bool { + ) -> LiveObjectUpdate? { mutableStateMutex.withoutSync { mutableState in mutableState.apply( operation, @@ -357,6 +411,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, objectMessageSerialTimestamp: objectMessageSerialTimestamp, + sourceObjectMessage: sourceObjectMessage, objectsPool: &objectsPool, logger: logger, internalQueue: mutableStateMutex.dispatchQueue, @@ -366,6 +421,14 @@ internal final class InternalDefaultLiveMap: Sendable { } } + /// Deregisters all of this map's subscriptions, per the RTLO4b4c3c tombstone teardown. + /// Used by the deferred (sync) emission path after emitting a tombstone update. + internal func nosync_deregisterSubscriptionsForTombstone() { + mutableStateMutex.withoutSync { mutableState in + mutableState.liveObjectMutableState.unsubscribeAll() + } + } + /// Applies a `MAP_SET` operation to a key, per RTLM7. /// /// This is currently exposed just so that the tests can test RTLM7 without having to go through a convoluted replaceData(…) call, but I _think_ that it's going to be used in further contexts when we introduce the handling of incoming object operations in a future spec PR. @@ -392,12 +455,13 @@ internal final class InternalDefaultLiveMap: Sendable { /// Applies a `MAP_REMOVE` operation to a key, per RTLM8. /// /// This is currently exposed just so that the tests can test RTLM8 without having to go through a convoluted replaceData(…) call, but I _think_ that it's going to be used in further contexts when we introduce the handling of incoming object operations in a future spec PR. - internal func testsOnly_applyMapRemoveOperation(key: String, operationTimeserial: String?, operationSerialTimestamp: Date?) -> LiveObjectUpdate { + internal func testsOnly_applyMapRemoveOperation(key: String, operationTimeserial: String?, operationSerialTimestamp: Date?, objectsPool: ObjectsPool) -> LiveObjectUpdate { mutableStateMutex.withSync { mutableState in mutableState.applyMapRemoveOperation( key: key, operationTimeserial: operationTimeserial, operationSerialTimestamp: operationSerialTimestamp, + objectsPool: objectsPool, logger: logger, clock: clock, ) @@ -405,21 +469,48 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Test-only method to apply a MAP_CLEAR operation, per RTLM24. - internal func testsOnly_applyMapClearOperation(serial: String?) -> LiveObjectUpdate { + internal func testsOnly_applyMapClearOperation(serial: String?, objectsPool: ObjectsPool) -> LiveObjectUpdate { mutableStateMutex.withSync { mutableState in mutableState.applyMapClearOperation( serial: serial, + objectsPool: objectsPool, ) } } + /// Test-only wrapper over the RTLM14 `nosync_isEntryTombstoned` helper. + /// + /// This is a pure function of its arguments, so it does not need to execute on the internal queue. + internal func testsOnly_isEntryTombstoned(_ entry: InternalObjectsMapEntry, objectsPool: ObjectsPool) -> Bool { + MutableState.nosync_isEntryTombstoned(entry, objectsPool: objectsPool) + } + /// Resets the map's data, per RTO4b2. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map. - internal func nosync_resetData() { + /// - Returns: The keys reported as `removed` by the emitted update (see + /// `MutableState.resetData`); used by the RTO4b reset path to fan out to path subscriptions. + @discardableResult + internal func nosync_resetData() -> [String] { mutableStateMutex.withoutSync { mutableState in mutableState.resetData(userCallbackQueue: userCallbackQueue) } } + /// RTO27a1: Clears this map's data to its zero value (an empty map) **without emitting any + /// update event**, and drops the parent references it holds on its referenced children + /// (RTLO4e9) so the parent-reference graph stays consistent once the data is gone. + /// + /// Used by the RTO27a DETACHED/FAILED channel-state clear. Unlike ``nosync_resetData`` (the + /// RTO4b reset) it emits no event, and the object itself remains in the pool. + internal func nosync_clearDataToZeroValue(objectsPool: ObjectsPool) { + mutableStateMutex.withoutSync { mutableState in + // RTLO4e9: drop the parent references this map holds on its referenced children, so + // those children no longer record this map as a parent once its data is cleared. + mutableState.nosync_dropHeldParentReferences(objectsPool: objectsPool) + // RTLM4: reset the map's data to the zero value (empty map, nil clearTimeserial). + mutableState.resetDataToZeroValued() + } + } + /// Releases entries that were tombstoned more than `gracePeriod` ago, per RTLM19. internal func nosync_releaseTombstonedEntries(gracePeriod: TimeInterval, clock: SimpleClock) { mutableStateMutex.withoutSync { mutableState in @@ -457,6 +548,55 @@ internal final class InternalDefaultLiveMap: Sendable { } } + // MARK: - Parent-reference graph (RTLO3f) + + /// The object's RTLO3f `parentReferences`, accessed on the internal queue. + internal var nosync_parentReferences: [String: Set] { + mutableStateMutex.withoutSync { mutableState in + mutableState.liveObjectMutableState.parentReferences + } + } + + /// Records that the map identified by `parentObjectID` references this object at `key`, per RTLO4g. + internal func nosync_addParentReference(parentObjectID: String, key: String) { + mutableStateMutex.withoutSync { mutableState in + mutableState.nosync_addParentReference(parentObjectID: parentObjectID, key: key) + } + } + + /// Removes the recorded reference from the map identified by `parentObjectID` at `key`, per RTLO4h. + internal func nosync_removeParentReference(parentObjectID: String, key: String) { + mutableStateMutex.withoutSync { mutableState in + mutableState.nosync_removeParentReference(parentObjectID: parentObjectID, key: key) + } + } + + /// Resets `parentReferences` to an empty map, per RTO5c10a. + internal func nosync_clearParentReferences() { + mutableStateMutex.withoutSync { mutableState in + mutableState.nosync_clearParentReferences() + } + } + + /// Computes all key-paths from root to this object, per RTLO4f. Delegates to the pool DFS, + /// reading only this object's `objectID` up front so no mutex is held across the traversal. + internal func nosync_getFullPaths(objectsPool: ObjectsPool) -> [[String]] { + objectsPool.nosync_getFullPaths(forObjectID: nosync_objectID) + } + + /// The raw internal data map, accessed on the internal queue. Used by the RTO5c10 rebuild. + internal var nosync_rawData: [String: InternalObjectsMapEntry] { + mutableStateMutex.withoutSync { mutableState in + mutableState.data + } + } + + /// Returns whether a map entry should be considered tombstoned, per RTLM14. Used by the + /// RTO5c10 rebuild; a pure function of its arguments, so it does not need the internal queue. + internal static func nosync_isEntryTombstoned(_ entry: InternalObjectsMapEntry, objectsPool: ObjectsPool) -> Bool { + MutableState.nosync_isEntryTombstoned(entry, objectsPool: objectsPool) + } + // MARK: - Mutable state and the operations that affect it private struct MutableState: InternalLiveObject { @@ -496,8 +636,19 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM6f: Tombstone if state indicates tombstoned if state.tombstone { + // RTLO4e10: the root object must never be tombstoned — an ObjectState with + // tombstone set for `root` is a faulty message. Log and return a noop update + // without performing any of the subsequent RTLO4e steps. + if liveObjectMutableState.objectID == ObjectsPool.rootKey { + logger.log("Ignoring ObjectState tombstone targeting the root object (RTLO4e10)", level: .warn) + return .noop + } + let dataBeforeTombstoning = data + // RTLO4e9: drop the parent references this map holds on its referenced children + nosync_dropHeldParentReferences(objectsPool: objectsPool) + tombstone( objectMessageSerialTimestamp: objectMessageSerialTimestamp, logger: logger, @@ -505,8 +656,13 @@ internal final class InternalDefaultLiveMap: Sendable { userCallbackQueue: userCallbackQueue, ) - // RTLM6f1 - return .update(.init(update: dataBeforeTombstoning.mapValues { _ in .removed })) + // RTLM6f1. Sync-originated (RTO4b2a) so objectMessage stays nil; tombstone drives + // the RTLO4b4c3c teardown when the deferred update is emitted. + // RTLO4e5/RTLM22b: the update is the diff between the pre-tombstone data and the + // now-cleared data, which considers only NON-tombstoned entries. Entries that were + // already tombstoned were not visible to subscribers, so they must not be reported + // as newly `removed`. + return .update(.init(update: dataBeforeTombstoning.filter { !$0.value.tombstone }.mapValues { _ in .removed }, tombstone: true)) } // RTLM6g: Store the current data value as previousData for use in RTLM6h @@ -579,6 +735,7 @@ internal final class InternalDefaultLiveMap: Sendable { key: key, operationTimeserial: entry.timeserial, operationSerialTimestamp: entry.serialTimestamp, + objectsPool: objectsPool, logger: logger, clock: clock, ) @@ -630,16 +787,17 @@ internal final class InternalDefaultLiveMap: Sendable { objectMessageSerial: String?, objectMessageSiteCode: String?, objectMessageSerialTimestamp: Date?, + sourceObjectMessage: ObjectMessage?, objectsPool: inout ObjectsPool, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, - ) -> Bool { + ) -> LiveObjectUpdate? { 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 false + return nil } // RTLM15c @@ -650,7 +808,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM15e // TODO: are we still meant to update siteTimeserials? https://github.com/ably/specification/pull/350/files#r2218718854 if liveObjectMutableState.isTombstone { - return false + return nil } switch operation.action { @@ -664,18 +822,16 @@ internal final class InternalDefaultLiveMap: Sendable { userCallbackQueue: userCallbackQueue, clock: clock, ) - // RTLM15d1a - liveObjectMutableState.emit(update, on: userCallbackQueue) - // RTLM15d1b - return true + // RTLM15d1a, RTLM15d1b: emit the enriched update (carrying the source message) + return nosync_emitAndTearDown(update, sourceObjectMessage: sourceObjectMessage, userCallbackQueue: userCallbackQueue) case .known(.mapSet): guard let mapSet = operation.mapSet else { logger.log("Could not apply MAP_SET since operation.mapSet is missing", level: .warn) - return false + return nil } guard let value = mapSet.value else { logger.log("Could not apply MAP_SET since operation.mapSet.value is missing", level: .warn) - return false + return nil } // RTLM15d6 @@ -689,13 +845,11 @@ internal final class InternalDefaultLiveMap: Sendable { userCallbackQueue: userCallbackQueue, clock: clock, ) - // RTLM15d6a - liveObjectMutableState.emit(update, on: userCallbackQueue) - // RTLM15d6b - return true + // RTLM15d6a, RTLM15d6b + return nosync_emitAndTearDown(update, sourceObjectMessage: sourceObjectMessage, userCallbackQueue: userCallbackQueue) case .known(.mapRemove): guard let mapRemove = operation.mapRemove else { - return false + return nil } // RTLM15d7 @@ -703,16 +857,26 @@ internal final class InternalDefaultLiveMap: Sendable { key: mapRemove.key, operationTimeserial: applicableOperation.objectMessageSerial, operationSerialTimestamp: objectMessageSerialTimestamp, + objectsPool: objectsPool, logger: logger, clock: clock, ) - // RTLM15d7a - liveObjectMutableState.emit(update, on: userCallbackQueue) - // RTLM15d7b - return true + // RTLM15d7a, RTLM15d7b + return nosync_emitAndTearDown(update, sourceObjectMessage: sourceObjectMessage, userCallbackQueue: userCallbackQueue) case .known(.objectDelete): + // RTLO4e10: the root object must never be tombstoned — an OBJECT_DELETE targeting + // `root` is a faulty message. Log and return a noop update without performing any + // of the subsequent RTLO4e steps. + if liveObjectMutableState.objectID == ObjectsPool.rootKey { + logger.log("Ignoring OBJECT_DELETE targeting the root object (RTLO4e10)", level: .warn) + return .noop + } + let dataBeforeApplyingOperation = data + // RTLO4e9: drop the parent references this map holds on its referenced children + nosync_dropHeldParentReferences(objectsPool: objectsPool) + // RTLM15d5 applyObjectDeleteOperation( objectMessageSerialTimestamp: objectMessageSerialTimestamp, @@ -721,23 +885,24 @@ internal final class InternalDefaultLiveMap: Sendable { userCallbackQueue: userCallbackQueue, ) - // RTLM15d5a - liveObjectMutableState.emit(.update(.init(update: dataBeforeApplyingOperation.mapValues { _ in .removed })), on: userCallbackQueue) - // RTLM15d5b - return true + // RTLM15d5a, RTLM15d5b: tombstone update drives the RTLO4b4c3c teardown. + // RTLO4e5/RTLM22b: diff considers only NON-tombstoned entries, so already-tombstoned + // entries (not visible to subscribers) must not be reported as newly `removed`. + let update: LiveObjectUpdate = .update(.init(update: dataBeforeApplyingOperation.filter { !$0.value.tombstone }.mapValues { _ in .removed }, tombstone: true)) + return nosync_emitAndTearDown(update, sourceObjectMessage: sourceObjectMessage, userCallbackQueue: userCallbackQueue) case .known(.mapClear): // RTLM15d8 let update = applyMapClearOperation( serial: applicableOperation.objectMessageSerial, + objectsPool: objectsPool, ) - // RTLM15d8a - liveObjectMutableState.emit(update, on: userCallbackQueue) - // RTLM15d8b - return true + // RTLM15d8a, RTLM15d8b. MAP_CLEAR clears the map's data but does not tombstone the + // object (tombstone stays false), so no teardown. + return nosync_emitAndTearDown(update, sourceObjectMessage: sourceObjectMessage, userCallbackQueue: userCallbackQueue) default: // RTLM15d4 logger.log("Operation \(operation) has unsupported action for LiveMap; discarding", level: .warn) - return false + return nil } } @@ -763,6 +928,11 @@ internal final class InternalDefaultLiveMap: Sendable { if !Self.canApplyMapOperation(entryTimeserial: existingEntry.timeserial, operationTimeserial: operationTimeserial) { return .noop } + // RTLM7a3: drop the parent reference held via the entry being overwritten + if let oldRefId = existingEntry.data?.objectId { + // RTLM7a3a, RTLM7a3b (with the DEV-15-class self-reference guard) + nosync_removeParentReferenceGuardingSelfReference(onObjectWithID: oldRefId, key: key, objectsPool: objectsPool) + } // RTLM7a2: Otherwise, apply the operation // RTLM7a2e: Set ObjectsMapEntry.data to the MapSet.value // RTLM7a2b: Set ObjectsMapEntry.timeserial to the operation's serial @@ -789,6 +959,9 @@ internal final class InternalDefaultLiveMap: Sendable { userCallbackQueue: userCallbackQueue, clock: clock, ) + // RTLM7g2: record the reverse reference for the newly referenced object + // (with the DEV-15-class self-reference guard) + nosync_addParentReferenceGuardingSelfReference(onObjectWithID: objectId, key: key, objectsPool: objectsPool) } // RTLM7f @@ -796,7 +969,7 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Applies a `MAP_REMOVE` operation to a key, per RTLM8. - internal mutating func applyMapRemoveOperation(key: String, operationTimeserial: String?, operationSerialTimestamp: Date?, logger: Logger, clock: SimpleClock) -> LiveObjectUpdate { + internal mutating func applyMapRemoveOperation(key: String, operationTimeserial: String?, operationSerialTimestamp: Date?, objectsPool: ObjectsPool, logger: Logger, clock: SimpleClock) -> LiveObjectUpdate { // (Note that, where the spec tells us to set ObjectsMapEntry.data to nil, we actually set it to an empty ObjectData, which is equivalent, since it contains no data) // RTLM8g @@ -822,6 +995,11 @@ internal final class InternalDefaultLiveMap: Sendable { if !Self.canApplyMapOperation(entryTimeserial: existingEntry.timeserial, operationTimeserial: operationTimeserial) { return .noop } + // RTLM8a3: drop the parent reference held via the entry being removed + if let oldRefId = existingEntry.data?.objectId { + // RTLM8a3a, RTLM8a3b (with the DEV-15-class self-reference guard) + nosync_removeParentReferenceGuardingSelfReference(onObjectWithID: oldRefId, key: key, objectsPool: objectsPool) + } // RTLM8a2: Otherwise, apply the operation // RTLM8a2a: Set ObjectsMapEntry.data to undefined/null // RTLM8a2b: Set ObjectsMapEntry.timeserial to the operation's serial @@ -914,13 +1092,17 @@ internal final class InternalDefaultLiveMap: Sendable { /// Applies a `MAP_CLEAR` operation, per RTLM24. internal mutating func applyMapClearOperation( serial: String?, + objectsPool: ObjectsPool, ) -> LiveObjectUpdate { guard let serial else { return .noop } - // RTLM24c - if let clearTimeserial, serial <= clearTimeserial { + // RTLM24c: discard only if the existing clearTimeserial is *strictly* (lexicographically) + // greater than the provided serial. On equality the operation is re-applied — note this + // differs from RTLM7h/RTLM8g (MAP_SET/MAP_REMOVE), which discard on `>=`. Using `<=` here + // would wrongly no-op a MAP_CLEAR whose serial equals clearTimeserial. + if let clearTimeserial, serial < clearTimeserial { return .noop } @@ -936,6 +1118,11 @@ internal final class InternalDefaultLiveMap: Sendable { }.keys for key in keysToRemove { + // RTLM24e1c: drop the parent reference held via the cleared entry + if let refId = data[key]?.data?.objectId { + // RTLM24e1c1, RTLM24e1c2 (with the DEV-15-class self-reference guard) + nosync_removeParentReferenceGuardingSelfReference(onObjectWithID: refId, key: key, objectsPool: objectsPool) + } data.removeValue(forKey: key) } @@ -944,15 +1131,44 @@ internal final class InternalDefaultLiveMap: Sendable { return .update(DefaultLiveMapUpdate(update: removedKeys)) } + /// Drops the parent references this map holds on the objects referenced by its entries, per RTLO4e9. + /// Called before the map's data is cleared during tombstoning (RTLO4e4), so that the referenced + /// children no longer record this (now-tombstoned) map as a parent. + /// + /// `mutating` because a self-referencing entry mutates this map's own `parentReferences` + /// (via the DEV-15-class self-reference guard) rather than re-entering via the pool entry. + internal mutating func nosync_dropHeldParentReferences(objectsPool: ObjectsPool) { + for (key, entry) in data { + guard let refId = entry.data?.objectId else { + continue + } + // RTLO4e9a, RTLO4e9b (with the DEV-15-class self-reference guard) + nosync_removeParentReferenceGuardingSelfReference(onObjectWithID: refId, key: key, objectsPool: objectsPool) + } + } + /// Resets the map's data and emits a `removed` event for the existing keys, per RTO4b2 and RTO4b2a. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map. - internal mutating func resetData(userCallbackQueue: DispatchQueue) { + /// + /// - Returns: The keys reported as `removed` by the emitted update, so the caller can fan the + /// reset out to path subscriptions (RTLO4b4c3b) after this map's mutex is released. + internal mutating func resetData(userCallbackQueue: DispatchQueue) -> [String] { // RTO4b2 let previousData = data resetDataToZeroValued() - // RTO4b2a - let mapUpdate = DefaultLiveMapUpdate(update: previousData.mapValues { _ in .removed }) - liveObjectMutableState.emit(.update(mapUpdate), on: userCallbackQueue) + // RTO4b2a: the update consists of entries for the keys that were removed. Per RTLM22b, + // only NON-tombstoned entries are user-visible, so already-tombstoned entries must not + // be reported as newly `removed`. + let mapUpdate = DefaultLiveMapUpdate(update: previousData.filter { !$0.value.tombstone }.mapValues { _ in .removed }) + // DEV-44: skip the instance-subscription emit when nothing was removed. This mirrors + // Kotlin's single `notifyUpdated` noop gate (RTLO4b4c1), which collapses both instance + // and path dispatch together on an empty update, and matches the path-dispatch branch in + // `nosync_onChannelAttached` that already skips on an empty diff. Without this guard an + // already-empty root reset would fire a spurious instance event. + if !mapUpdate.update.isEmpty { + liveObjectMutableState.emit(.update(mapUpdate), on: userCallbackQueue) + } + return Array(mapUpdate.update.keys) } /// Needed for ``InternalLiveObject`` conformance. @@ -1012,7 +1228,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map return data.values.count { entry in - !Self.nosync_isEntryTombstoned(entry, objectsPool: objectsPool) + !nosync_isEntryTombstonedGuardingSelfReference(entry, objectsPool: objectsPool) } } @@ -1024,7 +1240,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 data where !Self.nosync_isEntryTombstoned(entry, objectsPool: objectsPool) { + for (key, entry) in data where !nosync_isEntryTombstonedGuardingSelfReference(entry, objectsPool: objectsPool) { // Convert entry to LiveMapValue using the same logic as get(key:) if let value = nosync_convertEntryToLiveMapValue(entry, objectsPool: objectsPool) { result.append((key: key, value: value)) @@ -1036,8 +1252,10 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - Helper Methods + // Note: `fileprivate` (rather than `private`) so that the enclosing type's + // `testsOnly_isEntryTombstoned` wrapper can reach it; still not exposed outside this file. /// Returns whether a map entry should be considered tombstoned, per the check described in RTLM14. - private static func nosync_isEntryTombstoned(_ entry: InternalObjectsMapEntry, objectsPool: ObjectsPool) -> Bool { + fileprivate static func nosync_isEntryTombstoned(_ entry: InternalObjectsMapEntry, objectsPool: ObjectsPool) -> Bool { // RTLM14a if entry.tombstone { return true @@ -1054,6 +1272,58 @@ internal final class InternalDefaultLiveMap: Sendable { return false } + /// Instance-level RTLM14 tombstone check used by the read accessors (`get`/`size`/`entries`), + /// which run while this map's `mutableStateMutex` is exclusively held. + /// + /// It guards the RTLM14c *self-reference* case: if the entry references this very map, + /// delegating to the static helper would read the pool entry's `nosync_isTombstone`, + /// re-entering this map's already-held mutex — a Swift exclusive-access **crash** (the same + /// exclusivity class as DEV-15, the `getFullPaths` finding). In that case we answer the + /// tombstone question from the state already in hand. Kotlin has no exclusivity checker and + /// so needs no such guard; the observable behaviour is identical. + internal func nosync_isEntryTombstonedGuardingSelfReference(_ entry: InternalObjectsMapEntry, objectsPool: ObjectsPool) -> Bool { + // RTLM14a + if entry.tombstone { + return true + } + // RTLM14c self-reference guard (see doc comment). + if let objectId = entry.data?.objectId, objectId == liveObjectMutableState.objectID { + return liveObjectMutableState.isTombstone + } + // RTLM14b/RTLM14c for every other reference — safe to consult the pool. + return Self.nosync_isEntryTombstoned(entry, objectsPool: objectsPool) + } + + /// Records a parent reference from this map on the object with ID `objectID` (RTLO4g), + /// guarding the *self-reference* case. + /// + /// If `objectID` is this map's own objectID, going through the pool entry + /// (`objectsPool.entries[objectID]?.nosync_addParentReference`) would re-enter this map's + /// already-held `mutableStateMutex` — a Swift exclusive-access **crash** (the same + /// exclusivity class as DEV-15, the `getFullPaths` finding). A self-parent is a legitimate + /// graph edge (the map referencing itself under a key), so we record it directly on the + /// state already in hand; `ObjectsPool.nosync_getFullPaths`'s per-branch visited set + /// (RTLO4f2) suppresses the resulting self-loop. Kotlin has no exclusivity checker and so + /// needs no such guard; the observable behaviour is identical. + private mutating func nosync_addParentReferenceGuardingSelfReference(onObjectWithID objectID: String, key: String, objectsPool: ObjectsPool) { + if objectID == liveObjectMutableState.objectID { + nosync_addParentReference(parentObjectID: objectID, key: key) + } else { + objectsPool.entries[objectID]?.nosync_addParentReference(parentObjectID: liveObjectMutableState.objectID, key: key) + } + } + + /// Removes the parent reference this map holds on the object with ID `objectID` (RTLO4h), + /// guarding the *self-reference* case for the same reason as + /// ``nosync_addParentReferenceGuardingSelfReference(onObjectWithID:key:objectsPool:)``. + private mutating func nosync_removeParentReferenceGuardingSelfReference(onObjectWithID objectID: String, key: String, objectsPool: ObjectsPool) { + if objectID == liveObjectMutableState.objectID { + nosync_removeParentReference(parentObjectID: objectID, key: key) + } else { + objectsPool.entries[objectID]?.nosync_removeParentReference(parentObjectID: liveObjectMutableState.objectID, key: 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 nosync_convertEntryToLiveMapValue(_ entry: InternalObjectsMapEntry, objectsPool: ObjectsPool) -> InternalLiveMapValue? { @@ -1101,8 +1371,16 @@ internal final class InternalDefaultLiveMap: Sendable { return nil } - // RTLM5d2f3: If referenced object is tombstoned, return nil - if poolEntry.nosync_isTombstone { + // RTLM5d2f3: If referenced object is tombstoned, return nil. + // Self-reference guard: if the referenced object is this map itself, reading + // `poolEntry.nosync_isTombstone` would re-enter our already-held mutex — a Swift + // exclusive-access crash (same exclusivity class as DEV-15, the `getFullPaths` + // finding). Answer from the tombstone state already in hand. (Merely *reading* the + // `objectsPool.entries[objectId]` reference above does not enter the mutex.) + let referencedIsTombstoned = objectId == liveObjectMutableState.objectID + ? liveObjectMutableState.isTombstone + : poolEntry.nosync_isTombstone + if referencedIsTombstoned { return nil } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index f0a9c4337..7164b68af 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -14,6 +14,11 @@ internal protocol InternalRealtimeObjectsProtocol: LiveMapObjectsPoolDelegate { coreSDK: CoreSDK, callback: @escaping @Sendable (Result) -> Void, ) + + /// The channel's path-subscription registry (Kotlin's + /// `DefaultRealtimeObject.pathObjectSubscriptionRegister`). Used by ``DefaultPathObject`` to + /// register path subscriptions. Must be accessed on the internal queue. Spec: RTO24a. + var nosync_pathObjectSubscriptionRegister: PathObjectSubscriptionRegister { get } } /// This provides the implementation behind ``PublicDefaultRealtimeObjects``, via internal versions of the ``RealtimeObjects`` API. @@ -75,6 +80,41 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } } + /// Test-only setter that inserts or replaces an entry in the *owned* `ObjectsPool` held + /// inside `MutableState`, executing on the internal queue. + /// + /// Note that `testsOnly_objectsPool` returns a struct *copy*, so mutating that copy would not + /// affect this object's state; this seam goes through the mutex to the owned instance. + internal func testsOnly_setPoolEntry(_ entry: ObjectsPool.Entry, forObjectID objectID: String) { + mutableStateMutex.withSync { mutableState in + mutableState.objectsPool.testsOnly_setEntry(entry, forObjectID: objectID) + } + } + + /// Test-only read seam over the RTO17 sync state, executing on the internal queue. + internal var testsOnly_syncState: ObjectsSyncState { + mutableStateMutex.withSync { mutableState in + mutableState.state.toObjectsSyncState + } + } + + /// Test-only seam that applies the given inbound `OBJECT` object messages, executing on the + /// internal queue by forwarding to `MutableState.nosync_applyObjectProtocolMessageObjectMessage`. + internal func testsOnly_applyObjectMessages(_ objectMessages: [ProtocolTypes.InboundObjectMessage], source: ObjectsOperationSource) { + mutableStateMutex.withSync { mutableState in + for objectMessage in objectMessages { + mutableState.nosync_applyObjectProtocolMessageObjectMessage( + objectMessage, + source: source, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } + } + /// If this returns false, it means that there is currently no stored sync sequence ID, SyncObjectsPool, or BufferedObjectOperations. internal var testsOnly_hasSyncSequence: Bool { mutableStateMutex.withSync { mutableState in @@ -119,6 +159,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, + channelName: String = "", garbageCollectionOptions: GarbageCollectionOptions = .init() ) { self.logger = logger @@ -137,6 +178,11 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO userCallbackQueue: userCallbackQueue, clock: clock, ), + pathObjectSubscriptionRegister: .init( + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + ), + channelName: channelName, garbageCollectionGracePeriod: garbageCollectionOptions.gracePeriod, ), ) @@ -162,7 +208,50 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } deinit { + // The full teardown (matrix #18): cancel the GC task, fail any in-flight sync waiters and + // drop all subscriptions. `dispose()` is idempotent, so an earlier explicit `dispose()` call + // makes this a no-op beyond the (already-idempotent) GC cancellation. + dispose() + } + + /// The channel objects engine's internal serial queue (Kotlin's `sequentialScope`). Shared out + /// so the public proxy can construct path objects (whose accessors hop onto it). Every mutation + /// of this object's state still goes through `mutableStateMutex`. + internal var internalQueue: DispatchQueue { + mutableStateMutex.dispatchQueue + } + + // MARK: - Dispose lifecycle (matrix #18; Kotlin `DefaultRealtimeObject.dispose`) + + /// Tears down the resources associated with this objects engine, mirroring Kotlin's + /// `DefaultRealtimeObject.dispose`: + /// + /// - cancels the periodic garbage-collection `Task` (Kotlin `ObjectsPool.dispose`); + /// - fails every in-flight `publishAndApply` / `get()` sync waiter (Kotlin's `cancelChildren` + /// structured cancellation), so a suspended `get()` awaiting sync is resolved rather than + /// orphaned — it surfaces the RTO20e1 error (code 92008); + /// - drops all path subscriptions (Kotlin `pathObjectSubscriptionRegister.dispose`); + /// - drops all status-event (`.syncing`/`.synced`) subscriptions (Kotlin + /// `objectsManager.dispose` → `offAll`). + /// + /// It deliberately does **not** invalidate the sync state or the objects pool: like Kotlin, which + /// keeps `sequentialScope` alive so a later `get()` still works, the instance stays usable — a + /// subsequent `get()` returns the root path object once the channel re-syncs. Idempotent. + /// + /// `ObjectsPool` itself needs no dispose hook: it is a value type owned inside `MutableState` + /// whose only long-lived resource — the GC `Task` — is owned here and cancelled above. + internal func dispose() { garbageCollectionTask.cancel() + mutableStateMutex.withSync { mutableState in + // Fail any pending publishAndApply / get() sync waiters (RTO20e1 path). + mutableState.nosync_drainPublishAndApplySyncWaiters( + outcome: .channelStateFailed(state: .failed, reason: nil), + ) + // Drop all path subscriptions. + mutableState.pathObjectSubscriptionRegister.nosync_dispose() + // Drop all status-event subscriptions. + mutableState.offAll() + } } // MARK: - LiveMapObjectsPoolDelegate @@ -173,6 +262,16 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } } + // MARK: - Path subscriptions (RTO24) + + internal var nosync_pathObjectSubscriptionRegister: PathObjectSubscriptionRegister { + // A reference type shared out of the owned `MutableState`; every mutation still happens on + // the internal queue via the register's own `nosync_` methods. + mutableStateMutex.withoutSync { mutableState in + mutableState.pathObjectSubscriptionRegister + } + } + // MARK: - Internal methods that power RealtimeObjects conformance internal func getRoot(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { @@ -202,6 +301,46 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } } + /// RTO23c: Suspends until the initial object sync has completed (state `.synced`), for the + /// path-based `RealtimeObject.get()`. + /// + /// The state check and the waiter registration happen inside a **single** `mutableStateMutex` + /// block, so a `.synced` transition can never slip between them and be lost (the lost-wakeup + /// invariant, plan §1.1). This reuses the existing `publishAndApplySyncWaiters` machinery + /// (RTO20e/RTO20e1): the waiter is resumed with success when sync completes, and failed with the + /// RTO20e1 error (code 92008) if the channel enters DETACHED/SUSPENDED/FAILED — or if the engine + /// is disposed — while waiting. + /// + /// Unlike the internal `getRoot()` (the old RTO1 API, which registers its `.synced` listener in a + /// separate queue hop), this keeps check-and-register atomic. + internal func ensureSynced() async throws(ARTErrorInfo) { + try await withCheckedContinuation { (continuation: CheckedContinuation, Never>) in + mutableStateMutex.withSync { mutableState in + // Atomic with the registration below (same queue block): if already synced, resume now. + if mutableState.state.toObjectsSyncState == .synced { + continuation.resume(returning: .success(())) + return + } + // RTO1c-style signal that a get() has started waiting (used by the test suite). + waitingForSyncEventsContinuation.yield() + logger.log("get() started waiting for sync sequence to complete", level: .debug) + mutableState.publishAndApplySyncWaiters.append { _, outcome in + switch outcome { + case .synced: + continuation.resume(returning: .success(())) + case let .channelStateFailed(state, reason): + // RTO20e1 -> code 92008 (sync did not complete). + let error = LiveObjectsError.publishAndApplyFailedChannelStateChanged( + channelState: state, + reason: reason, + ) + continuation.resume(returning: .failure(error.toARTErrorInfo())) + } + } + } + }.get() + } + internal func createMap(entries: [String: InternalLiveMapValue], coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in do throws(ARTErrorInfo) { @@ -443,8 +582,32 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO // MARK: - Sending `OBJECT` ProtocolMessage + /// The maximum allowed total size, in bytes, for a set of `ObjectMessage`s published in one go. + /// + /// TODO: This should be the connection's negotiated `maxMessageSize` (TO3l8 / + /// `ConnectionDetails.maxMessageSize`), but the core SDK does not currently expose that value to + /// plugins via `_AblyPluginSupportPrivate` (`ConnectionDetailsProtocol` only surfaces + /// `objectsGCGracePeriod` and `siteCode`). We therefore fall back to the Ably default of 65536 + /// bytes (`ARTDefault.maxMessageSize`), matching `Defaults.maxMessageSize` in ably-java. When the + /// plugin API exposes the connection's limit, source it from there instead. + /// See https://github.com/ably/ably-liveobjects-swift-plugin/issues/13. + private static let defaultMaxMessageSize = 65536 + + /// RTO15d: Validates that the total size of `objectMessages` (each calculated per OM3) does not + /// exceed the connection's `maxMessageSize`. If it does, the publish is rejected with an + /// `ErrorInfo` of `statusCode` 400 and `code` 40009. + private static func ensureMessageSizeWithinLimit(_ objectMessages: [ProtocolTypes.OutboundObjectMessage]) throws(ARTErrorInfo) { + let maximumAllowedSize = defaultMaxMessageSize + let totalSize = objectMessages.reduce(0) { $0 + $1.size } + if totalSize > maximumAllowedSize { + throw LiveObjectsError.maxMessageSizeExceeded(size: totalSize, maxSize: maximumAllowedSize).toARTErrorInfo() + } + } + // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. internal func testsOnly_publish(objectMessages: [ProtocolTypes.OutboundObjectMessage], coreSDK: CoreSDK) async throws(ARTErrorInfo) { + // RTO15d: reject the publish if the total ObjectMessage size exceeds maxMessageSize. + try Self.ensureMessageSizeWithinLimit(objectMessages) try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in mutableStateMutex.withSync { _ in coreSDK.nosync_publish(objectMessages: objectMessages) { result in @@ -481,6 +644,17 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO coreSDK: CoreSDK, mutableStateCallback: @escaping @Sendable (inout MutableState, Result) -> Void, ) { + // RTO15d: Reject the publish before contacting the core SDK if the total ObjectMessage size + // exceeds maxMessageSize. + do throws(ARTErrorInfo) { + try Self.ensureMessageSizeWithinLimit(objectMessages) + } catch { + mutableStateMutex.withoutSync { mutableState in + mutableStateCallback(&mutableState, .failure(error)) + } + return + } + // RTO20b: Publish via the core SDK. The callback fires asynchronously on the internal queue. coreSDK.nosync_publish(objectMessages: objectMessages) { [self] result in let publishResult: PublishResult @@ -523,6 +697,16 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO return .createSynthetic(from: outboundMessage, serial: serial, siteCode: siteCode) } + // If every serial was null (all operations conflated by the server, RTO20d1), + // there is nothing to apply. Complete immediately rather than falling through to the + // RTO20e sync-wait — waiting to apply zero messages serves no purpose and would + // otherwise risk a spurious RTO20e1 (92008) failure if the channel dropped while + // waiting. Mirrors ably-java (DefaultRealtimeObject.kt: `if (syntheticMessages.isEmpty()) return`). + guard !syntheticMessages.isEmpty else { + mutableStateCallback(&mutableState, .success(())) + return + } + // RTO20e: Build a waiter closure that owns both the apply-on-success // and error-construction-on-failure logic. The waiter receives `inout MutableState` // so it can apply synthetic messages and pass the reference through to the callback, @@ -644,6 +828,18 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO private struct MutableState { internal var objectsPool: ObjectsPool + + /// The channel's path-subscription registry (RTO24a). Reference type: shared, queue-confined. + internal var pathObjectSubscriptionRegister: PathObjectSubscriptionRegister + + /// The name of the channel these objects belong to. Used to populate the `channel` field of + /// the public ``ObjectMessage`` produced at emission (PAOM2e/PAOM3b). + /// + /// - Note: `_AblyPluginSupportPrivate` currently exposes no channel-name accessor, so in + /// production this is presently `""` (see the `channelName` init parameter). The message is + /// not consumed by any public API until P3/P4; tests supply a real name directly. + internal var channelName: String = "" + internal var onChannelAttachedHasObjects: Bool? internal var objectsEventSubscriptionStorage = SubscriptionStorage() @@ -756,7 +952,19 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } // RTO4b1, RTO4b2: Reset the ObjectsPool to have a single empty root object - objectsPool.nosync_reset() + let removedRootKeys = objectsPool.nosync_reset() + + // RTLO4b4c3b: fan the RTO4b2a reset update out to path subscriptions too (nil message, + // RTO4b2a — sync-originated). Runs after `nosync_reset` returns (root's mutex released, + // DEV-15). An unchanged (already-empty) root produced an empty diff — nothing to + // dispatch, mirroring Kotlin's empty-diff -> noop collapse (RTLO4b4c1). + if !removedRootKeys.isEmpty { + nosync_notifyPathSubscriptions( + objectID: ObjectsPool.rootKey, + changedMapKeys: removedRootKeys, + message: nil, + ) + } // I have, for now, not directly implemented the "perform the actions for object sync completion" of RTO4b4 since my implementation doesn't quite match the model given there; here you only have a SyncObjectsPool if you have an OBJECT_SYNC in progress, which you might not have upon receiving an ATTACHED. Instead I've just implemented what seem like the relevant side effects. Can revisit this if "the actions for object sync completion" get more complex. @@ -786,14 +994,18 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO let syncCursor: SyncCursor? if let protocolMessageChannelSerial { - do { - // RTO5a - syncCursor = try SyncCursor(channelSerial: protocolMessageChannelSerial) - } catch { - logger.log("Failed to parse sync cursor: \(error)", level: .error) - return + // RTO5a: parse the channelSerial into a sync cursor. + syncCursor = SyncCursor(channelSerial: protocolMessageChannelSerial) + if syncCursor == nil { + // The channelSerial does not conform to the RTO5a1 `:` + // shape (no colon separator, or an empty sequence id). The specification does not + // define behaviour for this case; for cross-SDK consistency with ably-java we treat + // it the same as an absent channelSerial (RTO5a5), i.e. the sync data is taken to be + // entirely contained within this single OBJECT_SYNC. + logger.log("channelSerial \"\(protocolMessageChannelSerial)\" is not a valid RTO5a1 sync cursor; treating OBJECT_SYNC as self-contained per RTO5a5", level: .warn) } } else { + // RTO5a5: no channelSerial; the sync data is entirely contained within this OBJECT_SYNC. syncCursor = nil } @@ -846,6 +1058,9 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, + // RTLO4b4c3b: sync-originated updates fan out to path subscriptions too (nil + // message, RTO4b2a), after the RTO5c10 rebuild inside applySyncObjectsPool. + pathObjectSubscriptionRegister: pathObjectSubscriptionRegister, ) // RTO5c6 @@ -937,6 +1152,15 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO return } + // RTO9a2b: Discard unsupported actions *before* creating any zero-value object. Both this + // and the RTO9a3 check above must precede the RTO9a2a1 creation so that a message that + // will be discarded never leaves a spurious zero-value object in the pool (matches + // ably-java, ObjectsManager.applyObjectMessages). + guard case let .known(action) = operation.action else { + logger.log("Unsupported OBJECT operation action \(operation.action) received", level: .warn) + return + } + // RTO9a2a1, RTO9a2a2 let entry: ObjectsPool.Entry if let existingEntry = objectsPool.entries[operation.objectId] { @@ -956,32 +1180,60 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO entry = newEntry } - switch operation.action { - case let .known(action): - switch action { - case .mapCreate, .mapSet, .mapRemove, .counterCreate, .counterInc, .objectDelete, .mapClear: - // RTO9a2a3 - let applied = entry.nosync_apply( - operation, - source: source, - objectMessageSerial: objectMessage.serial, - objectMessageSiteCode: objectMessage.siteCode, - objectMessageSerialTimestamp: objectMessage.serialTimestamp, - objectsPool: &objectsPool, - ) + switch action { + case .mapCreate, .mapSet, .mapRemove, .counterCreate, .counterInc, .objectDelete, .mapClear: + // PAOM3: convert the inbound op-bearing message to the public ObjectMessage that + // the emitted update will carry (RTLO4b4d). The action is known here (unknown + // actions returned early above, never surfacing publicly — DEV-5), so the + // conversion yields a non-nil message. + let sourceObjectMessage = objectMessage.toPublicObjectMessage(channelName: channelName) + + // RTO9a2a3 + let result = entry.nosync_apply( + operation, + source: source, + objectMessageSerial: objectMessage.serial, + objectMessageSiteCode: objectMessage.siteCode, + objectMessageSerialTimestamp: objectMessage.serialTimestamp, + sourceObjectMessage: sourceObjectMessage, + objectsPool: &objectsPool, + ) - // RTO9a2a4 - if source == .local, applied, let serial = objectMessage.serial { - appliedOnAckSerials.insert(serial) - } + // RTO9a2a4 + if source == .local, result.applied, let serial = objectMessage.serial { + appliedOnAckSerials.insert(serial) + } + + // RTLO4b4c3b -> RTO24b: fan the emitted update out to path subscriptions. This + // runs *after* `nosync_apply` returns (so no live object's mutex is held during + // the `getFullPaths` DFS — see DEV-15) and only for a non-`.noop` update + // (RTLO4b4c1). The instance-subscription fan-out already happened inside apply. + if let changedMapKeys = result.changedMapKeysForPathEvent { + nosync_notifyPathSubscriptions( + objectID: operation.objectId, + changedMapKeys: changedMapKeys, + message: sourceObjectMessage, + ) } - case let .unknown(rawValue): - // RTO9a2b - logger.log("Unsupported OBJECT operation action \(rawValue) received", level: .warn) - return } } + /// Fans one object update out to path subscriptions, forwarding to the pool-hosted + /// `ObjectsPool.nosync_notifyPathSubscriptions` (which documents the RTO24b semantics and + /// the DEV-15 no-mutex-held precondition) with this state's register. + internal func nosync_notifyPathSubscriptions( + objectID: String, + changedMapKeys: [String], + message: ObjectMessage?, + ) { + objectsPool.nosync_notifyPathSubscriptions( + objectID: objectID, + changedMapKeys: changedMapKeys, + message: message, + register: pathObjectSubscriptionRegister, + ) + } + /// Drains all `nosync_publishAndApply` sync waiter closures, invoking each with the given outcome. /// /// Each waiter receives `&self` so it can apply synthetic messages and pass mutable state @@ -996,8 +1248,16 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } } - /// RTO20e1: Called when the channel enters detached, suspended, or failed state. - /// Drains all waiting `nosync_publishAndApply` closures with a `.channelStateFailed` outcome. + /// RTO27 / RTO20e1: Called when the channel transitions to a state other than `ATTACHED`. + /// + /// - RTO20e1: on `DETACHED`/`SUSPENDED`/`FAILED`, fails any in-flight `publishAndApply` / + /// `get()` sync waiter with the code-92008 error. + /// - RTO27a: on `DETACHED`/`FAILED` the current objects data can no longer be known, so + /// every object's data is cleared to its zero value **without emitting events** (RTO27a1) + /// and the `SyncObjectsPool` is cleared (RTO27a2). + /// - RTO27b: on `SUSPENDED` the stored objects data is retained unchanged (the connection + /// may still recover and the retained data remains a valid best-effort local copy), so no + /// clear is performed. internal mutating func nosync_onChannelStateChanged( toState state: _AblyPluginSupportPrivate.RealtimeChannelState, reason: ARTErrorInfo?, @@ -1005,21 +1265,46 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO ) { switch state { case .detached, .suspended, .failed: - guard !publishAndApplySyncWaiters.isEmpty else { - return + // RTO20e1: fail any in-flight publishAndApply / get() sync waiters. (Guarded so we + // only log/drain when there is something to drain; the RTO27 data handling below + // runs regardless of whether any waiters were present.) + if !publishAndApplySyncWaiters.isEmpty { + logger.log("Channel entered \(state) state; rejecting \(publishAndApplySyncWaiters.count) publishAndApply waiter(s)", level: .debug) + nosync_drainPublishAndApplySyncWaiters( + outcome: .channelStateFailed(state: state, reason: reason), + ) } - logger.log("Channel entered \(state) state; rejecting \(publishAndApplySyncWaiters.count) publishAndApply waiter(s)", level: .debug) - - // RTO20e1 - nosync_drainPublishAndApplySyncWaiters( - outcome: .channelStateFailed(state: state, reason: reason), - ) + // RTO27a / RTO27b: manage the stored objects data. + switch state { + case .detached, .failed: + // RTO27a: the current state of the objects data can no longer be known. + logger.log("Channel entered \(state) state; clearing objects data per RTO27a", level: .debug) + // RTO27a1: clear every object's data to its zero value, emitting no events. + objectsPool.nosync_clearObjectsData() + // RTO27a2: clear the SyncObjectsPool. + nosync_clearSyncObjectsPool() + default: + // RTO27b (SUSPENDED): retain the stored objects data unchanged. + break + } default: + // RTO27: no action for any other channel state. break } } + /// RTO27a2 (also RTO5c4): Clears the in-progress `SyncObjectsPool` — the accumulated + /// `OBJECT_SYNC` data of a partial multi-`ProtocolMessage` sync sequence — by discarding the + /// stored sync sequence. A no-op when no sync sequence is in progress. Deliberately does not + /// clear `bufferedObjectOperations` (RTO27a2 concerns only the `SyncObjectsPool`) nor change + /// the sync state, mirroring Kotlin's `ObjectsManager.clearSyncObjectsPool`. + private mutating func nosync_clearSyncObjectsPool() { + if case let .syncing(syncingData) = state { + syncingData.syncSequence = nil + } + } + internal typealias UpdateMutableState = @Sendable (_ action: (inout Self) -> Void) -> Void @discardableResult diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift index abfe9d7d8..1472b62bd 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift @@ -42,6 +42,30 @@ internal extension InternalLiveObject { liveObjectMutableState.emitLifecycleEvent(.deleted, on: userCallbackQueue) } + /// Stamps the source public object message onto `rawUpdate`, emits the enriched update to this + /// object's subscribers, and — if the update tombstones the object — deregisters all of this + /// object's subscriptions afterwards (RTLO4b4c3c teardown). This mirrors Kotlin's + /// `notifyUpdated` (`BaseRealtimeLiveObject.kt:290`), performed inline where cocoa emits. + /// + /// - Parameter sourceObjectMessage: the PAOM3-converted public message (op-bearing paths), or + /// `nil` for sync-originated updates (RTO4b2a). + /// - Returns: the enriched update (so the R-3 apply return carries the enrichment too). + mutating func nosync_emitAndTearDown( + _ rawUpdate: LiveObjectUpdate, + sourceObjectMessage: ObjectMessage?, + userCallbackQueue: DispatchQueue, + ) -> LiveObjectUpdate where Update: LiveObjectUpdatePayload { + // RTLO4b4d: stamp the source public message onto the update + let enriched = rawUpdate.nosync_stampingObjectMessage(sourceObjectMessage) + // RTLO4b4c2/RTLO4b4c3a: emit to instance listeners (noops are dropped in emit) + liveObjectMutableState.emit(enriched, on: userCallbackQueue) + // RTLO4b4c3c: tombstone teardown — deregister this object's subscriptions after emitting + if enriched.tombstone { + liveObjectMutableState.unsubscribeAll() + } + return enriched + } + /// Applies an `OBJECT_DELETE` operation, per RTLO5. mutating func applyObjectDeleteOperation( objectMessageSerialTimestamp: Date?, @@ -57,4 +81,41 @@ internal extension InternalLiveObject { userCallbackQueue: userCallbackQueue, ) } + + // MARK: - Parent-reference graph (RTLO3f, RTLO4f, RTLO4g, RTLO4h) + + /// Records that the map identified by `parentObjectID` references this object at `key`, per RTLO4g. + mutating func nosync_addParentReference(parentObjectID: String, key: String) { + // RTLO4g1, RTLO4g2: insert a new entry if absent, otherwise add the key to the existing set + liveObjectMutableState.parentReferences[parentObjectID, default: []].insert(key) + } + + /// Removes the recorded reference from the map identified by `parentObjectID` at `key`, per RTLO4h. + mutating func nosync_removeParentReference(parentObjectID: String, key: String) { + // RTLO4h1: no entry for this parent — do nothing + guard var keys = liveObjectMutableState.parentReferences[parentObjectID] else { + return + } + // RTLO4h2: remove the key from the entry's set + keys.remove(key) + if keys.isEmpty { + // RTLO4h3: if the entry's set is now empty, remove the entry entirely + liveObjectMutableState.parentReferences.removeValue(forKey: parentObjectID) + } else { + liveObjectMutableState.parentReferences[parentObjectID] = keys + } + } + + /// Resets `parentReferences` to its initial value (an empty map), per RTO5c10a. + mutating func nosync_clearParentReferences() { + liveObjectMutableState.parentReferences = [:] + } + + // Note (RTLO4f `getFullPaths`): the cycle-safe DFS lives on `ObjectsPool` + // (`nosync_getFullPaths(forObjectID:)`) rather than here. It must read each object's + // `parentReferences` through a brief, self-contained access and must never hold any single + // object's queue-mutex open across the traversal — doing so re-enters that mutex when the walk + // revisits the object, tripping Swift's exclusive-access checker. Placing it on the pool (which + // owns the graph) keeps every `nosync_parentReferences` read independent; the per-object + // `nosync_getFullPaths(objectsPool:)` forwarders delegate to it. } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalTypes.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalTypes.swift index 58b313abe..a440b7042 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalTypes.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalTypes.swift @@ -46,16 +46,30 @@ internal enum LiveMapUpdateAction: Sendable { case removed } +/// The message/tombstone enrichment carried by every non-noop update, per RTLO4b4d/RTLO4b4e (P2). +/// +/// - `objectMessage` is the PAOM3-converted public ``ObjectMessage`` from the source operation +/// message, or `nil` for sync-originated updates (RTO4b2a). +/// - `tombstone` is `true` when the update results from this object being tombstoned; the emitter +/// deregisters the object's subscriptions afterwards (RTLO4b4c3c). +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal protocol LiveObjectUpdatePayload: Sendable { + /// The source public object message (op-bearing only), or `nil` for sync-originated updates. + var objectMessage: ObjectMessage? { get set } + /// Whether this update tombstones the object. + var tombstone: Bool { get set } +} + /// Represents an update to an internal live map (``InternalDefaultLiveMap``). @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal protocol LiveMapUpdate: Sendable { +internal protocol LiveMapUpdate: LiveObjectUpdatePayload { /// The keys that have changed, along with their change status. var update: [String: LiveMapUpdateAction] { get } } /// Represents an update to an internal live counter (``InternalDefaultLiveCounter``). @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal protocol LiveCounterUpdate: Sendable { +internal protocol LiveCounterUpdate: LiveObjectUpdatePayload { /// Holds the numerical change to the counter value. var amount: Double { get } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index 20639a4d0..e13f977bb 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -21,6 +21,12 @@ internal struct LiveObjectMutableState { // RTLO3e internal var tombstonedAt: Date? + /// Reverse references: parent map `objectID` -> the set of keys at which that map references + /// this object. Keyed by `objectID` per RTLO3f (ids avoid map-to-map reference cycles and + /// survive pool replacement). Only mutated and traversed on the internal queue. + /// Spec: RTLO3f, RTLO3f2 (initialised to an empty map). + internal var parentReferences: [String: Set] = [:] + private enum EventName { case update } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift index c9f90f8cc..b997c054e 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift @@ -26,3 +26,30 @@ internal enum LiveObjectUpdate: Sendable { @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension LiveObjectUpdate: Equatable where Update: Equatable {} + +// MARK: - Message/tombstone enrichment (P2) + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension LiveObjectUpdate where Update: LiveObjectUpdatePayload { + /// The source public object message carried by an `update` payload (`nil` for `noop` or + /// sync-originated updates). Spec: RTLO4b4d. + var objectMessage: ObjectMessage? { + update?.objectMessage + } + + /// Whether this update tombstones the object. `false` for `noop`. Spec: RTLO4b4e. + var tombstone: Bool { + update?.tombstone ?? false + } + + /// Returns a copy of this update whose `update` payload carries the given source public object + /// message; `noop` updates are returned unchanged. Used to stamp the PAOM3 message onto an + /// update at emission time (RTLO4b4d). + func nosync_stampingObjectMessage(_ message: ObjectMessage?) -> Self { + guard case var .update(payload) = self else { + return self + } + payload.objectMessage = message + return .update(payload) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift index 9895c6ede..a8823d8ff 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift @@ -13,8 +13,15 @@ internal enum ObjectDiffHelpers { previousData: Double, newData: Double, ) -> LiveObjectUpdate { - // RTLC14b - .update(DefaultLiveCounterUpdate(amount: newData - previousData)) + // RTLC14b. A zero delta means the value did not actually change (e.g. re-applying an + // ObjectState whose count equals the current data). An update communicates a change + // (RTLO4b4a), so return the no-op update (permitted by RTLO4b4b) rather than a spurious + // zero-amount update that would fire subscriber callbacks for no change. Matches ably-java + // (LiveCounterManager.calculateUpdateFromDataDiff) and ably-js. + if newData == previousData { + return .noop + } + return .update(DefaultLiveCounterUpdate(amount: newData - previousData)) } /// Calculates the diff between two LiveMap data values, per RTLM22. @@ -53,6 +60,15 @@ internal enum ObjectDiffHelpers { } } + // An empty diff means nothing actually changed (e.g. re-applying an ObjectState that + // matches the current data, or clearing an already-empty map). An update communicates a + // change (RTLO4b4a), so return the no-op update (permitted by RTLO4b4b) rather than a + // spurious empty update that would fire subscriber callbacks for no change. Matches + // ably-java (LiveMapManager.calculateUpdateFromDataDiff) and ably-js. + if update.isEmpty { + return .noop + } + return .update(DefaultLiveMapUpdate(update: update)) } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 90b89b0dc..ca517cfa3 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -30,36 +30,56 @@ internal struct ObjectsPool { } } + /// The outcome of applying an operation to a LiveObject, carrying both the RTO9a2a4 dedup + /// signal and what the RTO24 path-subscription dispatch needs. + internal struct ApplyResult { + /// `true` if an operation was applied (a non-nil update, including a `.noop`), `false` if + /// it was skipped (RTLM15g/RTLC10g). Drives the RTO9a2a4 applied-on-ACK dedup. + internal let applied: Bool + /// The changed map keys of the emitted update, used to build the RTO24b2a2 deeper path + /// candidates (empty for a counter update). `nil` when nothing should be dispatched to + /// path subscriptions — i.e. the operation was skipped or produced a `.noop` update + /// (RTLO4b4c1). A non-`nil` (possibly empty) value means "dispatch a path event". + internal let changedMapKeysForPathEvent: [String]? + } + /// Applies an operation to a LiveObject, per RTO9a2a3. - /// - /// - Returns: `true` if the operation was applied, `false` if it was skipped. internal func nosync_apply( _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, objectMessageSerialTimestamp: Date?, + sourceObjectMessage: ObjectMessage? = nil, objectsPool: inout ObjectsPool, - ) -> Bool { + ) -> ApplyResult { switch self { case let .map(map): - map.nosync_apply( + // A non-`.noop` map update carries the set of keys it changed; those drive the + // RTO24b2a2 deeper path candidates. A `nil`/`.noop` update dispatches nothing. + let update = map.nosync_apply( operation, source: source, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, objectMessageSerialTimestamp: objectMessageSerialTimestamp, + sourceObjectMessage: sourceObjectMessage, objectsPool: &objectsPool, ) + return .init(applied: update != nil, changedMapKeysForPathEvent: update?.update.map { Array($0.update.keys) }) case let .counter(counter): - counter.nosync_apply( + // A counter update contributes no deeper candidates; a non-`.noop` update still + // dispatches a path event at the object's own path (empty key list). + let update = counter.nosync_apply( operation, source: source, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, objectMessageSerialTimestamp: objectMessageSerialTimestamp, + sourceObjectMessage: sourceObjectMessage, objectsPool: &objectsPool, ) + return .init(applied: update != nil, changedMapKeysForPathEvent: update?.update.map { _ in [String]() }) } } @@ -69,12 +89,40 @@ internal struct ObjectsPool { case counter(InternalDefaultLiveCounter, LiveObjectUpdate) /// Causes the referenced `LiveObject` to emit the stored event to its subscribers. + /// + /// If the update tombstones the object (a sync-originated tombstone, RTLM6f/RTLC6f), the + /// object's subscriptions are deregistered after emitting, per the RTLO4b4c3c teardown. internal func nosync_emit() { switch self { case let .map(map, update): map.nosync_emit(update) + if update.tombstone { + map.nosync_deregisterSubscriptionsForTombstone() + } case let .counter(counter, update): counter.nosync_emit(update) + if update.tombstone { + counter.nosync_deregisterSubscriptionsForTombstone() + } + } + } + + /// What the RTO24 path-subscription dispatch needs for this deferred (sync-originated) + /// update: the updated object's ID and the changed map keys (the RTO24b2a2 deeper + /// candidates; empty for a counter). `nil` for a `.noop` update — noops dispatch nothing + /// (RTLO4b4c1). + internal var nosync_pathDispatchInfo: (objectID: String, changedMapKeys: [String])? { + switch self { + case let .map(map, update): + guard let payload = update.update else { + return nil // .noop + } + return (objectID: map.nosync_objectID, changedMapKeys: Array(payload.update.keys)) + case let .counter(counter, update): + guard update.update != nil else { + return nil // .noop + } + return (objectID: counter.nosync_objectID, changedMapKeys: []) } } } @@ -150,6 +198,48 @@ internal struct ObjectsPool { map.testsOnly_tombstonedAt } } + + // MARK: - Parent-reference graph (RTLO3f) + + /// The object's RTLO3f `parentReferences`, accessed on the internal queue. + internal var nosync_parentReferences: [String: Set] { + switch self { + case let .counter(counter): + counter.nosync_parentReferences + case let .map(map): + map.nosync_parentReferences + } + } + + /// Records that the map identified by `parentObjectID` references this object at `key`, per RTLO4g. + internal func nosync_addParentReference(parentObjectID: String, key: String) { + switch self { + case let .counter(counter): + counter.nosync_addParentReference(parentObjectID: parentObjectID, key: key) + case let .map(map): + map.nosync_addParentReference(parentObjectID: parentObjectID, key: key) + } + } + + /// Removes the recorded reference from the map identified by `parentObjectID` at `key`, per RTLO4h. + internal func nosync_removeParentReference(parentObjectID: String, key: String) { + switch self { + case let .counter(counter): + counter.nosync_removeParentReference(parentObjectID: parentObjectID, key: key) + case let .map(map): + map.nosync_removeParentReference(parentObjectID: parentObjectID, key: key) + } + } + + /// Resets `parentReferences` to an empty map, per RTO5c10a. + internal func nosync_clearParentReferences() { + switch self { + case let .counter(counter): + counter.nosync_clearParentReferences() + case let .map(map): + map.nosync_clearParentReferences() + } + } } /// Keyed by `objectId`. @@ -215,6 +305,13 @@ internal struct ObjectsPool { } } + // MARK: - Test-only setters + + /// Test-only setter that inserts or replaces an entry for the given object ID. + internal mutating func testsOnly_setEntry(_ entry: Entry, forObjectID objectID: String) { + entries[objectID] = entry + } + // MARK: - Data manipulation /// Creates a zero-value object if it does not exist in the pool, per RTO6. This is used when applying a `MAP_SET` operation that contains a reference to another object. @@ -278,12 +375,19 @@ internal struct ObjectsPool { } /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1 and RTO5c2. + /// + /// - Parameter pathObjectSubscriptionRegister: When non-nil, each non-noop RTO5c7 deferred update + /// also fans out to path subscriptions with a `nil` message (RTO4b2a — sync-originated updates + /// never surface a public message), after the RTO5c10 parent-reference rebuild so paths reflect + /// the post-sync graph. `nil` (the default, used by tests driving the pool directly) skips path + /// dispatch. internal mutating func nosync_applySyncObjectsPool( _ syncObjectsPool: SyncObjectsPool, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, + pathObjectSubscriptionRegister: PathObjectSubscriptionRegister? = nil, ) { logger.log("applySyncObjectsPool called with \(syncObjectsPool.count) objects", level: .debug) @@ -338,14 +442,138 @@ internal struct ObjectsPool { } } + // RTO5c10: rebuild every parentReferences map after the pool has settled, so that + // getFullPaths is correct by the time the RTO5c7 notifications below are dispatched + nosync_rebuildParentReferences() + // RTO5c7: Emit the updates to existing objects for deferredUpdate in updatesToExistingObjects { deferredUpdate.nosync_emit() + + // RTLO4b4c3b -> RTO24b: fan the sync-originated update out to path subscriptions too, + // with a nil message (RTO4b2a). This runs after the RTO5c10 rebuild above, so the + // getFullPaths DFS sees the post-sync graph (Kotlin: rebuild-before-notify, + // `ObjectsManager.kt:174–175`). Noop updates dispatch nothing (RTLO4b4c1). + if let register = pathObjectSubscriptionRegister, let info = deferredUpdate.nosync_pathDispatchInfo { + nosync_notifyPathSubscriptions( + objectID: info.objectID, + changedMapKeys: info.changedMapKeys, + message: nil, + register: register, + ) + } } logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) } + /// Rebuilds all parent references from the settled pool state, per RTO5c10. Necessary after a + /// sync because objects may reference other objects that were not yet in the pool when their + /// references were first applied. + internal mutating func nosync_rebuildParentReferences() { + // RTO5c10a: reset every object's parentReferences to the initial (empty) value + for entry in entries.values { + entry.nosync_clearParentReferences() + } + + // RTO5c10b: for each map, re-add a reference on every non-tombstoned object-valued entry. + // We iterate the raw entries (rather than the resolved value() surface) since only + // entry.data.objectId is needed here; tombstoned entries (RTLM14) are skipped. + for (parentObjectID, entry) in entries { + guard case let .map(map) = entry else { + continue + } + for (key, mapEntry) in map.nosync_rawData { + guard let refId = mapEntry.data?.objectId else { + continue + } + if InternalDefaultLiveMap.nosync_isEntryTombstoned(mapEntry, objectsPool: self) { + continue + } + entries[refId]?.nosync_addParentReference(parentObjectID: parentObjectID, key: key) + } + } + } + + /// All key-paths from the root object to the object identified by `objectID`, per RTLO4f: one + /// per simple path in the parent-reference graph, cycle-safe, order unspecified. Returns `[[]]` + /// when `objectID` is root itself, and `[]` for an orphan (or an object absent from the pool). + /// + /// The DFS resolves each node's `parentReferences` through a brief, independent read + /// (`Entry.nosync_parentReferences`); it deliberately never keeps a single object's queue-mutex + /// open across the walk, so revisiting a node cannot cause an exclusive-access conflict. + internal func nosync_getFullPaths(forObjectID objectID: String) -> [[String]] { + var paths: [[String]] = [] + + // Each stack element pairs the object being visited with the path built so far and the set + // of objectIDs already visited on this branch. + var stack: [(objectID: String, path: [String], visited: Set)] = [ + (objectID: objectID, path: [], visited: []), + ] + + while let (currentID, currentPath, visited) = stack.popLast() { + // RTLO4f2: simple paths only — skip a node already visited on this branch (cycles) + if visited.contains(currentID) { + continue + } + let newVisited = visited.union([currentID]) + + // RTLO4f2: the empty path is contributed only when the walk reaches root + if currentID == Self.rootKey { + paths.append(currentPath) + continue + } + + // A stale/absent object (left the pool) contributes no further path + guard let parentReferences = entries[currentID]?.nosync_parentReferences else { + continue + } + + for (parentID, keys) in parentReferences { + for key in keys { + stack.append((objectID: parentID, path: [key] + currentPath, visited: newVisited)) + } + } + } + + // RTLO4f3: each simple path appears exactly once; order is unspecified + return paths + } + + /// Fans one object update out to path subscriptions (Kotlin's `notifyPathSubscriptions`, + /// `BaseRealtimeLiveObject.kt:318`). For every full path to the updated object (RTO24b1), + /// dispatches one path event whose candidates are the object's own path (most-preferred, + /// RTO24b2a1) followed by one deeper candidate per changed map key (RTO24b2a2). An orphaned + /// object (unreachable from root) produces no events (RTO24b1a). + /// + /// Hosted on the pool (like the `getFullPaths` DFS it drives — DEV-15) so both the operation + /// apply path and the sync deferred-update path can share it. + /// + /// - Important: Must be called with **no live object's queue-mutex held** (the `getFullPaths` + /// DFS re-reads each node's `parentReferences`; holding the starting object's mutex would + /// trip Swift's exclusive-access checker — see DEV-15). Callers invoke it after the + /// object-level apply/emit has returned. + /// + /// Spec: RTO24b (RTO24b1, RTO24b2, RTO24b2a1, RTO24b2a2). + internal func nosync_notifyPathSubscriptions( + objectID: String, + changedMapKeys: [String], + message: ObjectMessage?, + register: PathObjectSubscriptionRegister, + ) { + let pathsToThis = nosync_getFullPaths(forObjectID: objectID) // RTO24b1 + if pathsToThis.isEmpty { + return // orphaned object (not reachable from root) — no path events (RTO24b1a) + } + for pathToThis in pathsToThis { // RTO24b2 + var candidates = [pathToThis] // RTO24b2a1 — most preferred first + for key in changedMapKeys { + candidates.append(pathToThis + [key]) // RTO24b2a2 + } + register.nosync_notifyPathEvent(candidatePaths: candidates, message: message) + } + } + /// Creates a new object from a sync entry and adds it to the pool, per RTO5c1b. /// /// - Precondition: `state.objectId` must not be the root object ID, in order to preserve the RTO3b invariant that the root is always a map. @@ -406,7 +634,11 @@ internal struct ObjectsPool { } /// Removes all entries except the root, and clears the root's data. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map, per RTO4b. - internal mutating func nosync_reset() { + /// + /// - Returns: The root keys reported as `removed` by the emitted RTO4b2a update, so the caller + /// can fan the reset out to path subscriptions (RTLO4b4c3b) after the root's mutex is released. + @discardableResult + internal mutating func nosync_reset() -> [String] { let root = root // RTO4b1 @@ -414,7 +646,29 @@ internal struct ObjectsPool { // RTO4b2 // TODO: this one is unclear (are we meant to replace the root or just clear its data?) https://github.com/ably/specification/pull/333/files#r2183493458. I believe that the answer is that we should just clear its data but the spec point needs to be clearer, see https://github.com/ably/specification/pull/346/files#r2201434895. - root.nosync_resetData() + return root.nosync_resetData() + } + + /// RTO27a1: Clears the internal data of every object in the pool, resetting each to its zero + /// value (an empty map, or a counter of `0`) **without emitting any `LiveObjectUpdate` + /// events**. The objects themselves remain in the pool; only their data is cleared. Mirrors + /// Kotlin's `ObjectsPool.clearObjectsData(emitUpdateEvents = false)`. + /// + /// Each map additionally drops the parent references it holds on its referenced children + /// (RTLO4e9), so that once every object's data has been cleared the parent-reference graph is + /// left empty and consistent. Used by the RTO27a DETACHED/FAILED channel-state clear. + /// + /// Non-`mutating`: this reassigns no pool entry, only mutating the (reference-type) objects the + /// entries hold, so it can pass `self` down to each map's clear without an exclusivity conflict. + internal func nosync_clearObjectsData() { + for entry in entries.values { + switch entry { + case let .map(map): + map.nosync_clearDataToZeroValue(objectsPool: self) + case let .counter(counter): + counter.nosync_clearDataToZeroValue() + } + } } /// Performs garbage collection of tombstoned objects and map entries, per RTO10c. @@ -436,6 +690,13 @@ internal struct ObjectsPool { // RTO10c1b let shouldRelease = { + // RTO10c1b1: the object with ID `root` must never be removed from the ObjectsPool + // (RTO3b). It can never become tombstoned per RTLO4e10, so this exclusion is an + // additional safeguard for the RTO3b invariant. + guard key != Self.rootKey else { + return false + } + guard let tombstonedAt = entry.nosync_tombstonedAt else { return false } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/PathObjectSubscriptionRegister.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/PathObjectSubscriptionRegister.swift new file mode 100644 index 000000000..8214dffc8 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/PathObjectSubscriptionRegister.swift @@ -0,0 +1,150 @@ +import Ably +import Foundation + +/// Registry for ``PathObject`` subscriptions and path-event dispatch. One per ``RealtimeObject`` +/// (owned by ``InternalDefaultRealtimeObjects``), mirroring Kotlin's +/// `DefaultRealtimeObject.pathObjectSubscriptionRegister` and its `PathObjectSubscriptionRegister`. +/// +/// ## Concurrency (plan §1.1) +/// +/// The register is **queue-confined**: every entry point (`nosync_subscribe`, `nosync_unsubscribe`, +/// `nosync_notifyPathEvent`, `nosync_dispose`) runs on the objects engine's internal serial queue, +/// enforced by `dispatchPrecondition`. Subscriptions are registered from public callers via a hop +/// onto that queue (see ``DefaultPathObject/subscribe(options:listener:)``); dispatch already runs on +/// it (path notification happens inside the inbound-message apply path). There is **no lock**: the +/// mutable `subscriptions` map is plain queue-confined state. +/// +/// Listener callbacks are emitted on `userCallbackQueue` (never under any mutex — the issue #120 +/// convention), matching ``SubscriptionStorage``'s off-lock `async` dispatch. +/// +/// ## Why not ``SubscriptionStorage`` +/// +/// `SubscriptionStorage` broadcasts the *same* update to every subscriber of an event name. Path +/// dispatch is per-subscription: each registration resolves its *own* covered path from the +/// candidate list (RTO24b2b, depth-windowed coverage RTO24c1), so a uniform broadcast cannot express +/// it. This mirrors Kotlin, which likewise subclasses `EventEmitter` with a per-listener `apply` +/// rather than reusing the LiveMap/LiveCounter change coordinators verbatim. (Deviation candidate.) +/// +/// Spec: `RTO24`, `RTO24a`, `RTPO19`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal final class PathObjectSubscriptionRegister: @unchecked Sendable { + /// Constructs the ``PathObject`` carried by a path event, for a given (already dot-joined) path. + /// Supplied by the subscribing ``DefaultPathObject`` so the register need not itself hold the + /// channel object / core SDK. Returns `nil` when its captured context has been released, in which + /// case the subscription is effectively dead and the event is dropped. + internal typealias PathObjectFactory = @Sendable (_ joinedPath: String) -> (any PathObject)? + + /// A single registration: the EventEmitter-`Listener` analogue plus its coverage state. + private struct PathSubscription { + /// The subscription's stored path, as segments (copied at subscribe time). RTPO19f. + internal let segments: [String] + /// The RTPO19c1 depth window; `nil` means infinite depth. + internal let depth: Int? + internal let listener: PathObjectSubscriptionCallback + internal let makePathObject: PathObjectFactory + } + + private let internalQueue: DispatchQueue + private let userCallbackQueue: DispatchQueue + + /// Queue-confined registrations, keyed by an identity token used for deregistration. Identity + /// keying (rather than value equality) ensures `nosync_unsubscribe` removes exactly the intended + /// registration, mirroring Kotlin's plain-class `PathSubscription`. + private nonisolated(unsafe) var subscriptions: [UUID: PathSubscription] = [:] + + internal init(internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue) { + self.internalQueue = internalQueue + self.userCallbackQueue = userCallbackQueue + } + + /// Registers a subscription for `segments` with the given depth window, returning a + /// ``Subscription`` whose `unsubscribe()` deregisters it. Spec: RTPO19f. + internal func nosync_subscribe( + segments: [String], + depth: Int?, + listener: @escaping PathObjectSubscriptionCallback, + makePathObject: @escaping PathObjectFactory, + ) -> any Subscription { + dispatchPrecondition(condition: .onQueue(internalQueue)) + let id = UUID() + subscriptions[id] = .init(segments: segments, depth: depth, listener: listener, makePathObject: makePathObject) + let internalQueue = internalQueue + return ClosureSubscription { [weak self] in + // SUB2a/SUB2b: hop onto the internal queue to deregister; idempotent (a missing id is a + // no-op), and a no-op if the register has already been released. + internalQueue.ably_syncNoDeadlock { + self?.nosync_unsubscribe(id: id) + } + } + } + + /// Deregisters the subscription with the given identity token. Idempotent. Spec: RTPO19f1. + internal func nosync_unsubscribe(id: UUID) { + dispatchPrecondition(condition: .onQueue(internalQueue)) + subscriptions.removeValue(forKey: id) + } + + /// Dispatches one path event: each subscription covering any candidate path is notified at most + /// once, at the first (most-preferred) covered candidate; subscriptions covering none are + /// skipped. The listener receives a ``PathObjectSubscriptionEvent`` whose `object` points at the + /// chosen candidate path (RTO24b2b1 / RTPO19e1) and whose `message` is the source object message + /// (RTO24b2b2 / RTPO19e2). Callbacks are emitted on `userCallbackQueue`, off any mutex. + /// + /// Spec: RTO24b2b, RTO24c1. + internal func nosync_notifyPathEvent(candidatePaths: [[String]], message: ObjectMessage?) { + dispatchPrecondition(condition: .onQueue(internalQueue)) + for subscription in subscriptions.values { + // RTO24b2b: first (most-preferred) covered candidate, or skip this subscription. + guard let chosen = candidatePaths.first(where: { Self.covers(subscription, eventPath: $0) }) else { + continue + } + // The captured context may have been released; drop the event if so (dead subscription). + guard let object = subscription.makePathObject(PathSegments.join(chosen)) else { + continue + } + let event = PathObjectSubscriptionEvent(object: object, message: message) + let listener = subscription.listener + userCallbackQueue.async { + listener(event) + } + } + } + + /// Drops all subscriptions. Called when the owning ``RealtimeObject`` is disposed. + internal func nosync_dispose() { + dispatchPrecondition(condition: .onQueue(internalQueue)) + subscriptions.removeAll() + } + + /// A subscription covers `eventPath` iff its stored path is a prefix of it (exact match included) + /// and the relative depth is within the subscription's depth window. Spec: RTO24c1. + private static func covers(_ subscription: PathSubscription, eventPath: [String]) -> Bool { + let subPath = subscription.segments + if subPath.count > eventPath.count { + return false + } + for i in subPath.indices where eventPath[i] != subPath[i] { + return false + } + guard let depth = subscription.depth else { + return true // nil = infinite depth + } + return eventPath.count - subPath.count + 1 <= depth + } +} + +/// A ``Subscription`` whose `unsubscribe()` runs a closure (Kotlin's `onceSubscription`). Calling +/// `unsubscribe()` more than once simply re-runs the closure, whose effect is idempotent. Spec: +/// `SUB2a`, `SUB2b`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +private final class ClosureSubscription: Subscription { + private let action: @Sendable () -> Void + + internal init(action: @escaping @Sendable () -> Void) { + self.action = action + } + + internal func unsubscribe() { + action() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterInstance.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterInstance.swift index eb78fc32e..73e8ca625 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterInstance.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterInstance.swift @@ -1,31 +1,79 @@ import Ably +import Foundation +/// Default implementation of ``LiveCounterInstance`` (Kotlin `DefaultLiveCounterInstance`), bound to a +/// specific ``InternalDefaultLiveCounter`` (RTINS2a). Operations dereference the wrapped counter in +/// O(1) — no path resolution. Spec: `RTINS1`, `RTTS10b`. +/// +/// The RTO25b access-precondition checks are performed by the underlying node accessors +/// (`value(coreSDK:)`, `subscribe(...)`, `increment(...)`) — the same checks the internal engine +/// already enforces; no new checks are invented here. @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class DefaultLiveCounterInstance: LiveCounterInstance { - internal var id: String { - notImplemented() + private let node: InternalDefaultLiveCounter + private let coreSDK: CoreSDK + private let realtimeObjects: any InternalRealtimeObjectsProtocol + private let internalQueue: DispatchQueue + + /// The wrapped counter's `objectId`, captured once at construction (RTINS3a). It is immutable, so + /// the frozen non-throwing `id` property is a plain stored read (O(1)). + internal let id: String + + internal init( + node: InternalDefaultLiveCounter, + coreSDK: CoreSDK, + realtimeObjects: any InternalRealtimeObjectsProtocol, + internalQueue: DispatchQueue, + ) { + self.node = node + self.coreSDK = coreSDK + self.realtimeObjects = realtimeObjects + self.internalQueue = internalQueue + // RTINS3a: read the immutable objectId on the shared internal queue. + id = internalQueue.ably_syncNoDeadlock { node.nosync_objectID } } + // MARK: - LiveCounterInstance + internal var value: Double { get throws(ARTErrorInfo) { - notImplemented() + // RTINS4a/RTINS4b -> RTLC5 (the node accessor runs the RTO25b check) + try node.value(coreSDK: coreSDK) } } - internal func increment(amount _: Double) async throws(ARTErrorInfo) { - notImplemented() + internal func increment(amount: Double) async throws(ARTErrorInfo) { + // RTINS14c -> RTLC12 + try await node.increment(amount: amount, coreSDK: coreSDK, realtimeObjects: realtimeObjects) } - internal func decrement(amount _: Double) async throws(ARTErrorInfo) { - notImplemented() + internal func decrement(amount: Double) async throws(ARTErrorInfo) { + // RTINS15c -> RTLC13 + try await node.decrement(amount: amount, coreSDK: coreSDK, realtimeObjects: realtimeObjects) } @discardableResult - internal func subscribe(listener _: @escaping InstanceSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { - notImplemented() + internal func subscribe(listener: @escaping InstanceSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { + // RTINS16b (the node's subscribe runs the RTO25b check), RTINS16d -> RTLO4b + let response = try node.subscribe( + listener: { [weak self] update, _ in + guard let self else { + return + } + // RTINS16e1: an Instance wrapping this counter (identity-based, RTINS16g). + // RTINS16e2: the PAOM3-converted public message stamped onto the update in P2 (nil for + // sync-originated updates). + listener(.init(object: .liveCounter(self), message: update.objectMessage)) + }, + coreSDK: coreSDK, + ) + // RTINS16f + return DefaultSubscription(response: response) } internal func compactJson() throws(ARTErrorInfo) -> JSONValue { - notImplemented() + // RTINS11a/RTINS11b -> RTPO14 -> RTPO13d: a counter compacts to its current numeric value. + // The node's `value(coreSDK:)` runs the RTO25b check. + try .number(node.value(coreSDK: coreSDK)) } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterPathObject.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterPathObject.swift index c9d8faa14..dfd093ed1 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterPathObject.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterPathObject.swift @@ -1,61 +1,51 @@ import Ably -/// Skeleton implementation of ``LiveCounterPathObject``. Every member currently traps via -/// `notImplemented()`; this is a standalone `final class` (no shared base) so that we don't commit to -/// a particular implementation shape before the path-based API is actually built. `Sendable` is a -/// checked conformance: the class holds no state. +/// Default implementation of ``LiveCounterPathObject`` (Kotlin `DefaultLiveCounterPathObject`). +/// +/// Counters are terminal nodes (no navigation), so this only adds the counter read/write operations +/// on top of ``DefaultPathObject``. +/// +/// Spec: `RTTS6b`. @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal final class DefaultLiveCounterPathObject: LiveCounterPathObject, Sendable { - // MARK: - PathObject - - internal var path: String { - notImplemented() - } - - internal func instance() throws(ARTErrorInfo) -> Instance? { - notImplemented() - } - - internal func compactJson() throws(ARTErrorInfo) -> JSONValue? { - notImplemented() - } - - @discardableResult - internal func subscribe(options _: PathObjectSubscriptionOptions?, listener _: @escaping PathObjectSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { - notImplemented() - } - - internal func exists() throws(ARTErrorInfo) -> Bool { - notImplemented() - } - - internal func type() throws(ARTErrorInfo) -> ValueType? { - notImplemented() - } - - internal func asLiveMap() -> any LiveMapPathObject { - notImplemented() - } - - internal func asLiveCounter() -> any LiveCounterPathObject { - notImplemented() - } - - internal func asPrimitive() -> any PrimitivePathObject { - notImplemented() - } - - // MARK: - LiveCounterPathObject +internal final class DefaultLiveCounterPathObject: DefaultPathObject, LiveCounterPathObject, @unchecked Sendable { + // MARK: - Read (RTTS6b) internal func value() throws(ARTErrorInfo) -> Double? { - notImplemented() - } - - internal func increment(amount _: Double) async throws(ARTErrorInfo) { - notImplemented() - } - - internal func decrement(amount _: Double) async throws(ARTErrorInfo) { - notImplemented() + try ChannelConfigGuards.throwIfInvalidAccessApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) + // Not a LiveCounter (or unresolved) -> nil. + guard let resolved = try resolveValueAtCurrentPath(), case let .liveCounter(counterNode) = resolved else { + return nil + } + // RTPO7c via RTLC5c (the node accessor runs the RTO25b check). + return try counterNode.value(coreSDK: coreSDK) + } + + // MARK: - Writes (RTPO17, RTPO18) + + internal func increment(amount: Double) async throws(ARTErrorInfo) { + let counterNode = try resolvedCounterNodeForWrite(operation: "increment") // RTPO17b/c/e + // RTPO17d -> RTLC12. + try await counterNode.increment(amount: amount, coreSDK: coreSDK, realtimeObjects: channelObject) + } + + internal func decrement(amount: Double) async throws(ARTErrorInfo) { + let counterNode = try resolvedCounterNodeForWrite(operation: "decrement") // RTPO18b/c/e + // RTPO18d -> RTLC13. + try await counterNode.decrement(amount: amount, coreSDK: coreSDK, realtimeObjects: channelObject) + } + + // MARK: - Helpers + + /// Runs the write-API guard, resolves the path (throwing 92005 when unresolved, RTPO3c2) and + /// narrows to the backing counter node (throwing 92007 on a type mismatch, RTPO17e/RTPO18e). + private func resolvedCounterNodeForWrite(operation: String) throws(ARTErrorInfo) -> InternalDefaultLiveCounter { + try ChannelConfigGuards.throwIfInvalidWriteApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) // RTO26 + guard let resolved = try resolveValueAtCurrentPath() else { + throw LiveObjectsError.pathNotResolved(path: path).toARTErrorInfo() // RTPO3c2 + } + guard case let .liveCounter(counterNode) = resolved else { + throw LiveObjectsError.pathTypeMismatch(operationDescription: "Cannot \(operation) a non-LiveCounter object at path: \"\(path)\"").toARTErrorInfo() + } + return counterNode } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapInstance.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapInstance.swift index d08d6f569..1d6a09d8e 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapInstance.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapInstance.swift @@ -1,47 +1,293 @@ import Ably +import Foundation +/// Default implementation of ``LiveMapInstance`` (Kotlin `DefaultLiveMapInstance`), bound to a +/// specific ``InternalDefaultLiveMap`` (RTINS2a). Operations dereference the wrapped map in O(1) — no +/// path resolution. Spec: `RTINS1`, `RTTS10a`. +/// +/// The RTO25b access-precondition checks are performed by the underlying node accessors +/// (`get(...)`, `entries(...)`, `size(...)`, `set(...)`, `remove(...)`, `subscribe(...)`) — the same +/// checks the internal engine already enforces; no new checks are invented here. @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class DefaultLiveMapInstance: LiveMapInstance { - internal var id: String { - notImplemented() + private let node: InternalDefaultLiveMap + private let coreSDK: CoreSDK + private let realtimeObjects: any InternalRealtimeObjectsProtocol + private let internalQueue: DispatchQueue + + /// The wrapped map's `objectId`, captured once at construction (RTINS3a). It is immutable, so the + /// frozen non-throwing `id` property is a plain stored read (O(1)). + internal let id: String + + internal init( + node: InternalDefaultLiveMap, + coreSDK: CoreSDK, + realtimeObjects: any InternalRealtimeObjectsProtocol, + internalQueue: DispatchQueue, + ) { + self.node = node + self.coreSDK = coreSDK + self.realtimeObjects = realtimeObjects + self.internalQueue = internalQueue + // RTINS3a: read the immutable objectId on the shared internal queue. + id = internalQueue.ably_syncNoDeadlock { node.nosync_objectID } } - internal func get(key _: String) throws(ARTErrorInfo) -> Instance? { - notImplemented() + // MARK: - LiveMapInstance + + internal func get(key: String) throws(ARTErrorInfo) -> Instance? { + // RTINS5b (the node accessor runs the RTO25b check), RTINS5c + guard let value = try node.get(key: key, coreSDK: coreSDK, delegate: realtimeObjects) else { + // RTINS5c: an absent/dangling result stays nil + return nil + } + return Instance.from(internalValue: value, coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) } internal func entries() throws(ARTErrorInfo) -> [(key: String, value: Instance)] { - notImplemented() + // RTINS6b: delegate to the node's entries (tombstoned/dangling entries already excluded) and + // wrap each value in an Instance. + try node.entries(coreSDK: coreSDK, delegate: realtimeObjects).map { key, value in + (key: key, value: Instance.from(internalValue: value, coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue)) + } } internal func keys() throws(ARTErrorInfo) -> [String] { - notImplemented() + // RTINS7b -> RTLM12 + try node.keys(coreSDK: coreSDK, delegate: realtimeObjects) } internal func values() throws(ARTErrorInfo) -> [Instance] { - notImplemented() + // RTINS8b: the values of the entries + try entries().map(\.value) } internal var size: Int { get throws(ARTErrorInfo) { - notImplemented() + // RTINS9b -> RTLM10d + try node.size(coreSDK: coreSDK, delegate: realtimeObjects) } } - internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { - notImplemented() + internal func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) { + // RTINS12c -> RTLM20: convert the public value into the internal representation (materialising + // any LiveMap/LiveCounter blueprint into a real object) and delegate to the node's set. + let internalValue = try await Self.internalValue(from: value, coreSDK: coreSDK, realtimeObjects: realtimeObjects) + try await node.set(key: key, value: internalValue, coreSDK: coreSDK, realtimeObjects: realtimeObjects) } - internal func remove(key _: String) async throws(ARTErrorInfo) { - notImplemented() + internal func remove(key: String) async throws(ARTErrorInfo) { + // RTINS13c -> RTLM21 + try await node.remove(key: key, coreSDK: coreSDK, realtimeObjects: realtimeObjects) } @discardableResult - internal func subscribe(listener _: @escaping InstanceSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { - notImplemented() + internal func subscribe(listener: @escaping InstanceSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { + // RTINS16b (the node's subscribe runs the RTO25b check), RTINS16d -> RTLO4b + let response = try node.subscribe( + listener: { [weak self] update, _ in + guard let self else { + return + } + // RTINS16e1: an Instance wrapping this map (identity-based, RTINS16g). + // RTINS16e2: the PAOM3-converted public message stamped onto the update in P2 (nil for + // sync-originated updates). + listener(.init(object: .liveMap(self), message: update.objectMessage)) + }, + coreSDK: coreSDK, + ) + // RTINS16f + return DefaultSubscription(response: response) } internal func compactJson() throws(ARTErrorInfo) -> JSONValue { - notImplemented() + // RTINS11a/RTINS11b -> RTPO14: recursive compaction with cycle markers (see the helper). + var visited: Set = [] + return try Self.compactJson( + mapNode: node, + objectID: id, + coreSDK: coreSDK, + delegate: realtimeObjects, + internalQueue: internalQueue, + visited: &visited, + ) + } + + // MARK: - compactJson (RTPO13c / RTPO14b) + + /// Recursively compacts a map node to a JSON-serializable ``JSONValue`` object. + /// + /// Cycle handling mirrors ably-java (`InternalLiveMap.compactJson`): a visited object id is added + /// **before** iterating and never removed, so a map referenced twice on sibling branches yields the + /// marker on the second sibling too. A cycle is emitted as a single-property object + /// `{"objectId": }` — the **RTPO14b2** marker format (distinct from RTPO13c5's in-memory-reference + /// form, which is not JSON-serializable). Spec: `RTPO13c`, `RTPO14b`, `RTPO14b1`, `RTPO14b2`. + private static func compactJson( + mapNode: InternalDefaultLiveMap, + objectID: String, + coreSDK: CoreSDK, + delegate: any InternalRealtimeObjectsProtocol, + internalQueue: DispatchQueue, + visited: inout Set, + ) throws(ARTErrorInfo) -> JSONValue { + // RTPO14b2 parity: mark this map visited before descending. + visited.insert(objectID) + var result: [String: JSONValue] = [:] + // RTPO13c1: tombstoned (and dangling) entries are already excluded by `entries`. + for (key, value) in try mapNode.entries(coreSDK: coreSDK, delegate: delegate) { + switch value { + case let .liveMap(childNode): + // Read the child's immutable objectId via an independent queue access (never holding a + // node mutex across the recursion). + let childID = internalQueue.ably_syncNoDeadlock { childNode.nosync_objectID } + if visited.contains(childID) { + // RTPO14b2: cyclic reference -> {"objectId": } + result[key] = .object(["objectId": .string(childID)]) + } else { + // RTPO13c2: recurse into nested maps + result[key] = try compactJson( + mapNode: childNode, + objectID: childID, + coreSDK: coreSDK, + delegate: delegate, + internalQueue: internalQueue, + visited: &visited, + ) + } + case let .liveCounter(childNode): + // RTPO13c3: nested counters resolve to their numeric value + result[key] = try .number(childNode.value(coreSDK: coreSDK)) + case let .string(primitiveValue): + // RTPO13c4 (and RTPO14b1 for binary): primitives included as-is + result[key] = .string(primitiveValue) + case let .number(primitiveValue): + result[key] = .number(primitiveValue) + case let .bool(primitiveValue): + result[key] = .bool(primitiveValue) + case let .data(primitiveValue): + // RTPO14b1: binary encoded as base64 string + result[key] = .string(primitiveValue.base64EncodedString()) + case let .jsonArray(primitiveValue): + result[key] = .array(primitiveValue) + case let .jsonObject(primitiveValue): + result[key] = .object(primitiveValue) + } + } + return .object(result) + } + + // MARK: - Public -> internal value conversion (RTLM20e7) + + /// Converts a public ``LiveMapValue`` into the internal ``InternalLiveMapValue`` accepted by + /// `InternalDefaultLiveMap.set`. Primitives map 1:1; a ``LiveMap``/``LiveCounter`` blueprint is + /// materialised into a real pooled object first (RTLM20e7g), referenced thereafter by `objectId`. + /// + /// - Note (DEV): unlike ably-java, which batches every `*_CREATE` message with the `MAP_SET` into a + /// single publish (RTLM20e7g1/g2), this creates each blueprint object via its own + /// `createMap`/`createCounter` publish and then sets by reference. Behaviour is equivalent; the + /// wire traffic is not batched. Blueprint materialisation requires the concrete + /// ``InternalDefaultRealtimeObjects`` (the sole production conformer of + /// ``InternalRealtimeObjectsProtocol`` that can create objects). + private static func internalValue( + from value: LiveMapValue, + coreSDK: CoreSDK, + realtimeObjects: any InternalRealtimeObjectsProtocol, + ) async throws(ARTErrorInfo) -> InternalLiveMapValue { + switch value { + case let .primitive(primitive): + return internalValue(from: primitive) + case let .liveMap(blueprint): + // Recursively materialise the blueprint's entries, then create the map object. + var entries: [String: InternalLiveMapValue] = [:] + for (entryKey, entryValue) in blueprint.entries ?? [:] { + entries[entryKey] = try await internalValue(from: entryValue, coreSDK: coreSDK, realtimeObjects: realtimeObjects) + } + let node = try await Self.objectCreator(realtimeObjects).createMap(entries: entries, coreSDK: coreSDK) + return .liveMap(node) + case let .liveCounter(blueprint): + let node = try await Self.objectCreator(realtimeObjects).createCounter(count: blueprint.count, coreSDK: coreSDK) + return .liveCounter(node) + } + } + + /// 1:1 mapping of a public ``Primitive`` onto the internal ``InternalLiveMapValue`` representation. + private static func internalValue(from primitive: Primitive) -> InternalLiveMapValue { + switch primitive { + case let .string(value): + .string(value) + case let .number(value): + .number(value) + case let .bool(value): + .bool(value) + case let .data(value): + .data(value) + case let .jsonArray(value): + .jsonArray(value) + case let .jsonObject(value): + .jsonObject(value) + } + } + + /// Narrows to the concrete realtime-objects type that can create pooled objects for blueprint + /// materialisation. In production the delegate is always ``InternalDefaultRealtimeObjects``. + private static func objectCreator(_ realtimeObjects: any InternalRealtimeObjectsProtocol) -> InternalDefaultRealtimeObjects { + guard let creator = realtimeObjects as? InternalDefaultRealtimeObjects else { + preconditionFailure("Setting a LiveMap/LiveCounter blueprint value requires the concrete InternalDefaultRealtimeObjects") + } + return creator + } +} + +// MARK: - Instance construction seam (P4 `PathObject.instance()`) + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension Instance { + /// Builds the identity-addressed ``Instance`` wrapping a resolved map-entry value (RTINS2a, + /// RTTS7e). This is the seam the path layer's `PathObject.instance()` (P4) calls once it has + /// resolved a value. Because `InternalLiveMapValue` cannot be an unknown/unrepresentable type, this + /// is non-optional — a `nil` at an unresolved path is represented by the caller returning `nil`. + /// + /// Safe to call from any thread: it briefly hops onto `internalQueue` to read immutable object ids. + static func from( + internalValue: InternalLiveMapValue, + coreSDK: CoreSDK, + realtimeObjects: any InternalRealtimeObjectsProtocol, + internalQueue: DispatchQueue, + ) -> Instance { + switch internalValue { + case let .liveMap(node): + .liveMap(DefaultLiveMapInstance(node: node, coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue)) + case let .liveCounter(node): + .liveCounter(DefaultLiveCounterInstance(node: node, coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue)) + case let .string(value): + .primitive(DefaultPrimitiveInstance(value: .string(value), type: .string, coreSDK: coreSDK, internalQueue: internalQueue)) + case let .number(value): + .primitive(DefaultPrimitiveInstance(value: .number(value), type: .number, coreSDK: coreSDK, internalQueue: internalQueue)) + case let .bool(value): + .primitive(DefaultPrimitiveInstance(value: .bool(value), type: .boolean, coreSDK: coreSDK, internalQueue: internalQueue)) + case let .data(value): + .primitive(DefaultPrimitiveInstance(value: .data(value), type: .binary, coreSDK: coreSDK, internalQueue: internalQueue)) + case let .jsonArray(value): + .primitive(DefaultPrimitiveInstance(value: .jsonArray(value), type: .jsonArray, coreSDK: coreSDK, internalQueue: internalQueue)) + case let .jsonObject(value): + .primitive(DefaultPrimitiveInstance(value: .jsonObject(value), type: .jsonObject, coreSDK: coreSDK, internalQueue: internalQueue)) + } + } + + /// Builds the identity-addressed ``Instance`` wrapping an ``ObjectsPool`` entry — a directly + /// referenced `LiveObject` (e.g. the root map, or an `objectId`-resolved object). This is the P4 + /// seam for resolving a path to a concrete object rather than a map-entry value. + static func from( + poolEntry: ObjectsPool.Entry, + coreSDK: CoreSDK, + realtimeObjects: any InternalRealtimeObjectsProtocol, + internalQueue: DispatchQueue, + ) -> Instance { + switch poolEntry { + case let .map(node): + .liveMap(DefaultLiveMapInstance(node: node, coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue)) + case let .counter(node): + .liveCounter(DefaultLiveCounterInstance(node: node, coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue)) + } } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapPathObject.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapPathObject.swift index 1b3eb8796..4636375e2 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapPathObject.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapPathObject.swift @@ -1,81 +1,107 @@ import Ably -/// Skeleton implementation of ``LiveMapPathObject``. Every member currently traps via -/// `notImplemented()`; this is a standalone `final class` (no shared base) so that we don't commit to -/// a particular implementation shape before the path-based API is actually built. `Sendable` is a -/// checked conformance: the class holds no state. +/// Default implementation of ``LiveMapPathObject`` (Kotlin `DefaultLiveMapPathObject`), adding map +/// navigation and read/write operations on top of ``DefaultPathObject``. +/// +/// Spec: `RTTS6a`. @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal final class DefaultLiveMapPathObject: LiveMapPathObject, Sendable { - // MARK: - PathObject +internal final class DefaultLiveMapPathObject: DefaultPathObject, LiveMapPathObject, @unchecked Sendable { + // MARK: - Navigation (RTPO5, RTPO6) - internal var path: String { - notImplemented() + internal func get(key: String) -> any PathObject { + // RTPO5c/RTPO5d — purely navigational, no resolution; returns the untyped base node (RTTS3h). + DefaultPathObject(channelObject: channelObject, coreSDK: coreSDK, internalQueue: internalQueue, path: PathSegments.appendKey(path, key: key)) } - internal func instance() throws(ARTErrorInfo) -> Instance? { - notImplemented() + internal func at(path: String) -> any PathObject { + // RTPO6b/RTPO6c/RTPO6d — purely navigational, dot-delimited with backslash-escaped dots. + DefaultPathObject(channelObject: channelObject, coreSDK: coreSDK, internalQueue: internalQueue, path: PathSegments.appendPath(self.path, subPath: path)) } - internal func compactJson() throws(ARTErrorInfo) -> JSONValue? { - notImplemented() - } - - @discardableResult - internal func subscribe(options _: PathObjectSubscriptionOptions?, listener _: @escaping PathObjectSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { - notImplemented() - } - - internal func exists() throws(ARTErrorInfo) -> Bool { - notImplemented() - } - - internal func type() throws(ARTErrorInfo) -> ValueType? { - notImplemented() - } - - internal func asLiveMap() -> any LiveMapPathObject { - notImplemented() - } - - internal func asLiveCounter() -> any LiveCounterPathObject { - notImplemented() - } - - internal func asPrimitive() -> any PrimitivePathObject { - notImplemented() - } - - // MARK: - LiveMapPathObject - - internal func get(key _: String) -> any PathObject { - notImplemented() - } - - internal func at(path _: String) -> any PathObject { - notImplemented() - } + // MARK: - Reads (RTPO9, RTPO10, RTPO11, RTPO12) internal func entries() throws(ARTErrorInfo) -> [(key: String, value: any PathObject)] { - notImplemented() + try ChannelConfigGuards.throwIfInvalidAccessApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) // RTPO9a + // RTPO9d — not a LiveMap (or unresolved) -> empty. + guard let mapNode = try resolvedMapNode() else { + return [] + } + // RTPO9c — derive from the map's keys at call time; child paths as if by get(). + return try mapNode.keys(coreSDK: coreSDK, delegate: channelObject).map { key in + (key: key, value: get(key: key)) + } } internal func keys() throws(ARTErrorInfo) -> [String] { - notImplemented() + try ChannelConfigGuards.throwIfInvalidAccessApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) // RTPO10a + // RTPO10d — not a LiveMap (or unresolved) -> empty. + guard let mapNode = try resolvedMapNode() else { + return [] + } + // RTPO10c — via RTLM12. + return try mapNode.keys(coreSDK: coreSDK, delegate: channelObject) } internal func values() throws(ARTErrorInfo) -> [any PathObject] { - notImplemented() + try ChannelConfigGuards.throwIfInvalidAccessApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) // RTPO11a + // RTPO11d — not a LiveMap (or unresolved) -> empty. + guard let mapNode = try resolvedMapNode() else { + return [] + } + // RTPO11c — child paths as if by get(). + return try mapNode.keys(coreSDK: coreSDK, delegate: channelObject).map { key in + get(key: key) + } } internal func size() throws(ARTErrorInfo) -> Int? { - notImplemented() - } - - internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { - notImplemented() - } - - internal func remove(key _: String) async throws(ARTErrorInfo) { - notImplemented() + try ChannelConfigGuards.throwIfInvalidAccessApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) // RTPO12a + // RTPO12d — not a LiveMap (or unresolved) -> nil. + guard let mapNode = try resolvedMapNode() else { + return nil + } + // RTPO12c — via RTLM10d. + return try mapNode.size(coreSDK: coreSDK, delegate: channelObject) + } + + // MARK: - Writes (RTPO15, RTPO16) + + internal func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) { + try ChannelConfigGuards.throwIfInvalidWriteApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) // RTPO15b / RTO26 + // RTPO15c / RTPO3c2 — unresolved write path throws 92005. + guard let resolved = try resolveValueAtCurrentPath() else { + throw LiveObjectsError.pathNotResolved(path: path).toARTErrorInfo() + } + // RTPO15e — wrong-type write wrapper throws 92007. + guard case let .liveMap(mapInstance) = Instance.from(internalValue: resolved, coreSDK: coreSDK, realtimeObjects: channelObject, internalQueue: internalQueue) else { + throw LiveObjectsError.pathTypeMismatch(operationDescription: "Cannot set a key on a non-LiveMap object at path: \"\(path)\"").toARTErrorInfo() + } + // RTPO15d -> RTLM20 (via the instance layer, which materialises blueprint values). + try await mapInstance.set(key: key, value: value) + } + + internal func remove(key: String) async throws(ARTErrorInfo) { + try ChannelConfigGuards.throwIfInvalidWriteApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) // RTPO16b / RTO26 + // RTPO16c / RTPO3c2 — unresolved write path throws 92005. + guard let resolved = try resolveValueAtCurrentPath() else { + throw LiveObjectsError.pathNotResolved(path: path).toARTErrorInfo() + } + // RTPO16e — wrong-type write wrapper throws 92007. + guard case let .liveMap(mapInstance) = Instance.from(internalValue: resolved, coreSDK: coreSDK, realtimeObjects: channelObject, internalQueue: internalQueue) else { + throw LiveObjectsError.pathTypeMismatch(operationDescription: "Cannot remove a key from a non-LiveMap object at path: \"\(path)\"").toARTErrorInfo() + } + // RTPO16d -> RTLM21. + try await mapInstance.remove(key: key) + } + + // MARK: - Helpers + + /// Resolves the current path and narrows to the backing map node, or `nil` if the path is + /// unresolved or does not resolve to a map (RTPO9d/RTPO10d/RTPO11d/RTPO12d). + private func resolvedMapNode() throws(ARTErrorInfo) -> InternalDefaultLiveMap? { + guard let resolved = try resolveValueAtCurrentPath(), case let .liveMap(mapNode) = resolved else { + return nil + } + return mapNode } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPathObject.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPathObject.swift new file mode 100644 index 000000000..237dd590e --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPathObject.swift @@ -0,0 +1,183 @@ +import Ably +import Foundation + +/// Default implementation of ``PathObject``, the untyped node in the path-addressed view of the +/// LiveObjects graph (Kotlin `DefaultPathObject`). +/// +/// A `DefaultPathObject` stores a dot-delimited path and resolves it **fresh on every call** against +/// the live objects graph via ``resolveValueAtCurrentPath()``, walking root → children through the +/// pool. It never holds a stale reference: if the object at a path changes, the same path object +/// resolves to the new object on the next call. +/// +/// The `as*` casts (``asLiveMap()``/``asLiveCounter()``/``asPrimitive()``) return a typed view of the +/// same position without resolving it (RTTS5). Reads degrade to `nil`/`false`/empty on an unresolved +/// path (best-effort, RTPO3c1); writes throw 92005 (RTPO3c2) — implemented by the typed subclasses. +/// +/// The shared subclasses ``DefaultLiveMapPathObject``, ``DefaultLiveCounterPathObject`` and +/// ``DefaultPrimitivePathObject`` add the type-specific members on top of this base. +/// +/// Spec: `RTPO1`, `RTPO2`, `RTPO3`, `RTTS3`. +/// +/// `@unchecked Sendable`: a non-`final` base class cannot get a checked `Sendable` conformance, but +/// every stored property here is an immutable `let` of a `Sendable` type, and the typed subclasses +/// add no stored state — so the conformance is sound. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal class DefaultPathObject: PathObject, @unchecked Sendable { + /// The channel's realtime-objects engine (Kotlin's `DefaultRealtimeObject`). Supplies the objects + /// pool for resolution and backs the node accessors' pool-delegate parameter and the write path. + internal let channelObject: any InternalRealtimeObjectsProtocol + internal let coreSDK: CoreSDK + internal let internalQueue: DispatchQueue + + /// The stored dot-delimited path. The empty string is the root (zero segments, RTPO4c). + internal let path: String + + internal init( + channelObject: any InternalRealtimeObjectsProtocol, + coreSDK: CoreSDK, + internalQueue: DispatchQueue, + path: String, + ) { + self.channelObject = channelObject + self.coreSDK = coreSDK + self.internalQueue = internalQueue + self.path = path + } + + // MARK: - PathObject + + internal func instance() throws(ARTErrorInfo) -> Instance? { + try ChannelConfigGuards.throwIfInvalidAccessApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) // RTPO8a + // RTPO8e — unresolved path yields no instance. + guard let resolved = try resolveValueAtCurrentPath() else { + return nil + } + // RTPO8c/RTPO8f — wrap the resolved value (live object or primitive) in its typed Instance. + return Instance.from(internalValue: resolved, coreSDK: coreSDK, realtimeObjects: channelObject, internalQueue: internalQueue) + } + + internal func compactJson() throws(ARTErrorInfo) -> JSONValue? { + try ChannelConfigGuards.throwIfInvalidAccessApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) // RTPO14a / RTO25 + // RTPO3c1 — unresolved path returns nil (nullable compactJson). + guard let resolved = try resolveValueAtCurrentPath() else { + return nil + } + // RTPO14b — recursive compaction (cycle markers, base64 binary) is exactly the instance + // layer's `compactJson`; reuse it rather than re-deriving. + return try Instance.from(internalValue: resolved, coreSDK: coreSDK, realtimeObjects: channelObject, internalQueue: internalQueue).compactJson() + } + + internal func exists() throws(ARTErrorInfo) -> Bool { + try ChannelConfigGuards.throwIfInvalidAccessApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) + // RTTS4a — a value exists iff the path resolves. + return try resolveValueAtCurrentPath() != nil + } + + internal func type() throws(ARTErrorInfo) -> ValueType? { + try ChannelConfigGuards.throwIfInvalidAccessApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) + // RTTS4b3 (the diff, not the PR body) — nil when nothing resolves at the path. + guard let resolved = try resolveValueAtCurrentPath() else { + return nil + } + return Self.valueType(of: resolved) + } + + internal func asLiveMap() -> any LiveMapPathObject { + // RTTS5a — pure type refinement; does not resolve the path, never throws. + DefaultLiveMapPathObject(channelObject: channelObject, coreSDK: coreSDK, internalQueue: internalQueue, path: path) + } + + internal func asLiveCounter() -> any LiveCounterPathObject { + // RTTS5b + DefaultLiveCounterPathObject(channelObject: channelObject, coreSDK: coreSDK, internalQueue: internalQueue, path: path) + } + + internal func asPrimitive() -> any PrimitivePathObject { + // RTTS5c + DefaultPrimitivePathObject(channelObject: channelObject, coreSDK: coreSDK, internalQueue: internalQueue, path: path) + } + + @discardableResult + internal func subscribe(options: PathObjectSubscriptionOptions?, listener: @escaping PathObjectSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { + try ChannelConfigGuards.throwIfInvalidAccessApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) // RTPO19b + // RTPO19c1a / DEV-9 — the shipped `PathObjectSubscriptionOptions.init(depth:)` is non-throwing + // and frozen, so `depth <= 0` (40003) is validated here rather than in the initializer. + try ChannelConfigGuards.validateSubscriptionDepth(options?.depth) + + // The subscription's coverage path is this object's stored path, as segments (RTPO19f). + let segments = PathSegments.parseStored(path) + + // The factory that builds each event's PathObject (RTO24b2b1 / RTPO19e1). Captures this + // object's resolution context *weakly*, so the long-lived register never keeps the channel + // object (or core SDK) alive; a released context makes the subscription a silent no-op. + let channelObject = channelObject + let coreSDK = coreSDK + let internalQueue = internalQueue + let makePathObject: PathObjectSubscriptionRegister.PathObjectFactory = { [weak channelObject, weak coreSDK] joinedPath in + guard let channelObject, let coreSDK else { + return nil + } + return DefaultPathObject(channelObject: channelObject, coreSDK: coreSDK, internalQueue: internalQueue, path: joinedPath) + } + + // Hop onto the internal queue to register (the register is queue-confined). The returned + // Subscription's `unsubscribe()` deregisters (SUB2a/SUB2b). + return internalQueue.ably_syncNoDeadlock { + channelObject.nosync_pathObjectSubscriptionRegister.nosync_subscribe( + segments: segments, + depth: options?.depth, + listener: listener, + makePathObject: makePathObject, + ) + } + } + + // MARK: - Path resolution (RTPO3) + + /// RTPO3 path resolution against the local objects graph, evaluated fresh at call time. Returns + /// `nil` on resolution failure; read callers degrade per RTPO3c1, write callers throw 92005 per + /// RTPO3c2. + /// + /// The root is always present and always a map (RTO3b); the pool never replaces the root instance + /// (RTO4b2, RTO5c2a), so looking it up per call is equivalent to holding the RTPO2b root reference. + internal func resolveValueAtCurrentPath() throws(ARTErrorInfo) -> InternalLiveMapValue? { + // Read the root map node on the internal queue (the `nosync_` pool accessor must run there). + let rootNode = internalQueue.ably_syncNoDeadlock { channelObject.nosync_objectsPool.root } + var current: InternalLiveMapValue = .liveMap(rootNode) + // parseStored: an empty stored path is the root itself — zero segments (RTPO3b). + for segment in PathSegments.parseStored(path) { + // RTPO3a1 — a non-map value mid-path cannot be navigated further. + guard case let .liveMap(mapNode) = current else { + return nil + } + // RTPO3a2 — look up the next segment via RTLM5 (the node accessor runs the RTO25b check). + guard let next = try mapNode.get(key: segment, coreSDK: coreSDK, delegate: channelObject) else { + return nil + } + current = next + } + return current // RTPO3a3 + } + + /// Maps a resolved value to its ``ValueType`` (RTTS2). O(1), no queue hop. + private static func valueType(of value: InternalLiveMapValue) -> ValueType { + switch value { + case .liveMap: + .liveMap + case .liveCounter: + .liveCounter + case .string: + .string + case .number: + .number + case .bool: + .boolean + case .data: + .binary + case .jsonArray: + .jsonArray + case .jsonObject: + .jsonObject + } + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitiveInstance.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitiveInstance.swift index be677102e..947af8f60 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitiveInstance.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitiveInstance.swift @@ -1,18 +1,84 @@ import Ably +import Foundation +/// Default implementation of ``PrimitiveInstance`` (Kotlin `Default{String,Number,Boolean,Binary, +/// JsonArray,JsonObject}Instance`, collapsed per DEV-3). An `Instance` is identity/value-addressed +/// (RTINS2a): a primitive instance binds the already-extracted value, so reads are O(1) and never +/// re-resolve map state. Spec: `RTINS1`, `RTTS10c`. +/// +/// Because a primitive has no backing internal node (and hence no `DispatchQueueMutex`), the RTO25b +/// access-precondition check is run by hopping onto the shared `internalQueue` and reusing the same +/// `CoreSDK.nosync_validateChannelState` check that the map/counter node accessors run. @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class DefaultPrimitiveInstance: PrimitiveInstance { + private let primitive: Primitive + private let valueType: ValueType + private let coreSDK: CoreSDK + private let internalQueue: DispatchQueue + + internal init(value: Primitive, type: ValueType, coreSDK: CoreSDK, internalQueue: DispatchQueue) { + primitive = value + valueType = type + self.coreSDK = coreSDK + self.internalQueue = internalQueue + } + + // MARK: - PrimitiveInstance + internal var value: Primitive { get throws(ARTErrorInfo) { - notImplemented() + // RTINS4a: access API preconditions per RTO25 + try nosync_checkAccessPreconditionsOnQueue() + // RTINS4c: return the value directly + return primitive } } internal var type: ValueType { - notImplemented() + // RTTS8: O(1), no precondition check (matches the non-throwing frozen signature) + valueType } internal func compactJson() throws(ARTErrorInfo) -> JSONValue { - notImplemented() + // RTINS11a: access API preconditions per RTO25 + try nosync_checkAccessPreconditionsOnQueue() + // RTINS11b -> RTPO14: a primitive compacts to itself, with binary base64-encoded (RTPO14b1) + return Self.compactJson(for: primitive) + } + + // MARK: - Helpers + + /// Runs the RTO25b channel-state check (DETACHED/FAILED -> 90001) on the shared internal queue, + /// reusing the exact check the map/counter node accessors run (`value(coreSDK:)`, `get(...)`). + private func nosync_checkAccessPreconditionsOnQueue() throws(ARTErrorInfo) { + let result: Result = internalQueue.ably_syncNoDeadlock { + do throws(ARTErrorInfo) { + // RTO25b + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "PrimitiveInstance") + return .success(()) + } catch { + return .failure(error) + } + } + try result.get() + } + + /// Compacts a ``Primitive`` to a JSON-serializable ``JSONValue``, per RTPO13c4/RTPO14b1. + internal static func compactJson(for primitive: Primitive) -> JSONValue { + switch primitive { + case let .string(value): + .string(value) + case let .number(value): + .number(value) + case let .bool(value): + .bool(value) + case let .data(value): + // RTPO14b1: binary values are encoded as base64 strings + .string(value.base64EncodedString()) + case let .jsonArray(value): + .array(value) + case let .jsonObject(value): + .object(value) + } } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitivePathObject.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitivePathObject.swift index 8bbcce804..3e95a5c72 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitivePathObject.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitivePathObject.swift @@ -1,53 +1,35 @@ import Ably -/// Skeleton implementation of ``PrimitivePathObject``. Every member currently traps via -/// `notImplemented()`; this is a standalone `final class` (no shared base) so that we don't commit to -/// a particular implementation shape before the path-based API is actually built. `Sendable` is a -/// checked conformance: the class holds no state. +/// Default implementation of ``PrimitivePathObject`` (Kotlin's six `Default*PathObject` primitive +/// types, collapsed into one per DEV-2), a terminal primitive view adding a type-narrowed ``value()`` +/// on top of ``DefaultPathObject``. +/// +/// Spec: `RTTS6c`. @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal final class DefaultPrimitivePathObject: PrimitivePathObject, Sendable { - // MARK: - PathObject - - internal var path: String { - notImplemented() - } - - internal func instance() throws(ARTErrorInfo) -> Instance? { - notImplemented() - } - - internal func compactJson() throws(ARTErrorInfo) -> JSONValue? { - notImplemented() - } - - @discardableResult - internal func subscribe(options _: PathObjectSubscriptionOptions?, listener _: @escaping PathObjectSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { - notImplemented() - } - - internal func exists() throws(ARTErrorInfo) -> Bool { - notImplemented() - } - - internal func type() throws(ARTErrorInfo) -> ValueType? { - notImplemented() - } - - internal func asLiveMap() -> any LiveMapPathObject { - notImplemented() - } - - internal func asLiveCounter() -> any LiveCounterPathObject { - notImplemented() - } - - internal func asPrimitive() -> any PrimitivePathObject { - notImplemented() - } - - // MARK: - PrimitivePathObject - +internal final class DefaultPrimitivePathObject: DefaultPathObject, PrimitivePathObject, @unchecked Sendable { internal func value() throws(ARTErrorInfo) -> Primitive? { - notImplemented() + try ChannelConfigGuards.throwIfInvalidAccessApiConfiguration(coreSDK: coreSDK, internalQueue: internalQueue) + // Unresolved path -> nil. + guard let resolved = try resolveValueAtCurrentPath() else { + return nil + } + // DEV-2: unlike ably-java's six type-filtered primitive views, this returns whatever primitive + // resolved (RTTS6c collapse). A live object (map/counter) is not a primitive -> nil. + switch resolved { + case let .string(value): + return .string(value) + case let .number(value): + return .number(value) + case let .bool(value): + return .bool(value) + case let .data(value): + return .data(value) + case let .jsonArray(value): + return .jsonArray(value) + case let .jsonObject(value): + return .jsonObject(value) + case .liveMap, .liveCounter: + return nil + } } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultStatusSubscription.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultStatusSubscription.swift index 27cdff5cd..dcf3027b4 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultStatusSubscription.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultStatusSubscription.swift @@ -1,8 +1,17 @@ import Ably +/// Default implementation of the public ``StatusSubscription`` (RTO18f). It wraps the internal +/// engine's ``OnObjectsEventResponse`` handle so that `off()` deregisters the status listener +/// registered by ``RealtimeObject/on(event:callback:)``. Spec: `RTO18f`, `RTO18f1`. @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class DefaultStatusSubscription: StatusSubscription, Sendable { + private let response: any OnObjectsEventResponse + + internal init(response: any OnObjectsEventResponse) { + self.response = response + } + internal func off() { - notImplemented() + response.off() } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultSubscription.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultSubscription.swift index 0bc992e9f..7dd0fecae 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultSubscription.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultSubscription.swift @@ -1,8 +1,17 @@ import Ably +/// Default implementation of the public ``Subscription`` (SUB). It wraps the internal engine's +/// ``SubscribeResponse`` handle so that `unsubscribe()` deregisters the listener on the underlying +/// live object. Spec: `SUB2a`, `SUB2b`. @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class DefaultSubscription: Subscription, Sendable { + private let response: any SubscribeResponse + + internal init(response: any SubscribeResponse) { + self.response = response + } + internal func unsubscribe() { - notImplemented() + response.unsubscribe() } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/PathObject.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/PathObject.swift index f540334b2..c0e027e7f 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/PathObject.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/PathObject.swift @@ -34,8 +34,9 @@ public protocol PathObject: Sendable { /// is the empty string. Spec: `RTPO4`. var path: String { get } - /// Resolves the path and, if it resolves to a `LiveObject`, returns an ``Instance`` wrapping it. - /// Returns `nil` if the resolved value is a primitive or if resolution fails. Spec: `RTPO8`. + /// Resolves the path and returns an ``Instance`` wrapping the resolved value — whether that value + /// is a `LiveObject` (RTPO8c) or a primitive (RTPO8f). Returns `nil` only if resolution fails + /// (RTPO8e). Spec: `RTPO8`. func instance() throws(ARTErrorInfo) -> Instance? /// Resolves the path and returns a JSON-serializable, recursively-compacted representation of the diff --git a/LiveObjects/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/LiveObjects/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 437292311..7de3a57ea 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -497,6 +497,291 @@ internal extension ProtocolTypes.ObjectState { } } +// MARK: - PAOM3: wire (protocol) -> public conversion + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension ProtocolTypes.InboundObjectMessage { + /// Builds the user-facing ``ObjectMessage`` (PAOM) from this inbound message, per PAOM3. + /// + /// Returns `nil` unless the message carries an operation with a *known* action (PAOM3a1): a + /// message with no operation is not surfaced, and an unknown wire action must never surface + /// publicly (there is no `UNKNOWN` case in the public ``ObjectOperationAction``; DEV-5). Callers + /// only pass op-bearing, non-sync messages (sync-originated updates carry `nil`, RTO4b2a). + /// + /// - Parameter channelName: the name of the channel the message was received on (PAOM2e/PAOM3b). + func toPublicObjectMessage(channelName: String) -> ObjectMessage? { + // PAOM3a1: precondition — the source message must carry an operation with a known action. + guard let operation, let publicOperation = operation.toPublicObjectOperation() else { + return nil + } + + return .init( + id: id, // PAOM2a + clientId: clientId, // PAOM2b + connectionId: connectionId, // PAOM2c + timestamp: timestamp, // PAOM2d + channel: channelName, // PAOM2e, PAOM3b + operation: publicOperation, // PAOM2f + serial: serial, // PAOM2g + serialTimestamp: serialTimestamp, // PAOM2h + siteCode: siteCode, // PAOM2i + extras: extras, // PAOM2j + ) + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension ProtocolTypes.ObjectOperation { + /// Converts this operation to the public ``ObjectOperation`` (PAOOP), resolving the outbound-only + /// `*CreateWithObjectId` variants back to their derived create payloads (PAOOP3b/PAOOP3c). + /// + /// Returns `nil` for an unknown wire action, which must never surface publicly (DEV-5). + func toPublicObjectOperation() -> ObjectOperation? { + // PAOOP2a: an unknown action has no public representation. + guard case let .known(action) = action else { + return nil + } + + let publicAction: ObjectOperationAction = switch action { + case .mapCreate: + .mapCreate + case .mapSet: + .mapSet + case .mapRemove: + .mapRemove + case .counterCreate: + .counterCreate + case .counterInc: + .counterInc + case .objectDelete: + .objectDelete + case .mapClear: + .mapClear + } + + // PAOOP3b: prefer mapCreate, else the MapCreate the WithObjectId variant was derived from. + let resolvedMapCreate = mapCreate ?? mapCreateWithObjectId?.derivedFrom + // PAOOP3c: prefer counterCreate, else the derived CounterCreate. + let resolvedCounterCreate = counterCreate ?? counterCreateWithObjectId?.derivedFrom + + return .init( + action: publicAction, // PAOOP2a + objectId: objectId, // PAOOP2b + mapCreate: resolvedMapCreate?.toPublicMapCreate(), // PAOOP2c/PAOOP3b + mapSet: mapSet.map { .init(key: $0.key, value: $0.value?.toPublicObjectData() ?? .init()) }, // PAOOP2d + mapRemove: mapRemove.map { .init(key: $0.key) }, // PAOOP2e + counterCreate: resolvedCounterCreate.map { .init(count: $0.count?.doubleValue ?? 0) }, // PAOOP2f/PAOOP3c + counterInc: counterInc.map { .init(number: $0.number.doubleValue) }, // PAOOP2g + objectDelete: objectDelete.map { _ in .init() }, // PAOOP2h + mapClear: mapClear.map { _ in .init() }, // PAOOP2i + ) + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension ProtocolTypes.MapCreate { + func toPublicMapCreate() -> MapCreate { + // The public `ObjectsMapSemantics` has only `.lww` (unknown semantics are dropped, a recorded + // deviation), so every map surfaces as `.lww`. + .init( + semantics: .lww, // MCR2a + entries: entries?.mapValues { $0.toPublicObjectsMapEntry() } ?? [:], // MCR2b + ) + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension ProtocolTypes.ObjectsMapEntry { + func toPublicObjectsMapEntry() -> ObjectsMapEntry { + .init( + tombstone: tombstone, // OME2a + timeserial: timeserial, // OME2b + serialTimestamp: serialTimestamp, // OME2d + data: data?.toPublicObjectData(), // OME2c + ) + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension ProtocolTypes.ObjectData { + /// Converts internal (decoded) object data to the public ``ObjectData`` (OD2). The public shape + /// exposes decoded binary directly and has no `encoding`; `number` is a `Double` and `json` is a + /// raw JSON string. + func toPublicObjectData() -> ObjectData { + .init( + objectId: objectId, // OD2a + encoding: nil, // OD2b — internal data is already decoded + boolean: boolean, // OD2c + bytes: bytes, // OD2d + number: number?.doubleValue, // OD2e + string: string, // OD2f + json: json?.toJSONString, // OD2g + ) + } +} + +// MARK: - Message size calculation (OM3, OOP4, OST3, OD3) + +// The size algorithm below is a direct port of ably-java's `WireObjectMessage.size()` +// (io.ably.lib.liveobjects.message). It is implemented on the `ProtocolTypes` (non-wire) types +// rather than on `OutboundWireObjectMessage` because ably-java's algorithm depends on the +// `MapCreate`/`CounterCreate` retained in `mapCreateWithObjectId`/`counterCreateWithObjectId` +// (RTLMV4j5 / RTLCV4g5), which ably-java keeps as `@Transient` fields on its wire type but which +// our `toWire(...)` conversion deliberately drops. Those payloads survive only on the +// `ProtocolTypes` types, which is also exactly what `nosync_publish` carries. +// +// Note on string measurement, mirrored byte-for-byte from ably-java (which is not internally +// consistent, and in places diverges from the spec's "length" wording): +// - `clientId`, `MapCreate`/`MapSet`/`MapRemove` keys and `ObjectData.string`/`json` are +// measured as their UTF-8 byte length (`String.byteSize` in ably-java). +// - `extras` (OM3d) and `ObjectsMap` entry keys (OMP4a1) are measured as their UTF-16 length +// (`String.length` in ably-java). +// For ASCII these coincide; they differ only for non-ASCII text. + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension ProtocolTypes.OutboundObjectMessage { + /// The size of this `ObjectMessage` in bytes, calculated per OM3. Used by RTO15d to enforce `maxMessageSize`. + var size: Int { + // OM3a: sum of the sizes of the clientId, operation, object and extras properties. + let clientIdSize = clientId?.utf8.count ?? 0 // OM3f (ably-java: UTF-8 byte length) + let operationSize = operation?.size ?? 0 // OM3b, OOP4 + let objectSize = object?.size ?? 0 // OM3c, OST3 + // OM3d: the string length of the JSON representation of `extras` (ably-java: UTF-16 length). + let extrasSize = extras.map { JSONObjectOrArray.object($0).toJSONString.utf16.count } ?? 0 + // OM3e: a null or omitted property contributes zero. + return clientIdSize + operationSize + objectSize + extrasSize + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension ProtocolTypes.ObjectOperation { + /// The size of this `ObjectOperation` in bytes, calculated per OOP4. + var size: Int { + // OOP4h: map create component — `mapCreate`, else the `MapCreate` retained in `mapCreateWithObjectId`, else zero. + let mapCreateSize = mapCreate?.size ?? mapCreateWithObjectId?.derivedFrom?.size ?? 0 // OOP4h1, OOP4h2, OOP4h3 + let mapSetSize = mapSet?.size ?? 0 // OOP4i, MST3 + let mapRemoveSize = mapRemove?.size ?? 0 // OOP4j, MRM3 + // OOP4k: counter create component — `counterCreate`, else the `CounterCreate` retained in `counterCreateWithObjectId`, else zero. + let counterCreateSize = counterCreate?.size ?? counterCreateWithObjectId?.derivedFrom?.size ?? 0 // OOP4k1, OOP4k2, OOP4k3 + let counterIncSize = counterInc?.size ?? 0 // OOP4l, CIN3 + // OOP4g / OOP4f: sum of the components; a null or omitted property contributes zero. + return mapCreateSize + mapSetSize + mapRemoveSize + counterCreateSize + counterIncSize + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension ProtocolTypes.ObjectState { + /// The size of this `ObjectState` in bytes, calculated per OST3. + var size: Int { + let mapSize = map?.size ?? 0 // OST3b, OMP4 + let counterSize = counter?.size ?? 0 // OST3c, OCN3 + let createOpSize = createOp?.size ?? 0 // OST3d, OOP4 + // OST3a / OST3e: sum of the properties; a null or omitted property contributes zero. + return mapSize + counterSize + createOpSize + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension ProtocolTypes.MapCreate { + /// The size of this `MapCreate` in bytes, calculated per MCR3. + var size: Int { + // MCR3a: sum over entries of the key size plus the entry size. ably-java measures the key + // as its UTF-8 byte length here (diverging from MCR3a1's "length" wording); mirrored as-is. + entries?.reduce(0) { $0 + $1.key.utf8.count + $1.value.size } ?? 0 // MCR3a1, MCR3a2, MCR3b + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension ProtocolTypes.MapSet { + /// The size of this `MapSet` in bytes, calculated per MST3. + var size: Int { + // MST3a: sum of the key and value sizes. ably-java measures the key as its UTF-8 byte length. + key.utf8.count + (value?.size ?? 0) // MST3b (OD3), MST3c, MST3d + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension ProtocolTypes.ObjectsMap { + /// The size of this `ObjectsMap` in bytes, calculated per OMP4. + var size: Int { + // OMP4a: sum over entries of the key size plus the entry size. Per OMP4a1 (and ably-java) the + // key is measured as its length; ably-java uses `String.length`, i.e. the UTF-16 length. + entries?.reduce(0) { $0 + $1.key.utf16.count + $1.value.size } ?? 0 // OMP4a1, OMP4a2, OMP4b + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension ProtocolTypes.ObjectsMapEntry { + /// The size of this `ObjectsMapEntry` in bytes, calculated per OME3. + var size: Int { + // OME3a, OME3b: equal to the size of the `data` property (OD3). OME3c: null/omitted is zero. + data?.size ?? 0 + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension ProtocolTypes.ObjectData { + /// The size of this `ObjectData` in bytes, calculated per OD3. + var size: Int { + // OD3: ably-java returns the size of the first present leaf, checked in this order. + if let string { + return string.utf8.count // OD3e (ably-java: UTF-8 byte length) + } + if number != nil { + return 8 // OD3d + } + if boolean != nil { + return 1 // OD3b + } + if let bytes { + return bytes.count // OD3c (actual binary length, not the base64 representation) + } + if let json { + return json.toJSONString.utf8.count // OD3g (byte length of the JSON-encoded string) + } + return 0 // OD3f + } +} + +// The following four types are shared between the wire and non-wire models, so their size +// calculations live here alongside the rest of the OM3 algorithm. + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension WireMapRemove { + /// The size of this `MapRemove` in bytes, calculated per MRM3. + var size: Int { + // MRM3a: the string length of the `key` property. ably-java measures it as its UTF-8 byte length. + key.utf8.count + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension WireCounterCreate { + /// The size of this `CounterCreate` in bytes, calculated per CCR3. + var size: Int { + // ably-java returns 8 unconditionally here (a `Double` is 8 bytes), i.e. it does not apply + // CCR3b's "0 if count is null" — mirrored as-is for byte-for-byte parity with ably-java. + 8 // CCR3a + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension WireCounterInc { + /// The size of this `CounterInc` in bytes, calculated per CIN3. + var size: Int { + 8 // CIN3a (`number` is always present on our type; a number is 8 bytes) + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal extension WireObjectsCounter { + /// The size of this `ObjectsCounter` in bytes, calculated per OCN3. + var size: Int { + // OCN3a: 8 if `count` is a number. OCN3b: 0 if `count` is null or omitted. + count != nil ? 8 : 0 + } +} + // MARK: - CustomDebugStringConvertible @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) diff --git a/LiveObjects/Sources/AblyLiveObjects/Protocol/SyncCursor.swift b/LiveObjects/Sources/AblyLiveObjects/Protocol/SyncCursor.swift index 8bd0eadba..3fdd6c8f6 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Protocol/SyncCursor.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Protocol/SyncCursor.swift @@ -1,4 +1,3 @@ -import Ably import Foundation /// The `OBJECT_SYNC` sync cursor, as extracted from a `channelSerial` per RTO5a1 and RTO5a4. @@ -8,24 +7,39 @@ internal struct SyncCursor { /// `nil` in the case where the objects sync sequence is complete (RTO5a4). internal var cursorValue: String? - internal enum Error: Swift.Error { - case channelSerialDoesNotMatchExpectedFormat(String) - } - /// Creates a `SyncCursor` from the `channelSerial` of an `OBJECT_SYNC` `ProtocolMessage`. - internal init(channelSerial: String) throws(ARTErrorInfo) { + /// + /// Per RTO5a1 the `channelSerial` is a two-part identifier `:`. + /// + /// Returns `nil` if `channelSerial` does not conform to that shape — that is, if it has no + /// colon separator or an empty sequence id. The specification does not define behaviour for a + /// non-conforming (but present) `channelSerial`; for cross-SDK consistency with ably-java the + /// caller treats a `nil` result the same as an absent `channelSerial` (RTO5a5), i.e. the sync + /// data is taken to be entirely contained within the single `OBJECT_SYNC`. + /// + /// Note that, unlike ably-java's `^([\w-]+):(.*)$` regex, we do not restrict the sequence-id + /// character set: any characters up to the first colon are accepted. The specification places + /// no such restriction, and rejecting an otherwise-valid serial merely because of an unusual + /// character would risk silently dropping a real sync. + internal init?(channelSerial: String) { let scanner = Scanner(string: channelSerial) scanner.charactersToBeSkipped = nil - // Get everything up to the colon as the sequence ID - let sequenceID = scanner.scanUpToString(":") ?? "" + // Everything up to the first colon is the sequence id. We require a non-empty sequence id + // so that the sequence-id comparison performed by RTO5a2/RTO5a3 is meaningful. + // `scanUpToString` returns `nil` (rather than "") when there is nothing before the colon, + // so the empty-sequence-id cases (":cursor", ":") are rejected here. + guard let sequenceID = scanner.scanUpToString(":"), !sequenceID.isEmpty else { + return nil + } - // Check if we have a colon + // There must be a colon separator; a serial with no colon (e.g. "sequence123") is rejected. guard scanner.scanString(":") != nil else { - throw Error.channelSerialDoesNotMatchExpectedFormat(channelSerial).toARTErrorInfo() + return nil } - // Everything after the colon (if anything) is the cursor value + // Everything after the colon (if anything) is the cursor value. An empty cursor value marks + // the end of the sequence (RTO5a4). let remainingString = channelSerial[scanner.currentIndex...] let cursorValue = remainingString.isEmpty ? nil : String(remainingString) diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift index 1c2bbfc04..867f16c8f 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift @@ -33,11 +33,34 @@ internal final class PublicDefaultRealtimeObject: RealtimeObject { // MARK: - `RealtimeObject` protocol internal func get() async throws(ARTErrorInfo) -> any LiveMapPathObject { - notImplemented() + // RTO23a — object_subscribe mode guard (currently a stub; see ChannelConfigGuards for the + // plugin-API limitation). + try ChannelConfigGuards.throwIfMissingObjectSubscribeMode(coreSDK: coreSDK, internalQueue: proxied.internalQueue) + // RTO23e / RTL33 — ensure the channel is usable (reject FAILED; implicit attach is not + // available through the plugin API — see ChannelConfigGuards.ensureActiveChannel). + try ChannelConfigGuards.ensureActiveChannel(coreSDK: coreSDK, internalQueue: proxied.internalQueue) + // RTO23c — wait for the initial sync to complete (atomic state-check + waiter registration, + // 92008 if the channel leaves a usable state while waiting). + try await proxied.ensureSynced() + // RTO23d / RTTS6d — a LiveMapPathObject with an empty path, rooted at the channel's root map. + // The root reference is realised as a pool lookup at resolution time (the pool never replaces + // the root instance, RTO4b2/RTO5c2a), equivalent to holding the RTPO2b root reference. + return DefaultLiveMapPathObject( + channelObject: proxied, + coreSDK: coreSDK, + internalQueue: proxied.internalQueue, + path: "", + ) } @discardableResult - internal func on(event _: ObjectsEvent, callback _: @escaping @Sendable () -> Void) -> any StatusSubscription { - notImplemented() + internal func on(event: ObjectsEvent, callback: @escaping @Sendable () -> Void) -> any StatusSubscription { + // RTO18 — register on the internal engine's status-event emitter, which fires `.syncing` / + // `.synced` on `userCallbackQueue`. The public callback is zero-arg (the event is known from + // registration, DEV-11), so the internal response is discarded. + let response = proxied.on(event: event) { _ in + callback() + } + return DefaultStatusSubscription(response: response) } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift index 59886e95f..5cc894e62 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift @@ -14,6 +14,22 @@ internal enum LiveObjectsError { case publishAndApplyFailedChannelStateChanged(channelState: _AblyPluginSupportPrivate.RealtimeChannelState, reason: ARTErrorInfo?) /// RTO11h3d, RTO12h3d: A newly created object was not found in the pool after `publishAndApply`. case newlyCreatedObjectNotInPool(objectID: String) + /// RTO15d: The total size of the `ObjectMessage`s to be published (calculated per OM3) exceeds the connection's `maxMessageSize`. + case maxMessageSizeExceeded(size: Int, maxSize: Int) + /// RTPO3c2: A write operation (`set`/`remove`/`increment`/`decrement`) was attempted on a path + /// that does not resolve to a value. Code 92005. + case pathNotResolved(path: String) + /// RTTS5d2/RTTS9d, RTPO15e/RTPO16e/RTPO17e/RTPO18e: A typed write wrapper (`asLiveMap`/ + /// `asLiveCounter`) resolved to a value whose type does not match the wrapper. Code 92007. + case pathTypeMismatch(operationDescription: String) + /// RTO2a2/RTO2b2: The channel is missing a required channel mode (`object_subscribe` for reads, + /// `object_publish` for writes). Code 40024. + case channelModeRequired(mode: String) + /// RTO/DEV-7: The LiveObjects plugin is not configured on the client. Code 40019. (Reserved for + /// P5's plugin-missing decision; the code is landed here with the rest of the path-API error model.) + case pluginUnavailable + /// RTLMV4a/b, RTLCV4a, RTPO19c1a (depth validation): invalid input parameter. Code 40003. + case invalidInput(message: String) case other(Error) /// The ``ARTErrorInfo/code`` that should be returned for this error. @@ -29,11 +45,48 @@ internal enum LiveObjectsError { .unableToApplyObjectsOperationSyncDidNotComplete case .newlyCreatedObjectNotInPool: .internalError - case .other: + case .maxMessageSizeExceeded: + // RTO15d + .maxMessageLengthExceeded + case .pathNotResolved, + .pathTypeMismatch, + .channelModeRequired, + .pluginUnavailable, + .invalidInput, + .other: + // These path-API codes are not part of core `ARTErrorCode`; the real numeric code is + // supplied by `numericCode` (plan matrix #19: raw-int `ARTErrorInfo` from the plugin, no + // core `ARTStatus.h` change). `.badRequest` is only a placeholder for the `code` switch. .badRequest } } + /// The numeric error code returned to callers. Most cases derive from ``code``; the path-based + /// public-API codes (92005/92007/40024/40019/40003) are absent from core `ARTErrorCode` and are + /// returned as raw integers per plan matrix #19. + internal var numericCode: Int { + switch self { + case .pathNotResolved: + 92005 // RTPO3c2 + case .pathTypeMismatch: + 92007 // RTTS5d2/RTTS9d + case .channelModeRequired: + 40024 // RTO2a2/RTO2b2 + case .pluginUnavailable: + 40019 // DEV-7 + case .invalidInput: + 40003 // RTLMV4a/RTPO19c1a + case .objectsOperationFailedInvalidChannelState, + .counterInitialValueInvalid, + .counterIncrementAmountInvalid, + .publishAndApplyFailedChannelStateChanged, + .newlyCreatedObjectNotInPool, + .maxMessageSizeExceeded, + .other: + Int(code.rawValue) + } + } + /// The ``ARTErrorInfo/statusCode`` that should be returned for this error. internal var statusCode: Int { switch self { @@ -41,6 +94,12 @@ internal enum LiveObjectsError { .counterInitialValueInvalid, .counterIncrementAmountInvalid, .publishAndApplyFailedChannelStateChanged, + .maxMessageSizeExceeded, + .pathNotResolved, + .pathTypeMismatch, + .channelModeRequired, + .pluginUnavailable, + .invalidInput, .other: 400 case .newlyCreatedObjectNotInPool: @@ -62,6 +121,23 @@ internal enum LiveObjectsError { "operation could not be applied locally: channel entered \(channelState) state whilst waiting for objects sync to complete" case let .newlyCreatedObjectNotInPool(objectID: objectID): "Newly created object \(objectID) not found in pool after publishAndApply" + case let .maxMessageSizeExceeded(size: size, maxSize: maxSize): + // RTO15d - matches the message format used by ably-java + "ObjectMessages size \(size) exceeds maximum allowed size of \(maxSize) bytes" + case let .pathNotResolved(path: path): + // RTPO3c2 + "Path could not be resolved: \"\(path)\"" + case let .pathTypeMismatch(operationDescription: operationDescription): + // RTTS5d2/RTTS9d + operationDescription + case let .channelModeRequired(mode: mode): + // RTO2a2/RTO2b2 + "\"\(mode)\" channel mode must be set for this operation" + case .pluginUnavailable: + // DEV-7 + "The LiveObjects plugin is not configured on this client" + case let .invalidInput(message: message): + message case let .other(error): "\(error)" } @@ -77,6 +153,12 @@ internal enum LiveObjectsError { .counterInitialValueInvalid, .counterIncrementAmountInvalid, .newlyCreatedObjectNotInPool, + .maxMessageSizeExceeded, + .pathNotResolved, + .pathTypeMismatch, + .channelModeRequired, + .pluginUnavailable, + .invalidInput, .other: nil } @@ -90,7 +172,7 @@ internal enum LiveObjectsError { } return ARTErrorInfo.create( - withCode: Int(code.rawValue), + withCode: numericCode, status: statusCode, message: localizedDescription, additionalUserInfo: userInfo, @@ -141,13 +223,6 @@ extension WireValue.ConversionError: ConvertibleToLiveObjectsError { } } -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension SyncCursor.Error: ConvertibleToLiveObjectsError { - internal func toLiveObjectsError() -> LiveObjectsError { - .other(self) - } -} - @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension InboundWireObjectMessage.DecodingError: ConvertibleToLiveObjectsError { internal func toLiveObjectsError() -> LiveObjectsError { diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/PathSegments.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/PathSegments.swift new file mode 100644 index 000000000..12f96986f --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/PathSegments.swift @@ -0,0 +1,87 @@ +import Foundation + +/// Dot-delimited path <-> segment list conversions for the path-based public API. +/// +/// A dot inside a segment is escaped as `\.` (RTPO4b); parsing honours the escape (RTPO6b). This +/// mirrors ably-java's `PathSegments` (and, transitively, ably-js `pathobject.ts`) exactly, so the +/// stored-path string format agrees byte-for-byte across SDKs. +/// +/// Root convention: the root `PathObject` stores the empty string, which represents ZERO segments +/// (RTPO4c) — unlike ably-js, which stores segment arrays and never parses a stored path. Every +/// helper below therefore treats an empty *stored base path* as zero segments via ``parseStored(_:)``; +/// ``parse(_:)`` itself is only ever given user-supplied sub-paths (where `""` means one empty +/// segment, matching ably-js `at("")`) or non-empty stored paths. +/// +/// - Note (deviation, mirrored from ably-java): ``join(_:)`` escapes backslashes too (ably-js +/// `_escapePath` escapes only dots). ably-js stores segment ARRAYS and its escaped string is +/// display-only, but here the joined string IS the storage and gets re-parsed by ``parseStored(_:)`` +/// on every resolution. Without doubling backslashes, a key ending in `\` would collide with the +/// escaped-dot separator (`["a\", "b"]` -> `a\.b` -> re-parses as `["a.b"]`), breaking lookups and +/// subscriptions. Recorded as a deviation candidate. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal enum PathSegments { + /// RTPO6b — split on unescaped dots; `\.` yields a literal dot; any other `\x` keeps the + /// backslash; a trailing lone `\` is kept. `""` parses to one empty segment (ably-js parity). + /// Manual scanner (no regex lookbehind), ported from ably-js `pathobject.ts#at`. + internal static func parse(_ path: String) -> [String] { + parse(path, strict: false) + } + + private static func parse(_ path: String, strict: Bool) -> [String] { + var segments: [String] = [] + var currentSegment = "" + var escaping = false + for char in path { + if escaping { + // User-supplied paths keep the escape character unless it escapes a dot, replicating + // ably-js behaviour where only escaped dots are unescaped; stored paths were produced + // by `join`, which escapes both '.' and '\', so strict mode unescapes both. + if char != ".", !(strict && char == "\\") { + currentSegment.append("\\") + } + currentSegment.append(char) + escaping = false + continue + } + switch char { + case "\\": + escaping = true + case ".": + segments.append(currentSegment) + currentSegment = "" + default: + currentSegment.append(char) + } + } + if escaping { + currentSegment.append("\\") + } + segments.append(currentSegment) + return segments + } + + /// RTPO4a/RTPO4b — join segments, escaping dots (and backslashes, per the deviation note) inside + /// segments. Empty list -> `""` (RTPO4c). + internal static func join(_ segments: [String]) -> String { + segments + .map { $0.replacingOccurrences(of: "\\", with: "\\\\").replacingOccurrences(of: ".", with: "\\.") } + .joined(separator: ".") + } + + /// Stored-path parsing: empty stored path = root = zero segments. Use for `this.path`, never raw + /// ``parse(_:)``. Stored paths only ever come from ``join(_:)``, so this inverts join's full + /// escaping (`\\` -> `\` as well as `\.` -> `.`). + internal static func parseStored(_ path: String) -> [String] { + path.isEmpty ? [] : parse(path, strict: true) + } + + /// RTPO5c — append one raw key (escaping it) to an existing stored path. + internal static func appendKey(_ path: String, key: String) -> String { + join(parseStored(path) + [key]) + } + + /// RTPO6c — append a dot-delimited sub-path to an existing stored path. + internal static func appendPath(_ path: String, subPath: String) -> String { + join(parseStored(path) + parse(subPath)) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/DefaultInstanceTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/DefaultInstanceTests.swift new file mode 100644 index 000000000..952a232c3 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/DefaultInstanceTests.swift @@ -0,0 +1,481 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// Tests for the Phase 3 instance layer: ``DefaultLiveMapInstance``, ``DefaultLiveCounterInstance``, +/// ``DefaultPrimitiveInstance`` and the ``Instance`` construction seam. Everything is driven through +/// the existing mocks (`MockCoreSDK`, `MockRealtimeObjects`, `MockLiveMapObjectsPoolDelegate`). +/// +/// Integration-tier / mock-WebSocket cases from `uts/objects/unit/instance.md` (those declaring +/// `setup_synced_channel` etc.) are out of scope for this native suite and are not ported here. +struct DefaultInstanceTests { + // MARK: - Construction helpers + + private static func makeCounter(objectID: String = "counter:1@0", data: Double = 0, internalQueue: DispatchQueue) -> InternalDefaultLiveCounter { + InternalDefaultLiveCounter(testsOnly_data: data, objectID: objectID, logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + private static func makeMap(objectID: String = "map:1@0", data: [String: InternalObjectsMapEntry] = [:], internalQueue: DispatchQueue) -> InternalDefaultLiveMap { + InternalDefaultLiveMap(testsOnly_data: data, objectID: objectID, logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + // MARK: - Instance factory / construction (RTINS1, RTINS2a, RTINS3a) + + // @spec RTINS3a - the factory binds a map node and surfaces its objectId + @Test + func factoryProducesLiveMapInstance() throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let node = Self.makeMap(objectID: "map:abc@0", internalQueue: internalQueue) + + let instance = Instance.from(internalValue: .liveMap(node), coreSDK: coreSDK, realtimeObjects: MockRealtimeObjects(), internalQueue: internalQueue) + + guard case let .liveMap(mapInstance) = instance else { + Issue.record("Expected .liveMap") + return + } + #expect(instance.type == .liveMap) + #expect(mapInstance.id == "map:abc@0") + } + + // @spec RTINS3a - the factory binds a counter node and surfaces its objectId + @Test + func factoryProducesLiveCounterInstance() throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let node = Self.makeCounter(objectID: "counter:abc@0", internalQueue: internalQueue) + + let instance = Instance.from(internalValue: .liveCounter(node), coreSDK: coreSDK, realtimeObjects: MockRealtimeObjects(), internalQueue: internalQueue) + + guard case let .liveCounter(counterInstance) = instance else { + Issue.record("Expected .liveCounter") + return + } + #expect(instance.type == .liveCounter) + #expect(counterInstance.id == "counter:abc@0") + } + + // @spec RTTS8 - each primitive value type maps to the correct ValueType + @Test(arguments: [ + (InternalLiveMapValue.string("s"), ValueType.string), + (InternalLiveMapValue.number(1), ValueType.number), + (InternalLiveMapValue.bool(true), ValueType.boolean), + (InternalLiveMapValue.data(Data([1, 2])), ValueType.binary), + (InternalLiveMapValue.jsonArray(["a"]), ValueType.jsonArray), + (InternalLiveMapValue.jsonObject(["k": "v"]), ValueType.jsonObject), + ]) + func factoryProducesPrimitiveInstanceWithCorrectType(value: InternalLiveMapValue, expectedType: ValueType) throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + let instance = Instance.from(internalValue: value, coreSDK: coreSDK, realtimeObjects: MockRealtimeObjects(), internalQueue: internalQueue) + + guard case let .primitive(primitiveInstance) = instance else { + Issue.record("Expected .primitive") + return + } + #expect(instance.type == expectedType) + #expect(primitiveInstance.type == expectedType) + } + + // MARK: - LiveMapInstance reads (RTINS5, RTINS6, RTINS7, RTINS8, RTINS9) + + // @spec RTINS5c - get returns an Instance wrapping the value, or nil when absent + @Test + func mapGetReturnsWrappedInstanceOrNil() throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects(objectsPoolDelegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) + let node = Self.makeMap(data: ["k": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "hello"))], internalQueue: internalQueue) + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(node), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + + let got = try #require(try mapInstance.get(key: "k")) + guard case let .primitive(primitive) = got else { + Issue.record("Expected primitive") + return + } + #expect(try primitive.value == .string("hello")) + #expect(try mapInstance.get(key: "missing") == nil) + } + + // @spec RTINS6 - entries; @spec RTINS7 - keys; @spec RTINS8 - values; @spec RTINS9 - size + @Test + func mapEntriesKeysValuesSize() throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects(objectsPoolDelegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) + let node = Self.makeMap( + data: [ + "a": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "x")), + "b": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(number: NSNumber(value: 5))), + ], + internalQueue: internalQueue, + ) + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(node), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + + #expect(try mapInstance.size == 2) + #expect(try Set(mapInstance.keys()) == ["a", "b"]) + #expect(try mapInstance.entries().count == 2) + #expect(try mapInstance.values().count == 2) + let entriesByKey = try Dictionary(uniqueKeysWithValues: mapInstance.entries().map { ($0.key, $0.value) }) + #expect(entriesByKey["a"]?.type == .string) + #expect(entriesByKey["b"]?.type == .number) + } + + // MARK: - LiveCounterInstance / PrimitiveInstance reads (RTINS4) + + // @spec RTINS4b - counter value delegates to the node + @Test + func counterValue() throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let node = Self.makeCounter(data: 42, internalQueue: internalQueue) + + guard case let .liveCounter(counterInstance) = Instance.from(internalValue: .liveCounter(node), coreSDK: coreSDK, realtimeObjects: MockRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .liveCounter") + return + } + #expect(try counterInstance.value == 42) + } + + // @spec RTINS4c - a primitive returns its value directly + @Test + func primitiveValueAndType() throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + guard case let .primitive(primitive) = Instance.from(internalValue: .string("direct"), coreSDK: coreSDK, realtimeObjects: MockRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .primitive") + return + } + #expect(try primitive.value == .string("direct")) + #expect(primitive.type == .string) + } + + // @spec RTO25b - a read on a DETACHED channel throws + @Test + func readOnDetachedChannelThrows() throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .detached, internalQueue: internalQueue) + let node = Self.makeCounter(data: 1, internalQueue: internalQueue) + + guard case let .liveCounter(counterInstance) = Instance.from(internalValue: .liveCounter(node), coreSDK: coreSDK, realtimeObjects: MockRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .liveCounter") + return + } + #expect(throws: ARTErrorInfo.self) { + _ = try counterInstance.value + } + + guard case let .primitive(primitive) = Instance.from(internalValue: .string("x"), coreSDK: coreSDK, realtimeObjects: MockRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .primitive") + return + } + #expect(throws: ARTErrorInfo.self) { + _ = try primitive.value + } + } + + // MARK: - compactJson (RTINS11 -> RTPO13c / RTPO14b) + + // @spec RTPO14b1 - a primitive compacts to itself; binary is base64-encoded + @Test + func primitiveCompactJson() throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + guard case let .primitive(stringPrimitive) = Instance.from(internalValue: .string("hi"), coreSDK: coreSDK, realtimeObjects: MockRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .primitive") + return + } + #expect(try stringPrimitive.compactJson() == .string("hi")) + + let bytes = Data([0x01, 0x02, 0x03]) + guard case let .primitive(binaryPrimitive) = Instance.from(internalValue: .data(bytes), coreSDK: coreSDK, realtimeObjects: MockRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .primitive") + return + } + #expect(try binaryPrimitive.compactJson() == .string(bytes.base64EncodedString())) + } + + // @spec RTPO13d - a counter compacts to its numeric value + @Test + func counterCompactJson() throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let node = Self.makeCounter(data: 9, internalQueue: internalQueue) + + guard case let .liveCounter(counterInstance) = Instance.from(internalValue: .liveCounter(node), coreSDK: coreSDK, realtimeObjects: MockRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .liveCounter") + return + } + #expect(try counterInstance.compactJson() == .number(9)) + } + + // @spec RTPO13c2 - nested maps recurse; @spec RTPO13c3 - nested counters resolve to their value + @Test + func mapCompactJsonNested() throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let poolDelegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + + let nestedCounter = Self.makeCounter(objectID: "counter:c1@0", data: 7, internalQueue: internalQueue) + let nestedMap = Self.makeMap(objectID: "map:m1@0", data: ["x": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "y"))], internalQueue: internalQueue) + poolDelegate.objects["counter:c1@0"] = .counter(nestedCounter) + poolDelegate.objects["map:m1@0"] = .map(nestedMap) + + let root = Self.makeMap( + objectID: "map:root@0", + data: [ + "str": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "hello")), + "cnt": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:c1@0")), + "m": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "map:m1@0")), + ], + internalQueue: internalQueue, + ) + let realtimeObjects = MockRealtimeObjects(objectsPoolDelegate: poolDelegate) + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(root), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + let expected: JSONValue = .object([ + "str": .string("hello"), + "cnt": .number(7), + "m": .object(["x": .string("y")]), + ]) + #expect(try mapInstance.compactJson() == expected) + } + + // @spec RTPO14b2 - a cyclic reference is emitted as {"objectId": } + @Test + func mapCompactJsonCycle() throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let poolDelegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + + // An indirect cycle A -> B -> A. (A direct self-reference is not used: the internal engine's + // `entries()` -> `nosync_isEntryTombstoned` re-enters the same map's mutex for a self-referencing + // entry, an exclusive-access conflict — a pre-existing engine limitation, unrelated to the + // compactJson cycle handling exercised here.) + let mapA = Self.makeMap(objectID: "map:a@0", data: ["toB": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "map:b@0"))], internalQueue: internalQueue) + let mapB = Self.makeMap(objectID: "map:b@0", data: ["toA": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "map:a@0"))], internalQueue: internalQueue) + poolDelegate.objects["map:a@0"] = .map(mapA) + poolDelegate.objects["map:b@0"] = .map(mapB) + let realtimeObjects = MockRealtimeObjects(objectsPoolDelegate: poolDelegate) + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(mapA), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + // RTPO14b2: when the walk revisits A (already-visited), the reference is a single-property + // object carrying the objectId, rather than infinitely recursing. + let expected: JSONValue = .object(["toB": .object(["toA": .object(["objectId": .string("map:a@0")])])]) + #expect(try mapInstance.compactJson() == expected) + } + + // MARK: - Mutations via the RTO20 publish path (RTINS12, RTINS14) + + // @spec RTINS14c - increment publishes a COUNTER_INC operation + @Test + func counterIncrementPublishesCounterInc() async throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects() + let published = Published() + realtimeObjects.setPublishAndApplyHandler { messages in + published.set(messages) + return .success(()) + } + let node = Self.makeCounter(objectID: "counter:x@0", internalQueue: internalQueue) + + guard case let .liveCounter(counterInstance) = Instance.from(internalValue: .liveCounter(node), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveCounter") + return + } + try await counterInstance.increment(amount: 5) + + let messages = try #require(published.get()) + #expect(messages.count == 1) + #expect(messages[0].operation?.action == .known(.counterInc)) + #expect(messages[0].operation?.objectId == "counter:x@0") + #expect(messages[0].operation?.counterInc?.number == NSNumber(value: 5)) + } + + // @spec RTINS12c - set publishes a MAP_SET operation + @Test + func mapSetPublishesMapSet() async throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects() + let published = Published() + realtimeObjects.setPublishAndApplyHandler { messages in + published.set(messages) + return .success(()) + } + let node = Self.makeMap(objectID: "map:x@0", internalQueue: internalQueue) + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(node), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + try await mapInstance.set(key: "greeting", value: .primitive(.string("hi"))) + + let messages = try #require(published.get()) + #expect(messages.count == 1) + #expect(messages[0].operation?.action == .known(.mapSet)) + #expect(messages[0].operation?.objectId == "map:x@0") + #expect(messages[0].operation?.mapSet?.key == "greeting") + #expect(messages[0].operation?.mapSet?.value?.string == "hi") + } + + // MARK: - Subscriptions (RTINS16) + + // @spec RTINS16e1 - the event carries an Instance wrapping the object + // @spec RTINS16e2 - the event carries the PAOM3 message from the update + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func counterSubscribeReceivesEnrichedEvent() async throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let node = Self.makeCounter(objectID: "counter:sub@0", internalQueue: internalQueue) + + guard case let .liveCounter(counterInstance) = Instance.from(internalValue: .liveCounter(node), coreSDK: coreSDK, realtimeObjects: MockRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .liveCounter") + return + } + + let subscriber = Subscriber(callbackQueue: .main) + let subscription = try counterInstance.subscribe(listener: subscriber.createListener()) + + let sourceMessage = ObjectMessage(channel: "channel", operation: .init(action: .counterInc, objectId: "counter:sub@0")) + var pool = ObjectsPool(logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + _ = node.nosync_apply( + TestFactories.objectOperation(action: .known(.counterInc), objectId: "counter:sub@0", counterInc: TestFactories.counterInc(number: 3)), + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + sourceObjectMessage: sourceMessage, + objectsPool: &pool, + ) + } + + let invocations = await subscriber.getInvocations() + #expect(invocations.count == 1) + let event = try #require(invocations.first) + #expect(event.message == sourceMessage) + // RTINS16e1: the wrapped object is the same counter (identity), exposing the same id. + guard case let .liveCounter(eventCounter) = event.object else { + Issue.record("Expected .liveCounter in event") + return + } + #expect(eventCounter.id == "counter:sub@0") + subscription.unsubscribe() + } + + // @spec RTINS16d - a map subscription receives update events + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func mapSubscribeReceivesEvent() async throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects(objectsPoolDelegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) + let node = Self.makeMap(objectID: "map:sub@0", internalQueue: internalQueue) + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(node), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + + let subscriber = Subscriber(callbackQueue: .main) + try mapInstance.subscribe(listener: subscriber.createListener()) + + var pool = ObjectsPool(logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + _ = node.nosync_apply( + TestFactories.objectOperation(action: .known(.mapSet), objectId: "map:sub@0", mapSet: ProtocolTypes.MapSet(key: "k", value: ProtocolTypes.ObjectData(string: "v"))), + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + + let invocations = await subscriber.getInvocations() + #expect(invocations.count == 1) + guard case .liveMap = try #require(invocations.first).object else { + Issue.record("Expected .liveMap in event") + return + } + } + + // @spec RTLO4b4c3c - the subscription is torn down when the object is tombstoned + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func counterSubscriptionDiesOnTombstone() async throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let node = Self.makeCounter(objectID: "counter:tomb@0", internalQueue: internalQueue) + + guard case let .liveCounter(counterInstance) = Instance.from(internalValue: .liveCounter(node), coreSDK: coreSDK, realtimeObjects: MockRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .liveCounter") + return + } + + let subscriber = Subscriber(callbackQueue: .main) + try counterInstance.subscribe(listener: subscriber.createListener()) + + // A live emit before tombstoning proves the subscription is active. + internalQueue.ably_syncNoDeadlock { + node.nosync_emit(.update(.init(amount: 1))) + } + + // Apply an OBJECT_DELETE, tombstoning the counter (emits the tombstone update + tears down). + var pool = ObjectsPool(logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + _ = node.nosync_apply( + TestFactories.objectOperation(action: .known(.objectDelete), objectId: "counter:tomb@0"), + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + + // After teardown, further emits must not reach the subscriber. + internalQueue.ably_syncNoDeadlock { + node.nosync_emit(.update(.init(amount: 1))) + } + + let invocations = await subscriber.getInvocations() + // One live emit + one tombstone update; the post-tombstone emit is not delivered. + #expect(invocations.count == 2) + } + + /// A tiny thread-safe holder for the messages captured by a `publishAndApply` handler. + private final class Published: @unchecked Sendable { + private let mutex = NSLock() + private var messages: [ProtocolTypes.OutboundObjectMessage]? + func set(_ value: [ProtocolTypes.OutboundObjectMessage]) { + mutex.withLock { messages = value } + } + + func get() -> [ProtocolTypes.OutboundObjectMessage]? { + mutex.withLock { messages } + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/DefaultPathObjectTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/DefaultPathObjectTests.swift new file mode 100644 index 000000000..22689979b --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/DefaultPathObjectTests.swift @@ -0,0 +1,396 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// Tests for the Phase 4 (part 1) path layer: ``PathSegments``, ``DefaultPathObject`` and its typed +/// subclasses, the ``ChannelConfigGuards`` state checks, and the extended error model. Path +/// *subscriptions* (RTPO19/RTO24) are part 2 and are not exercised here (they still trap). +/// +/// Everything is driven through a seeded ``ObjectsPool`` and a local ``SeededRealtimeObjects`` double +/// so path resolution walks root -> children exactly as production does. +struct DefaultPathObjectTests { + // MARK: - Construction helpers + + private static func makeMap(objectID: String, data: [String: InternalObjectsMapEntry] = [:], internalQueue: DispatchQueue) -> InternalDefaultLiveMap { + InternalDefaultLiveMap(testsOnly_data: data, objectID: objectID, logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + private static func makeCounter(objectID: String, data: Double, internalQueue: DispatchQueue) -> InternalDefaultLiveCounter { + InternalDefaultLiveCounter(testsOnly_data: data, objectID: objectID, logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + /// Builds a seeded pool whose root map has: + /// - `s` -> string "hi" + /// - `n` -> number 5 + /// - `cnt` -> counter (value 42) + /// - `m` -> nested map with entry `deep` -> string "leaf" + /// + /// Returns the realtime-objects double, the shared queue and a core SDK in the requested state. + private static func makeFixture(channelState: _AblyPluginSupportPrivate.RealtimeChannelState = .attached) -> (realtimeObjects: SeededRealtimeObjects, coreSDK: MockCoreSDK, internalQueue: DispatchQueue) { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: channelState, internalQueue: internalQueue) + + let nestedMap = makeMap( + objectID: "map:child@0", + data: ["deep": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "leaf"))], + internalQueue: internalQueue, + ) + let counter = makeCounter(objectID: "counter:c@0", data: 42, internalQueue: internalQueue) + let root = makeMap( + objectID: ObjectsPool.rootKey, + data: [ + "s": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "hi")), + "n": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(number: NSNumber(value: 5))), + "cnt": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:c@0")), + "m": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "map:child@0")), + ], + internalQueue: internalQueue, + ) + + var pool = ObjectsPool( + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + testsOnly_otherEntries: [ + "counter:c@0": .counter(counter), + "map:child@0": .map(nestedMap), + ], + ) + // Replace the auto-created empty root with our seeded root map. + pool.testsOnly_setEntry(.map(root), forObjectID: ObjectsPool.rootKey) + + return (SeededRealtimeObjects(pool: pool, internalQueue: internalQueue), coreSDK, internalQueue) + } + + private static func rootPathObject(_ fixture: (realtimeObjects: SeededRealtimeObjects, coreSDK: MockCoreSDK, internalQueue: DispatchQueue)) -> DefaultLiveMapPathObject { + DefaultLiveMapPathObject(channelObject: fixture.realtimeObjects, coreSDK: fixture.coreSDK, internalQueue: fixture.internalQueue, path: "") + } + + // MARK: - PathSegments escaping (RTPO4, RTPO6) + + // @spec RTPO4b - dots inside a segment are escaped; @spec RTPO6b - parsing honours the escape + @Test + func pathSegmentsRoundTrip() { + // Empty stored path is the root: zero segments (RTPO4c). + #expect(PathSegments.parseStored("").isEmpty) + #expect(PathSegments.join([]).isEmpty) + + // A dot inside a segment survives a join -> parseStored round-trip. + #expect(PathSegments.join(["a.b", "c"]) == #"a\.b.c"#) + #expect(PathSegments.parseStored(#"a\.b.c"#) == ["a.b", "c"]) + + // A backslash inside a segment is doubled by join and restored by parseStored (the deviation). + #expect(PathSegments.join([#"a\"#, "b"]) == #"a\\.b"#) + #expect(PathSegments.parseStored(#"a\\.b"#) == [#"a\"#, "b"]) + + // User sub-path parsing: "" is one empty segment (ably-js `at("")` parity). + #expect(PathSegments.parse("") == [""]) + #expect(PathSegments.parse("x.y") == ["x", "y"]) + + // appendKey escapes the raw key; appendPath parses a dot-delimited sub-path. + #expect(PathSegments.appendKey("root", key: "a.b") == #"root.a\.b"#) + #expect(PathSegments.appendPath("root", subPath: "x.y") == "root.x.y") + // Appending to the empty (root) base path yields just the escaped key. + #expect(PathSegments.appendKey("", key: "k") == "k") + } + + // MARK: - Resolution & type discrimination (RTPO3, RTTS4) + + // @spec RTTS4a - exists reflects resolution; @spec RTTS4b - type discriminates the resolved value + @Test + func resolvesRootAndNestedValues() throws { + let fixture = Self.makeFixture() + let root = Self.rootPathObject(fixture) + + // Root (empty path) is always the root map. + #expect(try root.exists()) + #expect(try root.type() == .liveMap) + + // Nested primitive, number, counter and map. + #expect(try root.get(key: "s").type() == .string) + #expect(try root.get(key: "n").type() == .number) + #expect(try root.get(key: "cnt").type() == .liveCounter) + #expect(try root.get(key: "m").type() == .liveMap) + // Deep navigation via at(). + #expect(try root.at(path: "m.deep").type() == .string) + #expect(try root.at(path: "m.deep").exists()) + } + + // @spec RTPO3c1 - an unresolved path degrades to nil/false for all reads + @Test + func unresolvedPathDegradesGracefully() throws { + let fixture = Self.makeFixture() + let root = Self.rootPathObject(fixture) + + let missing = root.get(key: "nope") + #expect(try !missing.exists()) // RTTS4a + #expect(try missing.type() == nil) // RTTS4b3 + #expect(try missing.instance() == nil) // RTPO8e + #expect(try missing.compactJson() == nil) // RTPO3c1 + #expect(try missing.asPrimitive().value() == nil) + #expect(try missing.asLiveCounter().value() == nil) + // A path that tries to navigate through a primitive mid-path is also unresolved (RTPO3a1). + #expect(try !root.at(path: "s.child").exists()) + } + + // MARK: - Navigation (RTPO5, RTPO6) + + // @spec RTPO5c - get appends an escaped key; @spec RTPO6c - at appends a parsed sub-path + @Test + func navigationBuildsStoredPath() { + let fixture = Self.makeFixture() + let root = Self.rootPathObject(fixture) + + #expect(root.get(key: "a").path == "a") + #expect(root.get(key: "a.b").path == #"a\.b"#) // dot in key is escaped + #expect(root.at(path: "x.y").path == "x.y") + #expect(root.get(key: "a").asLiveMap().at(path: "b.c").path == "a.b.c") + } + + // MARK: - Casts (RTTS5) + + // @spec RTTS5a - casts are pure type refinements that preserve the path and never resolve + @Test + func castsPreservePathWithoutResolving() { + let fixture = Self.makeFixture() + let node = Self.rootPathObject(fixture).get(key: "cnt") + + #expect(node.asLiveMap().path == "cnt") + #expect(node.asLiveCounter().path == "cnt") + #expect(node.asPrimitive().path == "cnt") + } + + // MARK: - compactJson (RTPO14) & instance (RTPO8) + + // @spec RTPO14b - compactJson recursively compacts maps, counters and primitives + @Test + func compactJsonPerType() throws { + let fixture = Self.makeFixture() + let root = Self.rootPathObject(fixture) + + #expect(try root.get(key: "s").compactJson() == .string("hi")) + #expect(try root.get(key: "cnt").compactJson() == .number(42)) // RTPO13d + #expect(try root.get(key: "m").compactJson() == .object(["deep": .string("leaf")])) // RTPO13c2 + } + + // @spec RTPO8c - instance wraps a live object; @spec RTPO8f - instance wraps a primitive + @Test + func instanceWrapsResolvedValue() throws { + let fixture = Self.makeFixture() + let root = Self.rootPathObject(fixture) + + guard case .liveCounter = try #require(try root.get(key: "cnt").instance()) else { + Issue.record("Expected .liveCounter instance") + return + } + guard case .liveMap = try #require(try root.get(key: "m").instance()) else { + Issue.record("Expected .liveMap instance") + return + } + // RTPO8f: a primitive resolves to a primitive Instance (not nil). + guard case let .primitive(primitive) = try #require(try root.get(key: "s").instance()) else { + Issue.record("Expected .primitive instance") + return + } + #expect(try primitive.value == .string("hi")) + } + + // MARK: - Map reads (RTPO9, RTPO10, RTPO11, RTPO12) + + // @spec RTPO10 - keys; @spec RTPO12 - size; @spec RTPO9 - entries; @spec RTPO11 - values + @Test + func mapReads() throws { + let fixture = Self.makeFixture() + let root = Self.rootPathObject(fixture) + + #expect(try Set(root.keys()) == ["s", "n", "cnt", "m"]) + #expect(try root.size() == 4) + #expect(try root.entries().count == 4) + #expect(try root.values().count == 4) + // entries()/values() yield child path objects addressed as if by get(). + let entriesByKey = try Dictionary(uniqueKeysWithValues: root.entries().map { ($0.key, $0.value) }) + #expect(entriesByKey["s"]?.path == "s") + + // A map read on a non-map path degrades: nil size, empty collections (RTPO12d/RTPO10d). + let counterPath = root.get(key: "cnt").asLiveMap() + #expect(try counterPath.size() == nil) + #expect(try counterPath.keys().isEmpty) + } + + // MARK: - Counter read (RTTS6b) + + // @spec RTTS6b - a counter path resolves to its value; a non-counter path yields nil + @Test + func counterValue() throws { + let fixture = Self.makeFixture() + let root = Self.rootPathObject(fixture) + + #expect(try root.get(key: "cnt").asLiveCounter().value() == 42) + // Wrong type via the counter cast degrades to nil on read. + #expect(try root.get(key: "s").asLiveCounter().value() == nil) + } + + // MARK: - Writes via the publish path (RTPO15, RTPO17) + + // @spec RTPO15d - set publishes a MAP_SET operation for the resolved map + @Test + func mapSetPublishesMapSet() async throws { + let fixture = Self.makeFixture() + let root = Self.rootPathObject(fixture) + + // Set on the root map (empty path always resolves). + try await root.set(key: "greeting", value: .primitive(.string("hi"))) + + let messages = try #require(fixture.realtimeObjects.capturedMessages) + #expect(messages.count == 1) + #expect(messages[0].operation?.action == .known(.mapSet)) + #expect(messages[0].operation?.objectId == ObjectsPool.rootKey) + #expect(messages[0].operation?.mapSet?.key == "greeting") + } + + // @spec RTPO17d - increment publishes a COUNTER_INC operation for the resolved counter + @Test + func counterIncrementPublishesCounterInc() async throws { + let fixture = Self.makeFixture() + let root = Self.rootPathObject(fixture) + + try await root.get(key: "cnt").asLiveCounter().increment(amount: 3) + + let messages = try #require(fixture.realtimeObjects.capturedMessages) + #expect(messages.count == 1) + #expect(messages[0].operation?.action == .known(.counterInc)) + #expect(messages[0].operation?.objectId == "counter:c@0") + #expect(messages[0].operation?.counterInc?.number == NSNumber(value: 3)) + } + + // MARK: - Write error model (92005, 92007) + + // @spec RTPO3c2 - a write on an unresolvable path throws 92005 + @Test + func writeOnUnresolvedPathThrows92005() async throws { + let fixture = Self.makeFixture() + let root = Self.rootPathObject(fixture) + + await #expect { () async throws in + try await root.get(key: "does-not-exist").asLiveMap().set(key: "k", value: .primitive(.string("v"))) + } throws: { error in + (error as? ARTErrorInfo)?.code == 92005 + } + } + + // @spec RTPO17e - a write through a mismatched-type cast throws 92007 + @Test + func writeThroughWrongTypeCastThrows92007() async throws { + let fixture = Self.makeFixture() + let root = Self.rootPathObject(fixture) + + // "cnt" resolves to a counter; incrementing through the counter cast is fine, but setting a + // key via the map cast must fail with a type mismatch. + await #expect { () async throws in + try await root.get(key: "cnt").asLiveMap().set(key: "k", value: .primitive(.string("v"))) + } throws: { error in + (error as? ARTErrorInfo)?.code == 92007 + } + } + + // MARK: - Channel-config guards (RTO25, RTO26) + + // @spec RTO25 - a read on a DETACHED channel throws (implementable state-check portion of the guard) + @Test + func readOnDetachedChannelThrows() throws { + let fixture = Self.makeFixture(channelState: .detached) + let root = Self.rootPathObject(fixture) + + #expect(throws: ARTErrorInfo.self) { + _ = try root.exists() + } + #expect(throws: ARTErrorInfo.self) { + _ = try root.get(key: "s").type() + } + } + + // @spec RTO26 - a write on a SUSPENDED channel throws (implementable state-check portion of the guard) + @Test + func writeOnSuspendedChannelThrows() async throws { + let fixture = Self.makeFixture(channelState: .suspended) + let root = Self.rootPathObject(fixture) + + await #expect(throws: ARTErrorInfo.self) { + try await root.set(key: "k", value: .primitive(.string("v"))) + } + } + + // MARK: - Depth validation helper (DEV-9 / RTPO19c1a) + + // @spec RTPO19c1a - depth <= 0 is rejected with 40003; the check lives in ChannelConfigGuards for part 2 + @Test + func subscriptionDepthValidation() throws { + // Valid: nil (unbounded) and any positive integer. + #expect(throws: Never.self) { + try ChannelConfigGuards.validateSubscriptionDepth(nil) + } + #expect(throws: Never.self) { + try ChannelConfigGuards.validateSubscriptionDepth(3) + } + for invalid in [0, -1] { + #expect { try ChannelConfigGuards.validateSubscriptionDepth(invalid) } throws: { error in + (error as? ARTErrorInfo)?.code == 40003 + } + } + } + + // MARK: - Error model numeric codes (matrix #19) + + // @spec RTPO3c2 - the path-API error codes are surfaced as their raw integers + @Test + func errorModelNumericCodes() { + #expect(LiveObjectsError.pathNotResolved(path: "x").numericCode == 92005) + #expect(LiveObjectsError.pathTypeMismatch(operationDescription: "x").numericCode == 92007) + #expect(LiveObjectsError.channelModeRequired(mode: "object_subscribe").numericCode == 40024) + #expect(LiveObjectsError.pluginUnavailable.numericCode == 40019) + #expect(LiveObjectsError.invalidInput(message: "x").numericCode == 40003) + // All are 400-class client errors. + #expect(LiveObjectsError.pathNotResolved(path: "x").statusCode == 400) + } +} + +// MARK: - Local test double + +/// A realtime-objects double that exposes a pre-seeded ``ObjectsPool`` for path resolution and +/// captures the messages published by the write path. Mirrors `MockRealtimeObjects` but with an +/// injectable full pool (so the root can carry seeded entries). +private final class SeededRealtimeObjects: InternalRealtimeObjectsProtocol { + private let poolMutex: DispatchQueueMutex + private let mutex = NSLock() + private nonisolated(unsafe) var _captured: [ProtocolTypes.OutboundObjectMessage]? + private let _pathObjectSubscriptionRegister: PathObjectSubscriptionRegister + + init(pool: ObjectsPool, internalQueue: DispatchQueue) { + poolMutex = DispatchQueueMutex(dispatchQueue: internalQueue, initialValue: pool) + _pathObjectSubscriptionRegister = PathObjectSubscriptionRegister(internalQueue: internalQueue, userCallbackQueue: .main) + } + + var nosync_objectsPool: ObjectsPool { + poolMutex.withoutSync { $0 } + } + + var nosync_pathObjectSubscriptionRegister: PathObjectSubscriptionRegister { + _pathObjectSubscriptionRegister + } + + var capturedMessages: [ProtocolTypes.OutboundObjectMessage]? { + mutex.withLock { _captured } + } + + func nosync_publishAndApply( + objectMessages: [ProtocolTypes.OutboundObjectMessage], + coreSDK: CoreSDK, + callback: @escaping @Sendable (Result) -> Void, + ) { + mutex.withLock { _captured = objectMessages } + callback(.success(())) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/UTSTestPoolFactories.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/UTSTestPoolFactories.swift new file mode 100644 index 000000000..5b8933e9a --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/UTSTestPoolFactories.swift @@ -0,0 +1,146 @@ +import _AblyPluginSupportPrivate +@testable import AblyLiveObjects +import Foundation + +// Mirrors the shared UTS helper `uts/objects/helpers/standard_test_pool.md` for the +// AblyLiveObjectsTests target: the canonical constants/serial helpers plus the +// `build_object_delete` operation-message builder that `TestFactories` did not yet cover. +// +// Name mapping (UTS pseudocode -> Swift): +// - `SITE_CODE` -> `UTSTestPool.utsSiteCode` +// - `POOL_SERIAL` -> `UTSTestPool.utsPoolSerial` +// - `ack_serial(m, i)` -> `UTSTestPool.utsAckSerial(msgSerial:_:)` +// - `remote_serial(i)` -> `UTSTestPool.utsRemoteSerial(_:)` +// - `below_ack_serial(i)` -> `UTSTestPool.utsBelowAckSerial(_:)` +// - `build_object_delete` -> `TestFactories.objectDeleteOperationMessage(...)` +// +// The other `build_*` operation builders from the helper are already covered by +// `TestFactories`: `build_counter_inc` (counterIncOperationMessage), `build_map_set` +// (mapSetOperationMessage), `build_map_remove` (mapRemoveOperationMessage), `build_map_clear` +// (mapClearOperationMessage), `build_map_create` (mapCreateOperationMessage), +// `build_counter_create` (counterCreateOperationMessage); and the state builders +// `build_object_state` variants via mapObjectState/counterObjectState/objectState. + +/// Canonical constants and serial helpers from the UTS standard test pool. +/// +/// See `uts/objects/helpers/standard_test_pool.md` -> "Canonical Constants". All serials are +/// compared lexicographically as strings (RTLM9e) and are defined relative to the pool baseline +/// `utsPoolSerial`. +enum UTSTestPool { + /// `SITE_CODE` — the harness ConnectionDetails siteCode (the connection's own site). + static let utsSiteCode = "test-site" + + /// `POOL_SERIAL` — the timeserial every standard-pool entry and object is seeded with. + static let utsPoolSerial = "t:0" + + /// `ack_serial(msgSerial, i)` — the serial the harness assigns to a locally-published + /// operation when it is applied on its ACK. First publish's first op = `utsAckSerial(0, 0)` + /// == `"t:1:0"`. Sorts AFTER `utsPoolSerial`. + static func utsAckSerial(msgSerial: Int, _ i: Int) -> String { + "t:\(msgSerial + 1):\(i)" + } + + /// `remote_serial(i)` — a remote inbound "winning" serial for a MAP_SET / MAP_REMOVE on an + /// existing pool entry. 0-based: `utsRemoteSerial(0)` == `"t:1"`. Sorts AFTER `utsPoolSerial`. + static func utsRemoteSerial(_ i: Int) -> String { + "t:\(i + 1)" + } + + /// `below_ack_serial(i)` — a serial that is NOT an ack_serial (escapes the RTO9a3 apply-on-ACK + /// echo dedup) yet sorts BELOW the first ack_serial (`utsAckSerial(0, 0)` == `"t:1:0"`), while + /// still after `utsPoolSerial`. 0-based: `utsBelowAckSerial(9)` == `"t:0:9"`. + static func utsBelowAckSerial(_ i: Int) -> String { + "t:0:\(i)" + } +} + +// MARK: - SyncObjectsPool construction + +extension SyncObjectsPool { + /// Test-only convenience to create a `SyncObjectsPool` from an array of `(state, serialTimestamp)` pairs, + /// wrapping each in an `InboundObjectMessage` and calling `accumulate`. + static func testsOnly_fromStates( + _ states: [(state: ProtocolTypes.ObjectState, serialTimestamp: Date?)], + logger: AblyLiveObjects.Logger = TestLogger(), + ) -> SyncObjectsPool { + var pool = SyncObjectsPool() + let messages = states.map { pair in + TestFactories.inboundObjectMessage( + object: pair.state, + serialTimestamp: pair.serialTimestamp, + ) + } + pool.accumulate(messages, logger: logger) + return pool + } +} + +// MARK: - build_object_delete (audit D-3) + +extension TestFactories { + /// Creates an InboundObjectMessage with an OBJECT_DELETE operation. + /// + /// Mirrors the UTS `build_object_delete` builder from + /// `uts/objects/helpers/standard_test_pool.md`. Takes `serial` / `siteCode` / + /// `serialTimestamp` as parameters, matching the other `*OperationMessage` builders. + static func objectDeleteOperationMessage( + objectId: String = "test:object@123", + serial: String = "ts1", + siteCode: String = "site1", + serialTimestamp: Date? = nil, + ) -> ProtocolTypes.InboundObjectMessage { + inboundObjectMessage( + operation: objectOperation( + action: .known(.objectDelete), + objectId: objectId, + objectDelete: WireObjectDelete(), + ), + serial: serial, + siteCode: siteCode, + serialTimestamp: serialTimestamp, + ) + } + + /// Creates an InboundObjectMessage with a MAP_SET operation carrying an arbitrary `ObjectData` + /// value (the `value:`-based `TestFactories.mapSetOperationMessage` only supports a `String`). + /// Used by the UTS map ports for `{ objectId: … }` / `{ number: … }` MapSet values. + static func mapSetOperationMessage( + objectId: String, + key: String, + data: ProtocolTypes.ObjectData, + serial: String, + siteCode: String, + ) -> ProtocolTypes.InboundObjectMessage { + inboundObjectMessage( + operation: objectOperation( + action: .known(.mapSet), + objectId: objectId, + mapSet: ProtocolTypes.MapSet(key: key, value: data), + ), + serial: serial, + siteCode: siteCode, + ) + } + + /// Creates an InboundObjectMessage with a MAP_REMOVE operation that also carries a + /// `serialTimestamp` (the base `TestFactories.mapRemoveOperationMessage` does not), needed to + /// exercise the RTLM8f `tombstonedAt` derivation. + static func mapRemoveOperationMessage( + objectId: String, + key: String, + serial: String, + siteCode: String, + serialTimestamp: Date?, + ) -> ProtocolTypes.InboundObjectMessage { + inboundObjectMessage( + operation: objectOperation( + action: .known(.mapRemove), + objectId: objectId, + mapRemove: WireMapRemove(key: key), + ), + serial: serial, + siteCode: siteCode, + serialTimestamp: serialTimestamp, + ) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 3d23f2b72..7a1fb9c05 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -453,7 +453,7 @@ struct InternalDefaultLiveCounterTests { objectsPool: &pool, ) } - #expect(!applied) + #expect(applied == nil) // Check that the COUNTER_INC side-effects didn't happen: // Verify the operation was discarded - data unchanged (should still be 5 from creation) @@ -491,7 +491,7 @@ struct InternalDefaultLiveCounterTests { objectsPool: &pool, ) } - #expect(applied) + #expect(applied != nil) // Verify the operation was applied - initial value merged (the full logic of RTLC8 is tested elsewhere; we just check for some of its side effects here) #expect(try counter.value(coreSDK: coreSDK) == 15) @@ -542,7 +542,7 @@ struct InternalDefaultLiveCounterTests { objectsPool: &pool, ) } - #expect(applied) + #expect(applied != nil) // Verify the operation was applied - amount added to data (the full logic of RTLC9 is tested elsewhere; we just check for some of its side effects here) #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 @@ -579,7 +579,7 @@ struct InternalDefaultLiveCounterTests { objectsPool: &pool, ) } - #expect(applied) + #expect(applied != nil) // Verify the operation was applied #expect(try counter.value(coreSDK: coreSDK) == 10) @@ -611,7 +611,7 @@ struct InternalDefaultLiveCounterTests { objectsPool: &pool, ) } - #expect(!applied) + #expect(applied == nil) // Check no update was emitted let subscriberInvocations = await subscriber.getInvocations() diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 6fcdf9003..318c8df4c 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -525,6 +525,59 @@ struct InternalDefaultLiveMapTests { #expect(Set(values.compactMap(\.stringValue)) == Set(["value1"])) } + // MARK: - Self-reference regression + + // Regression test: a DIRECT self-referencing entry (`data.objectId` == the map's own + // objectID) previously crashed the read accessors with a Swift exclusive-access conflict. + // The accessor holds the map's `mutableStateMutex` (via `withSync`) while the RTLM14c + // tombstone check / RTLM5d2f3 conversion re-enters the SAME mutex via + // `objectsPool.entries[objectId].nosync_isTombstone`. Indirect cycles (A→B→A) are fine; + // only self-reference crashed. See DEV-15 (`getFullPaths`) for the analogous exclusivity + // finding. Observable behaviour must match the Kotlin reference (which has no exclusivity + // checker and so needs no guard): a non-tombstoned self-reference is just a normal entry + // whose value is the map itself. + @Test + func selfReferencingEntryIsTreatedAsNormalEntry() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + + let selfObjectID = "map:self@1" + let map = InternalDefaultLiveMap( + testsOnly_data: [ + "selfRef": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: selfObjectID)), + ], + objectID: selfObjectID, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + // The entry references the containing map itself. + delegate.objects[selfObjectID] = .map(map) + + // Each of these previously crashed via re-entrant mutex access; they must now treat the + // self-reference as a normal LiveMap-valued entry. + let size = try map.size(coreSDK: coreSDK, delegate: delegate) + #expect(size == 1) + + let entries = try map.entries(coreSDK: coreSDK, delegate: delegate) + #expect(entries.count == 1) + #expect(entries.first?.key == "selfRef") + #expect(entries.first?.value.liveMapValue as AnyObject === map as AnyObject) + + let keys = try map.keys(coreSDK: coreSDK, delegate: delegate) + #expect(keys == ["selfRef"]) + + let values = try map.values(coreSDK: coreSDK, delegate: delegate) + #expect(values.count == 1) + #expect(values.first?.liveMapValue as AnyObject === map as AnyObject) + + let got = try map.get(key: "selfRef", coreSDK: coreSDK, delegate: delegate) + #expect(got?.liveMapValue as AnyObject === map as AnyObject) + } + // MARK: - Consistency Tests // @specOneOf(2/2) RTLM10d @@ -958,6 +1011,7 @@ struct InternalDefaultLiveMapTests { key: "key1", operationTimeserial: operationSerial, operationSerialTimestamp: nil, + objectsPool: pool, ) // Then: the operation is applied or discarded as expected @@ -989,7 +1043,7 @@ struct InternalDefaultLiveMapTests { ) // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 - let update = map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts1", operationSerialTimestamp: nil) + let update = map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts1", operationSerialTimestamp: nil, objectsPool: ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())) // Verify the operation was discarded - existing data unchanged #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") @@ -1017,7 +1071,7 @@ struct InternalDefaultLiveMapTests { ) // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 - let update = map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts2", operationSerialTimestamp: nil) + let update = map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts2", operationSerialTimestamp: nil, objectsPool: ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())) // Verify the operation was applied #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) @@ -1047,7 +1101,7 @@ struct InternalDefaultLiveMapTests { let internalQueue = TestFactories.createInternalQueue() let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) - let update = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1", operationSerialTimestamp: nil) + let update = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1", operationSerialTimestamp: nil, objectsPool: ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())) // Verify new entry was created let entry = map.testsOnly_data["newKey"] @@ -1066,7 +1120,7 @@ struct InternalDefaultLiveMapTests { let internalQueue = TestFactories.createInternalQueue() let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) - _ = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1", operationSerialTimestamp: nil) + _ = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1", operationSerialTimestamp: nil, objectsPool: ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())) // Verify tombstone is true for new entry #expect(map.testsOnly_data["newKey"]?.tombstone == true) @@ -1389,8 +1443,11 @@ struct InternalDefaultLiveMapTests { @Test(arguments: [ // serial < clearTimeserial: discard (operationSerial: "ts4" as String?, clearTimeserial: "ts5", expectedApplied: false), - // serial == clearTimeserial: discard - (operationSerial: "ts5" as String?, clearTimeserial: "ts5", expectedApplied: false), + // serial == clearTimeserial: RE-APPLY. RTLM24c discards only when clearTimeserial is + // *strictly* greater than serial, so on equality the operation is applied (unlike + // RTLM7h/RTLM8g which discard on `>=`). This case previously asserted `false`, which + // encoded a cocoa bug (the gate used `serial <= clearTimeserial`); corrected to the spec. + (operationSerial: "ts5" as String?, clearTimeserial: "ts5", expectedApplied: true), // serial > clearTimeserial: allow (operationSerial: "ts6" as String?, clearTimeserial: "ts5", expectedApplied: true), // serial is nil: discard @@ -1427,7 +1484,7 @@ struct InternalDefaultLiveMapTests { } // When: applying a MAP_CLEAR operation with the specified serial - let update = map.testsOnly_applyMapClearOperation(serial: operationSerial) + let update = map.testsOnly_applyMapClearOperation(serial: operationSerial, objectsPool: pool) // Then: the operation is applied or discarded as expected #expect(update.isNoop == !expectedApplied) @@ -1469,7 +1526,7 @@ struct InternalDefaultLiveMapTests { ) // When: applying a MAP_CLEAR operation with serial "ts3" - let update = map.testsOnly_applyMapClearOperation(serial: "ts3") + let update = map.testsOnly_applyMapClearOperation(serial: "ts3", objectsPool: ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())) // Then: entries with timeserial < "ts3" or nil are removed from internal data, others remain #expect(Set(map.testsOnly_data.keys) == ["equalToClear", "newerThanClear"]) @@ -1527,7 +1584,7 @@ struct InternalDefaultLiveMapTests { objectsPool: &pool, ) } - #expect(!applied) + #expect(applied == nil) // Check that the MAP_SET side-effects didn't happen: // Verify the operation was discarded - data unchanged (should still be "existing" from creation) @@ -1568,7 +1625,7 @@ struct InternalDefaultLiveMapTests { objectsPool: &pool, ) } - #expect(applied) + #expect(applied != nil) // Verify the operation was applied - initial value merged (the full logic of RTLM16 is tested elsewhere; we just check for some of its side effects here) #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "value1") @@ -1628,7 +1685,7 @@ struct InternalDefaultLiveMapTests { objectsPool: &pool, ) } - #expect(applied) + #expect(applied != nil) // Verify the operation was applied - value updated (the full logic of RTLM7 is tested elsewhere; we just check for some of its side effects here) #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") @@ -1687,7 +1744,7 @@ struct InternalDefaultLiveMapTests { objectsPool: &pool, ) } - #expect(applied) + #expect(applied != nil) // Verify the operation was applied - key removed (the full logic of RTLM8 is tested elsewhere; we just check for some of its side effects here) #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) @@ -1746,7 +1803,7 @@ struct InternalDefaultLiveMapTests { objectsPool: &pool, ) } - #expect(applied) + #expect(applied != nil) // Verify the operation was applied (the full logic of RTLM24 is tested elsewhere; we just check for some of its side effects here) #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) @@ -1785,7 +1842,7 @@ struct InternalDefaultLiveMapTests { objectsPool: &pool, ) } - #expect(applied) + #expect(applied != nil) // Verify the operation was applied #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") @@ -1817,7 +1874,7 @@ struct InternalDefaultLiveMapTests { objectsPool: &pool, ) } - #expect(!applied) + #expect(applied == nil) // Check no update was emitted let subscriberInvocations = await subscriber.getInvocations() @@ -2015,4 +2072,306 @@ struct InternalDefaultLiveMapTests { } } } + + /// Divergence #3: the tombstone / OBJECT_DELETE / reset teardown paths must report only the + /// NON-tombstoned entries as `removed`. Per RTLO4e5 the teardown update is the RTLM22 diff + /// between the pre-teardown data and the (now cleared) data, and RTLM22b considers only + /// non-tombstoned entries. An entry that was already tombstoned was never visible to + /// subscribers, so it must not be reported as newly `removed`. (Previously these paths mapped + /// ALL data entries — including already-tombstoned ones — to `removed`, which was internally + /// inconsistent with `ObjectDiffHelpers.calculateMapDiff`.) + struct TombstoneTeardownExcludesAlreadyTombstonedEntriesTests { + private static func makeSeededMap(objectID: String, internalQueue: DispatchQueue) -> InternalDefaultLiveMap { + InternalDefaultLiveMap( + testsOnly_data: [ + "kept": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "Alice")), + // Already tombstoned before the teardown — must be excluded from the removed update. + "gone": InternalObjectsMapEntry(tombstonedAt: Date(), timeserial: "01", data: nil), + ], + objectID: objectID, + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + // @specPartial RTLO4e5 - OBJECT_DELETE teardown reports only non-tombstoned entries as removed (RTLM15d5) + @Test + func objectDeleteExcludesAlreadyTombstonedEntries() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = Self.makeSeededMap(objectID: "map:test@1000", internalQueue: internalQueue) + map.testsOnly_setSiteTimeserials(["site1": "00"]) + + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let operation = TestFactories.objectOperation(action: .known(.objectDelete), objectId: "map:test@1000", objectDelete: WireObjectDelete()) + let update = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "01", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: Date(timeIntervalSince1970: 1_700_000_000), + objectsPool: &pool, + ) + } + + #expect(map.testsOnly_isTombstone == true) + // Only the non-tombstoned "kept" key is reported as removed; already-tombstoned "gone" is excluded. + let unwrapped = try #require(update?.update) + #expect(unwrapped.update == ["kept": .removed]) + #expect(update?.tombstone == true) + } + + // @specPartial RTLO4e5 - replaceData tombstone teardown (RTLM6f) reports only non-tombstoned entries as removed + @Test + func replaceDataTombstoneExcludesAlreadyTombstonedEntries() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = Self.makeSeededMap(objectID: "map:test@1000", internalQueue: internalQueue) + + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let update = internalQueue.ably_syncNoDeadlock { + map.nosync_replaceData( + using: TestFactories.mapObjectState(objectId: "map:test@1000", tombstone: true), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + + #expect(map.testsOnly_isTombstone == true) + let unwrapped = try #require(update.update) + #expect(unwrapped.update == ["kept": .removed]) + #expect(update.tombstone == true) + } + + // @specPartial RTO4b2a - reset teardown reports only non-tombstoned entries as removed + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func resetDataExcludesAlreadyTombstonedEntries() async throws { + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = Self.makeSeededMap(objectID: "root", internalQueue: internalQueue) + + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + internalQueue.ably_syncNoDeadlock { + map.nosync_resetData() + } + + let subscriberInvocations = await subscriber.getInvocations() + // Only "kept" is reported as removed; already-tombstoned "gone" is excluded. + #expect(subscriberInvocations.map(\.0) == [.init(update: ["kept": .removed])]) + } + } + + /// Regression tests for the parent-reference *mutation* sites' self-reference guard (an + /// extension of DEV-34, whose read-path counterpart is tested in + /// `AccessPropertiesTests.selfReferencingEntryIsTreatedAsNormalEntry`). + /// + /// A wire-delivered MAP_SET (RTLM7a3/RTLM7g2), MAP_REMOVE (RTLM8a3), MAP_CLEAR (RTLM24e1c) or + /// OBJECT_DELETE (RTLO4e9) touching an entry whose `data.objectId` equals the containing map's + /// own objectID previously crashed with a Swift exclusive-access conflict: the apply path holds + /// the map's `mutableStateMutex` while `objectsPool.entries[refId]?.nosync_(add|remove)ParentReference` + /// re-enters that same mutex. These are peer-controllable inputs, so they must not crash. + /// A self-parent is a legitimate graph edge; `getFullPaths`' RTLO4f2 cycle suppression handles + /// the resulting self-loop. + struct SelfReferenceParentReferenceGuardTests { + static let selfID = "map:self@1" + + /// Creates a map with the given data whose objectID is `selfID`, registered in a pool under + /// that same ID (so that pool lookups of a self-referencing entry resolve to the map itself). + private func makeFixture( + data: [String: InternalObjectsMapEntry], + internalQueue: DispatchQueue, + ) -> (map: InternalDefaultLiveMap, pool: ObjectsPool) { + let logger = TestLogger() + let map = InternalDefaultLiveMap( + testsOnly_data: data, + objectID: Self.selfID, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + let pool = ObjectsPool( + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + testsOnly_otherEntries: [Self.selfID: .map(map)], + ) + return (map, pool) + } + + /// A MAP_SET creating a self-referencing entry records the self-parent edge (RTLM7g2) + /// without re-entering the map's mutex, and `getFullPaths` suppresses the self-loop. + @Test + func mapSetAddingSelfReferenceRecordsSelfParentEdge() throws { + let internalQueue = TestFactories.createInternalQueue() + var (map, pool) = makeFixture(data: [:], internalQueue: internalQueue) + + let operation = TestFactories.objectOperation( + action: .known(.mapSet), + mapSet: ProtocolTypes.MapSet(key: "selfRef", value: ProtocolTypes.ObjectData(objectId: Self.selfID)), + ) + let applied = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(applied != nil) + + // RTLM7g2: the self-parent edge is recorded on the map itself. + #expect(map.testsOnly_parentReferences == [Self.selfID: ["selfRef"]]) + + // RTLO4f2: the self-loop contributes no paths (and does not loop forever); with no + // reference from root, there are no full paths at all. + #expect(map.testsOnly_getFullPaths(objectsPool: pool).isEmpty) + + // With a root reference added alongside the self-loop, exactly the root path is + // returned; the self-loop is suppressed by the per-branch visited set. + map.testsOnly_setParentReferences([Self.selfID: ["selfRef"], "root": ["m"]]) + #expect(map.testsOnly_getFullPaths(objectsPool: pool) == [["m"]]) + } + + /// A MAP_SET overwriting an existing self-referencing entry drops the self-parent edge + /// (RTLM7a3) without re-entering the map's mutex. + @Test + func mapSetOverwritingSelfReferencingEntryDropsSelfParentEdge() throws { + let internalQueue = TestFactories.createInternalQueue() + var (map, pool) = makeFixture( + data: ["selfRef": TestFactories.internalMapEntry(timeserial: "ts1", data: ProtocolTypes.ObjectData(objectId: Self.selfID))], + internalQueue: internalQueue, + ) + // Seed the self-parent edge that the existing entry represents. + map.testsOnly_setParentReferences([Self.selfID: ["selfRef"]]) + + let operation = TestFactories.objectOperation( + action: .known(.mapSet), + // The overwriting value's type is unimportant; a string keeps the assertion simple. + mapSet: ProtocolTypes.MapSet(key: "selfRef", value: ProtocolTypes.ObjectData(string: "overwritten")), + ) + let applied = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts2", // greater than the entry's "ts1" so RTLM9 allows it + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(applied != nil) + + // RTLM7a3: the self-parent edge held via the overwritten entry is dropped. + #expect(map.testsOnly_parentReferences.isEmpty) + } + + /// A MAP_REMOVE of a self-referencing entry drops the self-parent edge (RTLM8a3) without + /// re-entering the map's mutex. + @Test + func mapRemoveOfSelfReferencingEntryDropsSelfParentEdge() throws { + let internalQueue = TestFactories.createInternalQueue() + var (map, pool) = makeFixture( + data: ["selfRef": TestFactories.internalMapEntry(timeserial: "ts1", data: ProtocolTypes.ObjectData(objectId: Self.selfID))], + internalQueue: internalQueue, + ) + map.testsOnly_setParentReferences([Self.selfID: ["selfRef"]]) + + let operation = TestFactories.objectOperation( + action: .known(.mapRemove), + mapRemove: WireMapRemove(key: "selfRef"), + ) + let applied = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts2", // greater than the entry's "ts1" so RTLM9 allows it + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(applied != nil) + + // RTLM8a3: the self-parent edge held via the removed entry is dropped. + #expect(map.testsOnly_parentReferences.isEmpty) + // The entry itself is tombstoned. + #expect(map.testsOnly_data["selfRef"]?.tombstone == true) + } + + /// A MAP_CLEAR of a map containing a self-referencing entry drops the self-parent edge + /// (RTLM24e1c) without re-entering the map's mutex. + @Test + func mapClearWithSelfReferencingEntryDropsSelfParentEdge() throws { + let internalQueue = TestFactories.createInternalQueue() + var (map, pool) = makeFixture( + data: ["selfRef": TestFactories.internalMapEntry(timeserial: "ts1", data: ProtocolTypes.ObjectData(objectId: Self.selfID))], + internalQueue: internalQueue, + ) + map.testsOnly_setParentReferences([Self.selfID: ["selfRef"]]) + + let operation = TestFactories.objectOperation( + action: .known(.mapClear), + mapClear: WireMapClear(), + ) + let applied = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts2", // greater than the entry's "ts1" so RTLM24e1 clears it + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(applied != nil) + + // RTLM24e1c: the self-parent edge held via the cleared entry is dropped. + #expect(map.testsOnly_parentReferences.isEmpty) + #expect(map.testsOnly_data.isEmpty) + } + + /// An OBJECT_DELETE of a map containing a self-referencing entry drops the self-parent edge + /// (RTLO4e9, via the held-parent-references teardown) without re-entering the map's mutex. + @Test + func objectDeleteWithSelfReferencingEntryDropsSelfParentEdge() throws { + let internalQueue = TestFactories.createInternalQueue() + var (map, pool) = makeFixture( + data: ["selfRef": TestFactories.internalMapEntry(timeserial: "ts1", data: ProtocolTypes.ObjectData(objectId: Self.selfID))], + internalQueue: internalQueue, + ) + map.testsOnly_setParentReferences([Self.selfID: ["selfRef"]]) + + let operation = TestFactories.objectOperation( + action: .known(.objectDelete), + objectId: Self.selfID, + objectDelete: WireObjectDelete(), + ) + let applied = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(applied != nil) + + // RTLO4e9: the self-parent edge this map held on itself is dropped, and the map is + // tombstoned. + #expect(map.testsOnly_parentReferences.isEmpty) + #expect(map.testsOnly_isTombstone) + } + } } diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 171d309c3..a59a59aad 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -55,6 +55,69 @@ struct InternalDefaultRealtimeObjectsTests { #expect(pool.entries["map:2@456"] != nil) } + // MARK: - Non-conforming channelSerial (treated as self-contained per RTO5a5) + + // A channelSerial that does not conform to the RTO5a1 `:` shape + // (here, no colon separator) is treated the same as an absent channelSerial (RTO5a5): the + // sync data is taken to be entirely contained within this single OBJECT_SYNC, so the sync + // completes immediately rather than the whole OBJECT_SYNC being dropped. + // + // The specification does not define behaviour for a non-conforming channelSerial; this + // matches ably-java, whose `^([\w-]+):(.*)$` regex fails to match and falls back to the + // "sync data contained in this message" path. + @Test + func treatsChannelSerialWithoutColonAsSelfContainedSync() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let objectMessages = [ + TestFactories.simpleMapMessage(objectId: "map:1@123"), + TestFactories.simpleMapMessage(objectId: "map:2@456"), + ] + + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + + // A channelSerial with no colon separator does not conform to RTO5a1. + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: "sequence123", + ) + } + + // The sync completed in one shot (no sync sequence remains) rather than being dropped. + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + + // The objects were applied to the pool, proving the OBJECT_SYNC was not aborted. + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["map:1@123"] != nil) + #expect(pool.entries["map:2@456"] != nil) + } + + // As above, an empty sequence id (":cursor") does not conform to RTO5a1 and is treated as a + // self-contained OBJECT_SYNC (RTO5a5). This is a change from the previous cocoa behaviour, + // which accepted an empty sequence id and would have kept an in-flight sync sequence open. + @Test + func treatsChannelSerialWithEmptySequenceIDAsSelfContainedSync() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let objectMessages = [ + TestFactories.simpleMapMessage(objectId: "map:1@123"), + ] + + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: ":cursor456", + ) + } + + // The sync completed immediately (self-contained), leaving no in-flight sync sequence. + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["map:1@123"] != nil) + } + // MARK: - RTO5a1, RTO5a3, RTO5a4: Multi-ProtocolMessage Sync Tests // @spec RTO5a1 @@ -213,64 +276,16 @@ struct InternalDefaultRealtimeObjectsTests { #expect(realtimeObjects.testsOnly_appliedOnAckSerials.isEmpty) } - // MARK: - Error Handling Tests - - /// Test handling of invalid channelSerial format - @Test - func handlesInvalidChannelSerialFormat() async throws { - let internalQueue = TestFactories.createInternalQueue() - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) - let objectMessages = [TestFactories.mapObjectMessage(objectId: "map:1@123")] - - // Call with invalid channelSerial (missing colon) - internalQueue.ably_syncNoDeadlock { - realtimeObjects.nosync_handleObjectSyncProtocolMessage( - objectMessages: objectMessages, - protocolMessageChannelSerial: "invalid_format_no_colon", - ) - } - - // Verify no sync sequence was created due to parsing error - #expect(!realtimeObjects.testsOnly_hasSyncSequence) - - // Verify objects were not applied to pool - let pool = realtimeObjects.testsOnly_objectsPool - #expect(pool.entries["map:1@123"] == nil) - } + // Note: the previous `handlesInvalidChannelSerialFormat` (missing colon) and + // `handlesEmptySequenceId` (":cursor1" then ":") tests asserted the old behaviour, in which a + // non-conforming channelSerial aborted the OBJECT_SYNC or opened an empty-id sync sequence. + // That behaviour has changed — a non-conforming channelSerial is now treated as a + // self-contained OBJECT_SYNC per RTO5a5 — so those scenarios are now covered by + // `treatsChannelSerialWithoutColonAsSelfContainedSync` and + // `treatsChannelSerialWithEmptySequenceIDAsSelfContainedSync` above, and by `SyncCursorTests`. // MARK: - Edge Cases - /// Test with empty sequence ID - @Test - func handlesEmptySequenceId() async throws { - let internalQueue = TestFactories.createInternalQueue() - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) - let objectMessages = [TestFactories.mapObjectMessage(objectId: "map:1@123")] - - // Start sequence with empty sequence ID - internalQueue.ably_syncNoDeadlock { - realtimeObjects.nosync_handleObjectSyncProtocolMessage( - objectMessages: objectMessages, - protocolMessageChannelSerial: ":cursor1", - ) - } - - #expect(realtimeObjects.testsOnly_hasSyncSequence) - - // End sequence with empty sequence ID - internalQueue.ably_syncNoDeadlock { - realtimeObjects.nosync_handleObjectSyncProtocolMessage( - objectMessages: [], - protocolMessageChannelSerial: ":", - ) - } - - // Verify sequence completed successfully - #expect(!realtimeObjects.testsOnly_hasSyncSequence) - let pool = realtimeObjects.testsOnly_objectsPool - #expect(pool.entries["map:1@123"] != nil) - } - /// Test mixed object types in single sync @Test func handlesMixedObjectTypesInSync() async throws { @@ -445,7 +460,8 @@ struct InternalDefaultRealtimeObjectsTests { // Give root a non-nil clearTimeserial so we can verify it gets nilled out per RTLM4 // (using a serial before the entry timeserials so entries survive) - _ = originalPool.root.testsOnly_applyMapClearOperation(serial: "aaa") + let originalRoot = originalPool.root + _ = originalRoot.testsOnly_applyMapClearOperation(serial: "aaa", objectsPool: originalPool) #expect(originalPool.root.testsOnly_clearTimeserial == "aaa") let rootSubscriber = Subscriber(callbackQueue: .main) @@ -2088,4 +2104,179 @@ struct InternalDefaultRealtimeObjectsTests { #expect(error.statusCode == 400) } } + + /// Tests for `InternalDefaultRealtimeObjects.nosync_onChannelStateChanged`, covering the RTO27 + /// channel-state data lifecycle. + struct ChannelStateChangeTests { + /// Seeds `realtimeObjects` with a populated pool: `root` referencing a non-empty nested map + /// (`map:child@1` with key `k` → `"v"`) and a counter (`counter:child@2` == 42). Uses a + /// self-contained OBJECT_SYNC so the sync completes immediately (state becomes SYNCED). + private static func seedPopulatedPool( + _ realtimeObjects: InternalDefaultRealtimeObjects, + internalQueue: DispatchQueue, + ) { + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "root", entries: [ + "childMap": TestFactories.objectReferenceMapEntry(key: "childMap", objectId: "map:child@1").entry, + "childCounter": TestFactories.objectReferenceMapEntry(key: "childCounter", objectId: "counter:child@2").entry, + ]), + TestFactories.mapObjectMessage(objectId: "map:child@1", entries: [ + "k": TestFactories.stringMapEntry(key: "k", value: "v").entry, + ]), + TestFactories.counterObjectMessage(objectId: "counter:child@2", count: 42), + ], + protocolMessageChannelSerial: nil, // self-contained sync (RTO5a5) — completes immediately + ) + } + } + + // @spec RTO27a1 + // @spec RTO27a2 + @Test(arguments: [ + _AblyPluginSupportPrivate.RealtimeChannelState.detached, + _AblyPluginSupportPrivate.RealtimeChannelState.failed, + ]) + func detachedOrFailedClearsObjectsData(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + Self.seedPopulatedPool(realtimeObjects, internalQueue: internalQueue) + + // Precondition: pool is populated. + let pool = realtimeObjects.testsOnly_objectsPool + #expect(Set(pool.root.testsOnly_data.keys) == ["childMap", "childCounter"]) + let childMap = try #require(pool.entries["map:child@1"]?.mapValue) + let childCounter = try #require(pool.entries["counter:child@2"]?.counterValue) + #expect(!childMap.testsOnly_data.isEmpty) + #expect(childCounter.testsOnly_data == 42) + + // When the channel enters DETACHED / FAILED. + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelStateChanged(toState: channelState, reason: nil) + } + + // RTO27a1: every object's data is cleared to its zero value, but the objects remain in + // the pool (only their data is cleared). + let poolAfter = realtimeObjects.testsOnly_objectsPool + #expect(poolAfter.entries.count == 3) // root + 2 children still present + #expect(poolAfter.root.testsOnly_data.isEmpty) + #expect(try #require(poolAfter.entries["map:child@1"]?.mapValue).testsOnly_data.isEmpty) + #expect(try #require(poolAfter.entries["counter:child@2"]?.counterValue).testsOnly_data == 0) + // Root remains the same instance (data cleared in place). + #expect(poolAfter.root as AnyObject === pool.root as AnyObject) + } + + // @spec RTO27b + @Test + func suspendedRetainsObjectsData() throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + Self.seedPopulatedPool(realtimeObjects, internalQueue: internalQueue) + + // When the channel enters SUSPENDED. + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelStateChanged(toState: .suspended, reason: nil) + } + + // RTO27b: the stored objects data is retained unchanged. + let poolAfter = realtimeObjects.testsOnly_objectsPool + #expect(Set(poolAfter.root.testsOnly_data.keys) == ["childMap", "childCounter"]) + #expect(try !(#require(poolAfter.entries["map:child@1"]?.mapValue).testsOnly_data.isEmpty)) + #expect(try #require(poolAfter.entries["counter:child@2"]?.counterValue).testsOnly_data == 42) + } + + // RTO27a1 requires the clear to emit no LiveObjectUpdate events. + // @specPartial RTO27a1 - asserts the "without emitting any LiveObjectUpdate events" clause + @Test + func clearOnDetachedEmitsNoUpdateEvents() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + Self.seedPopulatedPool(realtimeObjects, internalQueue: internalQueue) + + let pool = realtimeObjects.testsOnly_objectsPool + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Subscribe to root, the nested map and the counter. + let rootSubscriber = Subscriber(callbackQueue: .main) + let mapSubscriber = Subscriber(callbackQueue: .main) + let counterSubscriber = Subscriber(callbackQueue: .main) + let childMap = try #require(pool.entries["map:child@1"]?.mapValue) + let childCounter = try #require(pool.entries["counter:child@2"]?.counterValue) + try pool.root.subscribe(listener: rootSubscriber.createListener(), coreSDK: coreSDK) + try childMap.subscribe(listener: mapSubscriber.createListener(), coreSDK: coreSDK) + try childCounter.subscribe(listener: counterSubscriber.createListener(), coreSDK: coreSDK) + + // When the channel enters DETACHED. + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelStateChanged(toState: .detached, reason: nil) + } + + // RTO27a1: no update events are emitted by the clear. + #expect(await rootSubscriber.getInvocations().isEmpty) + #expect(await mapSubscriber.getInvocations().isEmpty) + #expect(await counterSubscriber.getInvocations().isEmpty) + } + + // RTO27a2: a partial multi-ProtocolMessage sync sequence's SyncObjectsPool is discarded. + // @specPartial RTO27a2 - asserts the in-progress SyncObjectsPool is cleared + @Test + func detachedClearsInProgressSyncObjectsPool() { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + + // Start (but do not finish) a multi-ProtocolMessage sync sequence, so a SyncObjectsPool + // is accumulating. + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:sync@1"), + ], + protocolMessageChannelSerial: "seq1:cursor1", // non-terminal cursor — sync stays in progress + ) + } + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // When the channel enters DETACHED. + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelStateChanged(toState: .detached, reason: nil) + } + + // RTO27a2: the accumulated SyncObjectsPool (the stored sync sequence) is cleared. + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + } + + // Post-clear, a re-attach + OBJECT_SYNC repopulates the pool (RTO27a data loss is recoverable). + @Test + func reattachAndSyncRepopulatesAfterClear() throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + Self.seedPopulatedPool(realtimeObjects, internalQueue: internalQueue) + + // Clear via DETACHED. + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelStateChanged(toState: .detached, reason: nil) + } + #expect(realtimeObjects.testsOnly_objectsPool.root.testsOnly_data.isEmpty) + + // Re-attach with objects, then complete a fresh OBJECT_SYNC. + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "root", entries: [ + "childCounter": TestFactories.objectReferenceMapEntry(key: "childCounter", objectId: "counter:child@2").entry, + ]), + TestFactories.counterObjectMessage(objectId: "counter:child@2", count: 99), + ], + protocolMessageChannelSerial: nil, + ) + } + + // The pool is repopulated from the fresh sync. + let poolAfter = realtimeObjects.testsOnly_objectsPool + #expect(Set(poolAfter.root.testsOnly_data.keys) == ["childCounter"]) + #expect(try #require(poolAfter.entries["counter:child@2"]?.counterValue).testsOnly_data == 99) + } + } } diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift index 7320bf7b3..ea6c5f9a6 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift @@ -9,10 +9,21 @@ final class MockRealtimeObjects: InternalRealtimeObjectsProtocol { private let mutex = NSLock() private nonisolated(unsafe) var _publishAndApplyHandler: (([ProtocolTypes.OutboundObjectMessage]) -> Result)? + /// A real (unused-in-dispatch) register so the type conforms to `InternalRealtimeObjectsProtocol`. + /// Tests that exercise path-subscription dispatch use the real `InternalDefaultRealtimeObjects`. + private let _pathObjectSubscriptionRegister = PathObjectSubscriptionRegister( + internalQueue: DispatchQueue(label: "MockRealtimeObjects.internal"), + userCallbackQueue: DispatchQueue(label: "MockRealtimeObjects.userCallback"), + ) + init(objectsPoolDelegate: MockLiveMapObjectsPoolDelegate? = nil) { self.objectsPoolDelegate = objectsPoolDelegate } + var nosync_pathObjectSubscriptionRegister: PathObjectSubscriptionRegister { + _pathObjectSubscriptionRegister + } + var nosync_objectsPool: ObjectsPool { guard let objectsPoolDelegate else { preconditionFailure("MockRealtimeObjects was not initialised with an objectsPoolDelegate") diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift index f0104f8b7..fa1f647eb 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift @@ -5,7 +5,7 @@ import Testing struct ObjectDiffHelpersTests { /// Tests for the `calculateCounterDiff` method, covering RTLC14 specification points struct CalculateCounterDiffTests { - // @spec RTLC14b + // @specOneOf(1/2) RTLC14b @Test func calculatesDifference() { let update = ObjectDiffHelpers.calculateCounterDiff( @@ -14,6 +14,17 @@ struct ObjectDiffHelpersTests { ) #expect(update.update?.amount == 5.0) } + + // @specOneOf(2/2) RTLC14b - a zero delta (unchanged value) is collapsed to a no-op update + // (permitted by RTLO4b4b) so no spurious subscriber callback fires. Matches ably-java/js. + @Test + func zeroDeltaIsNoop() { + let update = ObjectDiffHelpers.calculateCounterDiff( + previousData: 10.0, + newData: 10.0, + ) + #expect(update.isNoop) + } } /// Tests for the `calculateMapDiff` method, covering RTLM22 specification points @@ -91,7 +102,9 @@ struct ObjectDiffHelpersTests { newData: newData, ) - #expect(update.update?.update.isEmpty == true) + // An empty diff (nothing changed) is collapsed to a no-op update (permitted by + // RTLO4b4b) rather than an empty non-noop update. Matches ably-java/js. + #expect(update.isNoop) } // @specOneOf(1/3) RTLM22b - Ignores tombstoned entries in previousData @@ -110,9 +123,9 @@ struct ObjectDiffHelpersTests { newData: newData, ) - // key1 was tombstoned in previousData, so it's not considered "removed" - #expect(update.update?.update["key1"] == nil) - #expect(update.update?.update.isEmpty == true) + // key1 was tombstoned in previousData, so it's not considered "removed"; the diff is + // therefore empty and collapses to a no-op (RTLO4b4b). + #expect(update.isNoop) } // @specOneOf(2/3) RTLM22b - Ignores tombstoned entries in newData @@ -149,8 +162,8 @@ struct ObjectDiffHelpersTests { newData: newData, ) - // Both tombstoned, so no change - #expect(update.update?.update.isEmpty == true) + // Both tombstoned, so no change; the empty diff collapses to a no-op (RTLO4b4b). + #expect(update.isNoop) } // Test combined changes diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 53ad9f58f..1d31bb7d3 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -1,26 +1,8 @@ import _AblyPluginSupportPrivate @testable import AblyLiveObjects +import Foundation import Testing -private extension SyncObjectsPool { - /// Test-only convenience to create a `SyncObjectsPool` from an array of `(state, serialTimestamp)` pairs, - /// wrapping each in an `InboundObjectMessage` and calling `accumulate`. - static func testsOnly_fromStates( - _ states: [(state: ProtocolTypes.ObjectState, serialTimestamp: Date?)], - logger: AblyLiveObjects.Logger = TestLogger(), - ) -> SyncObjectsPool { - var pool = SyncObjectsPool() - let messages = states.map { pair in - TestFactories.inboundObjectMessage( - object: pair.state, - serialTimestamp: pair.serialTimestamp, - ) - } - pool.accumulate(messages, logger: logger) - return pool - } -} - struct ObjectsPoolTests { /// Tests for the `createZeroValueObject` method, covering RTO6 specification points struct CreateZeroValueObjectTests { @@ -400,4 +382,42 @@ struct ObjectsPoolTests { #expect(pool.entries["map:toremove@1"] == nil) } } + + /// Tests for `nosync_performGarbageCollection`, covering RTO10c specification points. + struct GarbageCollectionTests { + // @spec RTO10c1b1 - the root object must never be removed by GC (RTO3b), even if it somehow + // became tombstoned past the grace period. Root can never actually be tombstoned per RTLO4e10, + // so this exercises the belt-and-braces safeguard by forcing that otherwise-unreachable state, + // alongside a non-root object that is correctly swept. + @Test + func rootObjectIsNeverGarbageCollectedEvenWhenTombstoned() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let clock = MockSimpleClock() + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: clock) + + // Add a non-root object so the sweep is observable. + let childID = "map:child@1" + internalQueue.ably_syncNoDeadlock { + _ = pool.createZeroValueObject(forObjectID: childID, logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: clock) + } + + // Force both root and the child into a past-grace tombstoned state (root's state is + // unreachable in production per RTLO4e10; we set it directly to test the safeguard). + let tombstonedAt = clock.now.addingTimeInterval(-3600) + pool.entries[ObjectsPool.rootKey]?.mapValue?.testsOnly_setTombstonedAt(tombstonedAt) + pool.entries[childID]?.mapValue?.testsOnly_setTombstonedAt(tombstonedAt) + + var continuation: AsyncStream.Continuation! + _ = AsyncStream { continuation = $0 } + + internalQueue.ably_syncNoDeadlock { + pool.nosync_performGarbageCollection(gracePeriod: 0, clock: clock, logger: logger, eventsContinuation: continuation) + } + + // The non-root child is swept; the root survives per RTO10c1b1 / RTO3b. + #expect(pool.entries[childID] == nil) + #expect(pool.entries[ObjectsPool.rootKey] != nil) + } + } } diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/ParentReferencesTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/ParentReferencesTests.swift new file mode 100644 index 000000000..602c118c5 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/ParentReferencesTests.swift @@ -0,0 +1,203 @@ +@testable import AblyLiveObjects +import Foundation +import Testing + +/// Native smoke tests for the parent-reference graph (RTLO3f, RTLO4g, RTLO4h, RTLO4f) and the +/// RTO5c10 post-sync rebuild. The full UTS port of `objects/unit/parent_references.md` is a +/// separate follow-up; these cover the core behaviours end-to-end. +struct ParentReferencesTests { + private static func makeCounter(objectID: String, internalQueue: DispatchQueue) -> InternalDefaultLiveCounter { + InternalDefaultLiveCounter.createZeroValued( + objectID: objectID, + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + private static func makeMap(objectID: String, data: [String: InternalObjectsMapEntry] = [:], internalQueue: DispatchQueue) -> InternalDefaultLiveMap { + InternalDefaultLiveMap( + testsOnly_data: data, + objectID: objectID, + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + // RTLO4g1, RTLO4g2: addParentReference creates a new entry for the first reference and adds + // further keys (and further parents) to the tracking map. + @Test + func addParentReferenceCreatesAndExtendsEntries() { + let internalQueue = TestFactories.createInternalQueue() + let child = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + + internalQueue.ably_syncNoDeadlock { + child.nosync_addParentReference(parentObjectID: "map:a@1000", key: "x") + // RTLO4g1: adding a second key to the same parent extends the existing set + child.nosync_addParentReference(parentObjectID: "map:a@1000", key: "y") + // RTLO4g: a different parent gets its own entry + child.nosync_addParentReference(parentObjectID: "map:b@1000", key: "p") + } + + #expect(child.testsOnly_parentReferences == [ + "map:a@1000": ["x", "y"], + "map:b@1000": ["p"], + ]) + } + + // RTLO4h1, RTLO4h2, RTLO4h3: removeParentReference drops a key, removes the entry when its set + // becomes empty, and no-ops for an absent parent/key. + @Test + func removeParentReferenceDropsKeyAndEntry() { + let internalQueue = TestFactories.createInternalQueue() + let child = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + child.testsOnly_setParentReferences(["map:parent@1000": ["score", "points"]]) + + internalQueue.ably_syncNoDeadlock { + // RTLO4h2: removes the key but leaves the others + child.nosync_removeParentReference(parentObjectID: "map:parent@1000", key: "score") + } + #expect(child.testsOnly_parentReferences == ["map:parent@1000": ["points"]]) + + internalQueue.ably_syncNoDeadlock { + // RTLO4h1: absent parent is a no-op + child.nosync_removeParentReference(parentObjectID: "map:other@1000", key: "points") + // RTLO4h3: removing the last key removes the entry entirely + child.nosync_removeParentReference(parentObjectID: "map:parent@1000", key: "points") + } + #expect(child.testsOnly_parentReferences.isEmpty) + } + + // RTO5c10a: clearParentReferences resets the map to empty. + @Test + func clearParentReferences() { + let internalQueue = TestFactories.createInternalQueue() + let child = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + child.testsOnly_setParentReferences(["map:parent@1000": ["score"]]) + + internalQueue.ably_syncNoDeadlock { + child.nosync_clearParentReferences() + } + + #expect(child.testsOnly_parentReferences.isEmpty) + } + + // RTLO4f2: root yields the empty key-path; an orphan yields no paths; a direct child of root + // yields its single key-path. + @Test + func getFullPathsForRootOrphanAndDirectChild() { + let internalQueue = TestFactories.createInternalQueue() + var pool = ObjectsPool(logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let counter = Self.makeCounter(objectID: "counter:score@1000", internalQueue: internalQueue) + let orphan = Self.makeCounter(objectID: "counter:orphan@1000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.counter(counter), forObjectID: "counter:score@1000") + pool.testsOnly_setEntry(.counter(orphan), forObjectID: "counter:orphan@1000") + + internalQueue.ably_syncNoDeadlock { + counter.nosync_addParentReference(parentObjectID: ObjectsPool.rootKey, key: "score") + } + + // RTLO4f2: root maps to the empty key-path + #expect(pool.root.testsOnly_getFullPaths(objectsPool: pool) == [[]]) + // RTLO4f: direct child of root + #expect(counter.testsOnly_getFullPaths(objectsPool: pool) == [["score"]]) + // RTLO4f: orphan (not reachable from root) + #expect(orphan.testsOnly_getFullPaths(objectsPool: pool).isEmpty) + } + + // RTLO4f2, RTLO4f3, RTLO4f4: multi-level nesting, diamond (multiple parents), and cycle + // suppression all produce the expected distinct simple paths. + @Test + func getFullPathsForDiamondAndCycle() { + let internalQueue = TestFactories.createInternalQueue() + var pool = ObjectsPool(logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Diamond + deep nesting: root --left--> map:l --mid--> map:m --target--> counter:t + // root --right--> map:r --target--> counter:t + let mapL = Self.makeMap(objectID: "map:l@1000", internalQueue: internalQueue) + let mapR = Self.makeMap(objectID: "map:r@1000", internalQueue: internalQueue) + let mapM = Self.makeMap(objectID: "map:m@1000", internalQueue: internalQueue) + let target = Self.makeCounter(objectID: "counter:t@1000", internalQueue: internalQueue) + for (id, entry): (String, ObjectsPool.Entry) in [ + ("map:l@1000", .map(mapL)), + ("map:r@1000", .map(mapR)), + ("map:m@1000", .map(mapM)), + ("counter:t@1000", .counter(target)), + ] { + pool.testsOnly_setEntry(entry, forObjectID: id) + } + + internalQueue.ably_syncNoDeadlock { + mapL.nosync_addParentReference(parentObjectID: ObjectsPool.rootKey, key: "left") + mapR.nosync_addParentReference(parentObjectID: ObjectsPool.rootKey, key: "right") + mapM.nosync_addParentReference(parentObjectID: "map:l@1000", key: "mid") + target.nosync_addParentReference(parentObjectID: "map:m@1000", key: "target") + target.nosync_addParentReference(parentObjectID: "map:r@1000", key: "target") + } + + let paths = target.testsOnly_getFullPaths(objectsPool: pool) + #expect(paths.count == 2) + #expect(paths.contains(["left", "mid", "target"])) + #expect(paths.contains(["right", "target"])) + + // Cycle suppression: add map:l as a parent of map:m via map:m, then create a back-edge + // map:l -> map:m so that the graph contains a cycle; getFullPaths must still terminate. + internalQueue.ably_syncNoDeadlock { + mapL.nosync_addParentReference(parentObjectID: "map:m@1000", key: "loop") + } + // map:l is reachable from root at ["left"]; the cycle back through map:m is suppressed. + let mapLPaths = mapL.testsOnly_getFullPaths(objectsPool: pool) + #expect(mapLPaths.contains(["left"])) + #expect(!mapLPaths.isEmpty) + } + + // RTO5c10, RTO5c10a, RTO5c10b: the rebuild resets every object's parentReferences and re-adds + // them from every map's non-tombstoned object-valued entries. + @Test + func postSyncRebuildPopulatesParentReferences() { + let internalQueue = TestFactories.createInternalQueue() + var pool = ObjectsPool(logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // root --score--> counter:score, root --profile--> map:profile --nested--> counter:nested + let rootMap = Self.makeMap( + objectID: ObjectsPool.rootKey, + data: [ + "score": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:score@1000")), + "profile": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "map:profile@1000")), + ], + internalQueue: internalQueue, + ) + let scoreCounter = Self.makeCounter(objectID: "counter:score@1000", internalQueue: internalQueue) + let profileMap = Self.makeMap( + objectID: "map:profile@1000", + data: ["nested": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:nested@1000"))], + internalQueue: internalQueue, + ) + let nestedCounter = Self.makeCounter(objectID: "counter:nested@1000", internalQueue: internalQueue) + + // Seed a stale reference to prove RTO5c10a clears it before the rebuild. + scoreCounter.testsOnly_setParentReferences(["map:stale@1000": ["old"]]) + + pool.testsOnly_setEntry(.map(rootMap), forObjectID: ObjectsPool.rootKey) + pool.testsOnly_setEntry(.counter(scoreCounter), forObjectID: "counter:score@1000") + pool.testsOnly_setEntry(.map(profileMap), forObjectID: "map:profile@1000") + pool.testsOnly_setEntry(.counter(nestedCounter), forObjectID: "counter:nested@1000") + + internalQueue.ably_syncNoDeadlock { + pool.nosync_rebuildParentReferences() + } + + #expect(scoreCounter.testsOnly_parentReferences == [ObjectsPool.rootKey: ["score"]]) + #expect(profileMap.testsOnly_parentReferences == [ObjectsPool.rootKey: ["profile"]]) + #expect(nestedCounter.testsOnly_parentReferences == ["map:profile@1000": ["nested"]]) + // The root has no parents; its stale-free state is preserved. + #expect(rootMap.testsOnly_parentReferences.isEmpty) + + // getFullPaths works after the rebuild. + #expect(nestedCounter.testsOnly_getFullPaths(objectsPool: pool).contains(["profile", "nested"])) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/PathObjectSubscriptionTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/PathObjectSubscriptionTests.swift new file mode 100644 index 000000000..a4a982feb --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/PathObjectSubscriptionTests.swift @@ -0,0 +1,451 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// Tests for the Phase 4 (part 2) path-subscription layer: ``PathObjectSubscriptionRegister``, its +/// depth-windowed coverage ranking (RTO24), and the `notifyPathSubscriptions` fan-out wired into the +/// inbound-operation apply path (RTLO4b4c3b -> RTO24b). +/// +/// Unlike the part-1 resolution/read tests (which drive a lightweight `SeededRealtimeObjects` +/// double), these must drive the **real** ``InternalDefaultRealtimeObjects``, since path dispatch is +/// owned by the engine's apply path. Objects are seeded into the engine's owned pool via +/// `testsOnly_setPoolEntry`, the parent-reference graph via each object's `testsOnly_setParentReferences`, +/// and updates are driven via `testsOnly_applyObjectMessages` (the gated RTO9 apply path). +/// +/// Subscriber callbacks are emitted on the engine's `userCallbackQueue` (off any mutex — issue #120). +/// Each test drains that serial queue with a `sync {}` barrier before asserting. +struct PathObjectSubscriptionTests { + private static let channelName = "test-channel" + + // MARK: - Fixture + + private struct Fixture { + let engine: InternalDefaultRealtimeObjects + let coreSDK: MockCoreSDK + let internalQueue: DispatchQueue + let userCallbackQueue: DispatchQueue + } + + /// Thread-safe collector for the events delivered to a path subscription. + private final class EventCollector: @unchecked Sendable { + private let lock = NSLock() + private var storedEvents: [PathObjectSubscriptionEvent] = [] + + func record(_ event: PathObjectSubscriptionEvent) { + lock.withLock { storedEvents.append(event) } + } + + var events: [PathObjectSubscriptionEvent] { + lock.withLock { storedEvents } + } + + /// The `.path` of every delivered event, sorted (delivery order across paths is unspecified). + var sortedPaths: [String] { + events.map(\.object.path).sorted() + } + } + + private static func makeFixture(channelState: _AblyPluginSupportPrivate.RealtimeChannelState = .attached) -> Fixture { + let internalQueue = TestFactories.createInternalQueue() + // A dedicated serial queue (not `.main`) so tests can drain it with a `sync {}` barrier. + let userCallbackQueue = DispatchQueue(label: "PathObjectSubscriptionTests.userCallback") + let coreSDK = MockCoreSDK(channelState: channelState, internalQueue: internalQueue) + let engine = InternalDefaultRealtimeObjects( + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: MockSimpleClock(), + channelName: channelName, + ) + return Fixture(engine: engine, coreSDK: coreSDK, internalQueue: internalQueue, userCallbackQueue: userCallbackQueue) + } + + /// A ``DefaultLiveMapPathObject`` rooted at the channel's root map (empty path), backed by the + /// real engine. + private static func rootPath(_ fixture: Fixture) -> DefaultLiveMapPathObject { + DefaultLiveMapPathObject(channelObject: fixture.engine, coreSDK: fixture.coreSDK, internalQueue: fixture.internalQueue, path: "") + } + + private static func makeCounter(objectID: String, _ fixture: Fixture) -> InternalDefaultLiveCounter { + InternalDefaultLiveCounter.createZeroValued( + objectID: objectID, + logger: TestLogger(), + internalQueue: fixture.internalQueue, + userCallbackQueue: fixture.userCallbackQueue, + clock: MockSimpleClock(), + ) + } + + private static func makeMap(objectID: String, data: [String: InternalObjectsMapEntry] = [:], _ fixture: Fixture) -> InternalDefaultLiveMap { + InternalDefaultLiveMap( + testsOnly_data: data, + objectID: objectID, + logger: TestLogger(), + internalQueue: fixture.internalQueue, + userCallbackQueue: fixture.userCallbackQueue, + clock: MockSimpleClock(), + ) + } + + /// Applies inbound operation messages through the gated engine apply path, then drains the user + /// callback queue so any dispatched listener has run before the caller asserts. + private static func applyAndDrain(_ messages: [ProtocolTypes.InboundObjectMessage], _ fixture: Fixture) { + fixture.engine.testsOnly_applyObjectMessages(messages, source: .channel) + fixture.userCallbackQueue.sync {} + } + + /// Applies a self-contained `OBJECT_SYNC` (nil channelSerial, RTO5a5) through the engine, then + /// drains the user callback queue so any dispatched listener has run before the caller asserts. + private static func syncAndDrain(_ messages: [ProtocolTypes.InboundObjectMessage], _ fixture: Fixture) { + fixture.internalQueue.ably_syncNoDeadlock { + fixture.engine.nosync_handleObjectSyncProtocolMessage( + objectMessages: messages, + protocolMessageChannelSerial: nil, + ) + } + fixture.userCallbackQueue.sync {} + } + + // MARK: - Basic delivery (RTPO19, RTO24b) + + // @spec RTO24b2a2 - a MAP_SET on the root notifies a subscription at the changed key's deeper path + // @spec RTPO19e1 - the event's object points at the path where the change occurred + // @spec RTPO19e2 - the event carries the source object message + @Test + func subscribeReceivesOnChangedKeyPathWithObjectAndMessage() throws { + let fixture = Self.makeFixture() + let collector = EventCollector() + + // Subscribe at "k"; a MAP_SET of root["k"] surfaces as the deeper candidate root-path + "k". + let subscription = try Self.rootPath(fixture).get(key: "k").subscribe { collector.record($0) } + defer { subscription.unsubscribe() } + + let message = TestFactories.mapSetOperationMessage(objectId: ObjectsPool.rootKey, key: "k", value: "v", serial: "ts1", siteCode: "site1") + Self.applyAndDrain([message], fixture) + + #expect(collector.events.count == 1) + let event = try #require(collector.events.first) + // RTPO19e1: object at the changed path, and it resolves to the just-set value. + #expect(event.object.path == "k") + #expect(try event.object.type() == .string) + // RTPO19e2: the PAOM3 public message that triggered the event. + #expect(event.message == message.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @spec RTO24b2a1 - a subscription at the updated object's own path is the most-preferred candidate + @Test + func subscribeOnRootReceivesObjectOwnPath() throws { + let fixture = Self.makeFixture() + let collector = EventCollector() + + // Subscribe at the root itself (empty path). The MAP_SET updates the root object, whose own + // path ([]) is the most-preferred candidate, so the chosen path is the empty root path. + let subscription = try Self.rootPath(fixture).subscribe { collector.record($0) } + defer { subscription.unsubscribe() } + + Self.applyAndDrain([TestFactories.mapSetOperationMessage(objectId: ObjectsPool.rootKey, key: "k", value: "v", serial: "ts1", siteCode: "site1")], fixture) + + #expect(collector.events.count == 1) + #expect(collector.events.first?.object.path.isEmpty == true) + } + + // MARK: - Depth-window coverage (RTO24c1) + + // @spec RTO24c1 - a subscription covers an event path iff its path is a prefix within the depth window + @Test + func depthWindowCoverageAndSiblingExclusion() throws { + let fixture = Self.makeFixture() + + // Graph: root -"p"-> map:p -"c"-> counter:c. counter:c's only full path is ["p", "c"]. + let parentMap = Self.makeMap(objectID: "map:p@1", fixture) + let counter = Self.makeCounter(objectID: "counter:c@1", fixture) + fixture.engine.testsOnly_setPoolEntry(.map(parentMap), forObjectID: "map:p@1") + fixture.engine.testsOnly_setPoolEntry(.counter(counter), forObjectID: "counter:c@1") + parentMap.testsOnly_setParentReferences([ObjectsPool.rootKey: ["p"]]) + counter.testsOnly_setParentReferences(["map:p@1": ["c"]]) + + let root = Self.rootPath(fixture) + let covered = EventCollector() // subscription at "p", depth 2 — covers ["p","c"] (relative depth 2) + let tooShallow = EventCollector() // subscription at "p", depth 1 — relative depth 2 > 1, excluded + let sibling = EventCollector() // subscription at "q" — not a prefix of ["p","c"], excluded + + let subCovered = try root.at(path: "p").subscribe(options: .init(depth: 2)) { covered.record($0) } + let subShallow = try root.at(path: "p").subscribe(options: .init(depth: 1)) { tooShallow.record($0) } + let subSibling = try root.at(path: "q").subscribe(options: .init(depth: 2)) { sibling.record($0) } + defer { + subCovered.unsubscribe() + subShallow.unsubscribe() + subSibling.unsubscribe() + } + + Self.applyAndDrain([TestFactories.counterIncOperationMessage(objectId: "counter:c@1", number: 5, serial: "ts1", siteCode: "site1")], fixture) + + #expect(covered.sortedPaths == ["p.c"]) // notified once, at the counter's full path + #expect(tooShallow.events.isEmpty) // depth window too small + #expect(sibling.events.isEmpty) // not on the path + } + + // MARK: - Unsubscribe (SUB2) + + // @spec SUB2a - unsubscribe stops further delivery + @Test + func unsubscribeStopsDelivery() throws { + let fixture = Self.makeFixture() + let collector = EventCollector() + + let subscription = try Self.rootPath(fixture).subscribe { collector.record($0) } + subscription.unsubscribe() + // SUB2b: a second unsubscribe is a harmless no-op. + subscription.unsubscribe() + + Self.applyAndDrain([TestFactories.mapSetOperationMessage(objectId: ObjectsPool.rootKey, key: "k", value: "v", serial: "ts1", siteCode: "site1")], fixture) + + #expect(collector.events.isEmpty) + } + + // MARK: - Depth validation (RTPO19c1a / DEV-9) + + // @spec RTPO19c1a - a non-positive subscription depth throws 40003 + @Test(arguments: [0, -1]) + func nonPositiveDepthThrows40003(depth: Int) throws { + let fixture = Self.makeFixture() + + do { + _ = try Self.rootPath(fixture).subscribe(options: .init(depth: depth)) { _ in } + Issue.record("Expected subscribe to throw for depth \(depth)") + } catch { + #expect(error.code == 40003) + } + } + + // A positive depth (and no options) is accepted. + @Test + func positiveAndNilDepthAccepted() throws { + let fixture = Self.makeFixture() + let root = Self.rootPath(fixture) + // Neither should throw. + try root.subscribe(options: .init(depth: 1)) { _ in }.unsubscribe() + try root.subscribe(options: nil) { _ in }.unsubscribe() + } + + // MARK: - Access-config guard (RTPO19b) + + // @specPartial RTPO19b - subscribe runs the access-API guard; the implementable subset is the + // channel-state check (a DETACHED/FAILED channel is rejected). The object-mode / echo checks need + // core accessors that the plugin API does not expose (see ChannelConfigGuards). + @Test(arguments: [_AblyPluginSupportPrivate.RealtimeChannelState.detached, .failed]) + func subscribeRejectedInUnusableChannelState(state: _AblyPluginSupportPrivate.RealtimeChannelState) throws { + let fixture = Self.makeFixture(channelState: state) + + #expect(throws: ARTErrorInfo.self) { + _ = try Self.rootPath(fixture).subscribe { _ in } + } + } + + // MARK: - Tombstone behaviour (RTLO4b4c3c1) + + // @spec RTLO4b4c3c1 - a tombstone update is delivered to path subscriptions, which are NOT torn + // down afterwards (unlike instance listeners, RTLO4b4c3c). A later update is still delivered. + @Test + func tombstoneIsDeliveredAndDoesNotTearDownPathSubscriptions() throws { + let fixture = Self.makeFixture() + let collector = EventCollector() + + // A nested counter reachable from root at "cnt". + let counter = Self.makeCounter(objectID: "counter:c@1", fixture) + fixture.engine.testsOnly_setPoolEntry(.counter(counter), forObjectID: "counter:c@1") + counter.testsOnly_setParentReferences([ObjectsPool.rootKey: ["cnt"]]) + + // Subscribe at the root with infinite depth so it covers both the delete and a later root set. + let subscription = try Self.rootPath(fixture).subscribe { collector.record($0) } + defer { subscription.unsubscribe() } + + // 1) OBJECT_DELETE tombstones the counter — a non-noop tombstone update fans out to the path sub. + Self.applyAndDrain([TestFactories.objectDeleteOperationMessage(objectId: "counter:c@1", serial: "ts1", siteCode: "site1")], fixture) + #expect(collector.events.count == 1) + #expect(collector.events.first?.object.path == "cnt") + + // 2) A subsequent, unrelated update still reaches the same subscription — proving the tombstone + // teardown (RTLO4b4c3c) did not deregister it (RTLO4b4c3c1). + Self.applyAndDrain([TestFactories.mapSetOperationMessage(objectId: ObjectsPool.rootKey, key: "k", value: "v", serial: "ts2", siteCode: "site1")], fixture) + #expect(collector.events.count == 2) + #expect(collector.events.last?.object.path.isEmpty == true) + } + + // MARK: - Multi-path (diamond) delivery via getFullPaths (RTO24b1/RTO24b2) + + // @spec RTO24b2 - one path event is dispatched per full path to the updated object + @Test + func diamondGraphDeliversOncePerPath() throws { + let fixture = Self.makeFixture() + let collector = EventCollector() + + // Diamond: root -"a"-> map:a -"x"-> counter:c, and root -"b"-> map:b -"y"-> counter:c. + // counter:c therefore has two full paths: ["a","x"] and ["b","y"]. + let mapA = Self.makeMap(objectID: "map:a@1", fixture) + let mapB = Self.makeMap(objectID: "map:b@1", fixture) + let counter = Self.makeCounter(objectID: "counter:c@1", fixture) + fixture.engine.testsOnly_setPoolEntry(.map(mapA), forObjectID: "map:a@1") + fixture.engine.testsOnly_setPoolEntry(.map(mapB), forObjectID: "map:b@1") + fixture.engine.testsOnly_setPoolEntry(.counter(counter), forObjectID: "counter:c@1") + mapA.testsOnly_setParentReferences([ObjectsPool.rootKey: ["a"]]) + mapB.testsOnly_setParentReferences([ObjectsPool.rootKey: ["b"]]) + counter.testsOnly_setParentReferences(["map:a@1": ["x"], "map:b@1": ["y"]]) + + // A single root subscription (infinite depth) covers both full paths, so it is notified twice + // — once per path event (RTO24b2). + let subscription = try Self.rootPath(fixture).subscribe { collector.record($0) } + defer { subscription.unsubscribe() } + + Self.applyAndDrain([TestFactories.counterIncOperationMessage(objectId: "counter:c@1", number: 5, serial: "ts1", siteCode: "site1")], fixture) + + #expect(collector.events.count == 2) + #expect(collector.sortedPaths == ["a.x", "b.y"]) + } + + // MARK: - Sync-originated updates (RTLO4b4c3b via OBJECT_SYNC; message nil per RTO4b2a) + + // @spec RTO4b2a - a sync-originated update carries no public message + @Test + func syncAppliedMapChangeDeliversWithNilMessage() throws { + let fixture = Self.makeFixture() + let collector = EventCollector() + + // Subscribe at "k"; the engine's root starts empty, so a sync stating root["k"] = "v" + // produces an `updated` diff for "k" on the (existing) root object. + let subscription = try Self.rootPath(fixture).get(key: "k").subscribe { collector.record($0) } + defer { subscription.unsubscribe() } + + let (key, entry) = TestFactories.stringMapEntry(key: "k", value: "v") + Self.syncAndDrain([TestFactories.rootObjectMessage(entries: [key: entry])], fixture) + + #expect(collector.events.count == 1) + let event = try #require(collector.events.first) + #expect(event.object.path == "k") + #expect(event.message == nil) // RTO4b2a + } + + // A counter changed by sync dispatches a path event at its own full path (RTO24b2a1), with nil + // message. The root is re-stated with identical data so its diff collapses to noop and only the + // counter's update dispatches. + @Test + func syncAppliedCounterChangeDeliversOnOwnPath() throws { + let fixture = Self.makeFixture() + let collector = EventCollector() + + // Seed: root -"cnt"-> counter:c (counter zero-valued). The sync re-states the same root data + // (noop diff for root) and a new counter value of 100 (non-noop counter diff). + let counter = Self.makeCounter(objectID: "counter:c@1", fixture) + fixture.engine.testsOnly_setPoolEntry(.counter(counter), forObjectID: "counter:c@1") + let rootData = ["cnt": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:c@1"))] + fixture.engine.testsOnly_setPoolEntry(.map(Self.makeMap(objectID: ObjectsPool.rootKey, data: rootData, fixture)), forObjectID: ObjectsPool.rootKey) + + let subscription = try Self.rootPath(fixture).get(key: "cnt").subscribe(options: .init(depth: 1)) { collector.record($0) } + defer { subscription.unsubscribe() } + + Self.syncAndDrain( + [ + TestFactories.rootObjectMessage(entries: ["cnt": TestFactories.mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:c@1"))]), + TestFactories.counterObjectMessage(objectId: "counter:c@1", count: 100), + ], + fixture, + ) + + #expect(collector.events.count == 1) + let event = try #require(collector.events.first) + #expect(event.object.path == "cnt") + #expect(event.message == nil) // RTO4b2a + } + + // @spec RTLO4b4c1 - a sync that changes nothing (empty diff -> noop) dispatches no path events + @Test + func noChangeSyncEmitsNothing() throws { + let fixture = Self.makeFixture() + let collector = EventCollector() + + // Seed root["k"] = "v", then sync exactly the same data — the RTLM22 diff is empty, which + // collapses to noop, so nothing is dispatched. + let rootData = ["k": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "v"))] + fixture.engine.testsOnly_setPoolEntry(.map(Self.makeMap(objectID: ObjectsPool.rootKey, data: rootData, fixture)), forObjectID: ObjectsPool.rootKey) + + // Root subscription with infinite depth — covers every possible candidate. + let subscription = try Self.rootPath(fixture).subscribe { collector.record($0) } + defer { subscription.unsubscribe() } + + let (key, entry) = TestFactories.stringMapEntry(key: "k", value: "v") + Self.syncAndDrain([TestFactories.rootObjectMessage(entries: [key: entry])], fixture) + + #expect(collector.events.isEmpty) + } + + // @spec RTO5c10 - the parent-reference rebuild runs before sync notifications, so a path event + // for an object re-parented during the sync is delivered at its NEW path + @Test + func reParentedObjectDeliversOnNewPathAfterRebuild() throws { + let fixture = Self.makeFixture() + let newPathCollector = EventCollector() + let oldPathCollector = EventCollector() + + // Seed: root -"old"-> counter:c (zero-valued). The sync moves the counter to root["new"] + // (dropping "old") and changes its value to 100. + let counter = Self.makeCounter(objectID: "counter:c@1", fixture) + fixture.engine.testsOnly_setPoolEntry(.counter(counter), forObjectID: "counter:c@1") + counter.testsOnly_setParentReferences([ObjectsPool.rootKey: ["old"]]) + let rootData = ["old": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:c@1"))] + fixture.engine.testsOnly_setPoolEntry(.map(Self.makeMap(objectID: ObjectsPool.rootKey, data: rootData, fixture)), forObjectID: ObjectsPool.rootKey) + + let root = Self.rootPath(fixture) + let subNew = try root.get(key: "new").subscribe(options: .init(depth: 1)) { newPathCollector.record($0) } + let subOld = try root.get(key: "old").subscribe(options: .init(depth: 1)) { oldPathCollector.record($0) } + defer { + subNew.unsubscribe() + subOld.unsubscribe() + } + + Self.syncAndDrain( + [ + TestFactories.rootObjectMessage(entries: ["new": TestFactories.mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:c@1"))]), + TestFactories.counterObjectMessage(objectId: "counter:c@1", count: 100), + ], + fixture, + ) + + // "new" gets two events: the root's own diff (removed "old"/updated "new" — chosen candidate + // ["new"]) and the counter's update at its REBUILT full path ["new"] (RTO5c10 before notify). + #expect(newPathCollector.events.count == 2) + #expect(newPathCollector.sortedPaths == ["new", "new"]) + #expect(newPathCollector.events.allSatisfy { $0.message == nil }) + + // "old" hears only the root's removal diff — the counter's update must NOT be delivered at + // its stale pre-sync path. + #expect(oldPathCollector.events.count == 1) + #expect(oldPathCollector.events.first?.object.path == "old") + } + + // The RTO4b reset path (ATTACHED without HAS_OBJECTS) also fans out to path subscriptions: the + // root's RTO4b2a `removed` diff is dispatched with a nil message. + @Test + func attachedWithoutObjectsResetDeliversRemovedKeys() throws { + let fixture = Self.makeFixture() + let collector = EventCollector() + + // Seed root["k"] = "v" so the reset actually removes something. + let rootData = ["k": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "v"))] + fixture.engine.testsOnly_setPoolEntry(.map(Self.makeMap(objectID: ObjectsPool.rootKey, data: rootData, fixture)), forObjectID: ObjectsPool.rootKey) + + let subscription = try Self.rootPath(fixture).get(key: "k").subscribe { collector.record($0) } + defer { subscription.unsubscribe() } + + fixture.internalQueue.ably_syncNoDeadlock { + fixture.engine.nosync_onChannelAttached(hasObjects: false) + } + fixture.userCallbackQueue.sync {} + + #expect(collector.events.count == 1) + let event = try #require(collector.events.first) + #expect(event.object.path == "k") + #expect(event.message == nil) // sync-originated (RTO4b2a) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/PublicRealtimeObjectTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/PublicRealtimeObjectTests.swift new file mode 100644 index 000000000..ed6babb23 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/PublicRealtimeObjectTests.swift @@ -0,0 +1,284 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// Tests for the Phase 5 public `RealtimeObject` surface (`PublicDefaultRealtimeObject`): `get()` +/// (RTO23), `on(event:callback:)` / `StatusSubscription.off()` (RTO18), the dispose lifecycle +/// (matrix #18) and `PublicObjectsStore` proxy identity. +/// +/// Everything is driven through a real `InternalDefaultRealtimeObjects` (the proxied engine) plus a +/// `MockCoreSDK`; sync is completed by feeding the engine an OBJECT_SYNC / ATTACHED directly. +struct PublicRealtimeObjectTests { + // MARK: - Helpers + + /// A minimal thread-safe call counter for asserting on asynchronously-delivered callbacks. + private final class CallCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + func increment() { lock.withLock { value += 1 } } + var isEmpty: Bool { lock.withLock { value == 0 } } + } + + private static func makeProxied(internalQueue: DispatchQueue) -> InternalDefaultRealtimeObjects { + InternalDefaultRealtimeObjects( + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + private static func makePublicObject( + channelState: _AblyPluginSupportPrivate.RealtimeChannelState = .attached, + ) -> (publicObject: PublicDefaultRealtimeObject, proxied: InternalDefaultRealtimeObjects, coreSDK: MockCoreSDK, internalQueue: DispatchQueue) { + let internalQueue = TestFactories.createInternalQueue() + let proxied = makeProxied(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: channelState, internalQueue: internalQueue) + let publicObject = PublicDefaultRealtimeObject(proxied: proxied, coreSDK: coreSDK, logger: TestLogger()) + return (publicObject, proxied, coreSDK, internalQueue) + } + + /// Drives the engine to `.synced` via a self-contained empty OBJECT_SYNC (RTO5a5), which + /// transitions Initialized -> Syncing -> Synced. + private static func driveToSynced(_ proxied: InternalDefaultRealtimeObjects, on internalQueue: DispatchQueue) { + internalQueue.ably_syncNoDeadlock { + proxied.nosync_handleObjectSyncProtocolMessage(objectMessages: [], protocolMessageChannelSerial: nil) + } + } + + // MARK: - get() (RTO23) + + // @spec RTO23d - get() returns a LiveMapPathObject rooted at the root map (empty path) once synced + @Test + func getReturnsRootPathObjectWhenSynced() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + Self.driveToSynced(proxied, on: internalQueue) + + let root = try await publicObject.get() + + // RTO23d / RTPO4c - the root path object has an empty (zero-segment) path. + #expect(root.path.isEmpty) + // It resolves to the channel's root map. + #expect(try root.exists()) + #expect(try root.type() == .liveMap) + } + + // @spec RTO23c - get() waits for the initial sync to complete, then resolves + @Test + func getWaitsForSyncThenResolves() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + + // Start get() before the sync completes - it should wait. + async let getTask = publicObject.get() + + // Confirm it has started waiting for sync. + _ = try #require(await proxied.testsOnly_waitingForSyncEvents.first { _ in true }) + + // Now complete the sync; the waiting get() should resolve. + Self.driveToSynced(proxied, on: internalQueue) + + let root = try await getTask + #expect(root.path.isEmpty) + } + + // @spec RTO4b - get() resolves via an ATTACHED with HAS_OBJECTS false (no-objects sync completion) + @Test + func getResolvesAfterNoObjectsAttach() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + + async let getTask = publicObject.get() + _ = try #require(await proxied.testsOnly_waitingForSyncEvents.first { _ in true }) + + // RTO4b: ATTACHED with HAS_OBJECTS false completes the sync immediately. + internalQueue.ably_syncNoDeadlock { + proxied.nosync_onChannelAttached(hasObjects: false) + } + + let root = try await getTask + #expect(root.path.isEmpty) + // A no-objects root is an empty map. + #expect(try root.size() == 0) + } + + // @spec RTO23c - get() waiting for sync fails with 92008 if the channel leaves a usable state (RTO20e1) + @Test + func getThrows92008WhenChannelFailsDuringWait() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + + async let getTask = publicObject.get() + _ = try #require(await proxied.testsOnly_waitingForSyncEvents.first { _ in true }) + + // The channel enters FAILED while get() is waiting for sync. + internalQueue.ably_syncNoDeadlock { + proxied.nosync_onChannelStateChanged(toState: .failed, reason: nil) + } + + do { + _ = try await getTask + Issue.record("Expected get() to throw when the channel fails during the sync wait") + } catch { + #expect((error as? ARTErrorInfo)?.code == 92008) + } + } + + // @spec RTL33c - get() on a FAILED channel is rejected before waiting for sync + @Test + func getOnFailedChannelThrows() async throws { + let (publicObject, _, _, _) = Self.makePublicObject(channelState: .failed) + + await #expect(throws: ARTErrorInfo.self) { + _ = try await publicObject.get() + } + } + + // MARK: - on(event:callback:) / StatusSubscription (RTO18) + + // @spec RTO18 - on(.synced) fires when the sync completes + @Test + func onSyncedFires() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + + await withCheckedContinuation { (continuation: CheckedContinuation) in + publicObject.on(event: .synced) { + continuation.resume() + } + Self.driveToSynced(proxied, on: internalQueue) + } + } + + // @spec RTO18 - on(.syncing) fires when a sync starts + @Test + func onSyncingFires() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + + await withCheckedContinuation { (continuation: CheckedContinuation) in + publicObject.on(event: .syncing) { + continuation.resume() + } + Self.driveToSynced(proxied, on: internalQueue) + } + } + + // @spec RTO18f1 - off() deregisters the status listener; it is not called for subsequent events + @Test + func offStopsStatusCallbacks() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + let counter = CallCounter() + + let subscription = publicObject.on(event: .synced) { + counter.increment() + } + // Deregister before any event is emitted. + subscription.off() + + Self.driveToSynced(proxied, on: internalQueue) + + // Allow any (erroneously) scheduled callback to be delivered on the callback queue. + try await Task.sleep(nanoseconds: 100_000_000) + #expect(counter.isEmpty) + } + + // MARK: - Dispose lifecycle (matrix #18) + + // dispose() fails a get() that is waiting for sync, but the instance remains usable for a later get() + @Test + func disposeCancelsWaitingGetAndInstanceStaysUsable() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + + // Start get() - it waits (not yet synced). + async let getTask = publicObject.get() + _ = try #require(await proxied.testsOnly_waitingForSyncEvents.first { _ in true }) + + // Dispose cancels the in-flight wait (surfacing 92008). + proxied.dispose() + do { + _ = try await getTask + Issue.record("Expected the in-flight get() to be cancelled by dispose()") + } catch { + #expect((error as? ARTErrorInfo)?.code == 92008) + } + + // The instance stays usable: complete a sync and a fresh get() resolves. + Self.driveToSynced(proxied, on: internalQueue) + let root = try await publicObject.get() + #expect(root.path.isEmpty) + } + + // dispose() drops status subscriptions; a later on()/off() still works (instance usable) + @Test + func disposeDropsStatusSubscriptionsButOnStillWorks() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + let preDisposeCounter = CallCounter() + + publicObject.on(event: .synced) { preDisposeCounter.increment() } + + // Dispose drops the existing subscription. + proxied.dispose() + + // A subscription registered after dispose still receives events. + try await withCheckedContinuation { (continuation: CheckedContinuation) in + publicObject.on(event: .synced) { + continuation.resume() + } + Self.driveToSynced(proxied, on: internalQueue) + } + + // The pre-dispose subscription was dropped and never fired. + #expect(preDisposeCounter.isEmpty) + } + + // MARK: - PublicObjectsStore proxy identity + + // The store returns the same public object across repeated fetches for the same proxied engine. + @Test + func publicObjectsStoreReturnsSameInstanceForSameProxied() { + let store = PublicObjectsStore() + let internalQueue = TestFactories.createInternalQueue() + let proxied = Self.makeProxied(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let args = PublicObjectsStore.RealtimeObjectCreationArgs(coreSDK: coreSDK, logger: TestLogger()) + + let first = store.getOrCreateRealtimeObject(proxying: proxied, creationArgs: args) + let second = store.getOrCreateRealtimeObject(proxying: proxied, creationArgs: args) + + #expect(first === second) + } + + // Distinct proxied engines get distinct public objects. + @Test + func publicObjectsStoreReturnsDistinctInstancesForDifferentProxied() { + let store = PublicObjectsStore() + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let args = PublicObjectsStore.RealtimeObjectCreationArgs(coreSDK: coreSDK, logger: TestLogger()) + + let proxiedA = Self.makeProxied(internalQueue: internalQueue) + let proxiedB = Self.makeProxied(internalQueue: internalQueue) + + let a = store.getOrCreateRealtimeObject(proxying: proxiedA, creationArgs: args) + let b = store.getOrCreateRealtimeObject(proxying: proxiedB, creationArgs: args) + + #expect(a !== b) + } + + // MARK: - Trap-free surface smoke + + // Every public `RealtimeObject` entry point is callable without trapping. + @Test + func trapFreeSurfaceSmoke() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + Self.driveToSynced(proxied, on: internalQueue) + + // get() + let root = try await publicObject.get() + #expect(root.path.isEmpty) + + // on(event:callback:) + StatusSubscription.off() + let subscription = publicObject.on(event: .synced) {} + subscription.off() + // off() is idempotent. + subscription.off() + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/SyncCursorTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/SyncCursorTests.swift index be056fba1..18357bb08 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/SyncCursorTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/SyncCursorTests.swift @@ -2,15 +2,20 @@ import Ably @testable import AblyLiveObjects import Testing +/// Tests for `SyncCursor`'s parsing of an `OBJECT_SYNC` `channelSerial` (RTO5a1, RTO5a4). +/// +/// A non-conforming `channelSerial` parses to `nil`; the resulting behaviour (treated as an absent +/// `channelSerial` per RTO5a5) is exercised end-to-end in +/// `InternalDefaultRealtimeObjectsTests.HandleObjectSyncProtocolMessageTests`. struct SyncCursorTests { - // The parsing described in RTO5a1 + // The parsing described in RTO5a1: `:`. @Test func validChannelSerialWithCursorValue() throws { // Given let channelSerial = "sequence123:cursor456" // When - let cursor = try SyncCursor(channelSerial: channelSerial) + let cursor = try #require(SyncCursor(channelSerial: channelSerial)) // Then #expect(cursor.sequenceID == "sequence123") @@ -18,14 +23,14 @@ struct SyncCursorTests { #expect(!cursor.isEndOfSequence) } - // The scenario described in RTO5a2 + // RTO5a4: the sequence is complete once the cursor is empty, i.e. `:`. @Test func validChannelSerialAtEndOfSequence() throws { // Given let channelSerial = "sequence123:" // When - let cursor = try SyncCursor(channelSerial: channelSerial) + let cursor = try #require(SyncCursor(channelSerial: channelSerial)) // Then #expect(cursor.sequenceID == "sequence123") @@ -33,73 +38,66 @@ struct SyncCursorTests { #expect(cursor.isEndOfSequence) } + // We deliberately do not restrict the sequence-id character set (unlike ably-java's `[\w-]+` + // regex). The specification places no such restriction, so any characters up to the first colon + // form the sequence id. This includes hyphens and underscores... @Test - func invalidChannelSerialWithoutColon() { + func validChannelSerialWithHyphenAndUnderscoreSequenceID() throws { // Given - let channelSerial = "sequence123" - - // When/Then - do { - _ = try SyncCursor(channelSerial: channelSerial) - Issue.record("Expected error was not thrown") - } catch { - guard let liveObjectsError = error.testsOnly_underlyingLiveObjectsError, - case .other(SyncCursor.Error.channelSerialDoesNotMatchExpectedFormat) = liveObjectsError - else { - Issue.record("Expected channelSerialDoesNotMatchExpectedFormat error") - return - } - } - } + let channelSerial = "seq-1_2:cursor456" - @Test - func invalidEmptyChannelSerial() { - // Given - let channelSerial = "" - - // When/Then - do { - _ = try SyncCursor(channelSerial: channelSerial) - Issue.record("Expected error was not thrown") - } catch { - guard let liveObjectsError = error.testsOnly_underlyingLiveObjectsError, - case .other(SyncCursor.Error.channelSerialDoesNotMatchExpectedFormat) = liveObjectsError - else { - Issue.record("Expected channelSerialDoesNotMatchExpectedFormat error") - return - } - } + // When + let cursor = try #require(SyncCursor(channelSerial: channelSerial)) + + // Then + #expect(cursor.sequenceID == "seq-1_2") + #expect(cursor.cursorValue == "cursor456") } - // The spec isn't explicit here but doesn't rule this out + // ...and also characters that ably-java's `[\w-]+` regex would reject (here, a dot). We accept + // them because rejecting an otherwise-valid serial would risk silently dropping a real sync. The + // colon is the first one, so the dot forms part of the sequence id, not the cursor value. @Test - func validChannelSerialWithEmptySequenceID() throws { + func validChannelSerialWithNonWordCharactersInSequenceID() throws { // Given - let channelSerial = ":cursor456" + let channelSerial = "seq.a:cursor456" // When - let cursor = try SyncCursor(channelSerial: channelSerial) + let cursor = try #require(SyncCursor(channelSerial: channelSerial)) // Then - // swiftlint:disable:next empty_string - #expect(cursor.sequenceID == "") + #expect(cursor.sequenceID == "seq.a") #expect(cursor.cursorValue == "cursor456") - #expect(!cursor.isEndOfSequence) } - // The spec isn't explicit here but doesn't rule this out + // A channelSerial with no colon separator does not conform to RTO5a1, so it parses to `nil`. The + // caller then treats the OBJECT_SYNC as self-contained (RTO5a5); see + // `HandleObjectSyncProtocolMessageTests.treatsChannelSerialWithoutColonAsSelfContainedSync`. @Test - func validChannelSerialWithEmptySequenceIDAtEndOfSequence() throws { - // Given - let channelSerial = ":" + func channelSerialWithoutColonReturnsNil() { + #expect(SyncCursor(channelSerial: "sequence123") == nil) + } - // When - let cursor = try SyncCursor(channelSerial: channelSerial) + // An empty channelSerial does not conform to RTO5a1, so it parses to `nil`. + @Test + func emptyChannelSerialReturnsNil() { + #expect(SyncCursor(channelSerial: "") == nil) + } - // Then - // swiftlint:disable:next empty_string - #expect(cursor.sequenceID == "") - #expect(cursor.cursorValue == nil) - #expect(cursor.isEndOfSequence) + // The specification is not explicit about an empty sequence id, but a sync sequence cannot be + // meaningfully identified (RTO5a2/RTO5a3 compare sequence ids) without one. For cross-SDK + // consistency with ably-java (whose `[\w-]+` regex requires a non-empty sequence id) we reject + // it; it parses to `nil` and is treated as a self-contained OBJECT_SYNC (RTO5a5). + // + // This reverses the previous cocoa behaviour, which accepted an empty sequence id. + @Test + func channelSerialWithEmptySequenceIDReturnsNil() { + #expect(SyncCursor(channelSerial: ":cursor456") == nil) + } + + // As above, an empty sequence id is rejected even when the cursor is also empty. + @Test + func channelSerialWithEmptySequenceIDAtEndOfSequenceReturnsNil() { + #expect(SyncCursor(channelSerial: ":") == nil) } } diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/TestsOnlySeamsTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/TestsOnlySeamsTests.swift new file mode 100644 index 000000000..744963062 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/TestsOnlySeamsTests.swift @@ -0,0 +1,393 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// Smoke tests for the test-access seams R-3, R-4, R-5 and R-6. +/// +/// Each test proves the seam compiles and round-trips (set → get); the seams' production +/// behaviour is exercised in depth by the neighbouring test suites. +struct TestsOnlySeamsTests { + // MARK: - R-3: gated apply returns the emitted update + + struct GatedApplyReturnsUpdateTests { + // Counter: rejected apply returns nil, accepted apply returns non-nil. + @Test + func counterApplyReturnsUpdateOrNil() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:1@1", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + // Seed the gate so that a lower serial is rejected. + counter.testsOnly_setSiteTimeserials(["site1": "ts2"]) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let operation = TestFactories.objectOperation( + action: .known(.counterInc), + counterInc: TestFactories.counterInc(number: 10), + ) + + // RTLO4a: serial "ts1" < existing "ts2" so the gate rejects → nil. + let rejected = internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(rejected == nil) + + // serial "ts3" > existing "ts2" so the gate accepts → non-nil update. + let accepted = internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts3", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(accepted != nil) + } + + // Map: rejected apply returns nil, accepted apply returns non-nil. + @Test + func mapApplyReturnsUpdateOrNil() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:1@1", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + // Seed the gate so that a lower serial is rejected. + map.testsOnly_setSiteTimeserials(["site1": "ts2"]) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let operation = TestFactories.objectOperation( + action: .known(.mapSet), + mapSet: ProtocolTypes.MapSet(key: "key", value: ProtocolTypes.ObjectData(string: "value")), + ) + + // RTLO4a: serial "ts1" < existing "ts2" so the gate rejects → nil. + let rejected = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(rejected == nil) + + // serial "ts3" > existing "ts2" so the gate accepts → non-nil update. + let accepted = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts3", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(accepted != nil) + } + } + + // MARK: - R-4: testsOnly_ setters + + struct SetterTests { + @Test + func counterSetters() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:1@1", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + counter.testsOnly_setSiteTimeserials(["site1": "ts1"]) + #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) + + let tombstonedAt = Date(timeIntervalSince1970: 1000) + counter.testsOnly_setTombstonedAt(tombstonedAt) + #expect(counter.testsOnly_tombstonedAt == tombstonedAt) + #expect(counter.testsOnly_isTombstone) + + counter.testsOnly_setCreateOperationIsMerged(true) + #expect(counter.testsOnly_createOperationIsMerged) + + counter.testsOnly_setData(42) + #expect(try counter.value(coreSDK: coreSDK) == 42) + } + + @Test + func mapSetters() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:1@1", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + map.testsOnly_setSiteTimeserials(["site1": "ts1"]) + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + + let tombstonedAt = Date(timeIntervalSince1970: 2000) + map.testsOnly_setTombstonedAt(tombstonedAt) + #expect(map.testsOnly_tombstonedAt == tombstonedAt) + #expect(map.testsOnly_isTombstone) + + map.testsOnly_setCreateOperationIsMerged(true) + #expect(map.testsOnly_createOperationIsMerged) + + map.testsOnly_setClearTimeserial("ts9") + #expect(map.testsOnly_clearTimeserial == "ts9") + } + + @Test + func objectsPoolSetEntry() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:1@1", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + pool.testsOnly_setEntry(.counter(counter), forObjectID: "counter:1@1") + #expect(pool.entries["counter:1@1"]?.counterValue === counter) + } + + @Test + func realtimeObjectsSetPoolEntry() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:1@1", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + realtimeObjects.testsOnly_setPoolEntry(.counter(counter), forObjectID: "counter:1@1") + // The setter must mutate the *owned* pool (not the struct copy returned by the getter). + #expect(realtimeObjects.testsOnly_objectsPool.entries["counter:1@1"]?.counterValue === counter) + } + } + + // MARK: - R-5: sync state read seam + + struct SyncStateTests { + @Test + func syncStateReadSeam() { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + #expect(realtimeObjects.testsOnly_syncState == .initialized) + } + } + + // MARK: - R-6: apply object messages and isEntryTombstoned wrapper + + struct ApplyObjectMessagesTests { + @Test + func applyObjectMessagesForwardsToMutableState() throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Applying a COUNTER_INC message creates the zero-value counter (RTO6) and applies the inc. + realtimeObjects.testsOnly_applyObjectMessages( + [ + TestFactories.counterIncOperationMessage(objectId: "counter:1@1", number: 7), + ], + source: .channel, + ) + + let counter = try #require(realtimeObjects.testsOnly_objectsPool.entries["counter:1@1"]?.counterValue) + #expect(try counter.value(coreSDK: coreSDK) == 7) + } + } + + struct IsEntryTombstonedTests { + @Test + func isEntryTombstonedWrapper() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:1@1", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let tombstonedEntry = InternalObjectsMapEntry(tombstonedAt: Date(timeIntervalSince1970: 1000), timeserial: nil, data: nil) + #expect(map.testsOnly_isEntryTombstoned(tombstonedEntry, objectsPool: pool)) + + let liveEntry = InternalObjectsMapEntry(data: ProtocolTypes.ObjectData(string: "value")) + #expect(!map.testsOnly_isEntryTombstoned(liveEntry, objectsPool: pool)) + } + } + + // MARK: - P2: update-model enrichment (objectMessage + tombstone) and PAOM3 + + /// Smoke tests for the update-model enrichment: op-path updates carry the source public + /// ``ObjectMessage`` (RTLO4b4d), sync-path updates carry `nil` (RTO4b2a), tombstoning sets the + /// `tombstone` flag (RTLO4b4e), and a tombstone teardown deregisters subscriptions (RTLO4b4c3c). + struct UpdateEnrichmentTests { + /// A public ObjectMessage carrying a COUNTER_INC operation, for use as a `sourceObjectMessage`. + private static func counterIncMessage(objectId: String) -> ObjectMessage { + .init( + id: "msg-1", + channel: "channel-1", + operation: .init(action: .counterInc, objectId: objectId, counterInc: .init(number: 10)), + serial: "ts1", + siteCode: "site1", + ) + } + + // An op-path apply threads the source public message onto the emitted/returned update. + @Test + func opPathApplyCarriesObjectMessage() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:1@1", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let sourceObjectMessage = Self.counterIncMessage(objectId: "counter:1@1") + let operation = TestFactories.objectOperation( + action: .known(.counterInc), + objectId: "counter:1@1", + counterInc: TestFactories.counterInc(number: 10), + ) + + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + sourceObjectMessage: sourceObjectMessage, + objectsPool: &pool, + ) + } + + #expect(update?.objectMessage == sourceObjectMessage) + #expect(update?.tombstone == false) + } + + // A sync-path replaceData produces an update with a nil message (RTO4b2a). + @Test + func syncPathReplaceDataHasNilMessage() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:1@1", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_replaceData( + using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5), + objectMessageSerialTimestamp: nil, + ) + } + + #expect(update.update?.amount == 5) + #expect(update.objectMessage == nil) + #expect(update.tombstone == false) + } + + // An OBJECT_DELETE apply sets the tombstone flag and still carries the source message. + @Test + func objectDeleteSetsTombstoneFlag() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:1@1", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let sourceObjectMessage = ObjectMessage( + channel: "channel-1", + operation: .init(action: .objectDelete, objectId: "counter:1@1", objectDelete: .init()), + serial: "ts1", + siteCode: "site1", + ) + let operation = TestFactories.objectOperation( + action: .known(.objectDelete), + objectId: "counter:1@1", + objectDelete: WireObjectDelete(), + ) + + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + sourceObjectMessage: sourceObjectMessage, + objectsPool: &pool, + ) + } + + #expect(update?.tombstone == true) + #expect(update?.objectMessage == sourceObjectMessage) + #expect(counter.testsOnly_isTombstone) + } + + // After a tombstone update is emitted, the object's subscriptions are deregistered + // (RTLO4b4c3c): the tombstone update is delivered, but no subsequent update is. + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func tombstoneDeregistersSubscriptions() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:1@1", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let subscriber = Subscriber(callbackQueue: .main) + try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + // Apply OBJECT_DELETE — emits the tombstone update, then tears down subscriptions. + let operation = TestFactories.objectOperation( + action: .known(.objectDelete), + objectId: "counter:1@1", + objectDelete: WireObjectDelete(), + ) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + // A further emission must not reach the (now-deregistered) subscriber. + counter.nosync_emit(.update(.init(amount: 99))) + } + + let invocations = await subscriber.getInvocations() + // Exactly one invocation: the tombstone update. The post-teardown emit is dropped. + #expect(invocations.count == 1) + #expect(invocations.first?.0.tombstone == true) + } + + // PAOM3: op-bearing inbound messages convert to a public ObjectMessage; a message without an + // operation, or with an unknown wire action, does not surface publicly (DEV-5). + @Test + func paom3Conversion() throws { + // Op-bearing → non-nil, with mapped fields. + let inbound = TestFactories.inboundObjectMessage( + id: "msg-1", + operation: TestFactories.objectOperation( + action: .known(.counterInc), + objectId: "counter:1@1", + counterInc: TestFactories.counterInc(number: 7), + ), + serial: "ts1", + siteCode: "site1", + ) + let converted = try #require(inbound.toPublicObjectMessage(channelName: "channel-1")) + #expect(converted.id == "msg-1") + #expect(converted.channel == "channel-1") + #expect(converted.serial == "ts1") + #expect(converted.operation.action == .counterInc) + #expect(converted.operation.counterInc?.number == 7) + + // No operation → nil. + #expect(TestFactories.objectMessageWithoutState().toPublicObjectMessage(channelName: "channel-1") == nil) + + // Unknown wire action → nil (never surfaces publicly, DEV-5). + let unknownAction = TestFactories.inboundObjectMessage( + operation: TestFactories.objectOperation(action: .unknown(99), objectId: "counter:1@1"), + ) + #expect(unknownAction.toPublicObjectMessage(channelName: "channel-1") == nil) + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InstanceUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InstanceUTSTests.swift new file mode 100644 index 000000000..e8b95f8a4 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InstanceUTSTests.swift @@ -0,0 +1,478 @@ +// @UTS objects/unit/instance.md + +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// `Instance` — the identity-addressed view of a LiveObject or primitive. +/// Derived from https://github.com/ably/specification/blob/main/uts/objects/unit/instance.md +/// (spec points `RTINS1`–`RTINS16`). +/// +/// The spec drives every case through `setup_synced_channel` + a mock WebSocket. Cocoa's `Instance` +/// layer is exercisable without any of that: build the internal node directly via its `testsOnly_` +/// initialiser and wrap it with the internal `Instance.from(...)` factory (the seam +/// `PathObject.instance()` uses in production). This mirrors the Phase-3 native suite +/// `LiveObjects/Tests/AblyLiveObjectsTests/DefaultInstanceTests.swift`; the mocks it relies on are +/// replicated locally in `helpers/ObjectsUTSHelpers.swift` (that target cannot be imported). +/// +/// ## DEV-1: Instance enum vs base-type + `as*` casts (recorded in deviations.md) +/// Cocoa models `Instance` as an enum (`.liveMap`/`.liveCounter`/`.primitive`) whose payloads are the +/// distinct `LiveMapInstance` / `LiveCounterInstance` / `PrimitiveInstance` protocols, each carrying +/// only its applicable members. This makes an entire family of spec "wrong-type" cases +/// **compile-time-unrepresentable** — the spec's runtime 92007 / null-return branches cannot be +/// written: +/// - RTINS3b (`Primitive.id() == null`): `PrimitiveInstance` has no `id`. +/// - RTINS4d (`InternalLiveMap.value() == null`): `LiveMapInstance` has no `value`. +/// - RTINS5d / RTINS6c / RTINS9c (non-map `get`/`entries`/`size`): those live only on `LiveMapInstance`. +/// - RTINS12d / RTINS13d (`set`/`remove` on non-map -> 92007): those live only on `LiveMapInstance`. +/// - RTINS14d / RTINS15d (`increment`/`decrement` on non-counter -> 92007): only on `LiveCounterInstance`. +/// - RTINS16c (`subscribe` on a primitive -> 92007): `PrimitiveInstance` has no `subscribe`. +/// +/// ## Mock-realtime adaptation +/// The unit mock (`ObjectsUTSRealtimeObjects`) captures `publishAndApply` messages but does not apply +/// them back onto the graph. So the mutation cases (RTINS12/13/14/14a/15/15a) assert the **published +/// operation** rather than the spec's post-apply value (`root.get(...).value() == ...`), which needs +/// the full `InternalDefaultRealtimeObjects` pipeline. +/// +/// ## Skipped — out of UNIT scope +/// - RTINS16g (subscription follows identity after the key is repointed): needs a multi-object graph +/// plus a `MAP_SET` that repoints `root.score`, i.e. the mock-WS send path. +/// - RTINS16h (subscribe has no side effects on channel state): needs a real channel/connection. +@Suite(.serialized) +final class InstanceUTSTests { + // MARK: - RTINS3: id property returns objectId + + // objects/unit/RTINS3/id-returns-objectid-0 — RTINS3a (LiveObject -> objectId). RTINS3b (primitive + // -> null) is compile-time-unrepresentable (DEV-1). + @Test + func RTINS3a_id_returns_objectid() throws { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + let counterNode = ObjectsUTS.makeCounter(objectID: "counter:score@1000", internalQueue: internalQueue) + let mapNode = ObjectsUTS.makeMap(objectID: "map:profile@1000", internalQueue: internalQueue) + + guard case let .liveCounter(counterInstance) = Instance.from(internalValue: .liveCounter(counterNode), coreSDK: coreSDK, realtimeObjects: ObjectsUTSRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .liveCounter") + return + } + #expect(counterInstance.id == "counter:score@1000") + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(mapNode), coreSDK: coreSDK, realtimeObjects: ObjectsUTSRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + #expect(mapInstance.id == "map:profile@1000") + } + + // MARK: - RTINS4: value() returns counter number or primitive + + // objects/unit/RTINS4/value-counter-0 — RTINS4b (counter -> value) and RTINS4c (primitive -> + // value). RTINS4d (map -> null) is compile-time-unrepresentable (DEV-1). + @Test + func RTINS4_value_counter_and_primitive() throws { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + let counterNode = ObjectsUTS.makeCounter(objectID: "counter:score@1000", data: 100, internalQueue: internalQueue) + + guard case let .liveCounter(counterInstance) = Instance.from(internalValue: .liveCounter(counterNode), coreSDK: coreSDK, realtimeObjects: ObjectsUTSRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .liveCounter") + return + } + #expect(try counterInstance.value == 100) // RTINS4b + + guard case let .primitive(primitive) = Instance.from(internalValue: .string("Alice"), coreSDK: coreSDK, realtimeObjects: ObjectsUTSRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .primitive") + return + } + #expect(try primitive.value == .string("Alice")) // RTINS4c + } + + // MARK: - RTINS5: get() returns Instance wrapping entry value + + // objects/unit/RTINS5/get-wraps-entry-0 — RTINS5c (look up key, wrap in Instance; nil when absent). + // RTINS5d (non-map -> null) is compile-time-unrepresentable (DEV-1). + @Test + func RTINS5c_get_wraps_entry() throws { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + // A pool holding the counter that the "score" entry references by objectId. + let scoreCounter = ObjectsUTS.makeCounter(objectID: "counter:score@1000", data: 100, internalQueue: internalQueue) + let poolDelegate = ObjectsUTSPoolDelegate(internalQueue: internalQueue, entries: ["counter:score@1000": .counter(scoreCounter)]) + let realtimeObjects = ObjectsUTSRealtimeObjects(poolDelegate: poolDelegate) + + let root = ObjectsUTS.makeMap( + objectID: "root", + data: [ + "name": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "Alice")), + "score": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:score@1000")), + ], + internalQueue: internalQueue, + ) + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(root), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + + let nameInst = try #require(try mapInstance.get(key: "name")) + guard case let .primitive(namePrimitive) = nameInst else { + Issue.record("Expected .primitive for name") + return + } + #expect(try namePrimitive.value == .string("Alice")) + + let scoreInst = try #require(try mapInstance.get(key: "score")) + guard case let .liveCounter(scoreCounterInstance) = scoreInst else { + Issue.record("Expected .liveCounter for score") + return + } + #expect(scoreCounterInstance.id == "counter:score@1000") + + #expect(try mapInstance.get(key: "nonexistent") == nil) + } + + // MARK: - RTINS6: entries() returns array of [key, Instance] pairs + + // objects/unit/RTINS6/entries-yields-instances-0 — RTINS6b. RTINS6c (non-map -> empty array) is + // compile-time-unrepresentable (DEV-1). + @Test + func RTINS6b_entries_yields_instances() throws { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + let realtimeObjects = ObjectsUTSRealtimeObjects(poolDelegate: ObjectsUTSPoolDelegate(internalQueue: internalQueue)) + let root = ObjectsUTS.makeMap( + data: [ + "name": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "Alice")), + "age": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(number: NSNumber(value: 30))), + ], + internalQueue: internalQueue, + ) + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(root), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + + let entries = try mapInstance.entries() + let entriesByKey = Dictionary(uniqueKeysWithValues: entries.map { ($0.key, $0.value) }) + #expect(entries.count == 2) + let nameInst = try #require(entriesByKey["name"]) + guard case let .primitive(namePrimitive) = nameInst else { + Issue.record("Expected .primitive for name") + return + } + #expect(try namePrimitive.value == .string("Alice")) + } + + // MARK: - RTINS9: size() returns non-tombstoned count + + // objects/unit/RTINS9/size-0 — RTINS9b (map -> entry count). RTINS9c (non-map -> null) is + // compile-time-unrepresentable (DEV-1). + @Test + func RTINS9b_size() throws { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + let realtimeObjects = ObjectsUTSRealtimeObjects(poolDelegate: ObjectsUTSPoolDelegate(internalQueue: internalQueue)) + let root = ObjectsUTS.makeMap( + data: [ + "a": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "x")), + "b": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "y")), + "c": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "z")), + ], + internalQueue: internalQueue, + ) + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(root), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + #expect(try mapInstance.size == 3) + } + + // MARK: - RTINS10: compact() recursively compacts + + // objects/unit/RTINS10/compact-0 — RTINS10b (behaves like PathObject#compact on the wrapped value). + @Test + func RTINS10_compact() throws { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + + let scoreCounter = ObjectsUTS.makeCounter(objectID: "counter:score@1000", data: 100, internalQueue: internalQueue) + let profileMap = ObjectsUTS.makeMap(objectID: "map:profile@1000", data: ["email": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "alice@example.com"))], internalQueue: internalQueue) + let poolDelegate = ObjectsUTSPoolDelegate(internalQueue: internalQueue, entries: [ + "counter:score@1000": .counter(scoreCounter), + "map:profile@1000": .map(profileMap), + ]) + + let root = ObjectsUTS.makeMap( + objectID: "root", + data: [ + "name": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "Alice")), + "score": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:score@1000")), + "profile": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(objectId: "map:profile@1000")), + ], + internalQueue: internalQueue, + ) + let realtimeObjects = ObjectsUTSRealtimeObjects(poolDelegate: poolDelegate) + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(root), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + let expected: JSONValue = .object([ + "name": .string("Alice"), + "score": .number(100), + "profile": .object(["email": .string("alice@example.com")]), + ]) + #expect(try mapInstance.compactJson() == expected) + } + + // MARK: - RTINS12: set() delegates to InternalLiveMap#set + + // objects/unit/RTINS12/set-delegates-0 — RTINS12c. Asserts the published MAP_SET operation (the + // mock does not apply locally, so the spec's post-apply `root.get("name").value() == "Bob"` is out + // of unit scope). + @Test + func RTINS12c_set_delegates() async throws { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + let realtimeObjects = ObjectsUTSRealtimeObjects() + let published = ObjectsUTSPublished() + realtimeObjects.setPublishAndApplyHandler { messages in + published.set(messages) + return .success(()) + } + let root = ObjectsUTS.makeMap(objectID: "root", internalQueue: internalQueue) + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(root), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + try await mapInstance.set(key: "name", value: .primitive(.string("Bob"))) + + let messages = try #require(published.get()) + #expect(messages.count == 1) + #expect(messages[0].operation?.action == .known(.mapSet)) + #expect(messages[0].operation?.objectId == "root") + #expect(messages[0].operation?.mapSet?.key == "name") + #expect(messages[0].operation?.mapSet?.value?.string == "Bob") + } + + // MARK: - RTINS13: remove() delegates to InternalLiveMap#remove + + // objects/unit/RTINS13/remove-delegates-0 — RTINS13c. Asserts the published MAP_REMOVE operation. + @Test + func RTINS13c_remove_delegates() async throws { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + let realtimeObjects = ObjectsUTSRealtimeObjects() + let published = ObjectsUTSPublished() + realtimeObjects.setPublishAndApplyHandler { messages in + published.set(messages) + return .success(()) + } + let root = ObjectsUTS.makeMap(objectID: "root", data: ["name": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "Alice"))], internalQueue: internalQueue) + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(root), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + try await mapInstance.remove(key: "name") + + let messages = try #require(published.get()) + #expect(messages.count == 1) + #expect(messages[0].operation?.action == .known(.mapRemove)) + #expect(messages[0].operation?.objectId == "root") + #expect(messages[0].operation?.mapRemove?.key == "name") + } + + // MARK: - RTINS14 / RTINS14a: increment() delegates to InternalLiveCounter#increment + + // objects/unit/RTINS14/increment-delegates-0 — RTINS14c. Asserts the published COUNTER_INC. + @Test + func RTINS14c_increment_delegates() async throws { + let (counterInstance, published) = try makeCounterInstanceCapturingPublish(objectID: "counter:score@1000", data: 100) + try await counterInstance.increment(amount: 25) + + let messages = try #require(published.get()) + #expect(messages.count == 1) + #expect(messages[0].operation?.action == .known(.counterInc)) + #expect(messages[0].operation?.objectId == "counter:score@1000") + #expect(messages[0].operation?.counterInc?.number == NSNumber(value: 25)) + } + + // objects/unit/RTINS14a/increment-default-0 — RTINS14a1 (amount defaults to 1). Asserts the + // published number is 1 (the spec's post-apply `value() == 101` needs the full pipeline). + @Test + func RTINS14a_increment_default() async throws { + let (counterInstance, published) = try makeCounterInstanceCapturingPublish(objectID: "counter:score@1000", data: 100) + try await counterInstance.increment() + + let messages = try #require(published.get()) + #expect(messages[0].operation?.counterInc?.number == NSNumber(value: 1)) + } + + // MARK: - RTINS15 / RTINS15a: decrement() delegates to InternalLiveCounter#decrement + + // objects/unit/RTINS15/decrement-delegates-0 — RTINS15c. Decrement is increment with a negated + // amount, so the published COUNTER_INC carries a negative number. + @Test + func RTINS15c_decrement_delegates() async throws { + let (counterInstance, published) = try makeCounterInstanceCapturingPublish(objectID: "counter:score@1000", data: 100) + try await counterInstance.decrement(amount: 10) + + let messages = try #require(published.get()) + #expect(messages[0].operation?.action == .known(.counterInc)) + #expect(messages[0].operation?.counterInc?.number == NSNumber(value: -10)) + } + + // objects/unit/RTINS15a/decrement-default-0 — RTINS15a1 (amount defaults to 1 => published -1). + @Test + func RTINS15a_decrement_default() async throws { + let (counterInstance, published) = try makeCounterInstanceCapturingPublish(objectID: "counter:score@1000", data: 100) + try await counterInstance.decrement() + + let messages = try #require(published.get()) + #expect(messages[0].operation?.counterInc?.number == NSNumber(value: -1)) + } + + // MARK: - RTINS16: subscribe() receives InstanceSubscriptionEvent + + // objects/unit/RTINS16/subscribe-receives-events-0 — RTINS16d/e1/f/g. The update is driven by + // applying a COUNTER_INC to the node directly (the unit stand-in for `mock_ws.send_to_client`). + @Test + func RTINS16_subscribe_receives_events() async throws { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + let node = ObjectsUTS.makeCounter(objectID: "counter:score@1000", data: 100, internalQueue: internalQueue) + + guard case let .liveCounter(counterInstance) = Instance.from(internalValue: .liveCounter(node), coreSDK: coreSDK, realtimeObjects: ObjectsUTSRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .liveCounter") + return + } + + let collector = ObjectsUTSEventCollector() + let sub = try counterInstance.subscribe(listener: collector.listener) // RTINS16f + + applyCounterInc(to: node, objectID: "counter:score@1000", number: 7, internalQueue: internalQueue) + + let events = await collector.events() + #expect(events.count == 1) + let event = try #require(events.first) + // RTINS16e1/g: the delivered event carries the Instance wrapping the counter that fired. + guard case let .liveCounter(eventCounter) = event.object else { + Issue.record("Expected .liveCounter in event") + return + } + #expect(eventCounter.id == "counter:score@1000") + sub.unsubscribe() + } + + // objects/unit/RTINS16e2/subscription-event-message-0 — RTINS16e1/e2. The event carries both the + // Instance and the PublicAPI::ObjectMessage derived from the triggering ObjectMessage. + @Test + func RTINS16e2_subscription_event_message() async throws { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + let realtimeObjects = ObjectsUTSRealtimeObjects(poolDelegate: ObjectsUTSPoolDelegate(internalQueue: internalQueue)) + let node = ObjectsUTS.makeMap(objectID: "root", internalQueue: internalQueue) + + guard case let .liveMap(mapInstance) = Instance.from(internalValue: .liveMap(node), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + Issue.record("Expected .liveMap") + return + } + + let collector = ObjectsUTSEventCollector() + try mapInstance.subscribe(listener: collector.listener) + + // The public ObjectMessage that the update is stamped with (RTINS16e2). + let sourceMessage = ObjectMessage( + channel: "test", + operation: ObjectOperation(action: .mapSet, objectId: "root", mapSet: MapSet(key: "name", value: ObjectData(string: "Bob"))), + ) + var pool = ObjectsUTS.freshPool(internalQueue: internalQueue) + internalQueue.ably_syncNoDeadlock { + _ = node.nosync_apply( + ProtocolTypes.ObjectOperation(action: .known(.mapSet), objectId: "root", mapSet: ProtocolTypes.MapSet(key: "name", value: ProtocolTypes.ObjectData(string: "Bob"))), + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + sourceObjectMessage: sourceMessage, + objectsPool: &pool, + ) + } + + let events = await collector.events() + #expect(events.count == 1) + let event = try #require(events.first) + guard case let .liveMap(eventMap) = event.object else { // RTINS16e1 + Issue.record("Expected .liveMap in event") + return + } + #expect(eventMap.id == "root") + let message = try #require(event.message) // RTINS16e2 + #expect(message.channel == "test") + #expect(message.operation.action == .mapSet) + #expect(message.operation.objectId == "root") + #expect(message.operation.mapSet?.key == "name") + } + + // MARK: - RTINS16f: subscribe() returns Subscription for deregistration + + // objects/unit/RTINS16f/subscribe-returns-subscription-0 — after `unsubscribe()` the listener must + // not fire for a subsequent update. + @Test + func RTINS16f_unsubscribe_stops_delivery() async throws { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + let node = ObjectsUTS.makeCounter(objectID: "counter:score@1000", data: 100, internalQueue: internalQueue) + + guard case let .liveCounter(counterInstance) = Instance.from(internalValue: .liveCounter(node), coreSDK: coreSDK, realtimeObjects: ObjectsUTSRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .liveCounter") + return + } + + let collector = ObjectsUTSEventCollector() + let sub = try counterInstance.subscribe(listener: collector.listener) + sub.unsubscribe() + + applyCounterInc(to: node, objectID: "counter:score@1000", number: 7, internalQueue: internalQueue) + + let events = await collector.events() + #expect(events.isEmpty) + } + + // MARK: - Helpers + + private func makeCounterInstanceCapturingPublish(objectID: String, data: Double) throws -> (any LiveCounterInstance, ObjectsUTSPublished) { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + let realtimeObjects = ObjectsUTSRealtimeObjects() + let published = ObjectsUTSPublished() + realtimeObjects.setPublishAndApplyHandler { messages in + published.set(messages) + return .success(()) + } + let node = ObjectsUTS.makeCounter(objectID: objectID, data: data, internalQueue: internalQueue) + guard case let .liveCounter(counterInstance) = Instance.from(internalValue: .liveCounter(node), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + throw NSError(domain: "InstanceTests", code: 0, userInfo: [NSLocalizedDescriptionKey: "Expected .liveCounter"]) + } + return (counterInstance, published) + } + + private func applyCounterInc(to node: InternalDefaultLiveCounter, objectID: String, number: Int, internalQueue: DispatchQueue) { + var pool = ObjectsUTS.freshPool(internalQueue: internalQueue) + internalQueue.ably_syncNoDeadlock { + _ = node.nosync_apply( + ProtocolTypes.ObjectOperation(action: .known(.counterInc), objectId: objectID, counterInc: WireCounterInc(number: NSNumber(value: number))), + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveCounterApiUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveCounterApiUTSTests.swift new file mode 100644 index 000000000..923de18a5 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveCounterApiUTSTests.swift @@ -0,0 +1,171 @@ +// @UTS objects/unit/internal_live_counter_api.md + +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// `InternalLiveCounter` public-facing API — value reads, increment/decrement writes, and update +/// events. +/// Derived from https://github.com/ably/specification/blob/main/uts/objects/unit/internal_live_counter_api.md +/// (spec points `RTLC5`, `RTLC11`–`RTLC13`). +/// +/// The spec declares mock-WebSocket infrastructure (`setup_synced_channel`, `MockWebSocket`, +/// `captured_messages`, `mock_ws.send_to_client`) for these cases. Per the UNIT-only scope, this file +/// ports the subset exercisable without it: the counter node is built directly and driven through the +/// public `LiveCounterInstance` (the counter surface the spec's `root.get("score")` resolves to), +/// with `publishAndApply` captured by a local mock and remote updates simulated by applying an +/// operation to the node (the unit stand-in for `mock_ws.send_to_client`). Mocks are replicated in +/// `helpers/ObjectsUTSHelpers.swift`. +/// +/// ## Mock-realtime adaptation +/// `ObjectsUTSRealtimeObjects` captures the published `ObjectMessage` but does not apply it back onto +/// the counter. So the spec's post-apply value assertions are out of unit scope: +/// - RTLC12 `increment-applies-locally` (`value() == 150`) — skipped; only the published COUNTER_INC +/// is asserted (RTLC12 `increment-sends-counter-inc`). +/// - RTLC13 `value() == 85` — skipped; only the negated published number is asserted. +/// +/// ## Compile-time-unrepresentable (recorded in deviations.md) +/// RTLC12e1's non-finite table: `increment(amount:)` takes a `Double`, so the `string`/`boolean`/ +/// `array`/`object`/`null` rows cannot be constructed. Only the representable non-finite doubles +/// (`NaN`/`Infinity`/`-Infinity`) reach the RTLC12e1 finiteness check — those are ported below. `null` +/// maps to the no-argument `increment()` default of 1 (see `InstanceTests.test_RTINS14a...`). +/// +/// ## Cross-referenced elsewhere (not in this spec's scope) +/// RTLC12b/c/d (write preconditions) are replaced by RTO26 and live in `objects/unit/realtime_object.md`. +@Suite(.serialized) +final class InternalLiveCounterApiUTSTests { + // MARK: - RTLC5: value() returns current counter data + + // objects/unit/RTLC5/value-returns-data-0 — RTLC5c (returns current data value). + @Test + func RTLC5c_value_returns_data() throws { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + let node = ObjectsUTS.makeCounter(objectID: "counter:score@1000", data: 100, internalQueue: internalQueue) + + guard case let .liveCounter(counter) = Instance.from(internalValue: .liveCounter(node), coreSDK: coreSDK, realtimeObjects: ObjectsUTSRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .liveCounter") + return + } + #expect(try counter.value == 100) + } + + // MARK: - RTLC12: increment sends v6 COUNTER_INC message + + // objects/unit/RTLC12/increment-sends-counter-inc-0 — RTLC12e2/e3/e5/g. Asserts the published + // COUNTER_INC (the spec's `captured_messages[0].state[0]`). + @Test + func RTLC12_increment_sends_counter_inc() async throws { + let (counter, published) = try makeCounter(objectID: "counter:score@1000", data: 100) + try await counter.increment(amount: 25) + + let messages = try #require(published.get()) + #expect(messages.count == 1) + #expect(messages[0].operation?.action == .known(.counterInc)) // RTLC12e2 + #expect(messages[0].operation?.objectId == "counter:score@1000") // RTLC12e3 + #expect(messages[0].operation?.counterInc?.number == NSNumber(value: 25)) // RTLC12e5 + } + + // MARK: - RTLC12e1: increment with non-finite amount throws 40003 + + // objects/unit/RTLC12e1/increment-non-number-0 — the representable non-finite doubles throw 40003. + @Test + func RTLC12e1_increment_non_finite_throws() async throws { + let (counter, _) = try makeCounter(objectID: "counter:score@1000", data: 100) + let error = await #expect(throws: ARTErrorInfo.self) { + try await counter.increment(amount: .nan) + } + #expect(error?.code == 40003) + } + + // objects/unit/RTLC12e1/increment-invalid-amounts-table-0 — every representable non-finite amount + // throws 40003. The non-numeric rows are compile-time-unrepresentable (Double parameter). + @Test + func RTLC12e1_increment_invalid_amounts_table() async throws { + let invalidAmounts: [(Double, String)] = [ + (.nan, "NaN"), + (.infinity, "Infinity"), + (-.infinity, "-Infinity"), + ] + for (amount, label) in invalidAmounts { + let (counter, _) = try makeCounter(objectID: "counter:score@1000", data: 100) + let error = await #expect(throws: ARTErrorInfo.self, "increment(\(label)) should throw 40003") { + try await counter.increment(amount: amount) + } + #expect(error?.code == 40003) + } + } + + // MARK: - RTLC13: decrement delegates to increment with negated amount + + // objects/unit/RTLC13/decrement-negates-0 — RTLC13b. Asserts the negated published number (the + // spec's `value() == 85` post-apply assertion needs the full pipeline; out of unit scope). + @Test + func RTLC13_decrement_negates() async throws { + let (counter, published) = try makeCounter(objectID: "counter:score@1000", data: 100) + try await counter.decrement(amount: 15) + + let messages = try #require(published.get()) + #expect(messages[0].operation?.counterInc?.number == NSNumber(value: -15)) + } + + // MARK: - RTLC11: LiveCounterUpdate emitted on increment + + // objects/unit/RTLC11/counter-update-on-inc-0 — RTLC11b1 (update carries the increment value). The + // remote update is simulated by applying a COUNTER_INC to the node with a stamped source message. + @Test + func RTLC11_counter_update_on_inc() async throws { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + let node = ObjectsUTS.makeCounter(objectID: "counter:score@1000", data: 100, internalQueue: internalQueue) + + guard case let .liveCounter(counter) = Instance.from(internalValue: .liveCounter(node), coreSDK: coreSDK, realtimeObjects: ObjectsUTSRealtimeObjects(), internalQueue: internalQueue) else { + Issue.record("Expected .liveCounter") + return + } + + let collector = ObjectsUTSEventCollector() + try counter.subscribe(listener: collector.listener) + + let sourceMessage = ObjectMessage( + channel: "test", + operation: ObjectOperation(action: .counterInc, objectId: "counter:score@1000", counterInc: CounterInc(number: 7)), + ) + var pool = ObjectsUTS.freshPool(internalQueue: internalQueue) + internalQueue.ably_syncNoDeadlock { + _ = node.nosync_apply( + ProtocolTypes.ObjectOperation(action: .known(.counterInc), objectId: "counter:score@1000", counterInc: WireCounterInc(number: NSNumber(value: 7))), + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "remote-site", + objectMessageSerialTimestamp: nil, + sourceObjectMessage: sourceMessage, + objectsPool: &pool, + ) + } + + let events = await collector.events() + #expect(events.count == 1) + let event = try #require(events.first) + #expect(event.message?.operation.counterInc?.number == 7) // RTLC11b1 + } + + // MARK: - Helpers + + private func makeCounter(objectID: String, data: Double) throws -> (any LiveCounterInstance, ObjectsUTSPublished) { + let internalQueue = ObjectsUTS.createInternalQueue() + let coreSDK = ObjectsUTSCoreSDK() + let realtimeObjects = ObjectsUTSRealtimeObjects() + let published = ObjectsUTSPublished() + realtimeObjects.setPublishAndApplyHandler { messages in + published.set(messages) + return .success(()) + } + let node = ObjectsUTS.makeCounter(objectID: objectID, data: data, internalQueue: internalQueue) + guard case let .liveCounter(counter) = Instance.from(internalValue: .liveCounter(node), coreSDK: coreSDK, realtimeObjects: realtimeObjects, internalQueue: internalQueue) else { + throw NSError(domain: "InternalLiveCounterApiTests", code: 0, userInfo: [NSLocalizedDescriptionKey: "Expected .liveCounter"]) + } + return (counter, published) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveCounterUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveCounterUTSTests.swift new file mode 100644 index 000000000..e30679e72 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveCounterUTSTests.swift @@ -0,0 +1,593 @@ +// @UTS objects/unit/internal_live_counter.md +// +// Full port of `objects/unit/internal_live_counter.md`: every case in that spec file. These drive +// `InternalDefaultLiveCounter` directly (no channel/connection infra), exercising the CRDT: zero +// value, COUNTER_INC / COUNTER_CREATE application, the RTLO4a serial gate, CHANNEL/LOCAL source +// handling, OBJECT_DELETE tombstoning, and `replaceData` (the sync path). +// +// Deviations from the UTS spec: +// - (D-1) Construction: the spec writes `InternalLiveCounter(objectId:)`. The Swift counter needs a +// logger/queue/callback-queue/clock, so it is built via +// `InternalDefaultLiveCounter.createZeroValued(...)` (the `makeCounter` helper). Standard mock +// preamble per the audit §2. +// - (D-2) Queue discipline: every mutating `nosync_*` entry point (`nosync_apply`, +// `nosync_replaceData`) runs inside `internalQueue.ably_syncNoDeadlock { }`. Construction-time +// `testsOnly_set*` seams hop onto the queue themselves, so setup writes are called WITHOUT the +// wrapper (calling them inside it would re-enter the queue mutex). +// - (D-3) Setup writes: the spec assigns internal state directly (`counter.data = 10`, +// `counter.siteTimeserials = {…}`, `counter.createOperationIsMerged = true`, +// `counter.isTombstone = true` / `counter.tombstonedAt = …`). These map to the Phase-0 seams +// `testsOnly_setData`, `testsOnly_setSiteTimeserials`, `testsOnly_setCreateOperationIsMerged`, +// `testsOnly_setTombstonedAt` (isTombstone is computed from tombstonedAt, so a non-nil +// `tombstonedAt` makes `isTombstone` true). +// - (D-4) Message decomposition: `counter.applyOperation(msg, source)` maps to +// `counter.nosync_apply(operation, source:, objectMessageSerial:, objectMessageSiteCode:, +// objectMessageSerialTimestamp:, sourceObjectMessage:, objectsPool:&)`. The spec's built `msg` is +// produced by `TestFactories` builders and decomposed into its operation + serial + siteCode + +// serialTimestamp; the PAOM3 public form `msg.toPublicObjectMessage(channelName:)` is passed as +// `sourceObjectMessage` (RTLO4b4d) so the returned update carries it. +// - (D-5) `nosync_apply` returns `LiveObjectUpdate<…>?`: `nil` == the operation was gate-rejected +// (RTLC7g). So the spec's `result == false` maps to `== nil`, `result IS NOT false` / `result == +// true` to `!= nil`; `update.noop` maps to `.isNoop`; `update.update.amount` to `update.update?.amount`. +// - (D-6) `update.objectMessage == msg`: the enriched update carries the public `ObjectMessage` +// (RTLO4b4d), asserted via field-level equality against `msg.toPublicObjectMessage(channelName:)`. +// `update.tombstone == true` maps to `update.tombstone` (RTLO4b4e, via `LiveObjectUpdatePayload`). +// - (D-7) RTO4b2a — the sync path (`nosync_replaceData`) is sync-originated, so its returned update +// carries `objectMessage == nil`. The spec's `ASSERT update.objectMessage == state_msg` for the +// RTLC6 / RTLC6f / RTLC14 cases therefore does NOT hold in cocoa; those are asserted as +// `update.objectMessage == nil` (the tombstone flag and amount ARE still carried and asserted). +// - (D-8) Reading `counter.data`: no plain getter — read via `counter.value(coreSDK:)` with a +// `MockCoreSDK` in a non-DETACHED/FAILED state (`.attaching`). +// - (D-9) `counterInc: {}` (operation present, `number` absent) is not constructible — +// `WireCounterInc.number` is non-optional; translated as an operation with `counterInc: nil` +// (functionally identical — no number present). +// - (D-10) Time: the spec's epoch-millis ints map to `Date(timeIntervalSince1970:)` seconds; the +// local clock (RTLO6b) is a controllable `MockSimpleClock`, so the "tombstonedAt from local clock" +// case asserts exact equality to the mock clock's time rather than a before/after range. + +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +struct InternalLiveCounterUTSTests { + /// The channel name used when converting inbound messages to their PAOM3 public form (PAOM2e). + private static let channelName = "test-channel" + + // MARK: - Helpers (D-1) + + private static func makeCounter(objectID: String, internalQueue: DispatchQueue) -> InternalDefaultLiveCounter { + InternalDefaultLiveCounter.createZeroValued( + objectID: objectID, + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + private static func makeCounter(objectID: String, internalQueue: DispatchQueue, clock: SimpleClock) -> InternalDefaultLiveCounter { + InternalDefaultLiveCounter.createZeroValued( + objectID: objectID, + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: clock, + ) + } + + private static func makePool(internalQueue: DispatchQueue) -> ObjectsPool { + ObjectsPool(logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + /// Reads `counter.data` via `value(coreSDK:)` (D-8). + private static func data(of counter: InternalDefaultLiveCounter, internalQueue: DispatchQueue) throws -> Double { + try counter.value(coreSDK: MockCoreSDK(channelState: .attaching, internalQueue: internalQueue)) + } + + /// Drives the gated `nosync_apply` from an inbound message, decomposing it (D-4) and passing its + /// PAOM3 public form as `sourceObjectMessage`. + private static func apply( + _ message: ProtocolTypes.InboundObjectMessage, + to counter: InternalDefaultLiveCounter, + source: ObjectsOperationSource = .channel, + pool: inout ObjectsPool, + internalQueue: DispatchQueue, + ) throws -> LiveObjectUpdate? { + let operation = try #require(message.operation) + return internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + operation, + source: source, + objectMessageSerial: message.serial, + objectMessageSiteCode: message.siteCode, + objectMessageSerialTimestamp: message.serialTimestamp, + sourceObjectMessage: message.toPublicObjectMessage(channelName: channelName), + objectsPool: &pool, + ) + } + } + + // MARK: - RTLC4 + + // @UTS objects/unit/RTLC4/zero-value-0 + @Test + func zeroValueCounter() { + let internalQueue = TestFactories.createInternalQueue() + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + #expect(counter.testsOnly_objectID == "counter:abc@1000") + #expect(counter.testsOnly_isTombstone == false) + #expect(counter.testsOnly_tombstonedAt == nil) + #expect(counter.testsOnly_createOperationIsMerged == false) + #expect(counter.testsOnly_siteTimeserials.isEmpty) + } + + // MARK: - RTLC9: COUNTER_INC + + // @UTS objects/unit/RTLC9/counter-inc-basic-0 + @Test + func counterIncAddsNumberToData() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + let msg = TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: "site1") + let update = try #require(Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue)) + + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 5) + #expect(update.isNoop == false) + #expect(update.update?.amount == 5) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLC9/counter-inc-negative-0 + @Test + func counterIncWithNegativeNumber() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + counter.testsOnly_setData(10) + counter.testsOnly_setSiteTimeserials(["site1": "00"]) + + let msg = TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: -3, serial: "01", siteCode: "site1") + let update = try #require(Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue)) + + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 7) + #expect(update.update?.amount == -3) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLC9/counter-inc-missing-number-0 + @Test + func counterIncWithMissingNumberIsNoop() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + counter.testsOnly_setData(10) + + // D-9: counterInc present but number absent -> operation with counterInc: nil + let msg = TestFactories.inboundObjectMessage( + operation: TestFactories.objectOperation(action: .known(.counterInc), objectId: "counter:abc@1000", counterInc: nil), + serial: "01", + siteCode: "site1", + ) + let update = try #require(Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue)) + + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 10) + #expect(update.isNoop == true) + } + + // @UTS objects/unit/RTLC9/counter-inc-accumulate-0 + @Test + func multipleCounterIncOperationsAccumulate() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + _ = try Self.apply(TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 10, serial: "01", siteCode: "site1"), to: counter, pool: &pool, internalQueue: internalQueue) + _ = try Self.apply(TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 20, serial: "02", siteCode: "site1"), to: counter, pool: &pool, internalQueue: internalQueue) + _ = try Self.apply(TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: -5, serial: "01", siteCode: "site2"), to: counter, pool: &pool, internalQueue: internalQueue) + + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 25) + } + + // MARK: - RTLC8, RTLC16: COUNTER_CREATE + + // @UTS objects/unit/RTLC8/counter-create-merge-0 + @Test + func counterCreateMergesInitialCount() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + let msg = TestFactories.counterCreateOperationMessage(objectId: "counter:abc@1000", count: 42, serial: "01", siteCode: "site1") + let update = try #require(Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue)) + + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 42) + #expect(counter.testsOnly_createOperationIsMerged == true) + #expect(update.update?.amount == 42) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLC8/counter-create-already-merged-0 + @Test + func counterCreateNoopWhenAlreadyMerged() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + counter.testsOnly_setData(42) + counter.testsOnly_setCreateOperationIsMerged(true) + counter.testsOnly_setSiteTimeserials(["site1": "00"]) + + let msg = TestFactories.counterCreateOperationMessage(objectId: "counter:abc@1000", count: 99, serial: "01", siteCode: "site1") + let update = try #require(Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue)) + + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 42) + #expect(update.isNoop == true) + } + + // @UTS objects/unit/RTLC16/counter-create-no-count-0 + @Test + func counterCreateWithMissingCountIsNoop() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + let msg = TestFactories.counterCreateOperationMessage(objectId: "counter:abc@1000", count: nil, serial: "01", siteCode: "site1") + let update = try #require(Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue)) + + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 0) + #expect(counter.testsOnly_createOperationIsMerged == true) + #expect(update.isNoop == true) + } + + // MARK: - RTLO4a: canApplyOperation (serial gate) + + // @UTS objects/unit/RTLO4a/apply-empty-site-serial-0 + @Test + func canApplyOperationAllowsWhenSiteSerialEmpty() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + let msg = TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: "site1") + let result = try Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue) + + #expect(result != nil) + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 5) + } + + // @UTS objects/unit/RTLO4a/reject-stale-serial-0 + @Test + func canApplyOperationRejectsStaleSerial() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + counter.testsOnly_setSiteTimeserials(["site1": "05"]) + counter.testsOnly_setData(10) + + let msg = TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 99, serial: "03", siteCode: "site1") + let result = try Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue) + + #expect(result == nil) + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 10) + } + + // @UTS objects/unit/RTLO4a/reject-equal-serial-0 + @Test + func canApplyOperationRejectsEqualSerial() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + counter.testsOnly_setSiteTimeserials(["site1": "05"]) + counter.testsOnly_setData(10) + + let msg = TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 99, serial: "05", siteCode: "site1") + let result = try Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue) + + #expect(result == nil) + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 10) + } + + // @UTS objects/unit/RTLO4a/warn-invalid-serial-0 + @Test + func canApplyOperationWarnsOnEmptySerialOrSiteCode() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + let result1 = try Self.apply( + TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 5, serial: "", siteCode: "site1"), + to: counter, + pool: &pool, + internalQueue: internalQueue, + ) + let result2 = try Self.apply( + TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: ""), + to: counter, + pool: &pool, + internalQueue: internalQueue, + ) + + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 0) + #expect(result1 == nil) + #expect(result2 == nil) + } + + // MARK: - RTLC7c: source handling + + // @UTS objects/unit/RTLC7c/channel-source-updates-serials-0 + @Test + func channelSourceUpdatesSiteTimeserials() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + let msg = TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: "site1") + _ = try Self.apply(msg, to: counter, source: .channel, pool: &pool, internalQueue: internalQueue) + + #expect(counter.testsOnly_siteTimeserials["site1"] == "01") + } + + // @UTS objects/unit/RTLC7c/local-source-no-serial-update-0 + @Test + func localSourceDoesNotUpdateSiteTimeserials() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + let msg = TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: "site1") + _ = try Self.apply(msg, to: counter, source: .local, pool: &pool, internalQueue: internalQueue) + + #expect(counter.testsOnly_siteTimeserials.isEmpty) + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 5) + } + + // MARK: - RTLC7g + + // @UTS objects/unit/RTLC7g/apply-returns-true-0 + @Test + func applyOperationReturnsTrueOnSuccess() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + let msg = TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: "site1") + let result = try Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue) + + // D-5: spec `result == true` (applied) maps to a non-nil returned update + #expect(result != nil) + } + + // MARK: - RTLO4e, RTLO5, RTLO6: OBJECT_DELETE + + // @UTS objects/unit/RTLO5/object-delete-tombstones-0 + @Test + func objectDeleteTombstonesCounter() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + counter.testsOnly_setData(42) + counter.testsOnly_setSiteTimeserials(["site1": "00"]) + + let msg = TestFactories.objectDeleteOperationMessage(objectId: "counter:abc@1000", serial: "01", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1_700_000_000)) + let update = try #require(Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue)) + + #expect(counter.testsOnly_isTombstone == true) + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 0) + #expect(counter.testsOnly_tombstonedAt == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(update.update?.amount == -42) + #expect(update.tombstone == true) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLC7e/tombstoned-reject-ops-0 + @Test + func operationsOnTombstonedCounterAreRejected() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + // D-3: isTombstone is computed from tombstonedAt + counter.testsOnly_setTombstonedAt(Date(timeIntervalSince1970: 1_700_000_000)) + + let msg = TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: "site1") + let result = try Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue) + + #expect(result == nil) + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 0) + } + + // @UTS objects/unit/RTLO6/tombstoned-at-from-serial-timestamp-0 + @Test + func tombstonedAtFromSerialTimestamp() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + let msg = TestFactories.objectDeleteOperationMessage(objectId: "counter:abc@1000", serial: "01", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1_700_000_050)) + _ = try Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue) + + #expect(counter.testsOnly_tombstonedAt == Date(timeIntervalSince1970: 1_700_000_050)) + } + + // @UTS objects/unit/RTLO6/tombstoned-at-local-clock-0 + @Test + func tombstonedAtFromLocalClockWhenNoSerialTimestamp() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + // D-10: controllable clock; assert exact equality rather than a before/after range + let clockTime = Date(timeIntervalSince1970: 1_700_000_099) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue, clock: MockSimpleClock(currentTime: clockTime)) + + let msg = TestFactories.objectDeleteOperationMessage(objectId: "counter:abc@1000", serial: "01", siteCode: "site1", serialTimestamp: nil) + _ = try Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue) + + #expect(counter.testsOnly_tombstonedAt == clockTime) + } + + // MARK: - RTLC7d3 + + // @UTS objects/unit/RTLC7d3/unsupported-action-0 + @Test + func unsupportedActionIsDiscarded() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + // A MAP_SET action targeting the counter is unsupported for LiveCounter + let msg = TestFactories.mapSetOperationMessage(objectId: "counter:abc@1000", key: "x", value: "y", serial: "01", siteCode: "site1") + let result = try Self.apply(msg, to: counter, pool: &pool, internalQueue: internalQueue) + + #expect(result == nil) + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 0) + } + + // MARK: - RTLC6: replaceData (sync path) + + // @UTS objects/unit/RTLC6/replace-data-basic-0 + @Test + func replaceDataSetsDataFromObjectState() throws { + let internalQueue = TestFactories.createInternalQueue() + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + counter.testsOnly_setData(10) + counter.testsOnly_setCreateOperationIsMerged(true) + counter.testsOnly_setSiteTimeserials(["site1": "00"]) + + let state = TestFactories.counterObjectState(objectId: "counter:abc@1000", siteTimeserials: ["site2": "05"], count: 50) + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } + + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 50) + #expect(counter.testsOnly_siteTimeserials == ["site2": "05"]) + #expect(counter.testsOnly_createOperationIsMerged == false) + #expect(update.update?.amount == 40) + // D-7: RTO4b2a — sync-originated, so objectMessage is nil (spec asserts == state_msg) + #expect(update.objectMessage == nil) + } + + // @UTS objects/unit/RTLC6/replace-data-with-create-op-0 + @Test + func replaceDataWithCreateOpMergesInitialValue() throws { + let internalQueue = TestFactories.createInternalQueue() + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + let state = TestFactories.counterObjectState( + objectId: "counter:abc@1000", + siteTimeserials: ["site1": "01"], + createOp: TestFactories.counterCreateOperation(objectId: "counter:abc@1000", count: 50), + count: 100, + ) + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } + + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 150) + #expect(counter.testsOnly_createOperationIsMerged == true) + #expect(update.update?.amount == 150) + #expect(update.objectMessage == nil) // D-7 + } + + // @UTS objects/unit/RTLC6e/replace-data-tombstoned-noop-0 + @Test + func replaceDataOnTombstonedCounterIsNoop() throws { + let internalQueue = TestFactories.createInternalQueue() + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + counter.testsOnly_setTombstonedAt(Date(timeIntervalSince1970: 1_700_000_000)) + + let state = TestFactories.counterObjectState(objectId: "counter:abc@1000", siteTimeserials: ["site1": "01"], count: 999) + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } + + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 0) + #expect(update.isNoop == true) + } + + // @UTS objects/unit/RTLC6f/replace-data-tombstone-flag-0 + @Test + func replaceDataWithTombstoneFlagTombstonesCounter() throws { + let internalQueue = TestFactories.createInternalQueue() + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + counter.testsOnly_setData(30) + + let state = TestFactories.counterObjectState(objectId: "counter:abc@1000", siteTimeserials: ["site1": "01"], tombstone: true, count: 0) + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } + + #expect(counter.testsOnly_isTombstone == true) + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 0) + #expect(update.update?.amount == -30) + #expect(update.tombstone == true) + #expect(update.objectMessage == nil) // D-7 + } + + // @UTS objects/unit/RTLC6/replace-data-missing-count-0 + @Test + func replaceDataWithMissingCountDefaultsToZero() throws { + let internalQueue = TestFactories.createInternalQueue() + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + counter.testsOnly_setData(42) + + let state = TestFactories.counterObjectState(objectId: "counter:abc@1000", siteTimeserials: ["site1": "01"], count: nil) + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } + + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 0) + #expect(update.update?.amount == -42) + #expect(update.objectMessage == nil) // D-7 + } + + // MARK: - RTLC14 + + // @UTS objects/unit/RTLC14/diff-calculation-0 + @Test + func diffCalculation() throws { + let internalQueue = TestFactories.createInternalQueue() + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + counter.testsOnly_setData(20) + + let state = TestFactories.counterObjectState(objectId: "counter:abc@1000", siteTimeserials: ["site1": "01"], count: 75) + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } + + #expect(update.update?.amount == 55) + #expect(update.objectMessage == nil) // D-7 + } + + // MARK: - RTLC8, RTLC16 + + // @UTS objects/unit/RTLC8/create-then-inc-0 + @Test + func counterCreateThenIncAccumulates() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + _ = try Self.apply(TestFactories.counterCreateOperationMessage(objectId: "counter:abc@1000", count: 100, serial: "01", siteCode: "site1"), to: counter, pool: &pool, internalQueue: internalQueue) + _ = try Self.apply(TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 25, serial: "02", siteCode: "site1"), to: counter, pool: &pool, internalQueue: internalQueue) + + #expect(try Self.data(of: counter, internalQueue: internalQueue) == 125) + #expect(counter.testsOnly_createOperationIsMerged == true) + } + + // MARK: - RTLO3 + + // @UTS objects/unit/RTLO3/live-object-init-properties-0 + @Test + func liveObjectPropertiesInitializedCorrectly() { + let internalQueue = TestFactories.createInternalQueue() + let counter = Self.makeCounter(objectID: "counter:test@2000", internalQueue: internalQueue) + + #expect(counter.testsOnly_objectID == "counter:test@2000") + #expect(counter.testsOnly_siteTimeserials.isEmpty) + #expect(counter.testsOnly_createOperationIsMerged == false) + #expect(counter.testsOnly_isTombstone == false) + #expect(counter.testsOnly_tombstonedAt == nil) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveMapApiUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveMapApiUTSTests.swift new file mode 100644 index 000000000..545e84270 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveMapApiUTSTests.swift @@ -0,0 +1,170 @@ +// @UTS objects/unit/internal_live_map_api.md + +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// `InternalLiveMap` public-facing API — value reads (`get`/`size`/`entries`/`keys`) and the v6 +/// `MAP_SET`/`MAP_REMOVE` write messages, surfaced through the path layer (`RTPO*` delegate to these). +/// Derived from https://github.com/ably/specification/blob/main/uts/objects/unit/internal_live_map_api.md +/// (spec points `RTLM5`, `RTLM10`–`RTLM12`, `RTLM20`–`RTLM21`, `RTLMV4`, `RTLCV4`). +/// +/// The spec declares mock-WebSocket infrastructure (`setup_synced_channel`, `MockWebSocket`, +/// `captured_messages`, `mock_ws.send_to_client`) for these cases. Per the UNIT-only scope this ports +/// the subset exercisable without it: reads run against the standard pool seeded directly +/// (`ObjectsUTS.standardPool`), and the write messages are captured by ``ObjectsUTSSeededRealtimeObjects`` +/// (the spec's `captured_messages[0].state[0]` maps to cocoa's `messages[0].operation`). Reads are +/// surfaced through a ``DefaultLiveMapPathObject`` — the map surface the spec's `root` resolves to. +/// +/// ## Mock-realtime adaptation (recorded in deviations.md) +/// The seeded double captures the published `ObjectMessage` but does not apply it back onto the map, +/// so the post-apply `set-applies-locally` (`root.get("name").value() == "Bob"`) is out of unit scope +/// (only the published MAP_SET is asserted). +/// +/// ## Bytes representation (deviation) +/// RTLM20e7f asserts `mapSet.value.bytes == "AQID"` (base64). Cocoa's outbound `ObjectData.bytes` holds +/// **raw `Data`**; the base64 encoding is applied at wire (JSON) serialization, below this layer. The +/// port asserts the raw `Data([1,2,3])`. +/// +/// ## Skipped — out of UNIT scope (need the concrete engine's async create pipeline / mock-WS) +/// - **RTLM20e7g (set with LiveCounter / LiveMap), RTLM20h1 (nested LiveMap + LiveCounter):** setting a +/// blueprint value materialises it via `RealtimeObjects.createCounter`/`createMap`, which +/// `DefaultLiveMapInstance` narrows to the concrete `InternalDefaultRealtimeObjects` (a +/// `preconditionFailure` otherwise). Those publish through the mock-WS OBJECT capture path — out of +/// scope; the seeded double cannot drive them. +/// - **RTLM20 set-invalid-values-table (function/undefined/symbol -> 40013):** `LiveMapValue` is a +/// closed enum, so a function / undefined / symbol value is compile-time-unrepresentable (as for +/// value_types.md RTLMV4c, DEV in deviations.md). +/// - **RTLM20d/RTLM21d write preconditions:** replaced by RTO26 (`objects/unit/realtime_object.md`). +@Suite(.serialized) +final class InternalLiveMapApiUTSTests { + // MARK: - Fixture + + private typealias Fixture = (root: DefaultLiveMapPathObject, realtimeObjects: ObjectsUTSSeededRealtimeObjects) + + private static func makeFixture() -> Fixture { + let internalQueue = ObjectsUTS.createInternalQueue() + let pool = ObjectsUTS.standardPool(internalQueue: internalQueue) + let realtimeObjects = ObjectsUTSSeededRealtimeObjects(pool: pool, internalQueue: internalQueue) + let coreSDK = ObjectsUTSCoreSDK() + let root = DefaultLiveMapPathObject(channelObject: realtimeObjects, coreSDK: coreSDK, internalQueue: internalQueue, path: "") + return (root, realtimeObjects) + } + + // MARK: - RTLM5: get() returns resolved value + + // objects/unit/RTLM5/get-string-value-0 — RTLM5d2 (returns the value at key, resolved). + @Test + func RTLM5_get_string_value() throws { + let root = Self.makeFixture().root + #expect(try root.get(key: "name").asPrimitive().value() == .string("Alice")) + #expect(try root.get(key: "age").asPrimitive().value() == .number(30)) + #expect(try root.get(key: "active").asPrimitive().value() == .bool(true)) + } + + // objects/unit/RTLM5/get-nonexistent-key-0 — no entry at key -> null. + @Test + func RTLM5_get_nonexistent_key() throws { + let root = Self.makeFixture().root + #expect(try root.get(key: "nonexistent").asPrimitive().value() == nil) + } + + // objects/unit/RTLM5/get-objectid-reference-0 — a data.objectId entry resolves from the pool. + @Test + func RTLM5_get_objectid_reference() throws { + let root = Self.makeFixture().root + #expect(try root.get(key: "score").asLiveCounter().value() == 100) + #expect(try root.at(path: "profile.email").asPrimitive().value() == .string("alice@example.com")) + } + + // MARK: - RTLM10: size() returns non-tombstoned count + + // objects/unit/RTLM10/size-non-tombstoned-0 — RTLM10d (number of non-tombstoned entries). + @Test + func RTLM10_size_non_tombstoned() throws { + let root = Self.makeFixture().root + #expect(try root.size() == 7) + } + + // MARK: - RTLM11: entries() yields key-value pairs + + // objects/unit/RTLM11/entries-yields-pairs-0 — RTLM11d (non-tombstoned key-value pairs). + @Test + func RTLM11_entries_yields_pairs() throws { + let root = Self.makeFixture().root + let keys = try Set(root.entries().map(\.key)) + #expect(keys == ["name", "age", "active", "score", "profile", "data", "avatar"]) + } + + // MARK: - RTLM12: keys() yields only keys + + // objects/unit/RTLM12/keys-0 — keys() returns the 7 non-tombstoned keys. + @Test + func RTLM12_keys() throws { + let root = Self.makeFixture().root + let keys = try root.keys() + #expect(keys.count == 7) + #expect(keys.contains("name")) + } + + // MARK: - RTLM20: set() sends MAP_SET message with v6 format + + // objects/unit/RTLM20/set-sends-map-set-0 — RTLM20e2/e3/e6/e7c/h2 (MAP_SET, objectId, key, string + // value, single-element array). + @Test + func RTLM20_set_sends_map_set() async throws { + let fixture = Self.makeFixture() + try await fixture.root.set(key: "name", value: .primitive(.string("Bob"))) + + let messages = try #require(fixture.realtimeObjects.capturedMessages) + #expect(messages.count == 1) // RTLM20h2 + let op = try #require(messages[0].operation) + #expect(op.action == .known(.mapSet)) // RTLM20e2 + #expect(op.objectId == ObjectsPool.rootKey) // RTLM20e3 + #expect(op.mapSet?.key == "name") // RTLM20e6 + #expect(op.mapSet?.value?.string == "Bob") // RTLM20e7c + } + + // objects/unit/RTLM20/set-value-types-0 — RTLM20e7b/d/e (json/number/boolean values). + @Test + func RTLM20_set_value_types() async throws { + let fixture = Self.makeFixture() + + try await fixture.root.set(key: "num_key", value: .primitive(.number(42))) + #expect(try #require(fixture.realtimeObjects.capturedMessages)[0].operation?.mapSet?.value?.number == NSNumber(value: 42)) // RTLM20e7d + + try await fixture.root.set(key: "bool_key", value: .primitive(.bool(false))) + #expect(try #require(fixture.realtimeObjects.capturedMessages)[0].operation?.mapSet?.value?.boolean == false) // RTLM20e7e + + try await fixture.root.set(key: "json_key", value: .primitive(.jsonObject(["nested": .bool(true)]))) + #expect(try #require(fixture.realtimeObjects.capturedMessages)[0].operation?.mapSet?.value?.json == .object(["nested": .bool(true)])) // RTLM20e7b + } + + // objects/unit/RTLM20/set-bytes-value-0 — RTLM20e7f (binary value). Cocoa holds raw Data; base64 is + // applied at wire serialization (deviation). + @Test + func RTLM20_set_bytes_value() async throws { + let fixture = Self.makeFixture() + try await fixture.root.set(key: "binary_data", value: .primitive(.data(Data([1, 2, 3])))) + + let messages = try #require(fixture.realtimeObjects.capturedMessages) + #expect(messages[0].operation?.mapSet?.value?.bytes == Data([1, 2, 3])) + } + + // MARK: - RTLM21: remove() sends MAP_REMOVE message + + // objects/unit/RTLM21/remove-sends-map-remove-0 — RTLM21e2/e5 (MAP_REMOVE action, key). + @Test + func RTLM21_remove_sends_map_remove() async throws { + let fixture = Self.makeFixture() + try await fixture.root.remove(key: "name") + + let messages = try #require(fixture.realtimeObjects.capturedMessages) + let op = try #require(messages[0].operation) + #expect(op.action == .known(.mapRemove)) // RTLM21e2 + #expect(op.objectId == ObjectsPool.rootKey) // RTLM21e3 + #expect(op.mapRemove?.key == "name") // RTLM21e5 + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveMapParentReferencesUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveMapParentReferencesUTSTests.swift new file mode 100644 index 000000000..13741af11 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveMapParentReferencesUTSTests.swift @@ -0,0 +1,282 @@ +// @UTS objects/unit/internal_live_map.md +// +// Partial port of `objects/unit/internal_live_map.md`: ONLY the six parent-reference cases +// (RTLM7a3 overwrite, RTLM7g2 new-entry, RTLM7 primitive-no-refs, RTLM8a3, RTLM24e1c, RTLO4e9). +// The remaining InternalLiveMap CRDT cases in that spec file are covered by +// `UTS/InternalLiveMapUTSTests.swift` (and the native `InternalDefaultLiveMapTests.swift`). +// +// These verify that map operations maintain the RTLO3f parent-reference graph: MAP_SET drops the old +// child's reference and adds the new child's (RTLM7a3 / RTLM7g2), MAP_REMOVE drops the child's ref +// (RTLM8a3), MAP_CLEAR drops refs for every cleared entry (RTLM24e1c), OBJECT_DELETE drops the refs the +// map holds on all its children (RTLO4e9), and primitive-valued sets touch no references. +// +// Deviations from the UTS spec: +// - (D-1) The spec constructs `InternalLiveCounter(objectId:)` / `InternalLiveMap(objectId:, semantics:)`. +// The Swift live objects need a logger/queue/callback-queue/clock; counters use +// `InternalDefaultLiveCounter.createZeroValued(...)` and maps are built via the +// `InternalDefaultLiveMap(testsOnly_data:objectID:...)` seam so initial `data` can be seeded (D-2). +// - (D-2) Spec `map.data = { "k": { data: { objectId: … }, timeserial: "01", tombstone: false } }` maps to +// the `testsOnly_data:` initializer argument, with each entry built by `TestFactories.internalMapEntry( +// timeserial:data:)`. `{ objectId: "x" }` → `ProtocolTypes.ObjectData(objectId: "x")`; `{ string: "x" }` +// → `ProtocolTypes.ObjectData(string: "x")`. Reads of `map.data[k]` map to `map.testsOnly_data[k]`. +// - (D-3) Drive path: spec `map.applyOperation(build_map_set/remove/clear(…), source: CHANNEL)` maps to +// the gated `nosync_apply(operation, source:, objectMessageSerial:, objectMessageSiteCode:, +// objectMessageSerialTimestamp:, sourceObjectMessage:, objectsPool:&)`. The built message is +// decomposed and its PAOM3 public form passed as `sourceObjectMessage` (RTLO4b4d) so the returned +// update carries it — see D-6. (An earlier revision drove the granular `testsOnly_applyMap*Operation` +// seams, which bypass the RTLM15 gate AND the message-stamping; they were switched to `nosync_apply` +// so the `update.objectMessage` assertions the spec has could be honoured. The maps' `siteTimeserials` +// start empty, so any first op passes the RTLO4a5 gate with no seeding needed.) `nosync_apply` is +// `nosync_`, so it runs on the internal queue. +// - (D-4) OBJECT_DELETE (RTLO4e9) is driven through the same `nosync_apply` with an objectDelete +// operation from `TestFactories.objectDeleteOperationMessage(...)`. The map's siteTimeserials are +// seeded `["site1": "00"]` so the op serial "01" passes the RTLM15 object gate. +// - (D-5) Spec `pool["id"] = obj` maps to `pool.testsOnly_setEntry(.counter(obj) / .map(obj), forObjectID: "id")`. +// Child parentReferences seeding `child.parentReferences = {…}` maps to `child.testsOnly_setParentReferences(_:)`, +// reads to `child.testsOnly_parentReferences`. Spec `Dict>` ↔ `[String: Set]`. +// - (D-6) LiveMapUpdate shape: the spec asserts `update.update == { "ref": "updated" }`, `update.objectMessage +// == msg`, and (for OBJECT_DELETE) `update.tombstone == true`. Swift's `LiveObjectUpdate< +// DefaultLiveMapUpdate>` exposes `.update` (a `DefaultLiveMapUpdate` whose `.update` is `[String: +// LiveMapUpdateAction]` of `.updated` / `.removed`), plus `.objectMessage` (the PAOM3 public message, +// RTLO4b4d) and `.tombstone` (RTLO4b4e). All three are now carried by the gated apply path and asserted +// here (`update.objectMessage == msg.toPublicObjectMessage(channelName:)`), so — unlike the P1 revision +// of this file — none are omitted. + +@testable import AblyLiveObjects +import Foundation +import Testing + +struct InternalLiveMapParentReferencesUTSTests { + /// The channel name used when converting inbound messages to their PAOM3 public form (PAOM2e). + private static let channelName = "test-channel" + + // MARK: - Helpers (D-1) + + private static func makeCounter(objectID: String, internalQueue: DispatchQueue) -> InternalDefaultLiveCounter { + InternalDefaultLiveCounter.createZeroValued( + objectID: objectID, + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + private static func makeMap(objectID: String, data: [String: InternalObjectsMapEntry] = [:], internalQueue: DispatchQueue) -> InternalDefaultLiveMap { + InternalDefaultLiveMap( + testsOnly_data: data, + objectID: objectID, + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + private static func makePool(internalQueue: DispatchQueue) -> ObjectsPool { + ObjectsPool(logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + /// Drives the gated `nosync_apply` from an inbound message, decomposing it (D-3) and passing its + /// PAOM3 public form as `sourceObjectMessage` (D-6). + private static func apply( + _ message: ProtocolTypes.InboundObjectMessage, + to map: InternalDefaultLiveMap, + pool: inout ObjectsPool, + internalQueue: DispatchQueue, + ) throws -> LiveObjectUpdate? { + let operation = try #require(message.operation) + return internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: message.serial, + objectMessageSiteCode: message.siteCode, + objectMessageSerialTimestamp: message.serialTimestamp, + sourceObjectMessage: message.toPublicObjectMessage(channelName: channelName), + objectsPool: &pool, + ) + } + } + + // @UTS objects/unit/RTLM7a3/map-set-overwrite-objectid-parent-refs-0 + @Test + func mapSetOverwriteEntryReferencingLiveObject() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + let oldCounter = Self.makeCounter(objectID: "counter:old@1000", internalQueue: internalQueue) + let newCounter = Self.makeCounter(objectID: "counter:new@2000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.counter(oldCounter), forObjectID: "counter:old@1000") + pool.testsOnly_setEntry(.counter(newCounter), forObjectID: "counter:new@2000") + + let map = Self.makeMap( + objectID: "root", + data: ["ref": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(objectId: "counter:old@1000"))], + internalQueue: internalQueue, + ) + // Simulate existing parentReference + oldCounter.testsOnly_setParentReferences(["root": ["ref"]]) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "ref", data: ProtocolTypes.ObjectData(objectId: "counter:new@2000"), serial: "02", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["ref"]?.data?.objectId == "counter:new@2000") + // removeParentReference was called on the old child + #expect(oldCounter.testsOnly_parentReferences["root"]?.contains("ref") != true) + // addParentReference was called on the new child + #expect(newCounter.testsOnly_parentReferences["root"]?.contains("ref") == true) + #expect(update.update?.update == ["ref": .updated]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLM7g2/map-set-new-entry-add-parent-ref-0 + @Test + func mapSetNewEntryReferencingLiveObject() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + let childCounter = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.counter(childCounter), forObjectID: "counter:child@1000") + + let map = Self.makeMap(objectID: "root", internalQueue: internalQueue) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "score", data: ProtocolTypes.ObjectData(objectId: "counter:child@1000"), serial: "01", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["score"]?.data?.objectId == "counter:child@1000") + #expect(childCounter.testsOnly_parentReferences["root"]?.contains("score") == true) + #expect(update.update?.update == ["score": .updated]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLM7/map-set-primitive-no-parent-refs-0 + @Test + func mapSetWithNonLiveObjectValueDoesNotAffectParentReferences() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + let oldCounter = Self.makeCounter(objectID: "counter:old@1000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.counter(oldCounter), forObjectID: "counter:old@1000") + + let map = Self.makeMap( + objectID: "root", + data: ["ref": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(objectId: "counter:old@1000"))], + internalQueue: internalQueue, + ) + oldCounter.testsOnly_setParentReferences(["root": ["ref"]]) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "ref", value: "plain_value", serial: "02", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["ref"]?.data?.string == "plain_value") + // removeParentReference was called on old child (entry previously had objectId) + #expect(oldCounter.testsOnly_parentReferences["root"]?.contains("ref") != true) + // No addParentReference call because the new value is a primitive + #expect(update.update?.update == ["ref": .updated]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLM8a3/map-remove-objectid-parent-refs-0 + @Test + func mapRemoveEntryReferencingLiveObject() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + let childCounter = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.counter(childCounter), forObjectID: "counter:child@1000") + + let map = Self.makeMap( + objectID: "root", + data: ["score": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(objectId: "counter:child@1000"))], + internalQueue: internalQueue, + ) + childCounter.testsOnly_setParentReferences(["root": ["score"]]) + + let msg = TestFactories.mapRemoveOperationMessage(objectId: "root", key: "score", serial: "02", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1_700_000_000)) + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["score"]?.tombstone == true) + // removeParentReference was called on the child + #expect(childCounter.testsOnly_parentReferences["root"]?.contains("score") != true) + #expect(update.update?.update == ["score": .removed]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLM24e1c/map-clear-parent-refs-0 + @Test + func mapClearRemovesParentReferencesForClearedEntries() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + let counterA = Self.makeCounter(objectID: "counter:a@1000", internalQueue: internalQueue) + let counterB = Self.makeCounter(objectID: "counter:b@1000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.counter(counterA), forObjectID: "counter:a@1000") + pool.testsOnly_setEntry(.counter(counterB), forObjectID: "counter:b@1000") + + let map = Self.makeMap( + objectID: "root", + data: [ + "ref_a": TestFactories.internalMapEntry(timeserial: "02", data: ProtocolTypes.ObjectData(objectId: "counter:a@1000")), + "ref_b": TestFactories.internalMapEntry(timeserial: "02", data: ProtocolTypes.ObjectData(objectId: "counter:b@1000")), + "primitive": TestFactories.internalMapEntry(timeserial: "02", data: ProtocolTypes.ObjectData(string: "hello")), + "newer": TestFactories.internalMapEntry(timeserial: "09", data: ProtocolTypes.ObjectData(string: "kept")), + ], + internalQueue: internalQueue, + ) + counterA.testsOnly_setParentReferences(["root": ["ref_a"]]) + counterB.testsOnly_setParentReferences(["root": ["ref_b"]]) + + let msg = TestFactories.mapClearOperationMessage(objectId: "root", serial: "05", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + // ref_a and ref_b removed (timeserial "02" < "05"), newer kept (timeserial "09" > "05") + #expect(map.testsOnly_data["ref_a"] == nil) + #expect(map.testsOnly_data["ref_b"] == nil) + #expect(map.testsOnly_data["primitive"] == nil) + #expect(map.testsOnly_data["newer"] != nil) + // removeParentReference was called on both child counters + #expect(counterA.testsOnly_parentReferences["root"]?.contains("ref_a") != true) + #expect(counterB.testsOnly_parentReferences["root"]?.contains("ref_b") != true) + #expect(update.update?.update == ["ref_a": .removed, "ref_b": .removed, "primitive": .removed]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLO4e9/tombstone-map-parent-refs-0 + @Test + func tombstoneMapRemovesParentReferencesForAllEntries() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + let childCounter = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + let childMap = Self.makeMap(objectID: "map:child@1000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.counter(childCounter), forObjectID: "counter:child@1000") + pool.testsOnly_setEntry(.map(childMap), forObjectID: "map:child@1000") + + let map = Self.makeMap( + objectID: "map:test@1000", + data: [ + "counter_ref": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(objectId: "counter:child@1000")), + "map_ref": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(objectId: "map:child@1000")), + "name": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "Alice")), + ], + internalQueue: internalQueue, + ) + // Seed the object gate so the OBJECT_DELETE (serial "01", site "site1") passes RTLM15 (D-4) + map.testsOnly_setSiteTimeserials(["site1": "00"]) + childCounter.testsOnly_setParentReferences(["map:test@1000": ["counter_ref"]]) + childMap.testsOnly_setParentReferences(["map:test@1000": ["map_ref"]]) + + let msg = TestFactories.objectDeleteOperationMessage(objectId: "map:test@1000", serial: "01", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1_700_000_000)) + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_isTombstone == true) + #expect(map.testsOnly_data.isEmpty) + // removeParentReference was called on both children + #expect(childCounter.testsOnly_parentReferences["map:test@1000"]?.contains("counter_ref") != true) + #expect(childMap.testsOnly_parentReferences["map:test@1000"]?.contains("map_ref") != true) + #expect(update.update?.update == ["counter_ref": .removed, "map_ref": .removed, "name": .removed]) + #expect(update.tombstone == true) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveMapUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveMapUTSTests.swift new file mode 100644 index 000000000..76429f929 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/InternalLiveMapUTSTests.swift @@ -0,0 +1,899 @@ +// @UTS objects/unit/internal_live_map.md +// +// Port of the REMAINING `objects/unit/internal_live_map.md` cases — everything except the six +// parent-reference cases already covered by `UTS/InternalLiveMapParentReferencesUTSTests.swift` +// (RTLM7a3 overwrite, RTLM7g2 new-entry, RTLM7 primitive-no-refs, RTLM8a3, RTLM24e1c, RTLO4e9). +// The two OTHER parent-reference cases the spec has but that file omitted — RTLM8 +// (map-remove-primitive-no-parent-refs) and RTLM7a3 (map-set-replace-objectid-both-refs) — ARE +// remaining, so they are ported here. +// +// These drive `InternalDefaultLiveMap` directly: zero value, MAP_SET / MAP_REMOVE / MAP_CLEAR / +// MAP_CREATE application, LWW (RTLM9) and clearTimeserial (RTLM7h/8g) gating, tombstoning +// (OBJECT_DELETE, RTLO5) and the RTLO4e10 root-delete rejection, the sync `replaceData` path +// (RTLM6*), GC (RTLM19), the RTLM22 diff, and the RTLM14 tombstoned-entry check. +// +// Deviations from the UTS spec: +// - (D-1) Construction: `InternalLiveMap(objectId:, semantics:[, pool:])` maps to +// `InternalDefaultLiveMap(testsOnly_data:objectID:…)` (so initial `data` can be seeded) or +// `.createZeroValued(objectID:…)`; there is no pool ctor param — the pool is passed `inout` +// per-op. Standard mock preamble per the audit §2. +// - (D-2) Queue discipline: mutating `nosync_*` entry points (`nosync_apply`, `nosync_replaceData`, +// `nosync_releaseTombstonedEntries`) run inside `internalQueue.ably_syncNoDeadlock { }`; +// `testsOnly_set*` seams hop onto the queue themselves, so setup writes are outside the wrapper. +// - (D-3) Setup writes: spec direct assignments (`map.data = {…}`, `map.clearTimeserial = "05"`, +// `map.isTombstone = true`, `map.siteTimeserials = {…}`) map to the `testsOnly_data:` ctor arg and +// the seams `testsOnly_setClearTimeserial`, `testsOnly_setTombstonedAt` (isTombstone is computed +// from tombstonedAt), `testsOnly_setSiteTimeserials`. Entry fields map to `InternalObjectsMapEntry` +// (`tombstone` computed from `tombstonedAt`); spec ms epoch → `Date`. A `{ data: null … }` +// tombstoned entry is built with `InternalObjectsMapEntry(tombstonedAt:timeserial:data:)` directly +// (the `TestFactories.internalMapEntry` helper's `data` is non-optional). +// - (D-4) Message decomposition: `map.applyOperation(msg, source)` maps to +// `map.nosync_apply(operation, source:, objectMessageSerial:, objectMessageSiteCode:, +// objectMessageSerialTimestamp:, sourceObjectMessage:, objectsPool:&)`. The built `msg` is +// decomposed and its PAOM3 public form (`toPublicObjectMessage(channelName:)`) passed as +// `sourceObjectMessage` (RTLO4b4d). +// - (D-5) `nosync_apply` returns `LiveObjectUpdate<…>?`: `nil` == gate-rejected (RTLM15b). So the +// spec's `result == false` / `update == false` maps to `== nil`; `update.noop` to `.isNoop`; +// `update.update` to `update.update?.update` (a `[String: LiveMapUpdateAction]` of `.updated` / +// `.removed`). +// - (D-6) `update.objectMessage == msg` maps to field-level equality against +// `msg.toPublicObjectMessage(channelName:)` (RTLO4b4d); `update.tombstone == true` to +// `update.tombstone` (RTLO4b4e). Both carried by the enriched update returned from the gated path. +// - (D-7) RTO4b2a — the sync path (`nosync_replaceData`) is sync-originated, so its returned update +// carries `objectMessage == nil`. The spec's `ASSERT update.objectMessage == state_msg` for the +// RTLM6 / RTLM6f cases therefore does NOT hold in cocoa; asserted as `objectMessage == nil`. +// - (D-8) Reads through functional accessors: `map.size()` / `map.get(key)` map to +// `size(coreSDK:delegate:)` / `get(key:coreSDK:delegate:)` with `MockCoreSDK` (`.attaching`) + +// `MockLiveMapObjectsPoolDelegate`. `map.data[k]` maps to `map.testsOnly_data[k]`. +// - (D-9) `InternalLiveMap.diff(prev, new)` (RTLM22) maps to +// `ObjectDiffHelpers.calculateMapDiff(previousData:newData:)`. +// - (D-10) `map.gcTombstonedEntries(grace, now)` (RTLM19) maps to +// `nosync_releaseTombstonedEntries(gracePeriod:clock:)` with grace in SECONDS (spec ms) and `now` +// supplied by a `MockSimpleClock`. +// - (D-11) IMPLEMENTATION FIX (flagged in the report): RTLO4e10 (the root object must never be +// tombstoned) was unimplemented — `InternalDefaultLiveMap`'s OBJECT_DELETE / tombstone-flag paths +// would wrongly tombstone `root`. Fixed minimally in `Sources/AblyLiveObjects/Internal/ +// InternalDefaultLiveMap.swift` (both `apply` OBJECT_DELETE and `replaceData` tombstone-flag paths +// now short-circuit to a noop for `objectID == root`). The two RTLO4e10 cases here exercise it. + +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +struct InternalLiveMapUTSTests { + /// The channel name used when converting inbound messages to their PAOM3 public form (PAOM2e). + private static let channelName = "test-channel" + + // MARK: - Helpers (D-1) + + private static func makeMap(objectID: String, data: [String: InternalObjectsMapEntry] = [:], internalQueue: DispatchQueue) -> InternalDefaultLiveMap { + InternalDefaultLiveMap( + testsOnly_data: data, + objectID: objectID, + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + private static func makeCounter(objectID: String, internalQueue: DispatchQueue) -> InternalDefaultLiveCounter { + InternalDefaultLiveCounter.createZeroValued( + objectID: objectID, + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + private static func makePool(internalQueue: DispatchQueue) -> ObjectsPool { + ObjectsPool(logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + private static func coreSDK(internalQueue: DispatchQueue) -> MockCoreSDK { + MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + } + + /// Drives the gated `nosync_apply` from an inbound message, decomposing it (D-4) and passing its + /// PAOM3 public form as `sourceObjectMessage`. + private static func apply( + _ message: ProtocolTypes.InboundObjectMessage, + to map: InternalDefaultLiveMap, + source: ObjectsOperationSource = .channel, + pool: inout ObjectsPool, + internalQueue: DispatchQueue, + ) throws -> LiveObjectUpdate? { + let operation = try #require(message.operation) + return internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: source, + objectMessageSerial: message.serial, + objectMessageSiteCode: message.siteCode, + objectMessageSerialTimestamp: message.serialTimestamp, + sourceObjectMessage: message.toPublicObjectMessage(channelName: channelName), + objectsPool: &pool, + ) + } + } + + // MARK: - RTLM4 + + // @UTS objects/unit/RTLM4/zero-value-0 + @Test + func zeroValueMap() { + let internalQueue = TestFactories.createInternalQueue() + let map = Self.makeMap(objectID: "root", internalQueue: internalQueue) + + #expect(map.testsOnly_data.isEmpty) + #expect(map.testsOnly_clearTimeserial == nil) + #expect(map.testsOnly_isTombstone == false) + #expect(map.testsOnly_createOperationIsMerged == false) + #expect(map.testsOnly_siteTimeserials.isEmpty) + } + + // MARK: - RTLM7: MAP_SET + + // @UTS objects/unit/RTLM7/map-set-new-entry-0 + @Test + func mapSetCreatesNewEntry() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap(objectID: "root", internalQueue: internalQueue) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "name", value: "Alice", serial: "01", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["name"]?.data == ProtocolTypes.ObjectData(string: "Alice")) + #expect(map.testsOnly_data["name"]?.timeserial == "01") + #expect(map.testsOnly_data["name"]?.tombstone == false) + #expect(update.update?.update == ["name": .updated]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLM7/map-set-update-entry-0 + @Test + func mapSetUpdatesExistingEntry() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: ["name": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "Alice"))], + internalQueue: internalQueue, + ) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "name", value: "Bob", serial: "02", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["name"]?.data == ProtocolTypes.ObjectData(string: "Bob")) + #expect(map.testsOnly_data["name"]?.timeserial == "02") + #expect(update.update?.update == ["name": .updated]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // MARK: - RTLM9: LWW + + // @UTS objects/unit/RTLM9/lww-reject-stale-0 + @Test + func lwwRejectsStaleSerialOnExistingEntry() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: ["name": TestFactories.internalMapEntry(timeserial: "05", data: ProtocolTypes.ObjectData(string: "Alice"))], + internalQueue: internalQueue, + ) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "name", value: "Bob", serial: "03", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["name"]?.data == ProtocolTypes.ObjectData(string: "Alice")) + #expect(update.isNoop == true) + } + + // @UTS objects/unit/RTLM9/lww-reject-equal-0 + @Test + func lwwRejectsEqualSerial() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: ["name": TestFactories.internalMapEntry(timeserial: "05", data: ProtocolTypes.ObjectData(string: "Alice"))], + internalQueue: internalQueue, + ) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "name", value: "Bob", serial: "05", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["name"]?.data == ProtocolTypes.ObjectData(string: "Alice")) + #expect(update.isNoop == true) + } + + // @UTS objects/unit/RTLM9b/both-empty-reject-0 + @Test + func bothSerialsEmptyRejectsOperation() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: ["name": TestFactories.internalMapEntry(timeserial: "", data: ProtocolTypes.ObjectData(string: "Alice"))], + internalQueue: internalQueue, + ) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "name", value: "Bob", serial: "", siteCode: "site1") + let update = try Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue) + + #expect(map.testsOnly_data["name"]?.data == ProtocolTypes.ObjectData(string: "Alice")) + // The op's serial is empty, so the OBJECT-level gate (RTLO4a3) rejects it before the + // entry-level RTLM9b comparison; nosync_apply returns nil (D-5). The RTLM9b "both empty" case + // is thus unreachable via applyOperation — a spec layering tension noted in the spec itself. + #expect(update == nil) + } + + // @UTS objects/unit/RTLM9d/missing-entry-serial-allows-0 + @Test + func missingEntrySerialAllowsOperation() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: ["name": TestFactories.internalMapEntry(timeserial: nil, data: ProtocolTypes.ObjectData(string: "Alice"))], + internalQueue: internalQueue, + ) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "name", value: "Bob", serial: "01", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["name"]?.data == ProtocolTypes.ObjectData(string: "Bob")) + #expect(update.update?.update == ["name": .updated]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // MARK: - RTLM7h, RTLM7g + + // @UTS objects/unit/RTLM7h/map-set-clear-timeserial-floor-0 + @Test + func mapSetRejectedWhenSerialAtOrBelowClearTimeserial() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap(objectID: "root", internalQueue: internalQueue) + map.testsOnly_setClearTimeserial("05") + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "name", value: "Alice", serial: "03", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["name"] == nil) + #expect(update.isNoop == true) + } + + // @UTS objects/unit/RTLM7g/map-set-objectid-creates-zero-value-0 + @Test + func mapSetWithObjectIdCreatesZeroValueObject() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap(objectID: "root", internalQueue: internalQueue) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "score", data: ProtocolTypes.ObjectData(objectId: "counter:new@2000"), serial: "01", siteCode: "site1") + _ = try Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue) + + let createdCounter = try #require(pool.entries["counter:new@2000"]?.counterValue) + #expect(try createdCounter.value(coreSDK: Self.coreSDK(internalQueue: internalQueue)) == 0) + } + + // MARK: - RTLM8: MAP_REMOVE + + // @UTS objects/unit/RTLM8/map-remove-existing-0 + @Test + func mapRemoveTombstonesExistingEntry() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: ["name": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "Alice"))], + internalQueue: internalQueue, + ) + + let msg = TestFactories.mapRemoveOperationMessage(objectId: "root", key: "name", serial: "02", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1_700_000_000)) + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["name"]?.data == nil) + #expect(map.testsOnly_data["name"]?.tombstone == true) + #expect(map.testsOnly_data["name"]?.timeserial == "02") + #expect(map.testsOnly_data["name"]?.tombstonedAt == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(update.update?.update == ["name": .removed]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLM8/map-remove-nonexistent-0 + @Test + func mapRemoveCreatesTombstonedEntryIfNotExists() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap(objectID: "root", internalQueue: internalQueue) + + let msg = TestFactories.mapRemoveOperationMessage(objectId: "root", key: "ghost", serial: "01", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1_700_000_000)) + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["ghost"]?.tombstone == true) + #expect(map.testsOnly_data["ghost"]?.tombstonedAt == Date(timeIntervalSince1970: 1_700_000_000)) + #expect(update.update?.update == ["ghost": .removed]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLM8g/map-remove-clear-timeserial-floor-0 + @Test + func mapRemoveRejectedWhenSerialAtOrBelowClearTimeserial() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: ["name": TestFactories.internalMapEntry(timeserial: "04", data: ProtocolTypes.ObjectData(string: "Alice"))], + internalQueue: internalQueue, + ) + map.testsOnly_setClearTimeserial("05") + + let msg = TestFactories.mapRemoveOperationMessage(objectId: "root", key: "name", serial: "03", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1_700_000_000)) + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["name"]?.data == ProtocolTypes.ObjectData(string: "Alice")) + #expect(map.testsOnly_data["name"]?.tombstone == false) + #expect(update.isNoop == true) + } + + // MARK: - RTLM24: MAP_CLEAR + + // @UTS objects/unit/RTLM24/map-clear-basic-0 + @Test + func mapClearSetsClearTimeserialAndRemovesOlderEntries() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: [ + "old": TestFactories.internalMapEntry(timeserial: "02", data: ProtocolTypes.ObjectData(string: "old")), + "new": TestFactories.internalMapEntry(timeserial: "06", data: ProtocolTypes.ObjectData(string: "new")), + "same": TestFactories.internalMapEntry(timeserial: "04", data: ProtocolTypes.ObjectData(string: "same")), + ], + internalQueue: internalQueue, + ) + + let msg = TestFactories.mapClearOperationMessage(objectId: "root", serial: "04", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_clearTimeserial == "04") + #expect(map.testsOnly_data["old"] == nil) + // RTLM24e1: removed only if clear serial is strictly greater than the entry's timeserial; + // "same" has timeserial "04" == the clear serial (not greater), so it is KEPT. + #expect(map.testsOnly_data["same"] != nil) + #expect(map.testsOnly_data["new"] != nil) + #expect(update.update?.update == ["old": .removed]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLM24c/map-clear-stale-0 + @Test + func mapClearRejectedWhenClearTimeserialAlreadyGreater() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap(objectID: "root", internalQueue: internalQueue) + map.testsOnly_setClearTimeserial("10") + + let msg = TestFactories.mapClearOperationMessage(objectId: "root", serial: "05", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_clearTimeserial == "10") + #expect(update.isNoop == true) + } + + // @UTS objects/unit/RTLM24/map-clear-preserves-newer-0 + @Test + func mapClearPreservesEntriesWithNewerSerial() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: [ + "before": TestFactories.internalMapEntry(timeserial: "03", data: ProtocolTypes.ObjectData(string: "a")), + "after": TestFactories.internalMapEntry(timeserial: "07", data: ProtocolTypes.ObjectData(string: "b")), + "no_ts": TestFactories.internalMapEntry(timeserial: nil, data: ProtocolTypes.ObjectData(string: "c")), + ], + internalQueue: internalQueue, + ) + + let msg = TestFactories.mapClearOperationMessage(objectId: "root", serial: "05", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["before"] == nil) + #expect(map.testsOnly_data["no_ts"] == nil) + #expect(map.testsOnly_data["after"]?.data == ProtocolTypes.ObjectData(string: "b")) + #expect(update.update?.update["before"] == .removed) + #expect(update.update?.update["no_ts"] == .removed) + #expect(update.update?.update["after"] == nil) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // MARK: - RTLM16, RTLM23: MAP_CREATE + + // @UTS objects/unit/RTLM16/map-create-merge-0 + @Test + func mapCreateMergesEntries() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap(objectID: "map:test@1000", internalQueue: internalQueue) + + let entries: [String: ProtocolTypes.ObjectsMapEntry] = [ + "name": ProtocolTypes.ObjectsMapEntry(tombstone: false, timeserial: "01", data: ProtocolTypes.ObjectData(string: "Alice")), + "removed_key": ProtocolTypes.ObjectsMapEntry(tombstone: true, timeserial: "01", data: nil, serialTimestamp: Date(timeIntervalSince1970: 1_700_000_000)), + ] + let msg = TestFactories.mapCreateOperationMessage(objectId: "map:test@1000", entries: entries, serial: "02", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["name"]?.data == ProtocolTypes.ObjectData(string: "Alice")) + #expect(map.testsOnly_data["removed_key"]?.tombstone == true) + #expect(map.testsOnly_createOperationIsMerged == true) + #expect(update.update?.update == ["name": .updated, "removed_key": .removed]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLM16b/map-create-already-merged-0 + @Test + func mapCreateNoopWhenAlreadyMerged() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap(objectID: "map:test@1000", internalQueue: internalQueue) + map.testsOnly_setCreateOperationIsMerged(true) + map.testsOnly_setSiteTimeserials(["site1": "00"]) + + let entries: [String: ProtocolTypes.ObjectsMapEntry] = [ + "name": ProtocolTypes.ObjectsMapEntry(tombstone: false, timeserial: "01", data: ProtocolTypes.ObjectData(string: "Bob")), + ] + let msg = TestFactories.mapCreateOperationMessage(objectId: "map:test@1000", entries: entries, serial: "01", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["name"] == nil) + #expect(update.isNoop == true) + } + + // MARK: - RTLM15c, RTLM15e + + // @UTS objects/unit/RTLM15c/channel-source-updates-serials-0 + @Test + func channelSourceUpdatesSiteTimeserials() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap(objectID: "root", internalQueue: internalQueue) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "x", data: ProtocolTypes.ObjectData(number: NSNumber(value: 1)), serial: "01", siteCode: "site1") + _ = try Self.apply(msg, to: map, source: .channel, pool: &pool, internalQueue: internalQueue) + + #expect(map.testsOnly_siteTimeserials["site1"] == "01") + } + + // @UTS objects/unit/RTLM15e/tombstoned-reject-ops-0 + @Test + func operationsOnTombstonedMapAreRejected() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap(objectID: "root", internalQueue: internalQueue) + // D-3: isTombstone is computed from tombstonedAt + map.testsOnly_setTombstonedAt(Date(timeIntervalSince1970: 1_700_000_000)) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "x", data: ProtocolTypes.ObjectData(number: NSNumber(value: 1)), serial: "01", siteCode: "site1") + let result = try Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue) + + #expect(result == nil) + #expect(map.testsOnly_data.isEmpty) + } + + // MARK: - RTLO5, RTLO4e10: OBJECT_DELETE / tombstoning + + // @UTS objects/unit/RTLO5/object-delete-tombstones-map-0 + @Test + func objectDeleteTombstonesMap() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "map:test@1000", + data: [ + "name": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "Alice")), + "age": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(number: NSNumber(value: 30))), + ], + internalQueue: internalQueue, + ) + map.testsOnly_setSiteTimeserials(["site1": "00"]) + + let msg = TestFactories.objectDeleteOperationMessage(objectId: "map:test@1000", serial: "01", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1_700_000_000)) + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_isTombstone == true) + #expect(map.testsOnly_data.isEmpty) + #expect(update.update?.update == ["name": .removed, "age": .removed]) + #expect(update.tombstone == true) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLO4e10/object-delete-root-noop-0 + // D-11: exercises the RTLO4e10 implementation fix. + @Test + func objectDeleteTargetingRootIsRejected() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: ["name": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "Alice"))], + internalQueue: internalQueue, + ) + map.testsOnly_setSiteTimeserials(["site1": "00"]) + + let msg = TestFactories.objectDeleteOperationMessage(objectId: "root", serial: "01", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1_700_000_000)) + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_isTombstone == false) + #expect(map.testsOnly_data["name"]?.data?.string == "Alice") + #expect(update.isNoop == true) + } + + // MARK: - RTLM14: tombstoned-entry check + + // @UTS objects/unit/RTLM14/tombstone-check-objectid-ref-0 + @Test + func tombstonedEntryCheckIncludesObjectIdReference() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let tombstonedCounter = Self.makeCounter(objectID: "counter:dead@1000", internalQueue: internalQueue) + tombstonedCounter.testsOnly_setTombstonedAt(Date(timeIntervalSince1970: 1_700_000_000)) + pool.testsOnly_setEntry(.counter(tombstonedCounter), forObjectID: "counter:dead@1000") + + let map = Self.makeMap( + objectID: "root", + data: [ + "alive": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "ok")), + "dead_entry": InternalObjectsMapEntry(tombstonedAt: Date(timeIntervalSince1970: 1_700_000_000), timeserial: "01", data: nil), + "dead_ref": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(objectId: "counter:dead@1000")), + ], + internalQueue: internalQueue, + ) + + let alive = try #require(map.testsOnly_data["alive"]) + let deadEntry = try #require(map.testsOnly_data["dead_entry"]) + let deadRef = try #require(map.testsOnly_data["dead_ref"]) + + // isEntryTombstoned reads the referenced object's `nosync_isTombstone`, which asserts + // ownership of the internal queue (D-2), so the checks run inside the queue block. + let (aliveTombstoned, deadEntryTombstoned, deadRefTombstoned) = internalQueue.ably_syncNoDeadlock { + ( + map.testsOnly_isEntryTombstoned(alive, objectsPool: pool), + map.testsOnly_isEntryTombstoned(deadEntry, objectsPool: pool), + map.testsOnly_isEntryTombstoned(deadRef, objectsPool: pool), + ) + } + + #expect(aliveTombstoned == false) + #expect(deadEntryTombstoned == true) + #expect(deadRefTombstoned == true) + } + + // @UTS objects/unit/RTLM14c/tombstoned-ref-yields-null-0 + @Test + func mapSetReferencingTombstonedObjectIdYieldsNull() throws { + let internalQueue = TestFactories.createInternalQueue() + let tombstonedCounter = Self.makeCounter(objectID: "counter:dead@1000", internalQueue: internalQueue) + tombstonedCounter.testsOnly_setTombstonedAt(Date(timeIntervalSince1970: 1_700_000_000)) + + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + delegate.objects["counter:dead@1000"] = .counter(tombstonedCounter) + + let map = Self.makeMap( + objectID: "root", + data: ["ref": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(objectId: "counter:dead@1000"))], + internalQueue: internalQueue, + ) + let coreSDK = Self.coreSDK(internalQueue: internalQueue) + + // The entry itself is not tombstoned, but the referenced object is (RTLM14c) + #expect(map.testsOnly_data["ref"]?.tombstone == false) + #expect(try map.size(coreSDK: coreSDK, delegate: delegate) == 0) + #expect(try map.get(key: "ref", coreSDK: coreSDK, delegate: delegate) == nil) + } + + // @UTS objects/unit/RTLM7/map-set-revives-tombstoned-0 + @Test + func mapSetRevivesTombstonedEntry() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: ["name": InternalObjectsMapEntry(tombstonedAt: Date(timeIntervalSince1970: 1_700_000_000), timeserial: "01", data: nil)], + internalQueue: internalQueue, + ) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "name", value: "Alice", serial: "02", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["name"]?.data == ProtocolTypes.ObjectData(string: "Alice")) + #expect(map.testsOnly_data["name"]?.tombstone == false) + #expect(map.testsOnly_data["name"]?.tombstonedAt == nil) + #expect(update.update?.update == ["name": .updated]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // MARK: - RTLM15d4 + + // @UTS objects/unit/RTLM15d4/unsupported-action-0 + @Test + func unsupportedActionIsDiscarded() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap(objectID: "root", internalQueue: internalQueue) + + // A COUNTER_INC action targeting the map is unsupported for LiveMap + let msg = TestFactories.counterIncOperationMessage(objectId: "root", number: 5, serial: "01", siteCode: "site1") + let result = try Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue) + + #expect(result == nil) + } + + // MARK: - RTLM6: replaceData (sync path) + + // @UTS objects/unit/RTLM6/replace-data-basic-0 + @Test + func replaceDataSetsDataFromObjectState() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: ["old": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "old"))], + internalQueue: internalQueue, + ) + map.testsOnly_setCreateOperationIsMerged(true) + + let state = TestFactories.objectState( + objectId: "root", + siteTimeserials: ["site2": "05"], + map: TestFactories.objectsMap( + entries: ["new": TestFactories.mapEntry(timeserial: "04", data: ProtocolTypes.ObjectData(string: "new"))], + clearTimeserial: "03", + ), + ) + let update = internalQueue.ably_syncNoDeadlock { + map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + + #expect(map.testsOnly_siteTimeserials == ["site2": "05"]) + #expect(map.testsOnly_createOperationIsMerged == false) + #expect(map.testsOnly_clearTimeserial == "03") + #expect(map.testsOnly_data["old"] == nil) + #expect(map.testsOnly_data["new"]?.data == ProtocolTypes.ObjectData(string: "new")) + #expect(update.update?.update == ["old": .removed, "new": .updated]) + // D-7: RTO4b2a — sync-originated, so objectMessage is nil (spec asserts == state_msg) + #expect(update.objectMessage == nil) + } + + // @UTS objects/unit/RTLM6c1/replace-data-tombstoned-entries-0 + @Test + func replaceDataSetsTombstonedAtOnTombstonedEntries() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap(objectID: "root", internalQueue: internalQueue) + + let state = TestFactories.objectState( + objectId: "root", + siteTimeserials: ["site1": "01"], + map: TestFactories.objectsMap(entries: [ + "dead": ProtocolTypes.ObjectsMapEntry(tombstone: true, timeserial: "01", data: nil, serialTimestamp: Date(timeIntervalSince1970: 1_700_000_050)), + ]), + ) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + + #expect(map.testsOnly_data["dead"]?.tombstonedAt == Date(timeIntervalSince1970: 1_700_000_050)) + } + + // @UTS objects/unit/RTLM6d/replace-data-with-create-op-0 + @Test + func replaceDataWithCreateOpMergesInitialEntries() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap(objectID: "map:test@1000", internalQueue: internalQueue) + + let state = TestFactories.mapObjectState( + objectId: "map:test@1000", + siteTimeserials: ["site1": "01"], + createOp: TestFactories.mapCreateOperation( + objectId: "map:test@1000", + entries: ["from_create": TestFactories.mapEntry(timeserial: "00", data: ProtocolTypes.ObjectData(string: "created"))], + ), + entries: ["from_sync": TestFactories.mapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "synced"))], + ) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + + #expect(map.testsOnly_data["from_sync"]?.data == ProtocolTypes.ObjectData(string: "synced")) + #expect(map.testsOnly_data["from_create"]?.data == ProtocolTypes.ObjectData(string: "created")) + #expect(map.testsOnly_createOperationIsMerged == true) + } + + // @UTS objects/unit/RTLM6f/replace-data-tombstone-flag-0 + @Test + func replaceDataWithTombstoneFlagTombstonesMap() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "map:test@1000", + data: ["name": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "Alice"))], + internalQueue: internalQueue, + ) + + let state = TestFactories.mapObjectState(objectId: "map:test@1000", siteTimeserials: ["site1": "01"], tombstone: true, entries: [:]) + let update = internalQueue.ably_syncNoDeadlock { + map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + + #expect(map.testsOnly_isTombstone == true) + #expect(map.testsOnly_data.isEmpty) + #expect(update.update?.update == ["name": .removed]) + #expect(update.tombstone == true) + #expect(update.objectMessage == nil) // D-7 + } + + // @UTS objects/unit/RTLO4e10/replace-data-tombstone-root-noop-0 + // D-11: exercises the RTLO4e10 implementation fix on the sync path. + @Test + func replaceDataWithTombstoneFlagTargetingRootIsRejected() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: ["name": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "Alice"))], + internalQueue: internalQueue, + ) + + let state = TestFactories.mapObjectState(objectId: "root", siteTimeserials: ["site1": "01"], tombstone: true, entries: [:]) + let update = internalQueue.ably_syncNoDeadlock { + map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + + #expect(map.testsOnly_isTombstone == false) + #expect(map.testsOnly_data["name"]?.data?.string == "Alice") + #expect(update.isNoop == true) + } + + // @UTS objects/unit/RTLM6i/replace-data-resets-clear-timeserial-0 + @Test + func replaceDataWithoutClearTimeserialResetsToNull() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: ["x": TestFactories.internalMapEntry(timeserial: "03", data: ProtocolTypes.ObjectData(number: NSNumber(value: 1)))], + internalQueue: internalQueue, + ) + map.testsOnly_setClearTimeserial("05") + + let state = TestFactories.mapObjectState( + objectId: "root", + siteTimeserials: ["site1": "01"], + entries: ["y": TestFactories.mapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(number: NSNumber(value: 2)))], + ) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + + #expect(map.testsOnly_clearTimeserial == nil) + #expect(map.testsOnly_data["y"] != nil) + } + + // MARK: - RTLM19: GC + + // @UTS objects/unit/RTLM19/gc-tombstoned-entries-0 + @Test + func gcRemovesTombstonedEntriesPastGracePeriod() { + let internalQueue = TestFactories.createInternalQueue() + // D-10: grace in SECONDS; `now` supplied by the mock clock. + let gracePeriod: TimeInterval = 86400 + let now = Date(timeIntervalSince1970: 1_700_100_000) + let clock = MockSimpleClock(currentTime: now) + + let map = Self.makeMap( + objectID: "root", + data: [ + "recent_dead": InternalObjectsMapEntry(tombstonedAt: now.addingTimeInterval(-1), timeserial: "01", data: nil), + "old_dead": InternalObjectsMapEntry(tombstonedAt: now.addingTimeInterval(-gracePeriod - 1), timeserial: "01", data: nil), + "alive": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "ok")), + ], + internalQueue: internalQueue, + ) + + internalQueue.ably_syncNoDeadlock { + map.nosync_releaseTombstonedEntries(gracePeriod: gracePeriod, clock: clock) + } + + #expect(map.testsOnly_data["recent_dead"] != nil) + #expect(map.testsOnly_data["old_dead"] == nil) + #expect(map.testsOnly_data["alive"] != nil) + } + + // MARK: - RTLM22: diff + + // @UTS objects/unit/RTLM22/diff-calculation-0 + @Test + func diffBetweenTwoDataStates() { + let previousData: [String: InternalObjectsMapEntry] = [ + "removed": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "gone")), + "changed": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "old")), + "unchanged": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "same")), + "was_dead": InternalObjectsMapEntry(tombstonedAt: Date(timeIntervalSince1970: 1_700_000_000), timeserial: "01", data: nil), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "added": TestFactories.internalMapEntry(timeserial: "02", data: ProtocolTypes.ObjectData(string: "new")), + "changed": TestFactories.internalMapEntry(timeserial: "02", data: ProtocolTypes.ObjectData(string: "new_val")), + "unchanged": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "same")), + "now_dead": InternalObjectsMapEntry(tombstonedAt: Date(timeIntervalSince1970: 1_700_000_000), timeserial: "02", data: nil), + ] + + let update = ObjectDiffHelpers.calculateMapDiff(previousData: previousData, newData: newData) + + #expect(update.update?.update["removed"] == .removed) + #expect(update.update?.update["added"] == .updated) + #expect(update.update?.update["changed"] == .updated) + #expect(update.update?.update["unchanged"] == nil) + #expect(update.update?.update["was_dead"] == nil) + #expect(update.update?.update["now_dead"] == nil) + } + + // MARK: - Remaining parent-reference cases (not among the six ported elsewhere) + + // @UTS objects/unit/RTLM8/map-remove-primitive-no-parent-refs-0 + @Test + func mapRemovePrimitiveDoesNotAffectParentReferences() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + let map = Self.makeMap( + objectID: "root", + data: ["name": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(string: "Alice"))], + internalQueue: internalQueue, + ) + + let msg = TestFactories.mapRemoveOperationMessage(objectId: "root", key: "name", serial: "02", siteCode: "site1", serialTimestamp: Date(timeIntervalSince1970: 1_700_000_000)) + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["name"]?.tombstone == true) + #expect(update.update?.update == ["name": .removed]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } + + // @UTS objects/unit/RTLM7a3/map-set-replace-objectid-both-refs-0 + @Test + func mapSetReplacingObjectIdCallsBothRemoveAndAdd() throws { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + let oldMap = Self.makeMap(objectID: "map:old@1000", internalQueue: internalQueue) + let newMap = Self.makeMap(objectID: "map:new@2000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.map(oldMap), forObjectID: "map:old@1000") + pool.testsOnly_setEntry(.map(newMap), forObjectID: "map:new@2000") + + let map = Self.makeMap( + objectID: "root", + data: ["child": TestFactories.internalMapEntry(timeserial: "01", data: ProtocolTypes.ObjectData(objectId: "map:old@1000"))], + internalQueue: internalQueue, + ) + oldMap.testsOnly_setParentReferences(["root": ["child"]]) + + let msg = TestFactories.mapSetOperationMessage(objectId: "root", key: "child", data: ProtocolTypes.ObjectData(objectId: "map:new@2000"), serial: "02", siteCode: "site1") + let update = try #require(Self.apply(msg, to: map, pool: &pool, internalQueue: internalQueue)) + + #expect(map.testsOnly_data["child"]?.data?.objectId == "map:new@2000") + // Old child no longer references root + #expect(oldMap.testsOnly_parentReferences["root"]?.contains("child") != true) + // New child references root + #expect(newMap.testsOnly_parentReferences["root"]?.contains("child") == true) + #expect(update.update?.update == ["child": .updated]) + #expect(update.objectMessage == msg.toPublicObjectMessage(channelName: Self.channelName)) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/LiveObjectSubscribeUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/LiveObjectSubscribeUTSTests.swift new file mode 100644 index 000000000..a8c322776 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/LiveObjectSubscribeUTSTests.swift @@ -0,0 +1,372 @@ +// @UTS objects/unit/live_object_subscribe.md + +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// `Instance#subscribe` (RTINS16) — data-update delivery, `Subscription` lifecycle, noop suppression, +/// the enriched `InstanceSubscriptionEvent.message`, and tombstone teardown. +/// Derived from https://github.com/ably/specification/blob/main/uts/objects/unit/live_object_subscribe.md +/// (spec points `RTLO4b`, `RTLO4b3`, `RTLO4b4c1`, `RTLO4b4c3a`, `RTLO4b4c3c`, `RTLO4b4d`, `RTLO4b4e`, +/// `RTLO4b6`, `RTLO4b7`). +/// +/// The spec drives every case through `setup_synced_channel` + `mock_ws.send_to_client` and subscribes +/// via `root.get("score").instance()` (RTINS16). Instance-subscribe delivery is owned by the engine's +/// apply path (a node emits to its own subscribers, RTLO4b4c3a), and the enriched +/// `InstanceSubscriptionEvent.message` is the public `ObjectMessage` the engine derives from the inbound +/// frame (`toPublicObjectMessage`). So — like `PathObjectSubscribeTests` — these drive the **real** +/// `InternalDefaultRealtimeObjects`: the standard graph is seeded into the engine's owned pool via +/// `testsOnly_setPoolEntry` + `testsOnly_setParentReferences`, and inbound OBJECT frames are replayed +/// via `testsOnly_applyObjectMessages` (the unit stand-in for `mock_ws.send_to_client`). Instances are +/// obtained through the production seam `root.get(key:).instance()`; subscriber callbacks run on the +/// engine's `userCallbackQueue`, drained with a `sync {}` barrier before asserting. No mock-WS / +/// connection is opened. Mirrors the native `DefaultInstanceTests` tombstone/subscribe cases. +/// +/// ## Deviations (recorded in deviations.md) +/// - **DEV-1 (Instance enum):** the spec's `instance.subscribe(...)` is reached by unwrapping the +/// `Instance` enum to its concrete `.liveCounter` / `.liveMap` payload (`LiveCounterInstance` / +/// `LiveMapInstance`), each of which carries `subscribe`. +/// - **RTLO4b4c1 noop shape:** the spec's `counterInc: {}` (present but number-less) is represented in +/// cocoa by an absent `counterInc` (its `WireCounterInc.number` is non-optional) — the same RTLC9h +/// noop branch. +@Suite(.serialized) +final class LiveObjectSubscribeUTSTests { + private static let channelName = "test" + + // MARK: - Fixture (shared with PathObjectSubscribeTests' pattern) + + private struct Fixture { + let engine: InternalDefaultRealtimeObjects + let coreSDK: ObjectsUTSCoreSDK + let internalQueue: DispatchQueue + let userCallbackQueue: DispatchQueue + } + + private static func makeFixture() -> Fixture { + let internalQueue = ObjectsUTS.createInternalQueue() + let userCallbackQueue = DispatchQueue(label: "LiveObjectSubscribeTests.userCallback") + let coreSDK = ObjectsUTSCoreSDK(channelState: .attached) + let engine = InternalDefaultRealtimeObjects( + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: MockSimpleClock(), + channelName: channelName, + ) + return Fixture(engine: engine, coreSDK: coreSDK, internalQueue: internalQueue, userCallbackQueue: userCallbackQueue) + } + + private static func rootPath(_ f: Fixture) -> DefaultLiveMapPathObject { + DefaultLiveMapPathObject(channelObject: f.engine, coreSDK: f.coreSDK, internalQueue: f.internalQueue, path: "") + } + + private static func makeCounter(objectID: String, data: Double = 0, _ f: Fixture) -> InternalDefaultLiveCounter { + InternalDefaultLiveCounter(testsOnly_data: data, objectID: objectID, logger: TestLogger(), internalQueue: f.internalQueue, userCallbackQueue: f.userCallbackQueue, clock: MockSimpleClock()) + } + + private static func makeMap(objectID: String, data: [String: InternalObjectsMapEntry] = [:], _ f: Fixture) -> InternalDefaultLiveMap { + InternalDefaultLiveMap(testsOnly_data: data, objectID: objectID, logger: TestLogger(), internalQueue: f.internalQueue, userCallbackQueue: f.userCallbackQueue, clock: MockSimpleClock()) + } + + /// Seeds the standard test graph (root + score/nested counters + profile/prefs maps) into the + /// engine's owned pool, with the standard parentReferences so path resolution reaches every node. + /// Entries are seeded at POOL_SERIAL "t:0" so remote MAP_SET serials ("t:1", …) win per-entry LWW. + private static func seedStandardGraph(_ f: Fixture) { + let score = makeCounter(objectID: "counter:score@1000", data: 100, f) + let nested = makeCounter(objectID: "counter:nested@1000", data: 5, f) + let prefs = makeMap(objectID: "map:prefs@1000", data: ["theme": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "dark"), timeserial: "t:0")], f) + let profile = makeMap( + objectID: "map:profile@1000", + data: [ + "email": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "alice@example.com"), timeserial: "t:0"), + "nested_counter": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:nested@1000"), timeserial: "t:0"), + "prefs": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(objectId: "map:prefs@1000"), timeserial: "t:0"), + ], + f, + ) + let root = makeMap( + objectID: ObjectsPool.rootKey, + data: [ + "name": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "Alice"), timeserial: "t:0"), + "score": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:score@1000"), timeserial: "t:0"), + "profile": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(objectId: "map:profile@1000"), timeserial: "t:0"), + ], + f, + ) + f.engine.testsOnly_setPoolEntry(.counter(score), forObjectID: "counter:score@1000") + f.engine.testsOnly_setPoolEntry(.counter(nested), forObjectID: "counter:nested@1000") + f.engine.testsOnly_setPoolEntry(.map(prefs), forObjectID: "map:prefs@1000") + f.engine.testsOnly_setPoolEntry(.map(profile), forObjectID: "map:profile@1000") + f.engine.testsOnly_setPoolEntry(.map(root), forObjectID: ObjectsPool.rootKey) + score.testsOnly_setParentReferences([ObjectsPool.rootKey: ["score"]]) + profile.testsOnly_setParentReferences([ObjectsPool.rootKey: ["profile"]]) + nested.testsOnly_setParentReferences(["map:profile@1000": ["nested_counter"]]) + prefs.testsOnly_setParentReferences(["map:profile@1000": ["prefs"]]) + } + + private static func applyAndDrain(_ messages: [ProtocolTypes.InboundObjectMessage], _ f: Fixture) { + f.engine.testsOnly_applyObjectMessages(messages, source: .channel) + f.userCallbackQueue.sync {} + } + + /// The `.liveCounter` instance for `root.get(key)` (RTINS16 wrapping via `PathObject.instance()`). + private static func counterInstance(_ f: Fixture, key: String) throws -> any LiveCounterInstance { + let instance = try #require(try Self.rootPath(f).get(key: key).instance()) + guard case let .liveCounter(counterInstance) = instance else { + throw NSError(domain: "LiveObjectSubscribeTests", code: 0, userInfo: [NSLocalizedDescriptionKey: "Expected .liveCounter for \(key)"]) + } + return counterInstance + } + + /// The `.liveMap` instance for `root.get(key)` (or root itself when `key` is nil). + private static func mapInstance(_ f: Fixture, key: String?) throws -> any LiveMapInstance { + let pathObject: any PathObject = key.map { Self.rootPath(f).get(key: $0) } ?? Self.rootPath(f) + let instance = try #require(try pathObject.instance()) + guard case let .liveMap(mapInstance) = instance else { + throw NSError(domain: "LiveObjectSubscribeTests", code: 0, userInfo: [NSLocalizedDescriptionKey: "Expected .liveMap"]) + } + return mapInstance + } + + // MARK: - RTLO4b: subscribe registers a listener for data updates + + // objects/unit/RTLO4b/subscribe-receives-updates-0 — RTLO4b3/RTLO4b4c3a/RTLO4b7: a Subscription is + // returned and the listener is called with the update. + @Test + func RTLO4b_subscribe_receives_updates() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let collector = ObjectsUTSInstanceEventCollector() + + let instance = try Self.counterInstance(f, key: "score") + let sub = try instance.subscribe(listener: collector.listener) + defer { sub.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")], f) + + #expect(collector.events.count == 1) + } + + // objects/unit/RTLO4b7/subscribe-returns-subscription-0 — RTLO4b7: `subscribe` returns a + // `Subscription` with an `unsubscribe` method (the returned value is usable to deregister). + @Test + func RTLO4b7_subscribe_returns_subscription() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + + let instance = try Self.counterInstance(f, key: "score") + let sub = try instance.subscribe { _ in } + // A Subscription is returned; unsubscribe is callable (idempotently). + sub.unsubscribe() + } + + // objects/unit/RTLO4b7/subscription-unsubscribe-stops-delivery-0 — after `unsubscribe()`, subsequent + // updates do not reach the listener (verified via a still-subscribed control listener). + @Test + func RTLO4b7_unsubscribe_stops_delivery() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let updates = ObjectsUTSInstanceEventCollector() + let control = ObjectsUTSInstanceEventCollector() + + let instance = try Self.counterInstance(f, key: "score") + let sub = try instance.subscribe(listener: updates.listener) + + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 5, serial: "01", siteCode: "remote")], f) + #expect(updates.events.count == 1) + + sub.unsubscribe() + + // Negative-assertion quiescence: a control listener that WILL fire on the next dispatch. + let ctrl = try instance.subscribe(listener: control.listener) + defer { ctrl.unsubscribe() } + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 10, serial: "02", siteCode: "remote")], f) + + #expect(control.events.count >= 1) // control delivered — an unsubscribed listener would also have fired + #expect(updates.events.count == 1) // …but it did not + } + + // objects/unit/RTLO4b7/subscription-unsubscribe-idempotent-0 — calling `unsubscribe()` twice does not + // throw. + @Test + func RTLO4b7_unsubscribe_idempotent() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + + let instance = try Self.counterInstance(f, key: "score") + let sub = try instance.subscribe { _ in } + sub.unsubscribe() + sub.unsubscribe() + } + + // MARK: - RTLO4b4c1: noop update does not trigger the listener + + // objects/unit/RTLO4b4c1/noop-no-trigger-0 — a number-less COUNTER_INC yields a `.noop` (RTLC9h) and + // must not fire the listener; the surrounding "01"/"03" increments (which do fire) bracket it. + @Test + func RTLO4b4c1_noop_no_trigger() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let updates = ObjectsUTSInstanceEventCollector() + let control = ObjectsUTSInstanceEventCollector() + + let instance = try Self.counterInstance(f, key: "score") + let sub = try instance.subscribe(listener: updates.listener) + defer { sub.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 5, serial: "01", siteCode: "remote")], f) + #expect(updates.events.count == 1) + + // The noop "02": passes the newness check but produces no LiveObjectUpdate. + Self.applyAndDrain([ObjectsUTS.counterIncNoopMessage(objectId: "counter:score@1000", serial: "02", siteCode: "remote")], f) + + // Quiescence via a separate control listener: "03" is dispatched after the noop, so once the + // control fires the noop has certainly been processed. + let ctrl = try instance.subscribe(listener: control.listener) + defer { ctrl.unsubscribe() } + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 3, serial: "03", siteCode: "remote")], f) + #expect(control.events.count >= 1) + + // The noop produced no update: the original listener fired only for "01" and "03". + #expect(updates.events.count == 2) + } + + // MARK: - RTLO4b6: subscribe has no side effects + + // objects/unit/RTLO4b6/subscribe-no-side-effects-0 — subscribing must not mutate the channel state. + // (The spec observes `channel.state`; the unit fixture has only the fixed `CoreSDK` state, so the + // observable subset is that subscribe neither throws nor changes that state.) + @Test + func RTLO4b6_subscribe_no_side_effects() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let stateBefore = f.coreSDK.nosync_channelState + + let instance = try Self.counterInstance(f, key: "score") + let sub = try instance.subscribe { _ in } + defer { sub.unsubscribe() } + + #expect(f.coreSDK.nosync_channelState == stateBefore) + } + + // MARK: - RTLO4b: subscribe on a map instance receives a LiveMapUpdate + + // objects/unit/RTLO4b/subscribe-map-update-0 — a MAP_SET on the subscribed map fires its listener. + @Test + func RTLO4b_subscribe_map_update() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let updates = ObjectsUTSInstanceEventCollector() + + let instance = try Self.mapInstance(f, key: nil) // root map + let sub = try instance.subscribe(listener: updates.listener) + defer { sub.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: ObjectsPool.rootKey, key: "name", value: ProtocolTypes.ObjectData(string: "Bob"), serial: "t:1", siteCode: "remote")], f) + + #expect(updates.events.count == 1) + } + + // MARK: - RTLO4b4c3c: tombstone deregisters all Instance#subscribe listeners + + // objects/unit/RTLO4b4c3c/tombstone-deregisters-listeners-0 — an OBJECT_DELETE tombstone is delivered + // to every listener (RTLO4b4c3a) and then deregisters them (RTLO4b4c3c); subsequent operations to + // the tombstoned object do not fire the (now-deregistered) listeners. + @Test + func RTLO4b4c3c_tombstone_deregisters_listeners() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let updatesA = ObjectsUTSInstanceEventCollector() + let updatesB = ObjectsUTSInstanceEventCollector() + let control = ObjectsUTSInstanceEventCollector() + + let instance = try Self.counterInstance(f, key: "score") + let subA = try instance.subscribe(listener: updatesA.listener) + let subB = try instance.subscribe(listener: updatesB.listener) + defer { subA.unsubscribe(); subB.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.objectDeleteMessage(objectId: "counter:score@1000", serial: "50", siteCode: "remote")], f) + + // Both listeners received the tombstone update. + #expect(updatesA.events.count == 1) + #expect(updatesA.events.first?.message?.operation.action == .objectDelete) + #expect(updatesB.events.count == 1) + #expect(updatesB.events.first?.message?.operation.action == .objectDelete) + + // Quiescence: a tombstoned object ignores further ops (RTLC7e), so use a SEPARATE live object as + // the control barrier. Once the control fires, the "51" dispatch to the dead object is processed. + let controlInstance = try Self.mapInstance(f, key: "profile") + let ctrl = try controlInstance.subscribe(listener: control.listener) + defer { ctrl.unsubscribe() } + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 3, serial: "51", siteCode: "remote")], f) + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: "map:profile@1000", key: "quiescence_probe", value: ProtocolTypes.ObjectData(string: "x"), serial: "52", siteCode: "remote")], f) + #expect(control.events.count >= 1) + + // The deregistered listeners did not fire again. + #expect(updatesA.events.count == 1) + #expect(updatesB.events.count == 1) + } + + // MARK: - RTLO4b4d: the event carries the source public ObjectMessage + + // objects/unit/RTLO4b4d/update-has-object-message-0 — RTLO4b4d/RTINS16e: the delivered event carries + // the public `ObjectMessage` derived from the triggering inbound frame. + @Test + func RTLO4b4d_update_has_object_message() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let updates = ObjectsUTSInstanceEventCollector() + + let instance = try Self.counterInstance(f, key: "score") + let sub = try instance.subscribe(listener: updates.listener) + defer { sub.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")], f) + + #expect(updates.events.count == 1) + let message = try #require(updates.events.first?.message) + #expect(message.serial == "99") + #expect(message.siteCode == "remote") + #expect(message.operation.action == .counterInc) + #expect(message.operation.objectId == "counter:score@1000") + #expect(message.channel == Self.channelName) + } + + // MARK: - RTLO4b4e: tombstone identified by OBJECT_DELETE action + + // objects/unit/RTLO4b4e/tombstone-flag-true-0 — a tombstoning event carries an OBJECT_DELETE action. + @Test + func RTLO4b4e_tombstone_flag_true() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let updates = ObjectsUTSInstanceEventCollector() + + let instance = try Self.counterInstance(f, key: "score") + let sub = try instance.subscribe(listener: updates.listener) + defer { sub.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.objectDeleteMessage(objectId: "counter:score@1000", serial: "50", siteCode: "remote")], f) + + #expect(updates.events.count == 1) + #expect(updates.events.first?.message?.operation.action == .objectDelete) + } + + // objects/unit/RTLO4b4e/tombstone-flag-false-0 — a normal update carries a non-OBJECT_DELETE action. + @Test + func RTLO4b4e_tombstone_flag_false() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let updates = ObjectsUTSInstanceEventCollector() + + let instance = try Self.counterInstance(f, key: "score") + let sub = try instance.subscribe(listener: updates.listener) + defer { sub.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")], f) + + #expect(updates.events.count == 1) + #expect(updates.events.first?.message?.operation.action == .counterInc) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectIdUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectIdUTSTests.swift new file mode 100644 index 000000000..bdaa83373 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectIdUTSTests.swift @@ -0,0 +1,142 @@ +// @UTS objects/unit/object_id.md +// +// Port of the UTS ObjectId generation spec (RTO14). ObjectId format is +// `{type}:{base64url(SHA-256(initialValue:nonce))}@{timestamp}`. +// +// Deviations from the UTS spec: +// - (D1) The spec passes timestamps as epoch-millisecond integers (e.g. 1700000000000). The +// Swift API takes a `Date`, so each is translated as `Date(timeIntervalSince1970: 1_700_000_000)` +// (= 1_700_000_000 s == 1700000000000 ms). The generated objectId still embeds the +// millisecond value `@1700000000000`. +// - (D2) The spec function is named `generateObjectId`; the Swift equivalent is +// `ObjectCreationHelpers.testsOnly_createObjectID(type:initialValue:nonce:timestamp:)`. +// - (D3) The spec file contains five `##` cases. A sixth test (`different-initialValue`) is added +// per the porting task, symmetric to `different-nonce`, asserting that a different initialValue +// yields a different objectId. It has no distinct spec-case name. +// +// Note: the spec asserts objectId structure (prefix / timestamp / base64url charset) and +// determinism/uniqueness, not literal hash strings; assertions here mirror the spec faithfully. + +@testable import AblyLiveObjects +import Foundation +import Testing + +struct ObjectIdUTSTests { + /// The spec's `1700000000000` epoch-millis, expressed as a `Date` (see deviation D1). + private static let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + + // @UTS objects/unit/RTO14/objectid-format-counter-0 + @Test + func objectIdFormatCounter() { + let objectId = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", + initialValue: #"{"counter":{"count":42}}"#, + nonce: "test-nonce-12345678", + timestamp: Self.timestamp, + ) + + #expect(objectId.hasPrefix("counter:")) + #expect(objectId.contains("@1700000000000")) + + let typePart = String(objectId.prefix { $0 != ":" }) + let rest = String(objectId.dropFirst(typePart.count + 1)) + let hashPart = String(rest.prefix { $0 != "@" }) + let tsPart = String(rest.dropFirst(hashPart.count + 1)) + + #expect(typePart == "counter") + #expect(tsPart == "1700000000000") + // hashPart IS a valid base64url string: no standard-base64 / padding characters + #expect(!hashPart.contains("+")) + #expect(!hashPart.contains("/")) + #expect(!hashPart.contains("=")) + } + + // @UTS objects/unit/RTO14/objectid-format-map-0 + @Test + func objectIdFormatMap() { + let objectId = ObjectCreationHelpers.testsOnly_createObjectID( + type: "map", + initialValue: #"{"map":{"semantics":"LWW","entries":{}}}"#, + nonce: "test-nonce-12345678", + timestamp: Self.timestamp, + ) + + #expect(objectId.hasPrefix("map:")) + #expect(objectId.contains("@1700000000000")) + } + + // @UTS objects/unit/RTO14/deterministic-0 + @Test + func deterministicForSameInputs() { + let id1 = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", + initialValue: #"{"counter":{"count":0}}"#, + nonce: "same-nonce-1234567", + timestamp: Self.timestamp, + ) + let id2 = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", + initialValue: #"{"counter":{"count":0}}"#, + nonce: "same-nonce-1234567", + timestamp: Self.timestamp, + ) + + #expect(id1 == id2) + } + + // @UTS objects/unit/RTO14/different-nonce-0 + @Test + func differentNonceProducesDifferentObjectId() { + let id1 = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", + initialValue: #"{"counter":{"count":0}}"#, + nonce: "nonce-aaaaaaaaaaaaa", + timestamp: Self.timestamp, + ) + let id2 = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", + initialValue: #"{"counter":{"count":0}}"#, + nonce: "nonce-bbbbbbbbbbbbb", + timestamp: Self.timestamp, + ) + + #expect(id1 != id2) + } + + // @UTS objects/unit/RTO14b/base64url-encoding-0 + @Test + func hashIsBase64urlEncoded() { + let objectId = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", + initialValue: #"{"counter":{"count":0}}"#, + nonce: "test-nonce-12345678", + timestamp: Self.timestamp, + ) + + let rest = String(objectId.drop { $0 != ":" }.dropFirst()) + let hashPart = String(rest.prefix { $0 != "@" }) + + #expect(!hashPart.contains("+")) + #expect(!hashPart.contains("/")) + #expect(!hashPart.hasSuffix("=")) + } + + // @UTS different-initialValue (added per porting task; see deviation D3) + @Test + func differentInitialValueProducesDifferentObjectId() { + let id1 = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", + initialValue: #"{"counter":{"count":0}}"#, + nonce: "same-nonce-1234567", + timestamp: Self.timestamp, + ) + let id2 = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", + initialValue: #"{"counter":{"count":1}}"#, + nonce: "same-nonce-1234567", + timestamp: Self.timestamp, + ) + + #expect(id1 != id2) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectsPoolUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectsPoolUTSTests.swift new file mode 100644 index 000000000..f00f4489f --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectsPoolUTSTests.swift @@ -0,0 +1,706 @@ +// @UTS objects/unit/objects_pool.md +// +// Port of the `objects/unit/objects_pool.md` spec — the ObjectsPool data structure and the +// INITIALIZED -> SYNCING -> SYNCED sync state machine, driven end-to-end through +// `InternalDefaultRealtimeObjects` (the spec's monolithic `pool` splits across `ObjectsPool` + +// `InternalDefaultRealtimeObjects`; see the audit report §3.4). +// +// Name mapping (UTS pseudocode -> Swift): +// - `pool = ObjectsPool()` -> `makeRealtimeObjects(internalQueue:)`; the pool is read +// back as `ro.testsOnly_objectsPool` (a struct *copy*, so +// every read re-fetches it; its `Entry` values are class +// instances, so `.root` is the live object). +// - `pool.syncState` -> `ro.testsOnly_syncState` (SYNCING/SYNCED/INITIALIZED). +// - `pool.processAttached(ProtocolMessage(flags: HAS_OBJECTS?))` -> `ro.nosync_onChannelAttached(hasObjects:)`. +// The attach path ignores the channelSerial (it is only +// consumed by the subsequent OBJECT_SYNC), so the spec's +// `channelSerial:` on ATTACHED is dropped. +// - `pool.processObjectSync(build_object_sync_message(ch, cursor, [states]))` +// -> `ro.nosync_handleObjectSyncProtocolMessage(objectMessages: +// protocolMessageChannelSerial:)`. The UTS cursor +// `"seq:"` (empty cursor) completes the sync (RTO5a4); +// `"seq:more"` keeps it open (SYNCING). +// - `pool.processObjectMessage(build_object_message(ch, [ops]))` +// -> `ro.nosync_handleObjectProtocolMessage(objectMessages:)`. +// - `pool.applyObjectMessages([...], source: LOCAL)` -> `ro.testsOnly_applyObjectMessages(_:source:)`. +// - `pool["id"] = obj` (seed) -> `ro.testsOnly_setPoolEntry(.map(...) / .counter(...), forObjectID:)`. +// - `pool["root"].data = {…}` (seed) -> seed a root `InternalDefaultLiveMap` built with +// `testsOnly_data:` and install it via `testsOnly_setPoolEntry`. +// - `realtime_object.bufferedObjectOperations.length` -> `ro.testsOnly_bufferedObjectOperationsCount` +// (nil unless SYNCING). +// - `realtime_object.appliedOnAckSerials` -> `ro.testsOnly_appliedOnAckSerials`. +// - `pool["root"].subscribe(cb)` -> `ro.testsOnly_objectsPool.root.subscribe(listener:coreSDK:)` +// via a `Subscriber` (hence the iOS/tvOS 17 availability on +// the two subscription-based cases, matching the existing +// `InternalDefaultRealtimeObjectsTests` RTO4b test). +// - `build_object_state(id, sts, {map/counter, createOp})` -> `TestFactories.mapObjectState` / +// `counterObjectState` (with `createOp:`), or a hand-built +// `ObjectState` when a `clearTimeserial` is needed +// (`TestFactories.mapObjectState` has no such param). +// +// Deviations from the UTS spec: +// - (D-1) Construction seam: the Swift engine needs logger/queue/callback-queue/clock, so `pool = +// ObjectsPool()` maps to `makeRealtimeObjects(internalQueue:)`. Reads go through the `testsOnly_*` +// seams above; every `nosync_*` entry point is wrapped in `internalQueue.ably_syncNoDeadlock { }`. +// - (D-2) `pool.syncState == INITIALIZED` at construction is asserted via `ro.testsOnly_syncState`; +// the spec's direct `pool.syncState = SYNCED` seed maps to `nosync_onChannelAttached(hasObjects: +// false)`, which performs the RTO4b immediate sync completion (INITIALIZED -> SYNCED) and leaves an +// empty pool — equivalent to the spec's intent of "start from a SYNCED empty pool". +// - (D-3) `objectMessage IS null` (RTO4b2a / RTO5c7): the sync-originated update carries +// `DefaultLiveMapUpdate.objectMessage == nil`; asserted directly on the captured update. +// - (D-4) RTO9a3 seeding: the spec seeds `appliedOnAckSerials = {"echo-serial-1"}` and +// `counter.data = 10` directly. With no direct serial setter, the serial is armed the way it is in +// production — a LOCAL apply (`testsOnly_applyObjectMessages(source: .local)`) of a COUNTER_INC of +// `0` carrying that serial (RTO9a2a4 inserts it; `+0` keeps the seeded value at 10). +// - (D-5) SKIPPED — `objects/unit/RTO7-RTO8/buffer-without-attached-0`: the spec asserts that an +// OBJECT message received in INITIALIZED (no preceding ATTACHED) is BUFFERED. cocoa deliberately +// diverges: `nosync_handleObjectProtocolMessage` buffers only in `.syncing`, and applies +// immediately otherwise (InternalDefaultRealtimeObjects.swift, with an explicit comment that +// "operations only arrive once attached, and we become SYNCING upon receipt of ATTACHED", so the +// INITIALIZED-buffer branch is unreachable in practice). The case cannot pass as written; reported +// as a deviation candidate. +// - (D-6) SKIPPED (cross-reference, not duplicated) — the three `objects/unit/RTO5c10/*` cases +// (sync-rebuilds / resync-rebuilds / empty-sync parentReferences) are already ported in +// `UTS/ParentReferencesUTSTests.swift` (its RTO5c10 section). See that file. +// - (D-7) IMPLEMENTATION FIX (surfaced by the RTO9a2b case, like D-11 of the map port): cocoa +// created the RTO9a2a1 zero-value object BEFORE checking the action, so an unsupported-action +// OBJECT message left a spurious zero-value object in the pool (the case here asserts pool size +// 1, i.e. only root). Fixed minimally in `Sources/AblyLiveObjects/Internal/ +// InternalDefaultRealtimeObjects.swift`: the RTO9a2b unknown-action guard now runs before the +// object creation, matching ably-java (`ObjectsManager.applyObjectMessages`, whose RTO9a3 comment +// likewise notes the discard checks must precede creation). + +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +struct ObjectsPoolUTSTests { + // MARK: - Helpers (D-1) + + private static func makeRealtimeObjects(internalQueue: DispatchQueue) -> InternalDefaultRealtimeObjects { + InternalDefaultRealtimeObjects( + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + private static func coreSDK(internalQueue: DispatchQueue) -> MockCoreSDK { + MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + } + + private static func attach(_ ro: InternalDefaultRealtimeObjects, hasObjects: Bool, internalQueue: DispatchQueue) { + internalQueue.ably_syncNoDeadlock { + ro.nosync_onChannelAttached(hasObjects: hasObjects) + } + } + + /// Note the `messages` array is the trailing parameter so multiline call sites keep every scalar + /// argument on the opening line (SwiftLint `multiline_arguments`). + private static func processSync( + _ ro: InternalDefaultRealtimeObjects, + channelSerial: String?, + internalQueue: DispatchQueue, + _ messages: [ProtocolTypes.InboundObjectMessage], + ) { + internalQueue.ably_syncNoDeadlock { + ro.nosync_handleObjectSyncProtocolMessage(objectMessages: messages, protocolMessageChannelSerial: channelSerial) + } + } + + private static func processObject( + _ ro: InternalDefaultRealtimeObjects, + internalQueue: DispatchQueue, + _ messages: [ProtocolTypes.InboundObjectMessage], + ) { + internalQueue.ably_syncNoDeadlock { + ro.nosync_handleObjectProtocolMessage(objectMessages: messages) + } + } + + /// Wraps an `ObjectState` as an OBJECT_SYNC `InboundObjectMessage` (`build_object_state` -> a sync + /// message's `object`). + private static func syncMsg(_ state: ProtocolTypes.ObjectState) -> ProtocolTypes.InboundObjectMessage { + TestFactories.inboundObjectMessage(object: state) + } + + /// The standard `build_object_state("root", {"aaa": "t:0"}, { map: {…}, createOp: mapCreate {} })` + /// — a sync-completing root map with the given entries and an (empty) create op. + private static func rootState(entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil) -> ProtocolTypes.ObjectState { + TestFactories.mapObjectState( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + createOp: TestFactories.mapCreateOperation(objectId: "root", entries: [:]), + entries: entries, + ) + } + + /// `build_object_state(id, {"aaa": "t:0"}, { counter: { count }, createOp?: counterCreate { createCount } })`. + private static func counterState(objectId: String, count: Int, createCount: Int?) -> ProtocolTypes.ObjectState { + TestFactories.counterObjectState( + objectId: objectId, + siteTimeserials: ["aaa": "t:0"], + createOp: createCount.map { TestFactories.counterCreateOperation(objectId: objectId, count: $0) }, + count: count, + ) + } + + /// A single-entry string map entry with the given serial (`{ data: { string }, timeserial }`). + private static func stringEntry(_ value: String, timeserial: String) -> ProtocolTypes.ObjectsMapEntry { + TestFactories.mapEntry(timeserial: timeserial, data: ProtocolTypes.ObjectData(string: value)) + } + + /// Builds a root `InternalDefaultLiveMap` pre-seeded with `data`, for the `pool["root"].data = {…}` + /// seed cases (installed via `testsOnly_setPoolEntry`). + private static func seededRoot(data: [String: InternalObjectsMapEntry], internalQueue: DispatchQueue) -> InternalDefaultLiveMap { + InternalDefaultLiveMap( + testsOnly_data: data, + objectID: "root", + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + private static func seededCounter(objectID: String, data: Double, internalQueue: DispatchQueue) -> InternalDefaultLiveCounter { + InternalDefaultLiveCounter( + testsOnly_data: data, + objectID: objectID, + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + /// A seeded string `InternalObjectsMapEntry` (`{ data: { string }, timeserial, tombstone: false }`). + private static func seededStringEntry(_ value: String, timeserial: String) -> InternalObjectsMapEntry { + InternalObjectsMapEntry(tombstonedAt: nil, timeserial: timeserial, data: ProtocolTypes.ObjectData(string: value)) + } + + // MARK: - RTO3 + + // @UTS objects/unit/RTO3/pool-init-root-0 + @Test + func poolInitialisesWithZeroValueRoot() throws { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + let pool = ro.testsOnly_objectsPool + // RTO3b, RTO3b1: pool always contains a zero-value InternalLiveMap with id "root". + let root = try #require(pool.entries["root"]?.mapValue) + #expect(pool.entries["root"] != nil) + #expect(root.testsOnly_data.isEmpty) + #expect(root.testsOnly_objectID == "root") + } + + // MARK: - RTO4a + + // @UTS objects/unit/RTO4/attached-has-objects-syncing-0 + @Test + func attachedWithHasObjectsStartsSyncing() { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + + // RTO4c: sync state transitions to SYNCING (server will send OBJECT_SYNC, RTO4a). + #expect(ro.testsOnly_syncState == .syncing) + } + + // MARK: - RTO4b + + // @UTS objects/unit/RTO4b/attached-no-objects-synced-0 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func attachedWithoutHasObjectsClearsPoolAndSyncs() async throws { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + // Seed a non-root object and root data (spec setup). + ro.testsOnly_setPoolEntry(.counter(Self.seededCounter(objectID: "counter:abc@1000", data: 0, internalQueue: internalQueue)), forObjectID: "counter:abc@1000") + let root = Self.seededRoot(data: ["name": Self.seededStringEntry("Alice", timeserial: "01")], internalQueue: internalQueue) + ro.testsOnly_setPoolEntry(.map(root), forObjectID: "root") + + let subscriber = Subscriber(callbackQueue: .main) + try root.subscribe(listener: subscriber.createListener(), coreSDK: Self.coreSDK(internalQueue: internalQueue)) + + Self.attach(ro, hasObjects: false, internalQueue: internalQueue) + + let pool = ro.testsOnly_objectsPool + #expect(ro.testsOnly_syncState == .synced) + // RTO4b1: all objects except root removed; RTO3b: root remains. + #expect(pool.entries["counter:abc@1000"] == nil) + #expect(pool.entries["root"] != nil) + // RTO4b2: root cleared to zero-value. + #expect(try #require(pool.entries["root"]?.mapValue).testsOnly_data.isEmpty) + + // RTO4b2a: a `removed` update was emitted for the removed key, WITHOUT an objectMessage. + let invocations = await subscriber.getInvocations() + let update = try #require(invocations.map(\.0).first) + #expect(update.update == ["name": .removed]) + #expect(update.objectMessage == nil) + } + + // MARK: - RTO5 + + // @UTS objects/unit/RTO5/sync-complete-sequence-0 + @Test + func objectSyncCompleteSequence() throws { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + let coreSDK = Self.coreSDK(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + Self.processSync(ro, channelSerial: "sync1:", internalQueue: internalQueue, [ + Self.syncMsg(Self.rootState(entries: ["name": Self.stringEntry("Alice", timeserial: "t:0")])), + Self.syncMsg(Self.counterState(objectId: "counter:abc@1000", count: 0, createCount: 42)), + ]) + + let pool = ro.testsOnly_objectsPool + // RTO5a4, RTO5c8: cursor empty -> sync complete -> SYNCED. + #expect(ro.testsOnly_syncState == .synced) + #expect(pool.entries["root"] != nil) + #expect(pool.entries["counter:abc@1000"] != nil) + // RTO5f1: entries stored from server state. + #expect(try #require(pool.entries["root"]?.mapValue).testsOnly_data["name"]?.data?.string == "Alice") + // count 0 (RTLC6c) + createOp 42 (RTLC16a) = 42. + #expect(try #require(pool.entries["counter:abc@1000"]?.counterValue).value(coreSDK: coreSDK) == 42) + } + + // MARK: - RTO5a2 + + // @UTS objects/unit/RTO5a2/new-sequence-discards-old-0 + @Test + func newSyncSequenceDiscardsPrevious() { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + // First sequence (kept open). + Self.processSync(ro, channelSerial: "seq1:more", internalQueue: internalQueue, [ + Self.syncMsg(Self.counterState(objectId: "counter:old@1000", count: 10, createCount: nil)), + ]) + + // New sequence id -> RTO5a2a discards the previous SyncObjectsPool; empty cursor completes. + Self.processSync(ro, channelSerial: "seq2:", internalQueue: internalQueue, [ + Self.syncMsg(Self.rootState()), + Self.syncMsg(Self.counterState(objectId: "counter:new@1000", count: 99, createCount: nil)), + ]) + + let pool = ro.testsOnly_objectsPool + #expect(ro.testsOnly_syncState == .synced) + #expect(pool.entries["counter:old@1000"] == nil) + #expect(pool.entries["counter:new@1000"] != nil) + } + + // MARK: - RTO5f2a + + // @UTS objects/unit/RTO5f2a/partial-map-merge-0 + @Test + func partialObjectStateMergeForMaps() throws { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + // First partial state for root (sequence kept open); no createOp. + Self.processSync(ro, channelSerial: "sync1:more", internalQueue: internalQueue, [ + Self.syncMsg(TestFactories.mapObjectState( + objectId: "root", + siteTimeserials: ["aaa": "t:0"], + entries: ["name": Self.stringEntry("Alice", timeserial: "t:0")], + )), + ]) + + // Second partial state for root; empty cursor completes. + Self.processSync(ro, channelSerial: "sync1:", internalQueue: internalQueue, [ + Self.syncMsg(Self.rootState(entries: ["age": TestFactories.mapEntry(timeserial: "t:0", data: ProtocolTypes.ObjectData(number: NSNumber(value: 30)))])), + ]) + + // RTO5f2a2: entries merged across the two partial states. + let root = try #require(ro.testsOnly_objectsPool.entries["root"]?.mapValue) + #expect(root.testsOnly_data["name"]?.data?.string == "Alice") + #expect(root.testsOnly_data["age"]?.data?.number == NSNumber(value: 30)) + } + + // MARK: - RTO5c2 + + // @UTS objects/unit/RTO5c2/remove-absent-objects-0 + @Test + func syncCompletionRemovesObjectsNotInSync() { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + ro.testsOnly_setPoolEntry(.counter(Self.seededCounter(objectID: "counter:old@1000", data: 99, internalQueue: internalQueue)), forObjectID: "counter:old@1000") + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + + Self.processSync(ro, channelSerial: "sync1:", internalQueue: internalQueue, [ + Self.syncMsg(Self.rootState()), + ]) + + let pool = ro.testsOnly_objectsPool + // RTO5c2: object not received during sync removed; RTO5c2a: root kept. + #expect(pool.entries["counter:old@1000"] == nil) + #expect(pool.entries["root"] != nil) + } + + // MARK: - RTO5c9 + + // @UTS objects/unit/RTO5c9/clear-applied-on-ack-serials-0 + @Test + func syncCompletionClearsAppliedOnAckSerials() { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + // Reach SYNCED, seed a counter, then arm appliedOnAckSerials via a LOCAL apply (D-4). + Self.attach(ro, hasObjects: false, internalQueue: internalQueue) + ro.testsOnly_setPoolEntry(.counter(Self.seededCounter(objectID: "counter:abc@1000", data: 0, internalQueue: internalQueue)), forObjectID: "counter:abc@1000") + ro.testsOnly_applyObjectMessages([ + TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 0, serial: "serial-1", siteCode: "site1"), + ], source: .local) + #expect(ro.testsOnly_appliedOnAckSerials.contains("serial-1")) + + // A subsequent sync must clear the set (RTO5c9). + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + Self.processSync(ro, channelSerial: "sync1:", internalQueue: internalQueue, [ + Self.syncMsg(Self.rootState()), + ]) + + #expect(ro.testsOnly_appliedOnAckSerials.isEmpty) + } + + // MARK: - RTO7, RTO8a + + // @UTS objects/unit/RTO8a/buffer-during-syncing-0 + @Test + func objectMessagesBufferedDuringSyncing() { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + Self.processObject(ro, internalQueue: internalQueue, [ + TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: "site1"), + ]) + + // RTO8a: buffered while SYNCING; RTO7a: buffer is an array (count 1); object not yet in pool. + #expect(ro.testsOnly_syncState == .syncing) + #expect(ro.testsOnly_bufferedObjectOperationsCount == 1) + #expect(ro.testsOnly_objectsPool.entries["counter:abc@1000"] == nil) + } + + // MARK: - RTO5c6, RTO8b + + // @UTS objects/unit/RTO5c6/apply-buffered-on-sync-0 + @Test + func bufferedOperationsAppliedOnSyncCompletion() throws { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + let coreSDK = Self.coreSDK(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + Self.processObject(ro, internalQueue: internalQueue, [ + TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 10, serial: "02", siteCode: "site1"), + ]) + + Self.processSync(ro, channelSerial: "sync1:", internalQueue: internalQueue, [ + Self.syncMsg(Self.rootState()), + Self.syncMsg(Self.counterState(objectId: "counter:abc@1000", count: 0, createCount: 100)), + ]) + + // RTO5c6: buffered op applied (source CHANNEL) after sync -> 100 (create) + 10 (buffered inc). + #expect(try #require(ro.testsOnly_objectsPool.entries["counter:abc@1000"]?.counterValue).value(coreSDK: coreSDK) == 110) + #expect(ro.testsOnly_bufferedObjectOperationsCount == nil) // no longer SYNCING + } + + // MARK: - RTO9a1 + + // @UTS objects/unit/RTO9a1/null-operation-warning-0 + @Test + func nullOperationIsDiscarded() { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: false, internalQueue: internalQueue) // reach SYNCED + Self.processObject(ro, internalQueue: internalQueue, [ + TestFactories.inboundObjectMessage(operation: nil, serial: "01", siteCode: "site1"), + ]) + + // RTO9a1: message with no operation discarded (no object created; only root remains). + #expect(ro.testsOnly_objectsPool.entries.count == 1) + } + + // MARK: - RTO9a3 + + // @UTS objects/unit/RTO9a3/dedup-applied-on-ack-0 + @Test + func appliedOnAckSerialsDeduplication() throws { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + let coreSDK = Self.coreSDK(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: false, internalQueue: internalQueue) // reach SYNCED + ro.testsOnly_setPoolEntry(.counter(Self.seededCounter(objectID: "counter:abc@1000", data: 10, internalQueue: internalQueue)), forObjectID: "counter:abc@1000") + // Arm appliedOnAckSerials = {"echo-serial-1"} via a LOCAL +0 apply (D-4). + ro.testsOnly_applyObjectMessages([ + TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 0, serial: "echo-serial-1", siteCode: "site1"), + ], source: .local) + #expect(ro.testsOnly_appliedOnAckSerials.contains("echo-serial-1")) + + // Echoed OBJECT message with the same serial -> discarded, serial removed. + Self.processObject(ro, internalQueue: internalQueue, [ + TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 5, serial: "echo-serial-1", siteCode: "site2"), + ]) + + #expect(try #require(ro.testsOnly_objectsPool.entries["counter:abc@1000"]?.counterValue).value(coreSDK: coreSDK) == 10) + #expect(!ro.testsOnly_appliedOnAckSerials.contains("echo-serial-1")) + } + + // MARK: - RTO9a2a4 + + // @UTS objects/unit/RTO9a2a4/local-source-adds-serial-0 + @Test + func localSourceAddsSerialToAppliedOnAckSerials() throws { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + let coreSDK = Self.coreSDK(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: false, internalQueue: internalQueue) // reach SYNCED + ro.testsOnly_setPoolEntry(.counter(Self.seededCounter(objectID: "counter:abc@1000", data: 0, internalQueue: internalQueue)), forObjectID: "counter:abc@1000") + + ro.testsOnly_applyObjectMessages([ + TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 5, serial: "local-serial-1", siteCode: "test-site"), + ], source: .local) + + // RTO9a2a4: LOCAL + applied -> serial added; the operation was applied (0 + 5). + #expect(ro.testsOnly_appliedOnAckSerials.contains("local-serial-1")) + #expect(try #require(ro.testsOnly_objectsPool.entries["counter:abc@1000"]?.counterValue).value(coreSDK: coreSDK) == 5) + } + + // MARK: - RTO9a2b + + // @UTS objects/unit/RTO9a2b/unsupported-action-warning-0 + @Test + func unsupportedActionIsDiscarded() { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: false, internalQueue: internalQueue) // reach SYNCED + Self.processObject(ro, internalQueue: internalQueue, [ + TestFactories.inboundObjectMessage( + // `.unknown(999)` — an unrecognised action code (the enum is Int-raw); mirrors the + // spec's `action: "UNKNOWN_ACTION"`. + operation: TestFactories.objectOperation(action: .unknown(999), objectId: "counter:abc@1000"), + serial: "01", + siteCode: "site1", + ), + ]) + + // RTO9a2b: unsupported action discarded (no object created; only root remains). + #expect(ro.testsOnly_objectsPool.entries.count == 1) + } + + // MARK: - RTO6 + + // @UTS objects/unit/RTO6/zero-value-from-prefix-0 + @Test + func zeroValueObjectCreationFromObjectIdPrefix() throws { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + let coreSDK = Self.coreSDK(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: false, internalQueue: internalQueue) // reach SYNCED + + // RTO6b3: "counter" prefix -> zero-value InternalLiveCounter. + Self.processObject(ro, internalQueue: internalQueue, [ + TestFactories.counterIncOperationMessage(objectId: "counter:new@2000", number: 5, serial: "01", siteCode: "site1"), + ]) + // RTO6b2: "map" prefix -> zero-value InternalLiveMap. + Self.processObject(ro, internalQueue: internalQueue, [ + TestFactories.mapSetOperationMessage(objectId: "map:new@2000", key: "key", value: "val", serial: "01", siteCode: "site1"), + ]) + + let pool = ro.testsOnly_objectsPool + let counter = try #require(pool.entries["counter:new@2000"]?.counterValue) + #expect(try counter.value(coreSDK: coreSDK) == 5) + let map = try #require(pool.entries["map:new@2000"]?.mapValue) + #expect(map.testsOnly_data["key"]?.data?.string == "val") + } + + // MARK: - RTO5d + + // @UTS objects/unit/RTO5d/null-object-skipped-0 + @Test + func objectSyncWithNullObjectFieldIsSkipped() { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + Self.processSync(ro, channelSerial: "sync1:", internalQueue: internalQueue, [ + TestFactories.inboundObjectMessage(object: nil), // RTO5d: null object -> skipped + Self.syncMsg(Self.rootState()), + ]) + + // The null-object message is skipped but the sync still completes. + #expect(ro.testsOnly_syncState == .synced) + } + + // MARK: - RTO5f3 + + // @UTS objects/unit/RTO5f3/unsupported-type-skipped-0 + @Test + func objectSyncWithUnsupportedObjectTypeIsSkipped() { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + Self.processSync(ro, channelSerial: "sync1:", internalQueue: internalQueue, [ + Self.syncMsg(Self.rootState()), + // Neither map nor counter present -> RTO5f3 log warning and skip. + Self.syncMsg(ProtocolTypes.ObjectState(objectId: "unknown:xyz@1000", siteTimeserials: [:], tombstone: false, createOp: nil, map: nil, counter: nil)), + ]) + + let pool = ro.testsOnly_objectsPool + #expect(ro.testsOnly_syncState == .synced) + #expect(pool.entries["unknown:xyz@1000"] == nil) + } + + // MARK: - RTO5e + + // @UTS objects/unit/RTO5e/object-sync-transitions-syncing-0 + @Test + func objectSyncTransitionsToSyncing() { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + // No preceding ATTACHED; an in-progress OBJECT_SYNC (non-empty cursor) transitions to SYNCING. + Self.processSync(ro, channelSerial: "sync1:more", internalQueue: internalQueue, [ + Self.syncMsg(Self.rootState()), + ]) + + #expect(ro.testsOnly_syncState == .syncing) + } + + // MARK: - RTO5c7 + + // @UTS objects/unit/RTO5c7/sync-emits-updates-0 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func syncCompletionEmitsUpdatesForExistingObjects() async throws { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + let root = Self.seededRoot(data: ["name": Self.seededStringEntry("Old", timeserial: "01")], internalQueue: internalQueue) + ro.testsOnly_setPoolEntry(.map(root), forObjectID: "root") + + let subscriber = Subscriber(callbackQueue: .main) + try root.subscribe(listener: subscriber.createListener(), coreSDK: Self.coreSDK(internalQueue: internalQueue)) + + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + Self.processSync(ro, channelSerial: "sync1:", internalQueue: internalQueue, [ + Self.syncMsg(Self.rootState(entries: ["name": Self.stringEntry("New", timeserial: "t:0")])), + ]) + + // RTO5c7: the previously-existing root emits its stored update; "name" changed -> updated. + let invocations = await subscriber.getInvocations() + let update = try #require(invocations.map(\.0).first { $0.update["name"] != nil }) + #expect(update.update["name"] == .updated) + } + + // MARK: - RTO5f2b + + // @UTS objects/unit/RTO5f2b/partial-counter-error-0 + @Test + func partialCounterStateIsSkipped() throws { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + let coreSDK = Self.coreSDK(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + Self.processSync(ro, channelSerial: "sync1:more", internalQueue: internalQueue, [ + Self.syncMsg(Self.counterState(objectId: "counter:abc@1000", count: 10, createCount: nil)), + ]) + // Second partial counter state for the same object -> RTO5f2b log error and skip (keep 10). + Self.processSync(ro, channelSerial: "sync1:", internalQueue: internalQueue, [ + Self.syncMsg(Self.rootState()), + Self.syncMsg(Self.counterState(objectId: "counter:abc@1000", count: 5, createCount: nil)), + ]) + + #expect(try #require(ro.testsOnly_objectsPool.entries["counter:abc@1000"]?.counterValue).value(coreSDK: coreSDK) == 10) + } + + // MARK: - RTO4d + + // @UTS objects/unit/RTO4d/attached-clears-buffer-0 + @Test + func attachedClearsBufferedOperations() { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + Self.processObject(ro, internalQueue: internalQueue, [ + TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: "site1"), + ]) + #expect(ro.testsOnly_bufferedObjectOperationsCount == 1) + + // RTO4d: a new ATTACHED clears bufferedObjectOperations. + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + #expect(ro.testsOnly_bufferedObjectOperationsCount == 0) + } + + // MARK: - RTO4, RTO5 + + // @UTS objects/unit/RTO4-RTO5/attached-during-syncing-resets-0 + @Test + func attachedDuringSyncingResetsSync() { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + Self.processSync(ro, channelSerial: "sync1:more", internalQueue: internalQueue, [ + Self.syncMsg(Self.counterState(objectId: "counter:old@1000", count: 10, createCount: nil)), + ]) + #expect(ro.testsOnly_syncState == .syncing) + + // A new ATTACHED during SYNCING resets the state machine; the fresh sync completes. + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + Self.processSync(ro, channelSerial: "sync2:", internalQueue: internalQueue, [ + Self.syncMsg(Self.rootState()), + Self.syncMsg(Self.counterState(objectId: "counter:new@1000", count: 99, createCount: nil)), + ]) + + let pool = ro.testsOnly_objectsPool + #expect(ro.testsOnly_syncState == .synced) + #expect(pool.entries["counter:old@1000"] == nil) + #expect(pool.entries["counter:new@1000"] != nil) + } + + // MARK: - RTO5, RTO7 + + // @UTS objects/unit/RTO5-RTO7/new-sync-keeps-buffer-0 + @Test + func newObjectSyncSequenceDoesNotClearBuffer() throws { + let internalQueue = TestFactories.createInternalQueue() + let ro = Self.makeRealtimeObjects(internalQueue: internalQueue) + let coreSDK = Self.coreSDK(internalQueue: internalQueue) + + Self.attach(ro, hasObjects: true, internalQueue: internalQueue) + Self.processObject(ro, internalQueue: internalQueue, [ + TestFactories.counterIncOperationMessage(objectId: "counter:abc@1000", number: 5, serial: "01", siteCode: "site1"), + ]) + #expect(ro.testsOnly_bufferedObjectOperationsCount == 1) + + // A new OBJECT_SYNC sequence discards only the SyncObjectsPool; the buffer is retained and + // applied on completion -> 100 (create) + 5 (retained buffered inc). + Self.processSync(ro, channelSerial: "seq2:", internalQueue: internalQueue, [ + Self.syncMsg(Self.rootState()), + Self.syncMsg(Self.counterState(objectId: "counter:abc@1000", count: 0, createCount: 100)), + ]) + + #expect(ro.testsOnly_syncState == .synced) + #expect(try #require(ro.testsOnly_objectsPool.entries["counter:abc@1000"]?.counterValue).value(coreSDK: coreSDK) == 105) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectsUTSHelpers.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectsUTSHelpers.swift new file mode 100644 index 000000000..bdea222b9 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectsUTSHelpers.swift @@ -0,0 +1,521 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +// Dedicated helper for the objects UNIT-tier UTS ports (the `objects/unit` public-tier specs: +// InstanceUTSTests, ValueTypesUTSTests, PathObject*UTSTests, …). This is the UTS suite's analogue of +// ably-java's `uts/unit/Helpers.kt`: a suite-local fixture namespace, kept dedicated rather than +// folded into the broader `AblyLiveObjectsTests` helpers. +// +// Now that the ports live in the `AblyLiveObjectsTests` target, the two truly-trivial no-op primitives +// this file used to replicate — `ObjectsUTSLogger` / `ObjectsUTSClock` — have been deduped to the +// native `TestLogger` / `MockSimpleClock`. The remaining `ObjectsUTS*` doubles are kept because they +// have no drop-in native equivalent (see `UTS/README.md`): the native `MockCoreSDK` init requires a +// mutating `internalQueue:`-backed channel-state mutex, `SeededRealtimeObjects` is `private` to +// `DefaultPathObjectTests`, and the native event collectors are `private` per-suite. +// +// UNIT SCOPE: none of these helpers open a connection, attach a channel, or run `setup_synced_channel`. +// Objects are built directly via the `testsOnly_` node initialisers and wrapped with the internal +// `Instance.from(...)` factory seam (the same seam `PathObject.instance()` uses in production). + +// MARK: - CoreSDK + +/// A minimal ``CoreSDK`` exposing a fixed channel state (for the RTO25/RTO26 precondition checks the +/// node accessors run) and a canned server time. Publishing goes through ``ObjectsUTSRealtimeObjects``, +/// not this type, so `nosync_publish` is never exercised by the unit ports. +final class ObjectsUTSCoreSDK: CoreSDK { + private let channelState: _AblyPluginSupportPrivate.RealtimeChannelState + private let serverTime: Date + + init(channelState: _AblyPluginSupportPrivate.RealtimeChannelState = .attached, serverTime: Date = Date()) { + self.channelState = channelState + self.serverTime = serverTime + } + + func nosync_publish(objectMessages _: [ProtocolTypes.OutboundObjectMessage], callback _: @escaping @Sendable (Result) -> Void) { + fatalError("nosync_publish is not exercised by the unit ports (writes go through ObjectsUTSRealtimeObjects)") + } + + func nosync_fetchServerTime(callback: @escaping @Sendable (Result) -> Void) { + callback(.success(serverTime)) + } + + func testsOnly_overridePublish(with _: @escaping ([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { + fatalError("testsOnly_overridePublish is not exercised by the unit ports") + } + + var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { + channelState + } +} + +// MARK: - RealtimeObjects + +/// A minimal ``InternalRealtimeObjectsProtocol`` (mirrors AblyLiveObjectsTests' `MockRealtimeObjects`). +/// Captures the messages passed to `publishAndApply` (the RTO20 write seam that map `set`/`remove` and +/// counter `increment`/`decrement` publish through) without touching a real channel. +/// +/// - Note: unlike the production `InternalDefaultRealtimeObjects`, this mock does **not** apply the +/// published operation back onto the object graph. Ported cases that would assert a value *after* +/// local apply (e.g. `value() == 150`) are therefore split: the unit port asserts the published +/// operation; the post-apply value assertion needs the full pipeline and is out of unit scope. +final class ObjectsUTSRealtimeObjects: InternalRealtimeObjectsProtocol { + private let poolDelegate: ObjectsUTSPoolDelegate? + private let mutex = NSLock() + private nonisolated(unsafe) var _handler: (([ProtocolTypes.OutboundObjectMessage]) -> Result)? + + /// A real (unused-in-dispatch) register so the type conforms to `InternalRealtimeObjectsProtocol`. + /// The unit ports do not exercise path-subscription dispatch (Phase 4 part 2). + private let _pathObjectSubscriptionRegister = PathObjectSubscriptionRegister( + internalQueue: DispatchQueue(label: "ObjectsUTSRealtimeObjects.internal"), + userCallbackQueue: DispatchQueue(label: "ObjectsUTSRealtimeObjects.userCallback"), + ) + + init(poolDelegate: ObjectsUTSPoolDelegate? = nil) { + self.poolDelegate = poolDelegate + } + + var nosync_pathObjectSubscriptionRegister: PathObjectSubscriptionRegister { + _pathObjectSubscriptionRegister + } + + var nosync_objectsPool: ObjectsPool { + guard let poolDelegate else { + preconditionFailure("ObjectsUTSRealtimeObjects was not initialised with a poolDelegate") + } + return poolDelegate.nosync_objectsPool + } + + func setPublishAndApplyHandler(_ handler: @escaping ([ProtocolTypes.OutboundObjectMessage]) -> Result) { + mutex.withLock { _handler = handler } + } + + func nosync_publishAndApply( + objectMessages: [ProtocolTypes.OutboundObjectMessage], + coreSDK _: CoreSDK, + callback: @escaping @Sendable (Result) -> Void, + ) { + let handler = mutex.withLock { _handler } + if let handler { + callback(handler(objectMessages)) + } else { + callback(.success(())) + } + } +} + +// MARK: - Pool delegate + +/// A pool delegate holding a fixed set of `objectId -> Entry` mappings, so that map `get`/`entries` +/// can resolve `objectId` references to nested objects (mirrors `MockLiveMapObjectsPoolDelegate`). +final class ObjectsUTSPoolDelegate: LiveMapObjectsPoolDelegate { + private let poolMutex: DispatchQueueMutex + + init(internalQueue: DispatchQueue, entries: [String: ObjectsPool.Entry] = [:]) { + poolMutex = DispatchQueueMutex( + dispatchQueue: internalQueue, + initialValue: ObjectsPool( + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + testsOnly_otherEntries: entries, + ), + ) + } + + var nosync_objectsPool: ObjectsPool { + poolMutex.withoutSync { $0 } + } +} + +// MARK: - Event collector + +/// A tiny thread-safe collector for ``InstanceSubscriptionEvent``s delivered to a subscribe listener. +/// +/// A single-type stand-in for AblyLiveObjectsTests' variadic `Subscriber`; avoiding parameter packs +/// keeps it clear of that type's `@available(iOS 17, tvOS 17)` gate. +final class ObjectsUTSEventCollector: Sendable { + private let callbackQueue: DispatchQueue + private let mutex = NSLock() + private nonisolated(unsafe) var _events: [InstanceSubscriptionEvent] = [] + + init(callbackQueue: DispatchQueue = .main) { + self.callbackQueue = callbackQueue + } + + /// The listener to hand to `subscribe(listener:)`. + var listener: InstanceSubscriptionCallback { + { [weak self] event in + self?.mutex.withLock { self?._events.append(event) } + } + } + + /// Drains `callbackQueue` (so all already-emitted deliveries have run) and returns the events so far. + func events() async -> [InstanceSubscriptionEvent] { + await withCheckedContinuation { continuation in + callbackQueue.async { continuation.resume() } + } + return mutex.withLock { _events } + } +} + +// MARK: - Captured publish + +/// A thread-safe holder for the messages a `publishAndApply` handler captures (mirrors the private +/// `Published` holder in AblyLiveObjectsTests' `DefaultInstanceTests`). +final class ObjectsUTSPublished: Sendable { + private let mutex = NSLock() + private nonisolated(unsafe) var messages: [ProtocolTypes.OutboundObjectMessage]? + + func set(_ value: [ProtocolTypes.OutboundObjectMessage]) { + mutex.withLock { messages = value } + } + + func get() -> [ProtocolTypes.OutboundObjectMessage]? { + mutex.withLock { messages } + } +} + +// MARK: - Construction / evaluation helpers + +/// Shared node-construction and blueprint-evaluation helpers for the objects UNIT-tier UTS ports. +enum ObjectsUTS { + static func createInternalQueue() -> DispatchQueue { + DispatchQueue(label: "io.ably.uts.objects.\(UUID().uuidString)", qos: .userInitiated) + } + + static func makeCounter(objectID: String = "counter:1@0", data: Double = 0, internalQueue: DispatchQueue) -> InternalDefaultLiveCounter { + InternalDefaultLiveCounter(testsOnly_data: data, objectID: objectID, logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + static func makeMap(objectID: String = "map:1@0", data: [String: InternalObjectsMapEntry] = [:], internalQueue: DispatchQueue) -> InternalDefaultLiveMap { + InternalDefaultLiveMap(testsOnly_data: data, objectID: objectID, logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + static func mapEntry(data: ProtocolTypes.ObjectData, timeserial: String = "ts1") -> InternalObjectsMapEntry { + InternalObjectsMapEntry(tombstonedAt: nil, timeserial: timeserial, data: data) + } + + /// A fresh empty ``ObjectsPool`` for feeding into `nosync_apply(...)`. + static func freshPool(internalQueue: DispatchQueue) -> ObjectsPool { + ObjectsPool(logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + // MARK: Standard test pool (path-object ports) + + /// Builds the shared standard LiveObjects tree used by the path-object UTS ports + /// (`objects/helpers/standard_test_pool.md`), seeded directly into an ``ObjectsPool`` (the unit + /// stand-in for `setup_synced_channel`, which materialises it via an OBJECT_SYNC). + /// + /// ``` + /// root (map) + /// name -> "Alice" age -> 30 + /// active -> true score -> counter:score@1000 (100) + /// profile -> map:profile@1000 data -> json {"tags": ["a","b"]} + /// avatar -> bytes [1,2,3] + /// map:profile@1000: email -> "alice@example.com", nested_counter -> counter:nested@1000 (5), + /// prefs -> map:prefs@1000 + /// map:prefs@1000: theme -> "dark" + /// ``` + /// + /// If `prefsBackRef` is true, `map:prefs@1000` gains a `back_ref -> map:profile@1000` entry, + /// forming the `profile -> prefs -> profile` cycle the RTPO14b2 compactJson test exercises. + static func standardPool(internalQueue: DispatchQueue, prefsBackRef: Bool = false) -> ObjectsPool { + let scoreCounter = makeCounter(objectID: "counter:score@1000", data: 100, internalQueue: internalQueue) + let nestedCounter = makeCounter(objectID: "counter:nested@1000", data: 5, internalQueue: internalQueue) + + var prefsData: [String: InternalObjectsMapEntry] = ["theme": mapEntry(data: ProtocolTypes.ObjectData(string: "dark"))] + if prefsBackRef { + prefsData["back_ref"] = mapEntry(data: ProtocolTypes.ObjectData(objectId: "map:profile@1000")) + } + let prefs = makeMap(objectID: "map:prefs@1000", data: prefsData, internalQueue: internalQueue) + + let profile = makeMap( + objectID: "map:profile@1000", + data: [ + "email": mapEntry(data: ProtocolTypes.ObjectData(string: "alice@example.com")), + "nested_counter": mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:nested@1000")), + "prefs": mapEntry(data: ProtocolTypes.ObjectData(objectId: "map:prefs@1000")), + ], + internalQueue: internalQueue, + ) + let root = makeMap( + objectID: ObjectsPool.rootKey, + data: [ + "name": mapEntry(data: ProtocolTypes.ObjectData(string: "Alice")), + "age": mapEntry(data: ProtocolTypes.ObjectData(number: NSNumber(value: 30))), + "active": mapEntry(data: ProtocolTypes.ObjectData(boolean: true)), + "score": mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:score@1000")), + "profile": mapEntry(data: ProtocolTypes.ObjectData(objectId: "map:profile@1000")), + "data": mapEntry(data: ProtocolTypes.ObjectData(json: .object(["tags": .array([.string("a"), .string("b")])]))), + "avatar": mapEntry(data: ProtocolTypes.ObjectData(bytes: Data([1, 2, 3]))), + ], + internalQueue: internalQueue, + ) + + var pool = freshPool(internalQueue: internalQueue) + pool.testsOnly_setEntry(.counter(scoreCounter), forObjectID: "counter:score@1000") + pool.testsOnly_setEntry(.counter(nestedCounter), forObjectID: "counter:nested@1000") + pool.testsOnly_setEntry(.map(prefs), forObjectID: "map:prefs@1000") + pool.testsOnly_setEntry(.map(profile), forObjectID: "map:profile@1000") + pool.testsOnly_setEntry(.map(root), forObjectID: ObjectsPool.rootKey) + return pool + } + + // MARK: Blueprint evaluation (value_types.md `evaluate(vt)`) + + /// A fixed timestamp for object-ID generation in evaluation tests (RTO14 uses it verbatim). + static let evaluationTimestamp = Date(timeIntervalSince1970: 1_754_042_434) + + /// Evaluates a ``LiveCounter`` blueprint into the outbound `ObjectMessage`(s) it generates. + /// + /// The cocoa evaluation seam is `ObjectCreationHelpers.creationOperationForLiveCounter(...)`; there + /// is no standalone public `evaluate`. The blueprint's internal `count` (RTLCV2a) is read here and + /// fed to that helper (DEV: evaluate seam differs from the spec's blueprint#evaluate). + static func evaluate(counter blueprint: LiveCounter) -> [ProtocolTypes.OutboundObjectMessage] { + [ObjectCreationHelpers.creationOperationForLiveCounter(count: blueprint.count, timestamp: evaluationTimestamp).objectMessage] + } + + /// Evaluates a ``LiveMap`` blueprint (primitive entries only) into the outbound `ObjectMessage`(s). + /// + /// The cocoa evaluation seam is `ObjectCreationHelpers.nosync_creationOperationForLiveMap(...)`. The + /// blueprint's internal `entries` (RTLMV2a) are converted to `InternalLiveMapValue`s and fed to it. + /// Nested `LiveMap`/`LiveCounter` blueprint entries are out of unit scope (they require the async + /// materialisation pipeline) and are rejected here. + static func evaluate(map blueprint: LiveMap, internalQueue: DispatchQueue) -> [ProtocolTypes.OutboundObjectMessage] { + let entries = (blueprint.entries ?? [:]).mapValues { internalValue(from: $0) } + let operation = internalQueue.ably_syncNoDeadlock { + ObjectCreationHelpers.nosync_creationOperationForLiveMap(entries: entries, timestamp: evaluationTimestamp) + } + return [operation.objectMessage] + } + + /// 1:1 mapping of a public primitive ``LiveMapValue`` onto its ``InternalLiveMapValue``, mirroring + /// `DefaultLiveMapInstance`'s (private) `internalValue(from:)`. Blueprint entries are unsupported. + private static func internalValue(from value: LiveMapValue) -> InternalLiveMapValue { + switch value { + case let .primitive(primitive): + switch primitive { + case let .string(v): + .string(v) + case let .number(v): + .number(v) + case let .bool(v): + .bool(v) + case let .data(v): + .data(v) + case let .jsonArray(v): + .jsonArray(v) + case let .jsonObject(v): + .jsonObject(v) + } + case .liveMap, .liveCounter: + preconditionFailure("Nested LiveMap/LiveCounter blueprint evaluation is out of UNIT scope") + } + } + + // MARK: Inbound message builders (subscribe ports; `mock_ws.send_to_client` stand-in) + + /// An inbound OBJECT operation message (the unit stand-in for a `build_object_message` frame + /// delivered by `mock_ws.send_to_client`). Mirrors the native `TestFactories.inboundObjectMessage`. + static func inboundOperation(_ operation: ProtocolTypes.ObjectOperation, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage { + ProtocolTypes.InboundObjectMessage(id: nil, clientId: nil, connectionId: nil, extras: nil, timestamp: nil, operation: operation, object: nil, serial: serial, siteCode: siteCode, serialTimestamp: nil) + } + + /// A MAP_SET operation message (`build_map_set`). Pass either a primitive `ObjectData` or one + /// carrying `objectId` to point the key at a live object. + static func mapSetMessage(objectId: String, key: String, value: ProtocolTypes.ObjectData, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage { + inboundOperation( + ProtocolTypes.ObjectOperation(action: .known(.mapSet), objectId: objectId, mapSet: ProtocolTypes.MapSet(key: key, value: value)), + serial: serial, + siteCode: siteCode, + ) + } + + /// A COUNTER_INC operation message (`build_counter_inc`). + static func counterIncMessage(objectId: String, number: Int, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage { + inboundOperation( + ProtocolTypes.ObjectOperation(action: .known(.counterInc), objectId: objectId, counterInc: WireCounterInc(number: NSNumber(value: number))), + serial: serial, + siteCode: siteCode, + ) + } + + /// A MAP_CLEAR operation message (`build_map_clear`). + static func mapClearMessage(objectId: String, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage { + inboundOperation( + ProtocolTypes.ObjectOperation(action: .known(.mapClear), objectId: objectId, mapClear: WireMapClear()), + serial: serial, + siteCode: siteCode, + ) + } + + /// An OBJECT_DELETE operation message (`build_object_delete`), which tombstones the target object. + /// Mirrors the native `TestFactories.objectDeleteOperationMessage`. + static func objectDeleteMessage(objectId: String, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage { + inboundOperation( + ProtocolTypes.ObjectOperation(action: .known(.objectDelete), objectId: objectId, objectDelete: WireObjectDelete()), + serial: serial, + siteCode: siteCode, + ) + } + + /// A noop COUNTER_INC operation message: a COUNTER_INC whose `counterInc` payload is absent, so the + /// counter apply produces a `.noop` (RTLC9h). The spec models the noop as `counterInc: {}` (present + /// but empty); cocoa's `WireCounterInc.number` is non-optional, so an empty/number-less increment is + /// represented by an absent `counterInc` — the same RTLC9h noop branch (recorded in deviations.md). + static func counterIncNoopMessage(objectId: String, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage { + inboundOperation( + ProtocolTypes.ObjectOperation(action: .known(.counterInc), objectId: objectId, counterInc: nil), + serial: serial, + siteCode: siteCode, + ) + } + + /// An inbound OBJECT_SYNC state message carrying a counter's full state (used to drive a + /// sync-originated update, whose delivered event has no public message per RTO4b2a). + static func counterSyncMessage(objectId: String, count: Int) -> ProtocolTypes.InboundObjectMessage { + ProtocolTypes.InboundObjectMessage( + id: nil, + clientId: nil, + connectionId: nil, + extras: nil, + timestamp: nil, + operation: nil, + object: ProtocolTypes.ObjectState( + objectId: objectId, + siteTimeserials: ["aaa": "t:0"], + tombstone: false, + createOp: nil, + map: nil, + counter: WireObjectsCounter(count: NSNumber(value: count)), + ), + serial: nil, + siteCode: nil, + serialTimestamp: nil, + ) + } + + /// An inbound OBJECT_SYNC state message re-stating the root map's entries. + static func rootSyncMessage(entries: [String: ProtocolTypes.ObjectsMapEntry]) -> ProtocolTypes.InboundObjectMessage { + ProtocolTypes.InboundObjectMessage( + id: nil, + clientId: nil, + connectionId: nil, + extras: nil, + timestamp: nil, + operation: nil, + object: ProtocolTypes.ObjectState( + objectId: ObjectsPool.rootKey, + siteTimeserials: ["aaa": "t:0"], + tombstone: false, + createOp: nil, + map: ProtocolTypes.ObjectsMap(semantics: .known(.lww), entries: entries), + counter: nil, + ), + serial: nil, + siteCode: nil, + serialTimestamp: nil, + ) + } + + /// An inbound map entry (wire form) for `rootSyncMessage`. + static func wireMapEntry(data: ProtocolTypes.ObjectData) -> ProtocolTypes.ObjectsMapEntry { + ProtocolTypes.ObjectsMapEntry(tombstone: false, timeserial: "t:0", data: data) + } +} + +// MARK: - Path-subscription event collector + +/// A thread-safe collector for ``PathObjectSubscriptionEvent``s delivered to a path-object subscribe +/// listener (the path-layer analogue of ``ObjectsUTSEventCollector``). +final class ObjectsUTSPathEventCollector: @unchecked Sendable { + private let lock = NSLock() + private var storedEvents: [PathObjectSubscriptionEvent] = [] + + /// The listener to hand to `subscribe(listener:)`. + var listener: PathObjectSubscriptionCallback { + { [weak self] event in + self?.lock.withLock { self?.storedEvents.append(event) } + } + } + + var events: [PathObjectSubscriptionEvent] { + lock.withLock { storedEvents } + } + + /// The `.path` of every delivered event, sorted (delivery order across paths is unspecified). + var sortedPaths: [String] { + events.map(\.object.path).sorted() + } +} + +// MARK: - Instance-subscription event collector (engine-driven instance-subscribe ports) + +/// A thread-safe collector for ``InstanceSubscriptionEvent``s that is read **synchronously** after the +/// engine's `userCallbackQueue` has been drained (the instance-layer analogue of +/// ``ObjectsUTSPathEventCollector``). Unlike ``ObjectsUTSEventCollector`` — which drains its own +/// `.main` callback queue asynchronously — this suits the `LiveObjectSubscribeTests` fixture, where +/// instance-subscribe deliveries land on the engine's own `userCallbackQueue` (drained via +/// `queue.sync {}`), after which `events` reads the accumulated deliveries directly. +final class ObjectsUTSInstanceEventCollector: @unchecked Sendable { + private let lock = NSLock() + private var storedEvents: [InstanceSubscriptionEvent] = [] + + /// The listener to hand to `subscribe(listener:)`. + var listener: InstanceSubscriptionCallback { + { [weak self] event in + self?.lock.withLock { self?.storedEvents.append(event) } + } + } + + var events: [InstanceSubscriptionEvent] { + lock.withLock { storedEvents } + } +} + +// MARK: - Seeded realtime objects (path-object ports) + +/// A realtime-objects double exposing a pre-seeded **full** ``ObjectsPool`` (root + nested objects) +/// for path resolution, and capturing the messages published by the write path. Mirrors the native +/// `SeededRealtimeObjects` double in `LiveObjects/Tests/AblyLiveObjectsTests/DefaultPathObjectTests.swift` +/// (which the UTS target cannot import). Unlike ``ObjectsUTSRealtimeObjects`` — whose pool delegate +/// auto-creates an empty root — this injects a fully-seeded pool so `DefaultPathObject` resolution +/// walks root -> children exactly as production does. +final class ObjectsUTSSeededRealtimeObjects: InternalRealtimeObjectsProtocol { + private let poolMutex: DispatchQueueMutex + private let mutex = NSLock() + private nonisolated(unsafe) var _captured: [ProtocolTypes.OutboundObjectMessage]? + private let _pathObjectSubscriptionRegister: PathObjectSubscriptionRegister + + init(pool: ObjectsPool, internalQueue: DispatchQueue) { + poolMutex = DispatchQueueMutex(dispatchQueue: internalQueue, initialValue: pool) + _pathObjectSubscriptionRegister = PathObjectSubscriptionRegister(internalQueue: internalQueue, userCallbackQueue: .main) + } + + var nosync_objectsPool: ObjectsPool { + poolMutex.withoutSync { $0 } + } + + var nosync_pathObjectSubscriptionRegister: PathObjectSubscriptionRegister { + _pathObjectSubscriptionRegister + } + + /// The messages captured by the most recent `publishAndApply` (write ports assert against this; + /// the mock does not apply them back onto the graph, so post-apply value reads are out of scope). + var capturedMessages: [ProtocolTypes.OutboundObjectMessage]? { + mutex.withLock { _captured } + } + + func nosync_publishAndApply( + objectMessages: [ProtocolTypes.OutboundObjectMessage], + coreSDK _: CoreSDK, + callback: @escaping @Sendable (Result) -> Void, + ) { + mutex.withLock { _captured = objectMessages } + callback(.success(())) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ParentReferencesUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ParentReferencesUTSTests.swift new file mode 100644 index 000000000..b7979890a --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ParentReferencesUTSTests.swift @@ -0,0 +1,523 @@ +// @UTS objects/unit/parent_references.md +// +// Port of the parent-reference graph spec (RTLO3f, RTLO4g, RTLO4h, RTLO4f) and the RTO5c10 +// post-sync rebuild. `parentReferences` is a `Dict>` keyed by parent +// InternalLiveMap objectId; each value is the set of keys at which that map references this object. +// `getFullPaths` walks these reverse edges to enumerate every key-path from root. +// +// Deviations from the UTS spec: +// - (D-1) The spec constructs `InternalLiveCounter(objectId:)` / `InternalLiveMap(objectId:, +// semantics:)`. The Swift live objects need a logger/queue/callback-queue/clock, so each is built +// via `InternalDefaultLiveCounter.createZeroValued(...)` / `InternalDefaultLiveMap.createZeroValued(...)` +// (see the `makeCounter` / `makeMap` helpers). Semantics default to LWW as in the spec. +// - (D-2) Spec reads `obj.parentReferences`; the Swift equivalent is the queue-hopping accessor +// `obj.testsOnly_parentReferences`. Spec assignment `obj.parentReferences = {…}` maps to +// `obj.testsOnly_setParentReferences(_:)`. +// - (D-3) Spec `obj.addParentReference(parent, key)` / `removeParentReference(parent, key)` take the +// parent *object* and derive its objectId. The Swift `nosync_addParentReference(parentObjectID:key:)` +// / `nosync_removeParentReference(parentObjectID:key:)` take the parent's objectId string directly, +// and (being `nosync_`) must run on the internal queue, so every call is wrapped in +// `internalQueue.ably_syncNoDeadlock { }`. +// - (D-4) Spec `obj.getFullPaths()` is a method on the live object. Per audit deviation DEV-15 the RTLO4f +// DFS lives on `ObjectsPool.nosync_getFullPaths(forObjectID:)` (Kotlin's in-object DFS re-enters the +// starting object's mutex — a Swift exclusive-access crash). The per-object wrapper +// `obj.testsOnly_getFullPaths(objectsPool:)` forwards to the pool, so every `obj.getFullPaths()` +// maps to `obj.testsOnly_getFullPaths(objectsPool: pool)`. +// - (D-5) Spec pool subscript: `pool["id"] = obj` maps to `pool.testsOnly_setEntry(.counter(obj) / +// .map(obj), forObjectID: "id")`; `pool["root"]` maps to `pool.root`; a read `pool["id"]` maps to +// `pool.entries[id]` (unwrapped via `#require`, then `.counterValue` / `.mapValue`). +// - (D-6) Spec `Dict>` maps 1:1 to Swift `[String: Set]`; `== {}` is +// asserted as `.isEmpty`, `== {"score"}` as `== ["score"]`. +// Note (update-model enrichment / strengthen pass): unlike the map parent-reference port, the +// `parent_references.md` spec asserts NOTHING about `update.objectMessage` or `update.tombstone` +// (its cases are pure graph/DFS/rebuild operations that return no LiveObjectUpdate), so there are +// no such assertions to add here. +// - (D-7) The RTO5c10 rebuild spec drives the pool via `processAttached(…)` + `processObjectSync( +// build_object_sync_message(…))`. The Swift pool exposes the post-sync path via +// `nosync_applySyncObjectsPool(_:logger:internalQueue:userCallbackQueue:clock:)`, which performs the +// RTO5c10 rebuild at its tail (`nosync_rebuildParentReferences()`). Sync object states are built with +// `TestFactories.mapObjectState` / `counterObjectState` and wrapped via `SyncObjectsPool.testsOnly_fromStates(…)`. +// There is no observable `syncState == SYNCED` at this layer, so that assertion is omitted; the rebuild +// itself is exercised directly. + +@testable import AblyLiveObjects +import Foundation +import Testing + +struct ParentReferencesUTSTests { + // MARK: - Helpers (D-1) + + private static func makeCounter(objectID: String, internalQueue: DispatchQueue) -> InternalDefaultLiveCounter { + InternalDefaultLiveCounter.createZeroValued( + objectID: objectID, + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + private static func makeMap(objectID: String, internalQueue: DispatchQueue) -> InternalDefaultLiveMap { + InternalDefaultLiveMap.createZeroValued( + objectID: objectID, + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + private static func makePool(internalQueue: DispatchQueue) -> ObjectsPool { + ObjectsPool(logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + // MARK: - RTLO3f2: parentReferences initialized to empty map + + // @UTS objects/unit/RTLO3f2/init-empty-counter-0 + @Test + func parentReferencesInitEmptyCounter() { + let internalQueue = TestFactories.createInternalQueue() + let counter = Self.makeCounter(objectID: "counter:abc@1000", internalQueue: internalQueue) + + #expect(counter.testsOnly_parentReferences.isEmpty) + } + + // @UTS objects/unit/RTLO3f2/init-empty-map-0 + @Test + func parentReferencesInitEmptyMap() { + let internalQueue = TestFactories.createInternalQueue() + let map = Self.makeMap(objectID: "map:abc@1000", internalQueue: internalQueue) + + #expect(map.testsOnly_parentReferences.isEmpty) + } + + // MARK: - RTLO4g: addParentReference + + // @UTS objects/unit/RTLO4g2/first-reference-new-entry-0 + @Test + func addParentReferenceCreatesNewEntryForFirstReference() { + let internalQueue = TestFactories.createInternalQueue() + let child = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + + internalQueue.ably_syncNoDeadlock { + child.nosync_addParentReference(parentObjectID: "map:parent@1000", key: "score") + } + + #expect(child.testsOnly_parentReferences["map:parent@1000"] == ["score"]) + } + + // @UTS objects/unit/RTLO4g1/second-key-same-parent-0 + @Test + func addParentReferenceAddsKeyToExistingEntry() { + let internalQueue = TestFactories.createInternalQueue() + let child = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + child.testsOnly_setParentReferences(["map:parent@1000": ["score"]]) + + internalQueue.ably_syncNoDeadlock { + child.nosync_addParentReference(parentObjectID: "map:parent@1000", key: "points") + } + + #expect(child.testsOnly_parentReferences["map:parent@1000"] == ["score", "points"]) + } + + // @UTS objects/unit/RTLO4g/different-parent-separate-entry-0 + @Test + func addParentReferenceDifferentParentCreatesSeparateEntry() { + let internalQueue = TestFactories.createInternalQueue() + let child = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + + internalQueue.ably_syncNoDeadlock { + child.nosync_addParentReference(parentObjectID: "map:a@1000", key: "x") + child.nosync_addParentReference(parentObjectID: "map:b@1000", key: "y") + } + + #expect(child.testsOnly_parentReferences["map:a@1000"] == ["x"]) + #expect(child.testsOnly_parentReferences["map:b@1000"] == ["y"]) + } + + // @UTS objects/unit/RTLO4g/multiple-parents-multiple-keys-0 + @Test + func addParentReferenceMultipleParentsMultipleKeys() { + let internalQueue = TestFactories.createInternalQueue() + let child = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + + internalQueue.ably_syncNoDeadlock { + child.nosync_addParentReference(parentObjectID: "map:a@1000", key: "x") + child.nosync_addParentReference(parentObjectID: "map:a@1000", key: "y") + child.nosync_addParentReference(parentObjectID: "map:b@1000", key: "p") + child.nosync_addParentReference(parentObjectID: "map:b@1000", key: "q") + } + + #expect(child.testsOnly_parentReferences["map:a@1000"] == ["x", "y"]) + #expect(child.testsOnly_parentReferences["map:b@1000"] == ["p", "q"]) + } + + // MARK: - RTLO4h: removeParentReference + + // @UTS objects/unit/RTLO4h1/nonexistent-parent-noop-0 + @Test + func removeParentReferenceNoopForNonexistentParent() { + let internalQueue = TestFactories.createInternalQueue() + let child = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + + internalQueue.ably_syncNoDeadlock { + child.nosync_removeParentReference(parentObjectID: "map:parent@1000", key: "score") + } + + #expect(child.testsOnly_parentReferences.isEmpty) + } + + // @UTS objects/unit/RTLO4h2/remove-key-leaves-others-0 + @Test + func removeParentReferenceRemovesKeyButLeavesOthers() { + let internalQueue = TestFactories.createInternalQueue() + let child = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + child.testsOnly_setParentReferences(["map:parent@1000": ["score", "points"]]) + + internalQueue.ably_syncNoDeadlock { + child.nosync_removeParentReference(parentObjectID: "map:parent@1000", key: "score") + } + + #expect(child.testsOnly_parentReferences["map:parent@1000"] == ["points"]) + } + + // @UTS objects/unit/RTLO4h3/remove-last-key-removes-entry-0 + @Test + func removeParentReferenceRemovesEntryWhenSetBecomesEmpty() { + let internalQueue = TestFactories.createInternalQueue() + let child = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + child.testsOnly_setParentReferences(["map:parent@1000": ["score"]]) + + internalQueue.ably_syncNoDeadlock { + child.nosync_removeParentReference(parentObjectID: "map:parent@1000", key: "score") + } + + #expect(child.testsOnly_parentReferences["map:parent@1000"] == nil) + #expect(child.testsOnly_parentReferences.isEmpty) + } + + // @UTS objects/unit/RTLO4h/remove-nonexistent-key-0 + @Test + func removeParentReferenceNonexistentKeyInExistingParent() { + let internalQueue = TestFactories.createInternalQueue() + let child = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + child.testsOnly_setParentReferences(["map:parent@1000": ["score"]]) + + internalQueue.ably_syncNoDeadlock { + child.nosync_removeParentReference(parentObjectID: "map:parent@1000", key: "nonexistent") + } + + #expect(child.testsOnly_parentReferences["map:parent@1000"] == ["score"]) + } + + // MARK: - RTLO4f: getFullPaths + + // @UTS objects/unit/RTLO4f2/root-returns-empty-path-0 + @Test + func getFullPathsForRootReturnsEmptyKeyPath() { + let internalQueue = TestFactories.createInternalQueue() + let pool = Self.makePool(internalQueue: internalQueue) + + let paths = pool.root.testsOnly_getFullPaths(objectsPool: pool) + #expect(paths.count == 1) + #expect(paths.contains([])) + } + + // @UTS objects/unit/RTLO4f/direct-child-single-path-0 + @Test + func getFullPathsForDirectChildOfRoot() { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + let counter = Self.makeCounter(objectID: "counter:score@1000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.counter(counter), forObjectID: "counter:score@1000") + + internalQueue.ably_syncNoDeadlock { + counter.nosync_addParentReference(parentObjectID: ObjectsPool.rootKey, key: "score") + } + + let paths = counter.testsOnly_getFullPaths(objectsPool: pool) + #expect(paths.count == 1) + #expect(paths.contains(["score"])) + } + + // @UTS objects/unit/RTLO4f/deep-nesting-0 + @Test + func getFullPathsForDeeplyNestedObject() { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + // root --profile--> map:profile --prefs--> map:prefs --theme_counter--> counter:theme + let profile = Self.makeMap(objectID: "map:profile@1000", internalQueue: internalQueue) + let prefs = Self.makeMap(objectID: "map:prefs@1000", internalQueue: internalQueue) + let themeCounter = Self.makeCounter(objectID: "counter:theme@1000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.map(profile), forObjectID: "map:profile@1000") + pool.testsOnly_setEntry(.map(prefs), forObjectID: "map:prefs@1000") + pool.testsOnly_setEntry(.counter(themeCounter), forObjectID: "counter:theme@1000") + + internalQueue.ably_syncNoDeadlock { + profile.nosync_addParentReference(parentObjectID: ObjectsPool.rootKey, key: "profile") + prefs.nosync_addParentReference(parentObjectID: "map:profile@1000", key: "prefs") + themeCounter.nosync_addParentReference(parentObjectID: "map:prefs@1000", key: "theme_counter") + } + + let paths = themeCounter.testsOnly_getFullPaths(objectsPool: pool) + #expect(paths.count == 1) + #expect(paths.contains(["profile", "prefs", "theme_counter"])) + } + + // @UTS objects/unit/RTLO4f/diamond-graph-0 + @Test + func getFullPathsWithMultipleParentsDiamondGraph() { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + // root --a--> map:A --x--> counter:leaf ; root --b--> map:B --y--> counter:leaf + let mapA = Self.makeMap(objectID: "map:a@1000", internalQueue: internalQueue) + let mapB = Self.makeMap(objectID: "map:b@1000", internalQueue: internalQueue) + let leaf = Self.makeCounter(objectID: "counter:leaf@1000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.map(mapA), forObjectID: "map:a@1000") + pool.testsOnly_setEntry(.map(mapB), forObjectID: "map:b@1000") + pool.testsOnly_setEntry(.counter(leaf), forObjectID: "counter:leaf@1000") + + internalQueue.ably_syncNoDeadlock { + mapA.nosync_addParentReference(parentObjectID: ObjectsPool.rootKey, key: "a") + mapB.nosync_addParentReference(parentObjectID: ObjectsPool.rootKey, key: "b") + leaf.nosync_addParentReference(parentObjectID: "map:a@1000", key: "x") + leaf.nosync_addParentReference(parentObjectID: "map:b@1000", key: "y") + } + + let paths = leaf.testsOnly_getFullPaths(objectsPool: pool) + #expect(paths.count == 2) + #expect(paths.contains(["a", "x"])) + #expect(paths.contains(["b", "y"])) + } + + // @UTS objects/unit/RTLO4f/single-parent-multiple-keys-0 + @Test + func getFullPathsWithSingleParentReferencingAtMultipleKeys() { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + let child = Self.makeCounter(objectID: "counter:child@1000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.counter(child), forObjectID: "counter:child@1000") + + internalQueue.ably_syncNoDeadlock { + child.nosync_addParentReference(parentObjectID: ObjectsPool.rootKey, key: "primary") + child.nosync_addParentReference(parentObjectID: ObjectsPool.rootKey, key: "alias") + } + + let paths = child.testsOnly_getFullPaths(objectsPool: pool) + #expect(paths.count == 2) + #expect(paths.contains(["primary"])) + #expect(paths.contains(["alias"])) + } + + // @UTS objects/unit/RTLO4f/orphan-returns-empty-0 + @Test + func getFullPathsForOrphanReturnsEmptyList() { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + let orphan = Self.makeCounter(objectID: "counter:orphan@1000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.counter(orphan), forObjectID: "counter:orphan@1000") + + let paths = orphan.testsOnly_getFullPaths(objectsPool: pool) + #expect(paths.isEmpty) + } + + // @UTS objects/unit/RTLO4f/cycle-suppression-0 + @Test + func getFullPathsSuppressesCycles() { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + // root --a--> map:A --b--> map:B --a--> map:A (cycle) + let mapA = Self.makeMap(objectID: "map:a@1000", internalQueue: internalQueue) + let mapB = Self.makeMap(objectID: "map:b@1000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.map(mapA), forObjectID: "map:a@1000") + pool.testsOnly_setEntry(.map(mapB), forObjectID: "map:b@1000") + + internalQueue.ably_syncNoDeadlock { + mapA.nosync_addParentReference(parentObjectID: ObjectsPool.rootKey, key: "a") + mapB.nosync_addParentReference(parentObjectID: "map:a@1000", key: "b") + // Create a cycle: map:A also has map:B as a parent + mapA.nosync_addParentReference(parentObjectID: "map:b@1000", key: "a") + } + + let pathsB = mapB.testsOnly_getFullPaths(objectsPool: pool) + #expect(pathsB.count == 1) + #expect(pathsB.contains(["a", "b"])) + + let pathsA = mapA.testsOnly_getFullPaths(objectsPool: pool) + #expect(pathsA.count == 1) + #expect(pathsA.contains(["a"])) + } + + // @UTS objects/unit/RTLO4f/complex-diamond-deep-0 + @Test + func getFullPathsWithComplexDiamondAndDeepNesting() { + let internalQueue = TestFactories.createInternalQueue() + var pool = Self.makePool(internalQueue: internalQueue) + + // root --left--> map:L --mid--> map:M --target--> counter:T + // root --right--> map:R --target--> counter:T + let mapL = Self.makeMap(objectID: "map:l@1000", internalQueue: internalQueue) + let mapR = Self.makeMap(objectID: "map:r@1000", internalQueue: internalQueue) + let mapM = Self.makeMap(objectID: "map:m@1000", internalQueue: internalQueue) + let target = Self.makeCounter(objectID: "counter:t@1000", internalQueue: internalQueue) + pool.testsOnly_setEntry(.map(mapL), forObjectID: "map:l@1000") + pool.testsOnly_setEntry(.map(mapR), forObjectID: "map:r@1000") + pool.testsOnly_setEntry(.map(mapM), forObjectID: "map:m@1000") + pool.testsOnly_setEntry(.counter(target), forObjectID: "counter:t@1000") + + internalQueue.ably_syncNoDeadlock { + mapL.nosync_addParentReference(parentObjectID: ObjectsPool.rootKey, key: "left") + mapR.nosync_addParentReference(parentObjectID: ObjectsPool.rootKey, key: "right") + mapM.nosync_addParentReference(parentObjectID: "map:l@1000", key: "mid") + target.nosync_addParentReference(parentObjectID: "map:m@1000", key: "target") + target.nosync_addParentReference(parentObjectID: "map:r@1000", key: "target") + } + + let paths = target.testsOnly_getFullPaths(objectsPool: pool) + #expect(paths.count == 2) + #expect(paths.contains(["left", "mid", "target"])) + #expect(paths.contains(["right", "target"])) + } + + // MARK: - RTO5c10: Post-sync rebuild (D-7) + + // @UTS objects/unit/RTO5c10/rebuild-from-sync-0 + @Test + func postSyncRebuildPopulatesParentReferencesFromMapEntries() throws { + let internalQueue = TestFactories.createInternalQueue() + let logger = TestLogger() + var pool = Self.makePool(internalQueue: internalQueue) + + // root --score--> counter:score, root --profile--> map:profile --nested--> counter:nested + let syncObjects = [ + TestFactories.mapObjectState( + objectId: ObjectsPool.rootKey, + entries: [ + "score": TestFactories.mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:score@1000")), + "profile": TestFactories.mapEntry(data: ProtocolTypes.ObjectData(objectId: "map:profile@1000")), + ], + ), + TestFactories.counterObjectState(objectId: "counter:score@1000", count: 100), + TestFactories.mapObjectState( + objectId: "map:profile@1000", + entries: ["nested": TestFactories.mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:nested@1000"))], + ), + TestFactories.counterObjectState(objectId: "counter:nested@1000", count: 5), + ] + + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool( + .testsOnly_fromStates(syncObjects.map { (state: $0, serialTimestamp: nil) }), + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + let score = try #require(pool.entries["counter:score@1000"]?.counterValue) + #expect(score.testsOnly_parentReferences[ObjectsPool.rootKey] == ["score"]) + + let profile = try #require(pool.entries["map:profile@1000"]?.mapValue) + #expect(profile.testsOnly_parentReferences[ObjectsPool.rootKey] == ["profile"]) + + let nested = try #require(pool.entries["counter:nested@1000"]?.counterValue) + #expect(nested.testsOnly_parentReferences["map:profile@1000"] == ["nested"]) + + // root has no parent references + #expect(pool.root.testsOnly_parentReferences.isEmpty) + + // getFullPaths works correctly after rebuild + #expect(score.testsOnly_getFullPaths(objectsPool: pool).contains(["score"])) + #expect(nested.testsOnly_getFullPaths(objectsPool: pool).contains(["profile", "nested"])) + } + + // @UTS objects/unit/RTO5c10a/rebuild-clears-stale-refs-0 + @Test + func postSyncRebuildClearsStaleParentReferences() throws { + let internalQueue = TestFactories.createInternalQueue() + let logger = TestLogger() + var pool = Self.makePool(internalQueue: internalQueue) + + // First sync: root --score--> counter:abc@1000 + let firstSync = [ + TestFactories.mapObjectState( + objectId: ObjectsPool.rootKey, + entries: ["score": TestFactories.mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:abc@1000"))], + ), + TestFactories.counterObjectState(objectId: "counter:abc@1000", count: 10), + ] + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool( + .testsOnly_fromStates(firstSync.map { (state: $0, serialTimestamp: nil) }), + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + let afterFirst = try #require(pool.entries["counter:abc@1000"]?.counterValue) + #expect(afterFirst.testsOnly_parentReferences[ObjectsPool.rootKey] == ["score"]) + + // Second sync: root --points--> counter:abc@1000 (key changed from "score" to "points") + let secondSync = [ + TestFactories.mapObjectState( + objectId: ObjectsPool.rootKey, + entries: ["points": TestFactories.mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:abc@1000"))], + ), + TestFactories.counterObjectState(objectId: "counter:abc@1000", count: 20), + ] + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool( + .testsOnly_fromStates(secondSync.map { (state: $0, serialTimestamp: nil) }), + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + let counter = try #require(pool.entries["counter:abc@1000"]?.counterValue) + // Old "score" reference should be gone, replaced by "points" + #expect(counter.testsOnly_parentReferences[ObjectsPool.rootKey] == ["points"]) + let paths = counter.testsOnly_getFullPaths(objectsPool: pool) + #expect(paths.contains(["points"])) + #expect(paths.count == 1) + } + + // @UTS objects/unit/RTO5c10/unreferenced-empty-refs-0 + @Test + func postSyncUnreferencedObjectsHaveEmptyParentReferences() throws { + let internalQueue = TestFactories.createInternalQueue() + let logger = TestLogger() + var pool = Self.makePool(internalQueue: internalQueue) + + let syncObjects = [ + TestFactories.mapObjectState( + objectId: ObjectsPool.rootKey, + entries: ["name": TestFactories.mapEntry(data: ProtocolTypes.ObjectData(string: "Alice"))], + ), + TestFactories.counterObjectState(objectId: "counter:orphan@1000", count: 42), + ] + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool( + .testsOnly_fromStates(syncObjects.map { (state: $0, serialTimestamp: nil) }), + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + // The counter exists in the pool but no InternalLiveMap entry points to it + let orphan = try #require(pool.entries["counter:orphan@1000"]?.counterValue) + #expect(orphan.testsOnly_parentReferences.isEmpty) + #expect(orphan.testsOnly_getFullPaths(objectsPool: pool).isEmpty) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/PathObjectMutationsUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/PathObjectMutationsUTSTests.swift new file mode 100644 index 000000000..04bf6501a --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/PathObjectMutationsUTSTests.swift @@ -0,0 +1,206 @@ +// @UTS objects/unit/path_object_mutations.md + +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// `PathObject` write operations — `set`/`remove` (map) and `increment`/`decrement` (counter), +/// their type-mismatch (92007) and unresolved-path (92005) error model. +/// Derived from https://github.com/ably/specification/blob/main/uts/objects/unit/path_object_mutations.md +/// (spec points `RTPO15`–`RTPO18`, `RTPO3c2`). +/// +/// The spec drives every case through `setup_synced_channel` + a mock WebSocket. Per the UNIT-only +/// scope this seeds the standard pool directly (`ObjectsUTS.standardPool`) behind an +/// ``ObjectsUTSSeededRealtimeObjects`` that captures the published operation. This mirrors the native +/// `DefaultPathObjectTests` write cases. +/// +/// ## Mock-realtime adaptation (recorded in deviations.md) +/// The seeded double captures `publishAndApply` messages but does **not** apply them back onto the +/// graph. So the spec's post-apply value reads (`root.get("name").value() == "Bob"`, +/// `root.get("score").value() == 125`) need the full `InternalDefaultRealtimeObjects` pipeline and are +/// out of unit scope: these ports assert the **published** operation instead. +/// +/// ## Deviations +/// - **DEV-2 (typed casts):** cocoa splits the spec's polymorphic `PathObject.set/remove/increment/ +/// decrement` across the typed casts. A map write goes through `asLiveMap().set(...)`; a counter +/// write through `asLiveCounter().increment(...)`. The RTPO15e/RTPO16e/RTPO17e/RTPO18e "wrong type" +/// branch is exercised by writing through the *mismatched* cast (e.g. `asLiveMap()` on a counter), +/// which throws 92007. +@Suite(.serialized) +final class PathObjectMutationsUTSTests { + // MARK: - Fixture + + private typealias Fixture = (root: DefaultLiveMapPathObject, realtimeObjects: ObjectsUTSSeededRealtimeObjects) + + private static func makeFixture(channelState: _AblyPluginSupportPrivate.RealtimeChannelState = .attached) -> Fixture { + let internalQueue = ObjectsUTS.createInternalQueue() + let pool = ObjectsUTS.standardPool(internalQueue: internalQueue) + let realtimeObjects = ObjectsUTSSeededRealtimeObjects(pool: pool, internalQueue: internalQueue) + let coreSDK = ObjectsUTSCoreSDK(channelState: channelState) + let root = DefaultLiveMapPathObject(channelObject: realtimeObjects, coreSDK: coreSDK, internalQueue: internalQueue, path: "") + return (root, realtimeObjects) + } + + // MARK: - RTPO15: set() delegates to InternalLiveMap#set + + // objects/unit/RTPO15/set-delegates-to-map-0 — RTPO15d (delegates to InternalLiveMap#set). Asserts + // the published MAP_SET (the mock does not apply, so the spec's post-apply value read is out of scope). + @Test + func RTPO15_set_delegates_to_map() async throws { + let fixture = Self.makeFixture() + try await fixture.root.set(key: "name", value: .primitive(.string("Bob"))) + + let messages = try #require(fixture.realtimeObjects.capturedMessages) + #expect(messages.count == 1) + #expect(messages[0].operation?.action == .known(.mapSet)) + #expect(messages[0].operation?.objectId == ObjectsPool.rootKey) + #expect(messages[0].operation?.mapSet?.key == "name") + #expect(messages[0].operation?.mapSet?.value?.string == "Bob") + } + + // objects/unit/RTPO15/set-nested-path-0 — RTPO15a2/RTPO15b (nested path resolves to the child map). + @Test + func RTPO15_set_nested_path() async throws { + let fixture = Self.makeFixture() + try await fixture.root.get(key: "profile").asLiveMap().set(key: "email", value: .primitive(.string("bob@example.com"))) + + let messages = try #require(fixture.realtimeObjects.capturedMessages) + #expect(messages.count == 1) + #expect(messages[0].operation?.action == .known(.mapSet)) + #expect(messages[0].operation?.objectId == "map:profile@1000") + #expect(messages[0].operation?.mapSet?.key == "email") + } + + // objects/unit/RTPO15d/set-non-map-throws-0 — RTPO15e (set on a non-map -> 92007). + @Test + func RTPO15d_set_non_map_throws() async throws { + let fixture = Self.makeFixture() + let error = await #expect(throws: ARTErrorInfo.self) { + try await fixture.root.get(key: "score").asLiveMap().set(key: "key", value: .primitive(.string("value"))) + } + #expect(error?.code == 92007) + } + + // MARK: - RTPO16: remove() delegates to InternalLiveMap#remove + + // objects/unit/RTPO16/remove-delegates-to-map-0 — RTPO16d (delegates to InternalLiveMap#remove). + @Test + func RTPO16_remove_delegates_to_map() async throws { + let fixture = Self.makeFixture() + try await fixture.root.remove(key: "name") + + let messages = try #require(fixture.realtimeObjects.capturedMessages) + #expect(messages.count == 1) + #expect(messages[0].operation?.action == .known(.mapRemove)) + #expect(messages[0].operation?.objectId == ObjectsPool.rootKey) + #expect(messages[0].operation?.mapRemove?.key == "name") + } + + // objects/unit/RTPO16d/remove-non-map-throws-0 — RTPO16e (remove on a non-map -> 92007). + @Test + func RTPO16d_remove_non_map_throws() async throws { + let fixture = Self.makeFixture() + let error = await #expect(throws: ARTErrorInfo.self) { + try await fixture.root.get(key: "score").asLiveMap().remove(key: "key") + } + #expect(error?.code == 92007) + } + + // MARK: - RTPO17: increment() delegates to InternalLiveCounter#increment + + // objects/unit/RTPO17/increment-delegates-to-counter-0 — RTPO17d (delegates to InternalLiveCounter# + // increment). Asserts the published COUNTER_INC (post-apply `value() == 125` is out of unit scope). + @Test + func RTPO17_increment_delegates_to_counter() async throws { + let fixture = Self.makeFixture() + try await fixture.root.get(key: "score").asLiveCounter().increment(amount: 25) + + let messages = try #require(fixture.realtimeObjects.capturedMessages) + #expect(messages.count == 1) + #expect(messages[0].operation?.action == .known(.counterInc)) + #expect(messages[0].operation?.objectId == "counter:score@1000") + #expect(messages[0].operation?.counterInc?.number == NSNumber(value: 25)) + } + + // objects/unit/RTPO17/increment-default-amount-0 — RTPO17a1 (amount defaults to 1). + @Test + func RTPO17_increment_default_amount() async throws { + let fixture = Self.makeFixture() + try await fixture.root.get(key: "score").asLiveCounter().increment() + + let messages = try #require(fixture.realtimeObjects.capturedMessages) + #expect(messages[0].operation?.counterInc?.number == NSNumber(value: 1)) + } + + // objects/unit/RTPO17d/increment-non-counter-throws-0 — RTPO17e (increment on a non-counter -> 92007). + @Test + func RTPO17d_increment_non_counter_throws() async throws { + let fixture = Self.makeFixture() + let error = await #expect(throws: ARTErrorInfo.self) { + try await fixture.root.asLiveCounter().increment(amount: 5) + } + #expect(error?.code == 92007) + } + + // MARK: - RTPO18: decrement() delegates to InternalLiveCounter#decrement + + // objects/unit/RTPO18/decrement-delegates-to-counter-0 — RTPO18d (decrement is increment with a + // negated amount, so the published COUNTER_INC carries -10). + @Test + func RTPO18_decrement_delegates_to_counter() async throws { + let fixture = Self.makeFixture() + try await fixture.root.get(key: "score").asLiveCounter().decrement(amount: 10) + + let messages = try #require(fixture.realtimeObjects.capturedMessages) + #expect(messages.count == 1) + #expect(messages[0].operation?.action == .known(.counterInc)) + #expect(messages[0].operation?.counterInc?.number == NSNumber(value: -10)) + } + + // objects/unit/RTPO18/decrement-default-amount-0 — RTPO18a1 (amount defaults to 1 => published -1). + @Test + func RTPO18_decrement_default_amount() async throws { + let fixture = Self.makeFixture() + try await fixture.root.get(key: "score").asLiveCounter().decrement() + + let messages = try #require(fixture.realtimeObjects.capturedMessages) + #expect(messages[0].operation?.counterInc?.number == NSNumber(value: -1)) + } + + // objects/unit/RTPO18d/decrement-non-counter-throws-0 — RTPO18e (decrement on a non-counter -> 92007). + @Test + func RTPO18d_decrement_non_counter_throws() async throws { + let fixture = Self.makeFixture() + let error = await #expect(throws: ARTErrorInfo.self) { + try await fixture.root.asLiveCounter().decrement(amount: 5) + } + #expect(error?.code == 92007) + } + + // MARK: - RTPO3c2: writes on an unresolvable path throw 92005 + + // objects/unit/RTPO3c2/set-unresolvable-throws-0 — RTPO3c2 (write on unresolvable path -> 92005/400). + @Test + func RTPO3c2_set_unresolvable_throws() async throws { + let fixture = Self.makeFixture() + let error = await #expect(throws: ARTErrorInfo.self) { + try await fixture.root.get(key: "nonexistent").asLiveMap().get(key: "deep").asLiveMap().set(key: "key", value: .primitive(.string("value"))) + } + #expect(error?.code == 92005) + #expect(error?.statusCode == 400) + } + + // objects/unit/RTPO3c2/increment-unresolvable-throws-0 — RTPO3c2 (write on unresolvable path -> + // 92005/400). + @Test + func RTPO3c2_increment_unresolvable_throws() async throws { + let fixture = Self.makeFixture() + let error = await #expect(throws: ARTErrorInfo.self) { + try await fixture.root.get(key: "nonexistent").asLiveCounter().increment(amount: 5) + } + #expect(error?.code == 92005) + #expect(error?.statusCode == 400) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/PathObjectSubscribeUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/PathObjectSubscribeUTSTests.swift new file mode 100644 index 000000000..3877b1813 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/PathObjectSubscribeUTSTests.swift @@ -0,0 +1,541 @@ +// @UTS objects/unit/path_object_subscribe.md + +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// `PathObject` subscriptions — path-registered listeners, RTO24 depth-window coverage, the +/// getFullPaths fan-out, and the delivered event's `object`/`message`. +/// Derived from https://github.com/ably/specification/blob/main/uts/objects/unit/path_object_subscribe.md +/// (spec points `RTPO19`, `RTO24`, `RTO25`). +/// +/// The spec drives every case through `setup_synced_channel` + `mock_ws.send_to_client`. Path dispatch +/// is owned by the engine's apply path (`RTLO4b4c3b -> RTO24b`), so — unlike the resolution/read/write +/// ports (which use a lightweight seeded double) — these drive the **real** +/// ``InternalDefaultRealtimeObjects``: the standard graph is seeded into the engine's owned pool via +/// `testsOnly_setPoolEntry` + `testsOnly_setParentReferences`, and inbound OBJECT / OBJECT_SYNC frames +/// are replayed via `testsOnly_applyObjectMessages` / `nosync_handleObjectSyncProtocolMessage` (the +/// unit stand-in for `mock_ws.send_to_client`). This mirrors the native `PathObjectSubscriptionTests`; +/// no mock-WS / connection is opened. Subscriber callbacks run on the engine's `userCallbackQueue`, +/// drained with a `sync {}` barrier before asserting. +/// +/// ## Deviations (recorded in deviations.md) +/// - **RTO24b2b (event object = chosen candidate path):** the event's `object.path` is the chosen +/// (most-preferred) covered candidate path, not the raw change site. +/// - **RTLO4b4c3c1 (path subs survive tombstone):** covered by the native suite; a tombstone update is +/// delivered and the path subscription is NOT torn down. +/// - **RTO4b2a (sync events carry no message):** a sync-originated update delivers `event.message == nil`. +/// +/// ## Skipped — out of UNIT scope +/// - **RTPO19g (subscribe has no side effects on channel state):** needs a real channel/connection to +/// observe `channel.state`; the unit fixture has only a fixed `CoreSDK` state. +@Suite(.serialized) +final class PathObjectSubscribeUTSTests { + private static let channelName = "test" + + // MARK: - Fixture + + private struct Fixture { + let engine: InternalDefaultRealtimeObjects + let coreSDK: ObjectsUTSCoreSDK + let internalQueue: DispatchQueue + let userCallbackQueue: DispatchQueue + } + + private static func makeFixture(channelState: _AblyPluginSupportPrivate.RealtimeChannelState = .attached) -> Fixture { + let internalQueue = ObjectsUTS.createInternalQueue() + let userCallbackQueue = DispatchQueue(label: "PathObjectSubscribeTests.userCallback") + let coreSDK = ObjectsUTSCoreSDK(channelState: channelState) + let engine = InternalDefaultRealtimeObjects( + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: MockSimpleClock(), + channelName: channelName, + ) + return Fixture(engine: engine, coreSDK: coreSDK, internalQueue: internalQueue, userCallbackQueue: userCallbackQueue) + } + + private static func rootPath(_ f: Fixture) -> DefaultLiveMapPathObject { + DefaultLiveMapPathObject(channelObject: f.engine, coreSDK: f.coreSDK, internalQueue: f.internalQueue, path: "") + } + + private static func makeCounter(objectID: String, data: Double = 0, _ f: Fixture) -> InternalDefaultLiveCounter { + InternalDefaultLiveCounter(testsOnly_data: data, objectID: objectID, logger: TestLogger(), internalQueue: f.internalQueue, userCallbackQueue: f.userCallbackQueue, clock: MockSimpleClock()) + } + + private static func makeMap(objectID: String, data: [String: InternalObjectsMapEntry] = [:], _ f: Fixture) -> InternalDefaultLiveMap { + InternalDefaultLiveMap(testsOnly_data: data, objectID: objectID, logger: TestLogger(), internalQueue: f.internalQueue, userCallbackQueue: f.userCallbackQueue, clock: MockSimpleClock()) + } + + /// Seeds the standard test graph (root + score/nested counters + profile/prefs maps) into the + /// engine's owned pool, with the standard parentReferences so path dispatch resolves full paths. + private static func seedStandardGraph(_ f: Fixture) { + let score = makeCounter(objectID: "counter:score@1000", data: 100, f) + let nested = makeCounter(objectID: "counter:nested@1000", data: 5, f) + // Entries are seeded at POOL_SERIAL "t:0" so remote MAP_SET serials ("t:1", "t:2", …) win the + // per-entry LWW comparison (RTLM9e) exactly as in the spec's standard pool. + let prefs = makeMap(objectID: "map:prefs@1000", data: ["theme": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "dark"), timeserial: "t:0")], f) + let profile = makeMap( + objectID: "map:profile@1000", + data: [ + "email": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "alice@example.com"), timeserial: "t:0"), + "nested_counter": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:nested@1000"), timeserial: "t:0"), + "prefs": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(objectId: "map:prefs@1000"), timeserial: "t:0"), + ], + f, + ) + let root = makeMap( + objectID: ObjectsPool.rootKey, + data: [ + "name": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(string: "Alice"), timeserial: "t:0"), + "score": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:score@1000"), timeserial: "t:0"), + "profile": ObjectsUTS.mapEntry(data: ProtocolTypes.ObjectData(objectId: "map:profile@1000"), timeserial: "t:0"), + ], + f, + ) + f.engine.testsOnly_setPoolEntry(.counter(score), forObjectID: "counter:score@1000") + f.engine.testsOnly_setPoolEntry(.counter(nested), forObjectID: "counter:nested@1000") + f.engine.testsOnly_setPoolEntry(.map(prefs), forObjectID: "map:prefs@1000") + f.engine.testsOnly_setPoolEntry(.map(profile), forObjectID: "map:profile@1000") + f.engine.testsOnly_setPoolEntry(.map(root), forObjectID: ObjectsPool.rootKey) + score.testsOnly_setParentReferences([ObjectsPool.rootKey: ["score"]]) + profile.testsOnly_setParentReferences([ObjectsPool.rootKey: ["profile"]]) + nested.testsOnly_setParentReferences(["map:profile@1000": ["nested_counter"]]) + prefs.testsOnly_setParentReferences(["map:profile@1000": ["prefs"]]) + } + + private static func applyAndDrain(_ messages: [ProtocolTypes.InboundObjectMessage], _ f: Fixture) { + f.engine.testsOnly_applyObjectMessages(messages, source: .channel) + f.userCallbackQueue.sync {} + } + + private static func syncAndDrain(_ messages: [ProtocolTypes.InboundObjectMessage], _ f: Fixture) { + f.internalQueue.ably_syncNoDeadlock { + f.engine.nosync_handleObjectSyncProtocolMessage(objectMessages: messages, protocolMessageChannelSerial: nil) + } + f.userCallbackQueue.sync {} + } + + // MARK: - RTPO19: subscribe receives events + + // objects/unit/RTPO19/subscribe-receives-events-0 — RTPO19d/e1/e2: a Subscription is returned, the + // event's object points at the change path, and the event carries the source public ObjectMessage. + @Test + func RTPO19_subscribe_receives_events() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let collector = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).get(key: "score").subscribe(listener: collector.listener) + defer { sub.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")], f) + + #expect(collector.events.count == 1) + let event = try #require(collector.events.first) + #expect(event.object.path == "score") // RTPO19e1 + let message = try #require(event.message) // RTPO19e2 + #expect(message.serial == "99") + #expect(message.siteCode == "remote") + #expect(message.operation.action == .counterInc) + #expect(message.channel == Self.channelName) + } + + // objects/unit/RTPO19e1/event-path-object-correct-0 — RTPO19e1: the event's PathObject resolves to + // the just-updated value (100 + 7 = 107). + @Test + func RTPO19e1_event_path_object_correct() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let collector = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).subscribe(listener: collector.listener) + defer { sub.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")], f) + + let event = try #require(collector.events.first) + #expect(event.object.path == "score") + #expect(try event.object.asLiveCounter().value() == 107) + } + + // objects/unit/RTPO19e2/event-message-delivery-0 — RTPO19e2/RTO24b2b2: the event's message copies + // channel/serial/siteCode/operation from the source ObjectMessage. + @Test + func RTPO19e2_event_message_delivery() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let collector = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).get(key: "score").subscribe(listener: collector.listener) + defer { sub.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 42, serial: "serial-1", siteCode: "site-a")], f) + + let message = try #require(collector.events.first?.message) + #expect(message.channel == Self.channelName) + #expect(message.serial == "serial-1") + #expect(message.siteCode == "site-a") + #expect(message.operation.action == .counterInc) + #expect(message.operation.objectId == "counter:score@1000") + #expect(message.operation.counterInc?.number == 42) + } + + // objects/unit/RTPO19e2/event-message-omitted-no-operation-0 — RTO4b2a: a sync-triggered update has + // no operation, so the delivered event has no message. + @Test + func RTPO19e2_event_message_omitted_for_sync() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let collector = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).get(key: "score").subscribe(options: .init(depth: 1), listener: collector.listener) + defer { sub.unsubscribe() } + + // Re-state root unchanged (noop diff) and change the counter's value via sync (200). + Self.syncAndDrain( + [ + ObjectsUTS.rootSyncMessage(entries: [ + "name": ObjectsUTS.wireMapEntry(data: ProtocolTypes.ObjectData(string: "Alice")), + "score": ObjectsUTS.wireMapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:score@1000")), + "profile": ObjectsUTS.wireMapEntry(data: ProtocolTypes.ObjectData(objectId: "map:profile@1000")), + ]), + ObjectsUTS.counterSyncMessage(objectId: "counter:score@1000", count: 200), + ], + f, + ) + + #expect(collector.events.count == 1) + #expect(collector.events.first?.object.path == "score") + #expect(collector.events.first?.message == nil) // RTO4b2a + } + + // MARK: - RTPO19b: access-config guard (RTO25) + + // objects/unit/RTPO19b/subscribe-precondition-detached-0 — RTO25b: subscribe on a DETACHED/FAILED + // channel throws 90001/400. + @Test(arguments: [_AblyPluginSupportPrivate.RealtimeChannelState.detached, .failed]) + func RTPO19b_subscribe_precondition_unusable_state(state: _AblyPluginSupportPrivate.RealtimeChannelState) throws { + let f = Self.makeFixture(channelState: state) + let error = #expect(throws: ARTErrorInfo.self) { + _ = try Self.rootPath(f).subscribe { _ in } + } + #expect(error?.code == 90001) + #expect(error?.statusCode == 400) + } + + // MARK: - RTPO19c1a: depth validation (DEV-9) + + // objects/unit/RTPO19c1a/subscribe-non-positive-depth-throws-0 & subscribe-negative-depth-throws-0 + // — a non-positive depth throws 40003. + @Test(arguments: [0, -1]) + func RTPO19c1a_non_positive_depth_throws(depth: Int) throws { + let f = Self.makeFixture() + let error = #expect(throws: ARTErrorInfo.self) { + _ = try Self.rootPath(f).subscribe(options: .init(depth: depth)) { _ in } + } + #expect(error?.code == 40003) + } + + // MARK: - RTPO19c1 / RTO24c1: depth-window coverage + + // objects/unit/RTPO19c1/subscribe-depth-1-self-only-0 — depth=1 covers only the exact path. + @Test + func RTPO19c1_depth_1_self_only() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let events = ObjectsUTSPathEventCollector() + let control = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).subscribe(options: .init(depth: 1), listener: events.listener) + let ctrl = try Self.rootPath(f).subscribe(listener: control.listener) // unlimited depth + defer { sub.unsubscribe(); ctrl.unsubscribe() } + + // Self event (root map update) — covered at depth 1. + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: ObjectsPool.rootKey, key: "name", value: ProtocolTypes.ObjectData(string: "Bob"), serial: "t:1", siteCode: "remote")], f) + #expect(events.events.count == 1) + + // Child event (root["score"] counter) — relativeDepth 2 > 1, NOT covered. Control (unlimited) + // fires, giving the negative-assertion quiescence barrier. + let controlBefore = control.events.count + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 7, serial: "100", siteCode: "remote")], f) + #expect(control.events.count > controlBefore) + #expect(events.events.count == 1) + } + + // objects/unit/RTPO19c1/subscribe-depth-2-children-0 — depth=2 covers self and one level of children. + @Test + func RTPO19c1_depth_2_children() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let events = ObjectsUTSPathEventCollector() + let control = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).subscribe(options: .init(depth: 2), listener: events.listener) + let ctrl = try Self.rootPath(f).subscribe(listener: control.listener) + defer { sub.unsubscribe(); ctrl.unsubscribe() } + + // Self (root) — covered. + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: ObjectsPool.rootKey, key: "name", value: ProtocolTypes.ObjectData(string: "Bob"), serial: "t:1", siteCode: "remote")], f) + #expect(events.events.count == 1) + + // Child (root["score"], relativeDepth 2) — covered. + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 7, serial: "100", siteCode: "remote")], f) + #expect(events.events.count == 2) + + // Grandchild (root["profile"]["nested_counter"], relativeDepth 3) — NOT covered. + let controlBefore = control.events.count + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:nested@1000", number: 1, serial: "101", siteCode: "remote")], f) + #expect(control.events.count > controlBefore) + #expect(events.events.count == 2) + } + + // objects/unit/RTPO19c1/subscribe-unlimited-depth-0 — no depth: events at any depth. + @Test + func RTPO19c1_unlimited_depth() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let events = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).subscribe(listener: events.listener) + defer { sub.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: ObjectsPool.rootKey, key: "name", value: ProtocolTypes.ObjectData(string: "Bob"), serial: "t:1", siteCode: "remote")], f) + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 7, serial: "100", siteCode: "remote")], f) + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: "map:prefs@1000", key: "theme", value: ProtocolTypes.ObjectData(string: "light"), serial: "t:2", siteCode: "remote")], f) + + #expect(events.events.count >= 3) + } + + // objects/unit/RTO24c1/depth-filtering-formula-0 — subPath prefix + depth-window formula: subscribe + // at "profile" depth 2 covers self and child, not grandchild. + @Test + func RTO24c1_depth_filtering_formula() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + // Seed a grandchild counter:deep under profile.prefs so the grandchild stimulus is a single + // depth-3 candidate (a COUNTER_INC yields only its own path). + let deep = Self.makeCounter(objectID: "counter:deep@3000", f) + f.engine.testsOnly_setPoolEntry(.counter(deep), forObjectID: "counter:deep@3000") + deep.testsOnly_setParentReferences(["map:prefs@1000": ["deep"]]) + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: "map:prefs@1000", key: "deep", value: ProtocolTypes.ObjectData(objectId: "counter:deep@3000"), serial: "50", siteCode: "remote")], f) + + let events = ObjectsUTSPathEventCollector() + let control = ObjectsUTSPathEventCollector() + let sub = try Self.rootPath(f).get(key: "profile").subscribe(options: .init(depth: 2), listener: events.listener) + let ctrl = try Self.rootPath(f).subscribe(listener: control.listener) + defer { sub.unsubscribe(); ctrl.unsubscribe() } + + // Self (profile) — covered. + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: "map:profile@1000", key: "email", value: ProtocolTypes.ObjectData(string: "bob@example.com"), serial: "t:1", siteCode: "remote")], f) + #expect(events.events.count == 1) + + // Child (profile.nested_counter, relativeDepth 2) — covered. + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:nested@1000", number: 3, serial: "100", siteCode: "remote")], f) + #expect(events.events.count == 2) + + // Grandchild (profile.prefs.deep, relativeDepth 3) — NOT covered. + let controlBefore = control.events.count + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:deep@3000", number: 1, serial: "101", siteCode: "remote")], f) + #expect(control.events.count > controlBefore) + #expect(events.events.count == 2) + } + + // objects/unit/RTO24c1/prefix-mismatch-0 — a subscription at "profile" ignores sibling changes. + @Test + func RTO24c1_prefix_mismatch() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let profileEvents = ObjectsUTSPathEventCollector() + let control = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).get(key: "profile").subscribe(listener: profileEvents.listener) + let ctrl = try Self.rootPath(f).subscribe(listener: control.listener) + defer { sub.unsubscribe(); ctrl.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")], f) + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: ObjectsPool.rootKey, key: "name", value: ProtocolTypes.ObjectData(string: "Bob"), serial: "t:1", siteCode: "remote")], f) + + #expect(control.events.count >= 2) // quiescence: both sends delivered + #expect(profileEvents.events.isEmpty) + } + + // MARK: - RTPO19d: Subscription unsubscribe (SUB2) + + // objects/unit/RTPO19d/subscribe-returns-subscription-0 — unsubscribe stops further delivery. + @Test + func RTPO19d_unsubscribe_stops_delivery() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let events = ObjectsUTSPathEventCollector() + let control = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).get(key: "score").subscribe(listener: events.listener) + let ctrl = try Self.rootPath(f).get(key: "score").subscribe(listener: control.listener) + defer { ctrl.unsubscribe() } + + sub.unsubscribe() + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 7, serial: "99", siteCode: "remote")], f) + + #expect(control.events.count >= 1) // quiescence: the still-subscribed control fired + #expect(events.events.isEmpty) + } + + // MARK: - RTPO19f: subscription follows path, not identity + + // objects/unit/RTPO19f/subscribe-follows-path-0 — after "score" is repointed to a new counter, the + // subscription still receives that new counter's events. + @Test + func RTPO19f_subscribe_follows_path() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let events = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).get(key: "score").subscribe(listener: events.listener) + defer { sub.unsubscribe() } + + // Repoint root["score"] to a brand-new counter, then increment the new counter. + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: ObjectsPool.rootKey, key: "score", value: ProtocolTypes.ObjectData(objectId: "counter:new@2000"), serial: "t:1", siteCode: "remote")], f) + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:new@2000", number: 10, serial: "100", siteCode: "remote")], f) + + #expect(events.events.contains { $0.object.path == "score" }) + } + + // MARK: - RTPO19: primitive path & MAP_CLEAR + + // objects/unit/RTPO19/subscribe-primitive-path-0 — a subscription on a primitive path fires when + // the map entry at that key changes. + @Test + func RTPO19_subscribe_primitive_path() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let events = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).get(key: "name").subscribe(listener: events.listener) + defer { sub.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: ObjectsPool.rootKey, key: "name", value: ProtocolTypes.ObjectData(string: "Bob"), serial: "t:1", siteCode: "remote")], f) + + #expect(events.events.count == 1) + #expect(events.events.first?.object.path == "name") + } + + // objects/unit/RTPO19/map-clear-triggers-child-events-0 — MAP_CLEAR fans out to subscriptions. + @Test + func RTPO19_map_clear_triggers_events() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let events = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).subscribe(listener: events.listener) + defer { sub.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.mapClearMessage(objectId: ObjectsPool.rootKey, serial: "99", siteCode: "remote")], f) + + #expect(events.events.count >= 1) + } + + // objects/unit/RTPO19/child-events-bubble-0 — child events bubble up to a parent subscription. + @Test + func RTPO19_child_events_bubble() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let events = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).get(key: "profile").subscribe(listener: events.listener) + defer { sub.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: "map:profile@1000", key: "email", value: ProtocolTypes.ObjectData(string: "bob@example.com"), serial: "t:1", siteCode: "remote")], f) + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:nested@1000", number: 3, serial: "100", siteCode: "remote")], f) + + #expect(events.events.count >= 2) + } + + // MARK: - RTO24b: candidate-path construction & multi-path dispatch + + // objects/unit/RTO24b2a/candidate-paths-map-keys-0 — a MAP_SET on root with key "score" notifies + // both the root subscription (candidate []) and the "score" subscription (candidate ["score"]). + @Test + func RTO24b2a_candidate_paths_map_keys() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let scoreEvents = ObjectsUTSPathEventCollector() + let rootEvents = ObjectsUTSPathEventCollector() + + let subScore = try Self.rootPath(f).get(key: "score").subscribe(listener: scoreEvents.listener) + let subRoot = try Self.rootPath(f).subscribe(listener: rootEvents.listener) + defer { subScore.unsubscribe(); subRoot.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: ObjectsPool.rootKey, key: "score", value: ProtocolTypes.ObjectData(objectId: "counter:new@2000"), serial: "t:1", siteCode: "remote")], f) + + #expect(scoreEvents.events.count == 1) + #expect(scoreEvents.events.first?.object.path == "score") + #expect(rootEvents.events.count == 1) + } + + // objects/unit/RTO24b2b/fires-once-per-dispatch-0 — a subscription covering multiple candidates + // fires exactly once per dispatch (with the most-preferred candidate). + @Test + func RTO24b2b_fires_once_per_dispatch() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let events = ObjectsUTSPathEventCollector() + + let sub = try Self.rootPath(f).subscribe(listener: events.listener) // unlimited: covers [] and ["score"] + defer { sub.unsubscribe() } + + // MAP_SET on root key "score": candidates [] and ["score"] — fires once. + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: ObjectsPool.rootKey, key: "score", value: ProtocolTypes.ObjectData(objectId: "counter:new@2000"), serial: "t:1", siteCode: "remote")], f) + // Control single-candidate dispatch — quiescence barrier. + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:new@2000", number: 1, serial: "100", siteCode: "remote")], f) + + #expect(events.events.count == 2) + } + + // objects/unit/RTO24b1/multi-path-dispatch-0 — an object reachable via two paths notifies both. + @Test + func RTO24b1_multi_path_dispatch() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let scoreEvents = ObjectsUTSPathEventCollector() + let aliasEvents = ObjectsUTSPathEventCollector() + + // Add a second reference "alias" -> counter:score@1000 so it has two full paths. + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: ObjectsPool.rootKey, key: "alias", value: ProtocolTypes.ObjectData(objectId: "counter:score@1000"), serial: "98", siteCode: "remote")], f) + + let subScore = try Self.rootPath(f).get(key: "score").subscribe(listener: scoreEvents.listener) + let subAlias = try Self.rootPath(f).get(key: "alias").subscribe(listener: aliasEvents.listener) + defer { subScore.unsubscribe(); subAlias.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.counterIncMessage(objectId: "counter:score@1000", number: 5, serial: "99", siteCode: "remote")], f) + + #expect(scoreEvents.events.count == 1) + #expect(scoreEvents.events.first?.object.path == "score") + #expect(aliasEvents.events.count == 1) + #expect(aliasEvents.events.first?.object.path == "alias") + } + + // objects/unit/RTO24b2c/listener-exception-caught-0 — the spec's throwing-listener branch is + // compile-time-unrepresentable: `PathObjectSubscriptionCallback` is a non-throwing `@Sendable` + // closure, so a listener cannot throw (recorded in deviations.md). The observable part — + // one listener's dispatch does not stop another's — is exercised with two independent listeners. + @Test + func RTO24b2c_multiple_listeners_independent() throws { + let f = Self.makeFixture() + Self.seedStandardGraph(f) + let first = ObjectsUTSPathEventCollector() + let second = ObjectsUTSPathEventCollector() + + let sub1 = try Self.rootPath(f).subscribe(listener: first.listener) + let sub2 = try Self.rootPath(f).subscribe(listener: second.listener) + defer { sub1.unsubscribe(); sub2.unsubscribe() } + + Self.applyAndDrain([ObjectsUTS.mapSetMessage(objectId: ObjectsPool.rootKey, key: "name", value: ProtocolTypes.ObjectData(string: "Bob"), serial: "t:1", siteCode: "remote")], f) + + #expect(first.events.count == 1) + #expect(second.events.count == 1) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/PathObjectUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/PathObjectUTSTests.swift new file mode 100644 index 000000000..98c21e36d --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/PathObjectUTSTests.swift @@ -0,0 +1,317 @@ +// @UTS objects/unit/path_object.md + +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// `PathObject` read operations — path navigation, resolution, and the typed read accessors. +/// Derived from https://github.com/ably/specification/blob/main/uts/objects/unit/path_object.md +/// (spec points `RTPO1`–`RTPO14`). +/// +/// The spec drives every case through `setup_synced_channel` + a mock WebSocket that materialises the +/// standard pool via OBJECT_SYNC. Per the UNIT-only scope this seeds the same standard tree directly +/// into an ``ObjectsPool`` (`ObjectsUTS.standardPool`), wrapped by ``ObjectsUTSSeededRealtimeObjects`` +/// so a ``DefaultLiveMapPathObject`` rooted at the empty path resolves root -> children exactly as +/// production does. This mirrors the native `DefaultPathObjectTests`. +/// +/// ## Deviations (recorded in deviations.md) +/// - **DEV-2 (no polymorphic `value()`):** cocoa has no base `PathObject.value()`; reads go through a +/// typed cast — `asPrimitive().value()` (`Primitive?`) or `asLiveCounter().value()` (`Double?`). +/// The spec's `po.value()` for a primitive/counter/map maps to those casts; a map or an unresolved +/// path yields `nil` from either cast (the spec's `value() == null`). +/// - **`getType()`/`type()`:** cocoa spells RTTS8's discriminator `type()` (see DEV in deviations.md). +/// +/// ## Compile-time-unrepresentable (recorded in deviations.md) +/// - **RTPO5b / RTPO6b** (`get(123)` / `at(123)` throw 40003): `get(key: String)` / `at(path: String)` +/// take `String`, so a non-string argument cannot be constructed. +/// +/// ## Skipped — out of UNIT scope (need the mock-WS / raw-native `compact()` surface) +/// - **RTPO13 / RTPO13c / RTPO13c5** (`compact()` returning raw native values: raw bytes, counters as +/// numbers, and cyclic references reused as the same in-memory object): cocoa exposes only +/// `compactJson()` (JSON-shaped: bytes as base64, cycles as `{objectId}`). The JSON-shaped subset is +/// ported below as RTPO14; the raw-native `compact()` has no cocoa surface. +@Suite(.serialized) +final class PathObjectUTSTests { + // MARK: - Fixture + + private static func makeRoot(prefsBackRef: Bool = false, channelState: _AblyPluginSupportPrivate.RealtimeChannelState = .attached) -> DefaultLiveMapPathObject { + let internalQueue = ObjectsUTS.createInternalQueue() + let pool = ObjectsUTS.standardPool(internalQueue: internalQueue, prefsBackRef: prefsBackRef) + let realtimeObjects = ObjectsUTSSeededRealtimeObjects(pool: pool, internalQueue: internalQueue) + let coreSDK = ObjectsUTSCoreSDK(channelState: channelState) + return DefaultLiveMapPathObject(channelObject: realtimeObjects, coreSDK: coreSDK, internalQueue: internalQueue, path: "") + } + + // MARK: - RTPO4: path() returns dot-delimited string + + // objects/unit/RTPO4/path-string-representation-0 — RTPO4a (dot-delimited), RTPO4c (empty == root). + @Test + func RTPO4_path_string_representation() { + let root = Self.makeRoot() + #expect(root.path.isEmpty) + #expect(root.get(key: "profile").path == "profile") + #expect(root.get(key: "profile").asLiveMap().get(key: "email").path == "profile.email") + } + + // objects/unit/RTPO4b/path-escapes-dots-0 — RTPO4b (dots within a segment escaped with backslash). + @Test + func RTPO4b_path_escapes_dots() { + let root = Self.makeRoot() + let po = root.get(key: "a.b").asLiveMap().get(key: "c") + #expect(po.path == #"a\.b.c"#) + } + + // MARK: - RTPO5: get() returns new PathObject with appended key + + // objects/unit/RTPO5/get-appends-key-0 — RTPO5c (key appended), RTPO5d (purely navigational). + @Test + func RTPO5_get_appends_key() { + let root = Self.makeRoot() + let child = root.get(key: "profile") + let grandchild = child.asLiveMap().get(key: "email") + #expect(child.path == "profile") + #expect(grandchild.path == "profile.email") + // RTPO5d: navigation does not resolve — a fresh object with a different (deeper) path. + #expect(child.path != root.path) + } + + // MARK: - RTPO6: at() parses dot-delimited path + + // objects/unit/RTPO6/at-parses-path-0 — RTPO6b (dots are separators), RTPO6d (== chained get()). + @Test + func RTPO6_at_parses_path() throws { + let root = Self.makeRoot() + let po = root.at(path: "profile.email") + #expect(po.path == "profile.email") + #expect(try po.asPrimitive().value() == .string("alice@example.com")) + } + + // objects/unit/RTPO6/at-escaped-dots-0 — RTPO6b (`\.` is a literal dot within a segment). + @Test + func RTPO6_at_respects_escaped_dots() { + let root = Self.makeRoot() + let po = root.at(path: #"a\.b.c"#) + #expect(po.path == #"a\.b.c"#) + } + + // MARK: - RTPO7: value() returns resolved primitive / counter value + + // objects/unit/RTPO7/value-counter-0 — RTPO7c (counter -> its numeric value). + @Test + func RTPO7_value_counter() throws { + let root = Self.makeRoot() + #expect(try root.get(key: "score").asLiveCounter().value() == 100) + } + + // objects/unit/RTPO7/value-primitive-0 — RTPO7d (primitive -> value directly). + @Test + func RTPO7_value_primitive() throws { + let root = Self.makeRoot() + #expect(try root.get(key: "name").asPrimitive().value() == .string("Alice")) + #expect(try root.get(key: "age").asPrimitive().value() == .number(30)) + #expect(try root.get(key: "active").asPrimitive().value() == .bool(true)) + } + + // objects/unit/RTPO7/value-bytes-0 — RTPO7d (binary primitive -> raw bytes). + @Test + func RTPO7_value_bytes() throws { + let root = Self.makeRoot() + #expect(try root.get(key: "avatar").asPrimitive().value() == .data(Data([1, 2, 3]))) + } + + // objects/unit/RTPO7d/value-livemap-null-0 — RTPO7e (map -> null; DEV-2: neither typed cast yields + // a value for a map). + @Test + func RTPO7d_value_livemap_null() throws { + let root = Self.makeRoot() + #expect(try root.get(key: "profile").asPrimitive().value() == nil) + #expect(try root.get(key: "profile").asLiveCounter().value() == nil) + } + + // objects/unit/RTPO7e/value-unresolvable-null-0 — RTPO7f (resolution failure -> null per RTPO3c1). + @Test + func RTPO7e_value_unresolvable_null() throws { + let root = Self.makeRoot() + #expect(try root.get(key: "nonexistent").asLiveMap().get(key: "deep").asPrimitive().value() == nil) + } + + // MARK: - RTPO8: instance() wraps the resolved value + + // objects/unit/RTPO8/instance-live-object-0 — RTPO8c (LiveObject -> Instance wrapping it). + @Test + func RTPO8_instance_live_object() throws { + let root = Self.makeRoot() + + guard case let .liveCounter(counterInst) = try #require(try root.get(key: "score").instance()) else { + Issue.record("Expected .liveCounter instance") + return + } + #expect(counterInst.id == "counter:score@1000") + + guard case let .liveMap(mapInst) = try #require(try root.get(key: "profile").instance()) else { + Issue.record("Expected .liveMap instance") + return + } + #expect(mapInst.id == "map:profile@1000") + } + + // objects/unit/RTPO8f/instance-primitive-wrapped-0 — RTPO8f (primitive -> Instance wrapping the + // primitive value), RTINS3b (primitive Instance has no id — compile-time-unrepresentable, DEV-1), + // RTINS4c (Instance#value returns the primitive directly). + @Test + func RTPO8f_instance_primitive_wrapped() throws { + let root = Self.makeRoot() + guard case let .primitive(primitive) = try #require(try root.get(key: "name").instance()) else { + Issue.record("Expected .primitive instance") + return + } + #expect(try primitive.value == .string("Alice")) + } + + // MARK: - RTPO9: entries() returns [key, PathObject] pairs + + // objects/unit/RTPO9/entries-yields-pairs-0 — RTPO9c (array of [key, PathObject]), RTPO9d + // (non-tombstoned only). + @Test + func RTPO9_entries_yields_pairs() throws { + let root = Self.makeRoot() + let entriesByKey = try Dictionary(uniqueKeysWithValues: root.entries().map { ($0.key, $0.value.path) }) + #expect(entriesByKey.count == 7) + #expect(entriesByKey["name"] == "name") + #expect(entriesByKey["profile"] == "profile") + } + + // objects/unit/RTPO9d/entries-non-map-empty-0 — RTPO9d (non-map -> empty array). + @Test + func RTPO9d_entries_non_map_empty() throws { + let root = Self.makeRoot() + #expect(try root.get(key: "score").asLiveMap().entries().isEmpty) + } + + // MARK: - RTPO10: keys() returns key strings + + // objects/unit/RTPO10/keys-returns-array-0 — RTPO10c (delegates to InternalLiveMap#keys). + @Test + func RTPO10_keys_returns_array() throws { + let root = Self.makeRoot() + let keys = try root.keys() + #expect(keys.count == 7) + #expect(Set(keys).isSuperset(of: ["name", "profile", "score"])) + } + + // objects/unit/RTPO10d/keys-non-map-empty-0 — RTPO10d (non-map -> empty array). + @Test + func RTPO10d_keys_non_map_empty() throws { + let root = Self.makeRoot() + #expect(try root.get(key: "score").asLiveMap().keys().isEmpty) + } + + // MARK: - RTPO11: values() returns PathObjects + + // objects/unit/RTPO11/values-returns-array-0 — RTPO11c (array of PathObjects; each path == key). + @Test + func RTPO11_values_returns_array() throws { + let root = Self.makeRoot() + let paths = try Set(root.values().map(\.path)) + #expect(paths.count == 7) + #expect(paths.isSuperset(of: ["name", "profile", "score"])) + } + + // objects/unit/RTPO11d/values-non-map-empty-0 — RTPO11d (non-map -> empty array). + @Test + func RTPO11d_values_non_map_empty() throws { + let root = Self.makeRoot() + #expect(try root.get(key: "score").asLiveMap().values().isEmpty) + } + + // MARK: - RTPO12: size() returns non-tombstoned count + + // objects/unit/RTPO12/size-count-0 — RTPO12c (delegates to InternalLiveMap#size). + @Test + func RTPO12_size_count() throws { + let root = Self.makeRoot() + #expect(try root.size() == 7) + #expect(try root.get(key: "profile").asLiveMap().size() == 3) + } + + // objects/unit/RTPO12c/size-non-map-null-0 — RTPO12d (non-map or resolution failure -> null). + @Test + func RTPO12c_size_non_map_null() throws { + let root = Self.makeRoot() + #expect(try root.get(key: "score").asLiveMap().size() == nil) + #expect(try root.get(key: "name").asLiveMap().size() == nil) + } + + // MARK: - RTPO14: compactJson() encodes binary as base64 and cycles as objectId + + // objects/unit/RTPO14/compact-json-bytes-0 — RTPO14b1 (binary values encoded as base64 strings). + // Also covers RTPO13's JSON-shaped subset (primitives/counter/nested map). + @Test + func RTPO14_compact_json_bytes() throws { + let root = Self.makeRoot() + let result = try #require(try root.compactJson()) + guard case let .object(obj) = result else { + Issue.record("Expected object") + return + } + #expect(obj["avatar"] == .string("AQID")) // base64([1,2,3]) + #expect(obj["name"] == .string("Alice")) + #expect(obj["age"] == .number(30)) + #expect(obj["active"] == .bool(true)) + #expect(obj["score"] == .number(100)) // RTPO13c: counter -> numeric value + #expect(obj["data"] == .object(["tags": .array([.string("a"), .string("b")])])) + // RTPO13c2: nested map recursively compacted. + #expect(obj["profile"] == .object([ + "email": .string("alice@example.com"), + "nested_counter": .number(5), + "prefs": .object(["theme": .string("dark")]), + ])) + } + + // objects/unit/RTPO14/compact-json-0 — RTPO14b2 (a cyclic reference is encoded as {objectId: ...}). + // The cycle is seeded directly (prefs.back_ref -> profile), the unit stand-in for the spec's + // `mock_ws.send_to_client` MAP_SET. + @Test + func RTPO14_compact_json_cycle_as_objectid() throws { + let root = Self.makeRoot(prefsBackRef: true) + let result = try #require(try root.get(key: "profile").compactJson()) + guard case let .object(profile) = result, + case let .object(prefs) = profile["prefs"] + else { + Issue.record("Expected profile.prefs object") + return + } + #expect(prefs["back_ref"] == .object(["objectId": .string("map:profile@1000")])) + } + + // MARK: - RTPO3: path resolution walks through InternalLiveMaps + + // objects/unit/RTPO3/path-resolution-walk-0 — RTPO3a (walk segments), RTPO3b (empty path -> root). + @Test + func RTPO3_path_resolution_walk() throws { + let root = Self.makeRoot() + #expect(try root.asPrimitive().value() == nil) // root is a map -> value() null (RTPO7e) + #expect(try root.at(path: "profile.prefs.theme").asPrimitive().value() == .string("dark")) + } + + // objects/unit/RTPO3a1/intermediate-not-map-0 — resolution fails when an intermediate is not a map. + @Test + func RTPO3a1_intermediate_not_map() throws { + let root = Self.makeRoot() + // "score" is a counter, so navigating through it fails -> read degrades to nil. + #expect(try root.get(key: "score").asLiveMap().get(key: "something").asPrimitive().value() == nil) + } + + // objects/unit/RTPO3c1/read-null-on-failure-0 — read operations return null on resolution failure. + @Test + func RTPO3c1_read_null_on_failure() throws { + let root = Self.makeRoot() + let missing = root.get(key: "nonexistent") + #expect(try missing.asPrimitive().value() == nil) + #expect(try missing.instance() == nil) + #expect(try missing.asLiveMap().size() == nil) + #expect(try missing.compactJson() == nil) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/PublicObjectMessageUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/PublicObjectMessageUTSTests.swift new file mode 100644 index 000000000..725b9f0e5 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/PublicObjectMessageUTSTests.swift @@ -0,0 +1,625 @@ +// @UTS objects/unit/public_object_message.md + +@testable import AblyLiveObjects +import Foundation +import Testing + +/// PublicAPI::ObjectMessage (PAOM) and PublicAPI::ObjectOperation (PAOOP) — public value types. +/// Derived from https://github.com/ably/specification/blob/main/uts/objects/unit/public_object_message.md +/// +/// These are pure data-structure tests (no mocks). They exercise the user-facing `ObjectMessage` / +/// `ObjectOperation` / payload value types (`Path Based API/Public/PublicObjectMessage.swift`): +/// construction, field semantics (optionality), the 7-case action enum, and Equatable/Sendable +/// conformance (PAOM1/PAOM2 and PAOOP1/PAOOP2 layer), plus the wire→public conversion itself (PAOM3 / +/// PAOOP3): `ProtocolTypes.InboundObjectMessage.toPublicObjectMessage(channelName:)` and +/// `ProtocolTypes.ObjectOperation.toPublicObjectOperation()` (the spec's +/// `PublicObjectMessage.fromObjectMessage` / `PublicObjectOperation.fromObjectOperation`). +/// +/// ## Deviations (recorded in deviations.md) +/// - DEV-5: the public `ObjectOperationAction` has exactly 7 cases and no `UNKNOWN`. +/// - DEV-6: `ObjectData` adds a Swift-only `encoding`; `json` is a raw `String?`; `number` is `Double?`; +/// `CounterCreate.count` / `CounterInc.number` are non-optional `Double`. +/// +/// Note: no `@available` annotation is placed on this suite (matching the AblyLiveObjectsTests +/// convention) — the Swift Testing `@Suite`/`@Test` macros cannot be applied to an `@available` +/// declaration, and referencing the macOS-11-gated LiveObjects types directly compiles here. +@Suite(.serialized) +final class PublicObjectMessageUTSTests { + // MARK: - PAOM1 / PAOM2: public ObjectMessage construction and fields + + // Mirrors the data of objects/unit/PAOM3/construction-all-fields-0, but exercises the *public* + // `ObjectMessage` initializer directly (PAOM1 user-constructible; PAOM2a–j all fields present). + @Test + func PAOM2_all_fields_populated() { + // Setup — timestamp 1700000000000 ms, serialTimestamp 1700000001000 ms as Date values. + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let serialTimestamp = Date(timeIntervalSince1970: 1_700_000_001) + + let message = ObjectMessage( + id: "msg-id-1", + clientId: "client-1", + connectionId: "conn-1", + timestamp: timestamp, + channel: "test-channel", + operation: ObjectOperation( + action: .mapSet, + objectId: "map:abc@1000", + mapSet: MapSet(key: "name", value: ObjectData(string: "Alice")), + ), + serial: "01", + serialTimestamp: serialTimestamp, + siteCode: "site1", + extras: ["key": "value"], + ) + + // Assertions + #expect(message.id == "msg-id-1") // PAOM2a + #expect(message.clientId == "client-1") // PAOM2b + #expect(message.connectionId == "conn-1") // PAOM2c + #expect(message.timestamp == timestamp) // PAOM2d + #expect(message.channel == "test-channel") // PAOM2e + #expect(message.serial == "01") // PAOM2g + #expect(message.serialTimestamp == serialTimestamp) // PAOM2h + #expect(message.siteCode == "site1") // PAOM2i + #expect(message.extras == ["key": "value"]) // PAOM2j + #expect(message.operation.action == .mapSet) // PAOM2f + #expect(message.operation.objectId == "map:abc@1000") + #expect(message.operation.mapSet?.key == "name") + } + + // Mirrors objects/unit/PAOM3/construction-optional-fields-missing-0: the public `ObjectMessage` + // initializer defaults every optional field to nil (PAOM2a–d, PAOM2g–j are optional); only + // `channel` (PAOM2e) and `operation` (PAOM2f) are required. + @Test + func PAOM2_optional_fields_default_to_nil() { + let message = ObjectMessage( + channel: "my-channel", + operation: ObjectOperation( + action: .counterInc, + objectId: "counter:abc@1000", + counterInc: CounterInc(number: 5), + ), + ) + + #expect(message.id == nil) // PAOM2a + #expect(message.clientId == nil) // PAOM2b + #expect(message.connectionId == nil) // PAOM2c + #expect(message.timestamp == nil) // PAOM2d + #expect(message.channel == "my-channel") // PAOM2e + #expect(message.serial == nil) // PAOM2g + #expect(message.serialTimestamp == nil) // PAOM2h + #expect(message.siteCode == nil) // PAOM2i + #expect(message.extras == nil) // PAOM2j + #expect(message.operation.action == .counterInc) // PAOM2f + } + + // MARK: - PAOOP1 / PAOOP2: public ObjectOperation construction (only-relevant-field per action) + + // For each action the public `ObjectOperation` initializer defaults the other operation-specific + // payloads to nil (PAOOP2c–i optional). Mirrors the per-action shapes of the PAOOP3a spec cases. + + @Test + func PAOOP2_map_set_only_relevant_field() { + let op = ObjectOperation( + action: .mapSet, + objectId: "map:abc@1000", + mapSet: MapSet(key: "color", value: ObjectData(string: "blue")), + ) + + #expect(op.action == .mapSet) // PAOOP2a + #expect(op.objectId == "map:abc@1000") // PAOOP2b + #expect(op.mapSet?.key == "color") // PAOOP2d + #expect(op.mapSet?.value.string == "blue") + #expect(op.mapCreate == nil) + #expect(op.mapRemove == nil) + #expect(op.counterCreate == nil) + #expect(op.counterInc == nil) + #expect(op.objectDelete == nil) + #expect(op.mapClear == nil) + } + + @Test + func PAOOP2_map_remove_only_relevant_field() { + let op = ObjectOperation( + action: .mapRemove, + objectId: "map:abc@1000", + mapRemove: MapRemove(key: "old-key"), + ) + + #expect(op.action == .mapRemove) + #expect(op.mapRemove?.key == "old-key") // PAOOP2e + #expect(op.mapCreate == nil) + #expect(op.mapSet == nil) + #expect(op.counterCreate == nil) + #expect(op.counterInc == nil) + #expect(op.objectDelete == nil) + #expect(op.mapClear == nil) + } + + @Test + func PAOOP2_counter_inc_only_relevant_field() { + let op = ObjectOperation( + action: .counterInc, + objectId: "counter:abc@1000", + counterInc: CounterInc(number: 42), + ) + + #expect(op.action == .counterInc) + #expect(op.counterInc?.number == 42) // PAOOP2g; DEV-6 number is Double + #expect(op.mapCreate == nil) + #expect(op.mapSet == nil) + #expect(op.mapRemove == nil) + #expect(op.counterCreate == nil) + #expect(op.objectDelete == nil) + #expect(op.mapClear == nil) + } + + @Test + func PAOOP2_object_delete_only_relevant_field() { + let op = ObjectOperation( + action: .objectDelete, + objectId: "counter:abc@1000", + objectDelete: ObjectDelete(), + ) + + #expect(op.action == .objectDelete) + #expect(op.objectDelete != nil) // PAOOP2h + #expect(op.mapCreate == nil) + #expect(op.mapSet == nil) + #expect(op.mapRemove == nil) + #expect(op.counterCreate == nil) + #expect(op.counterInc == nil) + #expect(op.mapClear == nil) + } + + @Test + func PAOOP2_map_clear_only_relevant_field() { + let op = ObjectOperation( + action: .mapClear, + objectId: "map:abc@1000", + mapClear: MapClear(), + ) + + #expect(op.action == .mapClear) + #expect(op.mapClear != nil) // PAOOP2i + #expect(op.mapCreate == nil) + #expect(op.mapSet == nil) + #expect(op.mapRemove == nil) + #expect(op.counterCreate == nil) + #expect(op.counterInc == nil) + #expect(op.objectDelete == nil) + } + + // MapCreate with entries — covers MapCreate (PAOOP2c), ObjectsMapEntry, ObjectData and the + // `.lww` semantics (the only public `ObjectsMapSemantics` case; DEV-5). + @Test + func PAOOP2_map_create_with_entries() { + let op = ObjectOperation( + action: .mapCreate, + objectId: "map:new@2000", + mapCreate: MapCreate( + semantics: .lww, + entries: ["key1": ObjectsMapEntry(data: ObjectData(string: "val1"))], + ), + ) + + #expect(op.action == .mapCreate) + #expect(op.mapCreate?.semantics == .lww) // MCR2a + #expect(op.mapCreate?.entries["key1"]?.data?.string == "val1") // MCR2b + #expect(op.counterCreate == nil) + } + + // CounterCreate — covers PAOOP2f and DEV-6 (count is a non-optional Double). + @Test + func PAOOP2_counter_create_with_count() { + let op = ObjectOperation( + action: .counterCreate, + objectId: "counter:new@2000", + counterCreate: CounterCreate(count: 50), + ) + + #expect(op.action == .counterCreate) + #expect(op.counterCreate?.count == 50) // CCR2a; DEV-6 count is Double + #expect(op.mapCreate == nil) + #expect(op.mapSet == nil) + #expect(op.mapRemove == nil) + #expect(op.counterInc == nil) + #expect(op.objectDelete == nil) + #expect(op.mapClear == nil) + } + + // MARK: - Public payload / data type semantics + + // OD2: `ObjectData` holds typed, decoded values. DEV-6: `number` is `Double?`, `json` is a raw + // `String?`, and `encoding` is a Swift-only field with no wire/Java counterpart. + @Test + func objectData_holds_typed_values() { + let data = ObjectData( + objectId: "map:ref@1", + encoding: "json", + boolean: true, + bytes: Data([1, 2, 3, 4]), + number: 3.5, + string: "hello", + json: #"{"a":1}"#, + ) + + #expect(data.objectId == "map:ref@1") // OD2a + #expect(data.encoding == "json") // OD2b (Swift-only; DEV-6) + #expect(data.boolean == true) // OD2c + #expect(data.bytes == Data([1, 2, 3, 4])) // OD2d + #expect(data.number == 3.5) // OD2e (Double; DEV-6) + #expect(data.string == "hello") // OD2f + #expect(data.json == #"{"a":1}"#) // OD2g (raw String; DEV-6) + + // All fields are optional and default to nil. + let empty = ObjectData() + #expect(empty.objectId == nil) + #expect(empty.encoding == nil) + #expect(empty.boolean == nil) + #expect(empty.bytes == nil) + #expect(empty.number == nil) + #expect(empty.string == nil) + #expect(empty.json == nil) + } + + // MARK: - DEV-5: action enum has 7 known cases, no UNKNOWN + + // The public `ObjectOperationAction` exposes exactly the 7 known actions and no `UNKNOWN` case + // (DEV-5). Verified by pairwise distinctness of the 7 cases (Equatable). + @Test + func DEV5_object_operation_action_seven_distinct_cases() { + let allActions: [ObjectOperationAction] = [ + .mapCreate, + .mapSet, + .mapRemove, + .counterCreate, + .counterInc, + .objectDelete, + .mapClear, + ] + + // 7 cases, all pairwise distinct. + #expect(allActions.count == 7) + for i in allActions.indices { + for j in allActions.indices where i != j { + #expect(allActions[i] != allActions[j]) + } + } + } + + // MARK: - Equatable conformance + + // The public value types are `Equatable`: two identically-constructed messages compare equal, and + // a difference in any field makes them unequal. + @Test + func public_types_are_equatable() { + func makeMessage(clientId: String) -> ObjectMessage { + ObjectMessage( + clientId: clientId, + channel: "ch", + operation: ObjectOperation( + action: .mapSet, + objectId: "map:abc@1000", + mapSet: MapSet(key: "k", value: ObjectData(string: "v")), + ), + ) + } + + #expect(makeMessage(clientId: "a") == makeMessage(clientId: "a")) + #expect(makeMessage(clientId: "a") != makeMessage(clientId: "b")) + } + + // MARK: - PAOM3: wire (protocol) -> public ObjectMessage conversion + + // objects/unit/PAOM3/construction-all-fields-0 — PAOM3b/PAOM3c/PAOM3d. A fully-populated inbound + // message converts to a public `ObjectMessage` with every field copied and the operation derived. + // Wire timestamps are `Date` values here (the wire↔Date translation happens at decode time); the + // spec's 1700000000000 ms / 1700000001000 ms are the same instants as the Dates below. + @Test + func PAOM3_construction_copies_all_fields() throws { + let timestamp = Date(timeIntervalSince1970: 1_700_000_000) + let serialTimestamp = Date(timeIntervalSince1970: 1_700_000_001) + + let source = ProtocolTypes.InboundObjectMessage( + id: "msg-id-1", + clientId: "client-1", + connectionId: "conn-1", + extras: ["key": "value"], + timestamp: timestamp, + operation: ProtocolTypes.ObjectOperation( + action: .known(.mapSet), + objectId: "map:abc@1000", + mapSet: ProtocolTypes.MapSet(key: "name", value: ProtocolTypes.ObjectData(string: "Alice")), + ), + serial: "01", + siteCode: "site1", + serialTimestamp: serialTimestamp, + ) + + let publicMsg = try #require(source.toPublicObjectMessage(channelName: "test-channel")) + + #expect(publicMsg.id == "msg-id-1") // PAOM2a + #expect(publicMsg.clientId == "client-1") // PAOM2b + #expect(publicMsg.connectionId == "conn-1") // PAOM2c + #expect(publicMsg.timestamp == timestamp) // PAOM2d + #expect(publicMsg.channel == "test-channel") // PAOM2e, PAOM3b + #expect(publicMsg.serial == "01") // PAOM2g + #expect(publicMsg.serialTimestamp == serialTimestamp) // PAOM2h + #expect(publicMsg.siteCode == "site1") // PAOM2i + #expect(publicMsg.extras == ["key": "value"]) // PAOM2j + #expect(publicMsg.operation.action == .mapSet) // PAOM3d, PAOOP2a + #expect(publicMsg.operation.objectId == "map:abc@1000") + #expect(publicMsg.operation.mapSet?.key == "name") + } + + // objects/unit/PAOM3/construction-optional-fields-missing-0 — PAOM2a–d/PAOM2g–j optional, PAOM3c. + // An inbound message with only the operation set converts with all optional fields nil. + @Test + func PAOM3_construction_optional_fields_missing() throws { + let source = ProtocolTypes.InboundObjectMessage( + operation: ProtocolTypes.ObjectOperation( + action: .known(.counterInc), + objectId: "counter:abc@1000", + counterInc: WireCounterInc(number: 5), + ), + ) + + let publicMsg = try #require(source.toPublicObjectMessage(channelName: "my-channel")) + + #expect(publicMsg.id == nil) // PAOM2a + #expect(publicMsg.clientId == nil) // PAOM2b + #expect(publicMsg.connectionId == nil) // PAOM2c + #expect(publicMsg.timestamp == nil) // PAOM2d + #expect(publicMsg.channel == "my-channel") // PAOM2e + #expect(publicMsg.serial == nil) // PAOM2g + #expect(publicMsg.serialTimestamp == nil) // PAOM2h + #expect(publicMsg.siteCode == nil) // PAOM2i + #expect(publicMsg.extras == nil) // PAOM2j + #expect(publicMsg.operation.action == .counterInc) // PAOM2f + } + + // objects/unit/PAOM3/channel-from-channel-name-0 — PAOM3b: `channel` comes from the passed-in + // channel name, never from the message itself (the inbound message has no channel field). + @Test + func PAOM3_channel_from_channel_name() throws { + let source = ProtocolTypes.InboundObjectMessage( + operation: ProtocolTypes.ObjectOperation( + action: .known(.objectDelete), + objectId: "counter:abc@1000", + objectDelete: WireObjectDelete(), + ), + ) + + let publicMsg = try #require(source.toPublicObjectMessage(channelName: "different-channel-name")) + + #expect(publicMsg.channel == "different-channel-name") + } + + // MARK: - PAOOP3: wire (protocol) -> public ObjectOperation conversion + + // objects/unit/PAOOP3/map-set-copies-fields-0 — PAOOP3a/PAOOP2d. + @Test + func PAOOP3_map_set_copies_fields() throws { + let source = ProtocolTypes.ObjectOperation( + action: .known(.mapSet), + objectId: "map:abc@1000", + mapSet: ProtocolTypes.MapSet(key: "color", value: ProtocolTypes.ObjectData(string: "blue")), + ) + + let op = try #require(source.toPublicObjectOperation()) + + #expect(op.action == .mapSet) + #expect(op.objectId == "map:abc@1000") + #expect(op.mapSet?.key == "color") // PAOOP2d + #expect(op.mapSet?.value.string == "blue") + #expect(op.mapCreate == nil) + #expect(op.mapRemove == nil) + #expect(op.counterCreate == nil) + #expect(op.counterInc == nil) + #expect(op.objectDelete == nil) + #expect(op.mapClear == nil) + } + + // objects/unit/PAOOP3/map-remove-copies-fields-0 — PAOOP3a/PAOOP2e. + @Test + func PAOOP3_map_remove_copies_fields() throws { + let source = ProtocolTypes.ObjectOperation( + action: .known(.mapRemove), + objectId: "map:abc@1000", + mapRemove: WireMapRemove(key: "old-key"), + ) + + let op = try #require(source.toPublicObjectOperation()) + + #expect(op.action == .mapRemove) + #expect(op.objectId == "map:abc@1000") + #expect(op.mapRemove?.key == "old-key") // PAOOP2e + #expect(op.mapCreate == nil) + #expect(op.mapSet == nil) + #expect(op.counterCreate == nil) + #expect(op.counterInc == nil) + #expect(op.objectDelete == nil) + #expect(op.mapClear == nil) + } + + // objects/unit/PAOOP3/counter-inc-copies-fields-0 — PAOOP3a/PAOOP2g. + @Test + func PAOOP3_counter_inc_copies_fields() throws { + let source = ProtocolTypes.ObjectOperation( + action: .known(.counterInc), + objectId: "counter:abc@1000", + counterInc: WireCounterInc(number: 42), + ) + + let op = try #require(source.toPublicObjectOperation()) + + #expect(op.action == .counterInc) + #expect(op.objectId == "counter:abc@1000") + #expect(op.counterInc?.number == 42) // PAOOP2g; DEV-6 number is Double + #expect(op.mapCreate == nil) + #expect(op.mapSet == nil) + #expect(op.mapRemove == nil) + #expect(op.counterCreate == nil) + #expect(op.objectDelete == nil) + #expect(op.mapClear == nil) + } + + // objects/unit/PAOOP3/object-delete-copies-fields-0 — PAOOP3a/PAOOP2h. + @Test + func PAOOP3_object_delete_copies_fields() throws { + let source = ProtocolTypes.ObjectOperation( + action: .known(.objectDelete), + objectId: "counter:abc@1000", + objectDelete: WireObjectDelete(), + ) + + let op = try #require(source.toPublicObjectOperation()) + + #expect(op.action == .objectDelete) + #expect(op.objectId == "counter:abc@1000") + #expect(op.objectDelete != nil) // PAOOP2h + #expect(op.mapCreate == nil) + #expect(op.mapSet == nil) + #expect(op.mapRemove == nil) + #expect(op.counterCreate == nil) + #expect(op.counterInc == nil) + #expect(op.mapClear == nil) + } + + // objects/unit/PAOOP3/map-clear-copies-fields-0 — PAOOP3a/PAOOP2i. + @Test + func PAOOP3_map_clear_copies_fields() throws { + let source = ProtocolTypes.ObjectOperation( + action: .known(.mapClear), + objectId: "map:abc@1000", + mapClear: WireMapClear(), + ) + + let op = try #require(source.toPublicObjectOperation()) + + #expect(op.action == .mapClear) + #expect(op.objectId == "map:abc@1000") + #expect(op.mapClear != nil) // PAOOP2i + #expect(op.mapCreate == nil) + #expect(op.mapSet == nil) + #expect(op.mapRemove == nil) + #expect(op.counterCreate == nil) + #expect(op.counterInc == nil) + #expect(op.objectDelete == nil) + } + + // objects/unit/PAOOP3/map-create-direct-0 — PAOOP3b1: a directly-present `mapCreate` is used as-is. + @Test + func PAOOP3_map_create_direct() throws { + let source = ProtocolTypes.ObjectOperation( + action: .known(.mapCreate), + objectId: "map:new@2000", + mapCreate: ProtocolTypes.MapCreate( + semantics: .known(.lww), + entries: ["key1": ProtocolTypes.ObjectsMapEntry(data: ProtocolTypes.ObjectData(string: "val1"))], + ), + ) + + let op = try #require(source.toPublicObjectOperation()) + + #expect(op.action == .mapCreate) + #expect(op.objectId == "map:new@2000") + #expect(op.mapCreate != nil) + #expect(op.mapCreate?.semantics == .lww) // DEV-5: LWW is the only semantics + #expect(op.mapCreate?.entries["key1"]?.data?.string == "val1") + #expect(op.counterCreate == nil) + } + + // objects/unit/PAOOP3/map-create-from-with-object-id-0 — PAOOP3b2: when only + // `mapCreateWithObjectId` is present, `mapCreate` resolves to the `MapCreate` it was derived from. + @Test + func PAOOP3_map_create_from_with_object_id() throws { + let derivedMapCreate = ProtocolTypes.MapCreate( + semantics: .known(.lww), + entries: ["x": ProtocolTypes.ObjectsMapEntry(data: ProtocolTypes.ObjectData(number: 10))], + ) + + let source = ProtocolTypes.ObjectOperation( + action: .known(.mapCreate), + objectId: "map:derived@3000", + mapCreateWithObjectId: ProtocolTypes.MapCreateWithObjectId( + initialValue: "aW5pdGlhbA==", + nonce: "nonce-1", + derivedFrom: derivedMapCreate, + ), + ) + + let op = try #require(source.toPublicObjectOperation()) + + #expect(op.action == .mapCreate) + #expect(op.objectId == "map:derived@3000") + #expect(op.mapCreate != nil) + #expect(op.mapCreate?.semantics == .lww) + #expect(op.mapCreate?.entries["x"]?.data?.number == 10) + #expect(op.counterCreate == nil) + } + + // objects/unit/PAOOP3/counter-create-from-with-object-id-0 — PAOOP3c2: when only + // `counterCreateWithObjectId` is present, `counterCreate` resolves to the `CounterCreate` derived from. + @Test + func PAOOP3_counter_create_from_with_object_id() throws { + let source = ProtocolTypes.ObjectOperation( + action: .known(.counterCreate), + objectId: "counter:derived@3000", + counterCreateWithObjectId: ProtocolTypes.CounterCreateWithObjectId( + initialValue: "aW5pdGlhbA==", + nonce: "nonce-1", + derivedFrom: WireCounterCreate(count: 100), + ), + ) + + let op = try #require(source.toPublicObjectOperation()) + + #expect(op.action == .counterCreate) + #expect(op.objectId == "counter:derived@3000") + #expect(op.counterCreate != nil) + #expect(op.counterCreate?.count == 100) // DEV-6: count is Double + #expect(op.mapCreate == nil) + } + + // objects/unit/PAOOP3/create-payloads-omitted-0 — PAOOP3b3/PAOOP3c3: with neither create variant + // present, both `mapCreate` and `counterCreate` are omitted. + @Test + func PAOOP3_create_payloads_omitted() throws { + let source = ProtocolTypes.ObjectOperation( + action: .known(.mapSet), + objectId: "map:abc@1000", + mapSet: ProtocolTypes.MapSet(key: "k", value: ProtocolTypes.ObjectData(string: "v")), + ) + + let op = try #require(source.toPublicObjectOperation()) + + #expect(op.mapCreate == nil) + #expect(op.counterCreate == nil) + } + + // objects/unit/PAOOP3/only-relevant-field-per-action-0 — PAOOP3a: a directly-present `counterCreate` + // is the only operation-specific field set; all others are nil. + @Test + func PAOOP3_only_relevant_field_per_action() throws { + let source = ProtocolTypes.ObjectOperation( + action: .known(.counterCreate), + objectId: "counter:new@2000", + counterCreate: WireCounterCreate(count: 50), + ) + + let op = try #require(source.toPublicObjectOperation()) + + #expect(op.action == .counterCreate) + #expect(op.objectId == "counter:new@2000") + #expect(op.counterCreate != nil) + #expect(op.counterCreate?.count == 50) + #expect(op.mapCreate == nil) + #expect(op.mapSet == nil) + #expect(op.mapRemove == nil) + #expect(op.counterInc == nil) + #expect(op.objectDelete == nil) + #expect(op.mapClear == nil) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/README.md b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/README.md new file mode 100644 index 000000000..3c0da5947 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/README.md @@ -0,0 +1,32 @@ +# UTS objects unit suite + +Skill-generated tests for the UTS `objects/unit` specs (`/uts-to-swift`), one Swift Testing suite per +spec, consolidated here in the `AblyLiveObjects` plugin's own test target (`AblyLiveObjectsTests`). + +This mirrors ably-java's layout, where all the objects-unit UTS ports live in one place +(`liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/unit/`) with `deviations.md` + `README.md` +alongside. All 15 `objects/unit` specs are ported here (16 test files — `internal_live_map.md` is split +across two suites, plus `ObjectsUTSHelpers.swift`). + +- **Why this target:** the internal-graph specs (`internal_live_counter`, `internal_live_map`, + `object_id`, `objects_pool`, `parent_references`) reach `internal` members via + `@testable import AblyLiveObjects`, which only this target can do; the public-tier specs used to live in + the standalone `UTS` target (`Test/UTS/`) but were consolidated here so all 15 sit together (java-parity). +- **`@UTS` tag convention:** every file's first line is `// @UTS objects/unit/.md`, naming the + source spec it ports. Suite types are named `UTSTests` to keep this directory uniformly named and + distinct from the native (non-UTS) suites in the parent directory. +- **Spec source:** the language-neutral specs live in the [`ably/specification`](https://github.com/ably/specification) + repo under `uts/objects/unit/`. +- **Deviations** — every place a port diverges from its spec — are recorded in + [`deviations.md`](deviations.md) (same discipline as `Test/UTS/deviations.md` for the rest/realtime tiers). + +## Not in this directory: the native (non-UTS) unit tests + +The plugin's own hand-written unit tests stay in the parent directory +(`LiveObjects/Tests/AblyLiveObjectsTests/`), **outside** `UTS/` — e.g. `DefaultInstanceTests`, +`ParentReferencesTests`, `PathObjectSubscriptionTests`, `PublicRealtimeObjectTests`, +`DefaultPathObjectTests`, `TestsOnlySeamsTests`, `WireObjectMessageSizeTests`, +`InternalDefaultRealtimeObjectsTests`, etc. These are the analogue of ably-java's non-UTS unit tests, +which it likewise keeps outside `uts/` (in `.../liveobjects/unit/`). Only skill-generated UTS spec ports +belong under `UTS/`. + diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/RealtimeObjectUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/RealtimeObjectUTSTests.swift new file mode 100644 index 000000000..b733ae966 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/RealtimeObjectUTSTests.swift @@ -0,0 +1,365 @@ +// @UTS objects/unit/realtime_object.md + +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// The public `RealtimeObject` entry point (`PublicDefaultRealtimeObject`): `get()` (RTO23), the +/// `.syncing`/`.synced` status events and `off()` (RTO17/RTO18/RTO19), the dispose lifecycle, and the +/// channel-state access/write preconditions (RTO25b/RTO26b). +/// Derived from https://github.com/ably/specification/blob/main/uts/objects/unit/realtime_object.md +/// (spec points `RTO2`, `RTO10`, `RTO15`, `RTO17`–`RTO20`, `RTO22`–`RTO27`). +/// +/// The spec drives every case through `setup_synced_channel` + `mock_ws.send_to_client`. Per the +/// UNIT-only scope, the engine-drivable subset is driven directly through a real +/// `InternalDefaultRealtimeObjects` proxied by a `PublicDefaultRealtimeObject` (with an +/// `ObjectsUTSCoreSDK` fixing the channel state): sync is completed by feeding the engine an empty +/// OBJECT_SYNC / ATTACHED via `nosync_handleObjectSyncProtocolMessage` / `nosync_onChannelAttached` +/// (the unit stand-in for `mock_ws.send_to_client`). This mirrors the native +/// `PublicRealtimeObjectTests` (whose `MockCoreSDK` / `TestLogger` / `TestFactories` the UTS target +/// cannot import — replicated by the `ObjectsUTS*` helpers). Status callbacks and get() resolution run +/// on `userCallbackQueue` (`.main`), drained before asserting. +/// +/// ## Deviations (recorded in deviations.md) +/// - **DEV-11 (zero-arg status callback):** `on(event:callback:)` takes a `() -> Void`; the event is +/// known from registration, so RTO18e's "listeners called with no arguments" is the shipped shape. +/// - **RTL33b (implicit attach not implementable):** `ensureActiveChannel` can only *read* channel +/// state through the plugin API (no attach seam), so `get()` on a DETACHED/INITIALIZED channel cannot +/// implicitly re-attach — only the RTL33c FAILED rejection (90001) is exercised (RTO23e-failed). +/// +/// ## Skipped — out of UNIT scope / not implementable (recorded in deviations.md) +/// - **Channel-mode guards (40024):** RTO23a, RTO2 mode-enforcement, RTO25a, RTO26a — the +/// `object_subscribe`/`object_publish` mode checks are stubbed (`ChannelConfigGuards`: no plugin +/// channel-modes accessor). **echoMessages guard (40000):** RTO26c — no plugin accessor. +/// - **Implicit-attach cases:** RTO23 get-implicit-attach, RTO23e get-reattaches-detached — RTL33b is +/// not implementable (see above). +/// - **Publish / apply-on-ACK pipeline:** RTO15, RTO20 (all variants — publish-and-apply-local, +/// missing-site-code, null-serial, waits-for-synced, e1 fails-on-detached/failed, echo-dedup, +/// ack-no-site-timeserials, ack-after-echo, ack-serials-cleared, subscription-fires-on-ack) — need the +/// mock-WS OBJECT publish + ACK-driven local apply, for which no unit harness exists. +/// - **Garbage collection:** RTO10, RTO10b1, RTO10c1b1 — need `enable_fake_timers()` + the GC task. +/// - **Path-subscription dispatch:** RTO24a, RTO24c1 — covered by `PathObjectSubscribeTests`. +/// - **RTO27 (channel-state data lifecycle):** implemented (DEV-51) — `nosync_onChannelStateChanged` +/// clears object data to zero without emitting events on DETACHED/FAILED (RTO27a) and clears the +/// in-progress SyncObjectsPool (RTO27a2), while retaining data on SUSPENDED (RTO27b). Its white-box +/// coverage requires internal `ObjectsPool` data introspection the black-box UTS target does not +/// expose, so it lives in the AblyLiveObjects unit suite +/// (`InternalDefaultRealtimeObjectsTests.ChannelStateChangeTests`). +@Suite(.serialized) +final class RealtimeObjectUTSTests { + // MARK: - Harness + + /// A thread-safe ordered log of emitted status-event names, for the sync-sequence assertions. + private final class EventLog: @unchecked Sendable { + private let lock = NSLock() + private var names: [String] = [] + func append(_ name: String) { lock.withLock { names.append(name) } } + var snapshot: [String] { lock.withLock { names } } + } + + /// A thread-safe call counter for asynchronously-delivered status callbacks. + private final class CallCounter: @unchecked Sendable { + private let lock = NSLock() + private var value = 0 + func increment() { lock.withLock { value += 1 } } + var count: Int { lock.withLock { value } } + } + + private static func makeProxied(internalQueue: DispatchQueue) -> InternalDefaultRealtimeObjects { + InternalDefaultRealtimeObjects( + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + channelName: "test", + ) + } + + private static func makePublicObject( + channelState: _AblyPluginSupportPrivate.RealtimeChannelState = .attached, + ) -> (publicObject: PublicDefaultRealtimeObject, proxied: InternalDefaultRealtimeObjects, coreSDK: ObjectsUTSCoreSDK, internalQueue: DispatchQueue) { + let internalQueue = ObjectsUTS.createInternalQueue() + let proxied = makeProxied(internalQueue: internalQueue) + let coreSDK = ObjectsUTSCoreSDK(channelState: channelState) + let publicObject = PublicDefaultRealtimeObject(proxied: proxied, coreSDK: coreSDK, logger: TestLogger()) + return (publicObject, proxied, coreSDK, internalQueue) + } + + /// Drives the engine to `.synced` via an empty OBJECT_SYNC (Initialized/Syncing -> Synced), the unit + /// stand-in for the spec's `build_object_sync_message` frame. + private static func driveToSynced(_ proxied: InternalDefaultRealtimeObjects, on internalQueue: DispatchQueue) { + internalQueue.ably_syncNoDeadlock { + proxied.nosync_handleObjectSyncProtocolMessage(objectMessages: [], protocolMessageChannelSerial: nil) + } + } + + /// A root `LiveMapPathObject` backed by an engine whose `CoreSDK` reports `channelState`, for the + /// access/write precondition guards (which read `CoreSDK.nosync_channelState`). + private static func makeRootPath(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) -> DefaultLiveMapPathObject { + let internalQueue = ObjectsUTS.createInternalQueue() + let proxied = makeProxied(internalQueue: internalQueue) + let coreSDK = ObjectsUTSCoreSDK(channelState: channelState) + return DefaultLiveMapPathObject(channelObject: proxied, coreSDK: coreSDK, internalQueue: internalQueue, path: "") + } + + /// Awaits one hop on `.main` so any status/get callbacks dispatched to `userCallbackQueue` are + /// delivered before assertions read them. + private static func drainMain() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + DispatchQueue.main.async { continuation.resume() } + } + } + + // MARK: - get() (RTO23) + + // objects/unit/RTO23/get-returns-path-object-0 & RTO23d/get-resolves-immediately-synced-0 — RTO23d: + // get() returns a LiveMapPathObject rooted at the root map (empty path) once synced. + @Test + func RTO23d_get_returns_root_path_object() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + Self.driveToSynced(proxied, on: internalQueue) + + let root = try await publicObject.get() + #expect(root.path.isEmpty) // RTO23d / RTPO4c + #expect(try root.type() == .liveMap) + + // Already SYNCED: a second get() resolves immediately, still rooted at the empty path. + let root2 = try await publicObject.get() + #expect(root2.path.isEmpty) + } + + // objects/unit/RTO23c/get-waits-for-synced-0 — RTO23c: get() before the sync completes waits, then + // resolves once SYNCED. + @Test + func RTO23c_get_waits_for_synced() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + + async let getTask = publicObject.get() + // Confirm get() has parked waiting for the sync. + _ = try #require(await proxied.testsOnly_waitingForSyncEvents.first { _ in true }) + + Self.driveToSynced(proxied, on: internalQueue) + + let root = try await getTask + #expect(root.path.isEmpty) + } + + // objects/unit/RTO23e/get-rejects-failed-0 — RTO23e/RTL33c: get() on a FAILED channel is rejected by + // ensure-active-channel with code 90001 / statusCode 400. + @Test + func RTO23e_get_rejects_failed_channel() async throws { + let (publicObject, _, _, _) = Self.makePublicObject(channelState: .failed) + + let error = await #expect(throws: ARTErrorInfo.self) { + _ = try await publicObject.get() + } + #expect(error?.code == 90001) + #expect(error?.statusCode == 400) + } + + // objects/unit/RTO4b — get() resolves via an ATTACHED with HAS_OBJECTS false (no-objects sync + // completion): the root is an empty map. + @Test + func RTO4b_get_resolves_after_no_objects_attach() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + + async let getTask = publicObject.get() + _ = try #require(await proxied.testsOnly_waitingForSyncEvents.first { _ in true }) + + internalQueue.ably_syncNoDeadlock { + proxied.nosync_onChannelAttached(hasObjects: false) + } + + let root = try await getTask + #expect(root.path.isEmpty) + #expect(try root.size() == 0) + } + + // MARK: - Status events (RTO17 / RTO18 / RTO19) + + // objects/unit/RTO17/sync-state-events-0 — RTO17b/RTO18b1/RTO18b2/RTO18e: on(.syncing) and + // on(.synced) fire, in that order, as the sync completes. (Zero-arg callbacks — DEV-11.) + @Test + func RTO17_RTO18_sync_state_events() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + let events = EventLog() + + publicObject.on(event: .syncing) { events.append("SYNCING") } + publicObject.on(event: .synced) { events.append("SYNCED") } + + Self.driveToSynced(proxied, on: internalQueue) + await Self.drainMain() + + #expect(events.snapshot == ["SYNCING", "SYNCED"]) + } + + // objects/unit/RTO18/on-synced-fires-0 — RTO18: on(.synced) fires when the sync completes. + @Test + func RTO18_on_synced_fires() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + + await withCheckedContinuation { (continuation: CheckedContinuation) in + publicObject.on(event: .synced) { continuation.resume() } + Self.driveToSynced(proxied, on: internalQueue) + } + } + + // objects/unit/RTO18d/duplicate-listener-0 — the SAME listener registered twice (via two on() calls) + // fires twice per event (RTE4 reference behaviour; cocoa does not de-duplicate). + @Test + func RTO18d_duplicate_listener_fires_twice() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + let counter = CallCounter() + let listener: @Sendable () -> Void = { counter.increment() } + + publicObject.on(event: .synced, callback: listener) + publicObject.on(event: .synced, callback: listener) + + Self.driveToSynced(proxied, on: internalQueue) + await Self.drainMain() + + #expect(counter.count == 2) + } + + // objects/unit/RTO19/off-deregisters-0 — RTO19/RTO18f1: off() deregisters the listener; it is not + // called for the subsequent sync. + @Test + func RTO19_off_deregisters() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + let counter = CallCounter() + + let subscription = publicObject.on(event: .synced) { counter.increment() } + subscription.off() + + Self.driveToSynced(proxied, on: internalQueue) + await Self.drainMain() + + #expect(counter.count == 0) // swiftformat:disable:this isEmpty — swiftlint:disable:this empty_count + } + + // objects/unit/RTO17-RTO18/sync-event-sequences-0 (scenario "initial attach") — a first sync emits + // [SYNCING, SYNCED] to listeners registered before it. + @Test + func RTO17_RTO18_sequence_initial_attach() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + let events = EventLog() + publicObject.on(event: .syncing) { events.append("SYNCING") } + publicObject.on(event: .synced) { events.append("SYNCED") } + + Self.driveToSynced(proxied, on: internalQueue) + await Self.drainMain() + + #expect(events.snapshot == ["SYNCING", "SYNCED"]) + } + + // objects/unit/RTO17-RTO18/sync-event-sequences-0 (scenario "re-sync on new ATTACHED") — a new + // ATTACHED (HAS_OBJECTS) followed by an OBJECT_SYNC re-emits [SYNCING, SYNCED]. + @Test + func RTO17_RTO18_sequence_resync_on_attached() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + Self.driveToSynced(proxied, on: internalQueue) // reach SYNCED first + await Self.drainMain() + + let events = EventLog() + publicObject.on(event: .syncing) { events.append("SYNCING") } + publicObject.on(event: .synced) { events.append("SYNCED") } + + internalQueue.ably_syncNoDeadlock { + proxied.nosync_onChannelAttached(hasObjects: true) + } + Self.driveToSynced(proxied, on: internalQueue) + await Self.drainMain() + + #expect(events.snapshot == ["SYNCING", "SYNCED"]) + } + + // objects/unit/RTO17-RTO18/sync-event-sequences-0 (scenario "ATTACHED without HAS_OBJECTS") — RTO4c + // emits SYNCING, RTO4b then completes immediately emitting SYNCED. + @Test + func RTO17_RTO18_sequence_attached_without_objects() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + Self.driveToSynced(proxied, on: internalQueue) + await Self.drainMain() + + let events = EventLog() + publicObject.on(event: .syncing) { events.append("SYNCING") } + publicObject.on(event: .synced) { events.append("SYNCED") } + + internalQueue.ably_syncNoDeadlock { + proxied.nosync_onChannelAttached(hasObjects: false) + } + await Self.drainMain() + + #expect(events.snapshot == ["SYNCING", "SYNCED"]) + } + + // MARK: - Access / write preconditions on channel state (RTO25b / RTO26b) + + // objects/unit/RTO25b/access-throws-detached-0 & access-throws-failed-0 — an access method (keys()) + // on a DETACHED/FAILED channel throws 90001 / 400. + @Test(arguments: [_AblyPluginSupportPrivate.RealtimeChannelState.detached, .failed]) + func RTO25b_access_throws_on_unusable_state(state: _AblyPluginSupportPrivate.RealtimeChannelState) throws { + let root = Self.makeRootPath(channelState: state) + let error = #expect(throws: ARTErrorInfo.self) { + _ = try root.keys() + } + #expect(error?.code == 90001) + #expect(error?.statusCode == 400) + } + + // objects/unit/RTO26b/write-throws-detached-0 & write-throws-failed-0 (+ SUSPENDED per RTO26b) — a + // write (set()) on a DETACHED/FAILED/SUSPENDED channel throws 90001 / 400. + @Test(arguments: [_AblyPluginSupportPrivate.RealtimeChannelState.detached, .failed, .suspended]) + func RTO26b_write_throws_on_unusable_state(state: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { + let root = Self.makeRootPath(channelState: state) + let error = await #expect(throws: ARTErrorInfo.self) { + try await root.set(key: "name", value: .primitive(.string("Bob"))) + } + #expect(error?.code == 90001) + #expect(error?.statusCode == 400) + } + + // MARK: - Dispose lifecycle + + // dispose() fails an in-flight get() that is waiting for sync (surfacing 92008), but the instance + // remains usable for a later get(). + @Test + func dispose_cancels_waiting_get_and_instance_stays_usable() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + + async let getTask = publicObject.get() + _ = try #require(await proxied.testsOnly_waitingForSyncEvents.first { _ in true }) + + proxied.dispose() + do { + _ = try await getTask + Issue.record("Expected the in-flight get() to be cancelled by dispose()") + } catch { + #expect((error as? ARTErrorInfo)?.code == 92008) + } + + // The instance stays usable: a fresh sync + get() resolves. + Self.driveToSynced(proxied, on: internalQueue) + let root = try await publicObject.get() + #expect(root.path.isEmpty) + } + + // dispose() drops existing status subscriptions; a subscription registered afterwards still fires. + @Test + func dispose_drops_status_subscriptions_but_on_still_works() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + let preDispose = CallCounter() + + publicObject.on(event: .synced) { preDispose.increment() } + proxied.dispose() + + await withCheckedContinuation { (continuation: CheckedContinuation) in + publicObject.on(event: .synced) { continuation.resume() } + Self.driveToSynced(proxied, on: internalQueue) + } + + #expect(preDispose.count == 0) // swiftformat:disable:this isEmpty — swiftlint:disable:this empty_count + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ValueTypesUTSTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ValueTypesUTSTests.swift new file mode 100644 index 000000000..57e5e0148 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ValueTypesUTSTests.swift @@ -0,0 +1,195 @@ +// @UTS objects/unit/value_types.md + +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// LiveCounter / LiveMap value types (blueprints) and their evaluation into `ObjectMessage`s. +/// Derived from https://github.com/ably/specification/blob/main/uts/objects/unit/value_types.md +/// (spec points `RTLCV1`–`RTLCV4`, `RTLMV1`–`RTLMV4`). +/// +/// Pure construction/evaluation tests — no mocks, no WebSocket. The blueprints are the public +/// `LiveCounter` / `LiveMap` value types (`Path Based API/Public/ValueTypes.swift`); their internal +/// `count` / `entries` are read via `@testable`. +/// +/// ## Cocoa evaluation seam (DEV-VT-1) +/// The spec models `evaluate(vt)` as a method on the blueprint returning `ObjectMessage`s. Cocoa has +/// no standalone `evaluate`: blueprint→message generation lives in `ObjectCreationHelpers` +/// (`creationOperationForLiveCounter` / `nosync_creationOperationForLiveMap`), invoked by +/// `RealtimeObjects.createCounter`/`createMap`. `ObjectsUTS.evaluate(counter:)`/`evaluate(map:)` +/// bridge the public blueprint to that seam. Consequently the retained-local create (spec +/// `msg.operation.counterCreate` / `mapCreate`, RTLCV4g5 / RTLMV4j5) is exposed as +/// `operation.counterCreateWithObjectId.derivedFrom` / `operation.mapCreateWithObjectId.derivedFrom` +/// on the outbound message, not as a separate top-level field. +/// +/// ## Compile-time-unrepresentable cases (recorded in deviations.md) +/// Swift's strongly-typed blueprint API makes several spec validation cases impossible to express, so +/// the runtime tests do not exist (the type system enforces at compile time what the spec enforces at +/// runtime): +/// - RTLCV3c / RTLCV4a `LiveCounter.create("not_a_number")`: `initialCount` is `Double`. +/// - RTLMV4a `LiveMap.create(null)`: `create()` (no entries) vs `create(entries:)` — there is no null. +/// - RTLMV4b non-String key: keys are `String` by type. +/// - RTLMV4c / 40013 "invalid value / graph object as map value": values are the closed `LiveMapValue` +/// enum — a function, or a live (non-blueprint) map, cannot be constructed as one. +/// +/// ## Skipped — out of UNIT scope +/// - RTLMV4d1/RTLMV4d2/RTLMV4k (nested depth-first evaluation): nested blueprint entries are +/// materialised by the async `createMap`/`createCounter` pipeline (concrete `InternalDefaultRealtimeObjects` +/// + `publishAndApply`), not the pure `ObjectCreationHelpers` seam. Not portable at unit tier. +/// - RTLCV4a finiteness (NaN/Infinity → 40003): counter initial-value finiteness is validated in +/// `InternalDefaultRealtimeObjects.createCounter` (RTO12f1), one layer above the pure evaluate seam, +/// so it is not reachable via `ObjectsUTS.evaluate(counter:)`. +@Suite(.serialized) +final class ValueTypesUTSTests { + // MARK: - RTLCV3: LiveCounter.create construction + + // objects/unit/RTLCV3/create-with-count-0 — RTLCV3a1/RTLCV3b (DEV-VT-1: `create(initialCount:)`, + // count is Double). + @Test + func RTLCV3_create_with_count() { + let vt = LiveCounter.create(initialCount: 42) + #expect(vt.count == 42) // RTLCV3b + } + + // objects/unit/RTLCV3/create-default-zero-0 — omitted initialCount defaults to 0. + @Test + func RTLCV3_create_default_zero() { + let vt = LiveCounter.create() + #expect(vt.count == 0) // swiftformat:disable:this isEmpty — swiftlint:disable:this empty_count + } + + // MARK: - RTLMV3: LiveMap.create construction + + // objects/unit/RTLMV3/create-with-entries-0 — RTLMV3a1/RTLMV3b. Entries are `LiveMapValue`; a bare + // string/number literal lands as `.primitive(.string)` / `.primitive(.number)` (DEV-VT-1). + @Test + func RTLMV3_create_with_entries() { + let vt = LiveMap.create(entries: [ + "name": "Alice", + "age": 30, + ]) + #expect(vt.entries?["name"] == .primitive(.string("Alice"))) + #expect(vt.entries?["age"] == .primitive(.number(30))) + } + + // objects/unit/RTLMV3/create-no-entries-0 — omitted entries => internal entries is nil. + @Test + func RTLMV3_create_no_entries() { + let vt = LiveMap.create() + #expect(vt.entries == nil) + } + + // MARK: - RTLCV4: LiveCounter evaluation + + // objects/unit/RTLCV4/evaluate-generates-message-0 — RTLCV4c/d/f/g1–g4. + @Test + func RTLCV4_evaluate_generates_message() throws { + let messages = ObjectsUTS.evaluate(counter: LiveCounter.create(initialCount: 42)) + + #expect(messages.count == 1) + let operation = try #require(messages[0].operation) + #expect(operation.action == .known(.counterCreate)) // RTLCV4g1 + #expect(operation.objectId.hasPrefix("counter:")) // RTLCV4f/g2 + #expect(operation.objectId.contains("@")) + let withObjectId = try #require(operation.counterCreateWithObjectId) + #expect(withObjectId.nonce.count >= 16) // RTLCV4d/g3 + #expect(!withObjectId.initialValue.isEmpty) // RTLCV4c/g4 + } + + // objects/unit/RTLCV4g5/retains-local-counter-create-0 — the retained local CounterCreate is + // `counterCreateWithObjectId.derivedFrom` in cocoa's outbound shape (DEV-VT-1). + @Test + func RTLCV4g5_retains_local_counter_create() throws { + let messages = ObjectsUTS.evaluate(counter: LiveCounter.create(initialCount: 42)) + let derivedFrom = try #require(messages[0].operation?.counterCreateWithObjectId?.derivedFrom) + #expect(derivedFrom.count == 42) + } + + // objects/unit/RTLCV4/evaluate-zero-count-0 — count 0 is valid and retained. + @Test + func RTLCV4_evaluate_zero_count() throws { + let messages = ObjectsUTS.evaluate(counter: LiveCounter.create(initialCount: 0)) + let derivedFrom = try #require(messages[0].operation?.counterCreateWithObjectId?.derivedFrom) + #expect(derivedFrom.count == 0) // swiftformat:disable:this isEmpty — swiftlint:disable:this empty_count + } + + // MARK: - RTLMV4: LiveMap evaluation + + // objects/unit/RTLMV4/evaluate-generates-message-0 — RTLMV4f/g/i/j1/j3/j4. + @Test + func RTLMV4_evaluate_generates_message() throws { + let messages = ObjectsUTS.evaluate(map: LiveMap.create(entries: ["name": "Alice"]), internalQueue: ObjectsUTS.createInternalQueue()) + + #expect(messages.count == 1) + let operation = try #require(messages[0].operation) + #expect(operation.action == .known(.mapCreate)) // RTLMV4j1 + #expect(operation.objectId.hasPrefix("map:")) // RTLMV4i + let withObjectId = try #require(operation.mapCreateWithObjectId) + #expect(withObjectId.nonce.count >= 16) // RTLMV4g/j3 + #expect(!withObjectId.initialValue.isEmpty) // RTLMV4f/j4 + } + + // objects/unit/RTLMV4j5/retains-local-map-create-0 — retained local MapCreate is + // `mapCreateWithObjectId.derivedFrom` (DEV-VT-1); semantics is LWW (RTLMV4e1). + @Test + func RTLMV4j5_retains_local_map_create() throws { + let messages = ObjectsUTS.evaluate(map: LiveMap.create(entries: ["name": "Alice"]), internalQueue: ObjectsUTS.createInternalQueue()) + let derivedFrom = try #require(messages[0].operation?.mapCreateWithObjectId?.derivedFrom) + #expect(derivedFrom.semantics == .known(.lww)) // RTLMV4e1 + #expect(derivedFrom.entries?["name"]?.data?.string == "Alice") + } + + // objects/unit/RTLMV4d/entry-value-types-0 — RTLMV4d3–d6 value-type -> data-field mapping. + @Test + func RTLMV4d_entry_value_types() throws { + let vt = LiveMap.create(entries: [ + "str": "hello", + "num": 42, + "bool": true, + "json_arr": [1, 2, 3], + "json_obj": ["key": "value"], + ]) + let messages = ObjectsUTS.evaluate(map: vt, internalQueue: ObjectsUTS.createInternalQueue()) + let entries = try #require(messages[0].operation?.mapCreateWithObjectId?.derivedFrom?.entries) + + #expect(entries["str"]?.data?.string == "hello") // RTLMV4d4 + #expect(entries["num"]?.data?.number == NSNumber(value: 42)) // RTLMV4d5 + #expect(entries["bool"]?.data?.boolean == true) // RTLMV4d6 + #expect(entries["json_arr"]?.data?.json == .array([1, 2, 3])) // RTLMV4d3 + #expect(entries["json_obj"]?.data?.json == .object(["key": "value"])) // RTLMV4d3 + } + + // objects/unit/RTLMV4e2/empty-entries-0 — undefined internal entries => empty MapCreate.entries. + @Test + func RTLMV4e2_empty_entries() throws { + let messages = ObjectsUTS.evaluate(map: LiveMap.create(), internalQueue: ObjectsUTS.createInternalQueue()) + let derivedFrom = try #require(messages[0].operation?.mapCreateWithObjectId?.derivedFrom) + #expect(derivedFrom.entries?.isEmpty == true) + } + + // objects/unit/RTLMV4d/map-set-all-types-table-0 — every supported value type maps to the correct + // data field (adapted to MAP_CREATE entries, the cocoa evaluate seam). The `null` scenario is + // omitted: `LiveMapValue` has no null case (compile-time-unrepresentable). + @Test + func RTLMV4d_all_types_table() throws { + let internalQueue = ObjectsUTS.createInternalQueue() + + func dataFor(_ value: LiveMapValue) throws -> ProtocolTypes.ObjectData { + let messages = ObjectsUTS.evaluate(map: LiveMap.create(entries: ["test_key": value]), internalQueue: internalQueue) + return try #require(messages[0].operation?.mapCreateWithObjectId?.derivedFrom?.entries?["test_key"]?.data) + } + + #expect(try dataFor("hello").string == "hello") + #expect(try dataFor(42).number == NSNumber(value: 42)) + #expect(try dataFor(3.14).number == NSNumber(value: 3.14)) + #expect(try dataFor(0).number == NSNumber(value: 0)) + #expect(try dataFor(-1).number == NSNumber(value: -1)) + #expect(try dataFor(true).boolean == true) + #expect(try dataFor(false).boolean == false) + #expect(try dataFor([1, "a"]).json == .array([1, "a"])) + #expect(try dataFor(["k": "v"]).json == .object(["k": "v"])) + // Binary: there is no binary literal for LiveMapValue; construct the primitive explicitly. + #expect(try dataFor(.primitive(.data(Data([1, 2, 3])))).bytes == Data([1, 2, 3])) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/deviations.md b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/deviations.md new file mode 100644 index 000000000..b49486be7 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/deviations.md @@ -0,0 +1,263 @@ +# Deviations — UTS objects unit suite (`AblyLiveObjectsTests/UTS`) + +> Records every place a generated `objects/unit` test deviates from its UTS spec. This is the +> ably-cocoa analogue of ably-java's +> `liveobjects/src/test/kotlin/io/ably/lib/liveobjects/uts/deviations.md`: the objects UTS ports live +> in the plugin's own test target (`AblyLiveObjectsTests`, all 15 specs under `UTS/`), so their +> deviations are recorded here alongside them rather than in the shared harness file +> (`Test/UTS/deviations.md`, which now covers only the rest/realtime tiers). +> +> Structural deviations named below (DEV-\*) are defined in +> `PORT_KOTLIN_TO_SWIFT/05_DEVIATIONS.md`. + +Deviation tests that assert the **spec-correct** behaviour (where the SDK is non-compliant) are gated +behind the `RUN_DEVIATIONS` environment variable so normal runs stay green: + +```swift +@Test(.enabled(if: ProcessInfo.processInfo.environment["RUN_DEVIATIONS"] != nil)) +``` + +Reproduce a single deviation with: + +```bash +RUN_DEVIATIONS=1 swift test --filter '/' +``` + +## UTS Spec Errors + +_None currently._ + +## Failing Tests (SDK non-compliance, spec-correct test skipped) + +_None currently._ + +### `objects/unit/realtime_object.md` → RTO27 (channel-state data lifecycle) — RESOLVED + +**RTO27a / RTO27b are now implemented in cocoa** (DEV-51). `MutableState.nosync_onChannelStateChanged` +now, in addition to draining the publishAndApply / `get()` sync waiters (RTO20e1 → 92008), clears every +object's data to its zero value without emitting update events on `DETACHED`/`FAILED` (RTO27a1) and +clears the in-progress `SyncObjectsPool` (RTO27a2), while retaining the stored data unchanged on +`SUSPENDED` (RTO27b). The spec-correct assertions (seed the standard pool, drive the state handler, +assert `pool["root"].data == {}` / counter `== 0` / nested map cleared with no update events) require +white-box `ObjectsPool` data introspection that the black-box UTS ports do not expose, so the +coverage lives in the native AblyLiveObjects unit suite instead — `InternalDefaultRealtimeObjectsTests`, +`ChannelStateChangeTests` (DETACHED/FAILED clear, SUSPENDED retention, no-events, SyncObjectsPool clear, +and post-clear re-attach + sync repopulation). There is no dedicated RTO27a/b block in +`objects/unit/realtime_object.md` to enable here. + +## Adapted Tests (assert actual SDK behaviour, deviation documented) + +### `objects/unit/public_object_message.md` → `UTS/PublicObjectMessageUTSTests.swift` + +The spec asserts against string action names and a nullable, loosely-typed public shape. ably-cocoa's +public value types (`Path Based API/Public/PublicObjectMessage.swift`) are strongly typed; the ports +assert the cocoa shape. Deviations (defined in `PORT_KOTLIN_TO_SWIFT/05_DEVIATIONS.md`): + +- **DEV-5** — Public enums drop `UNKNOWN`. `ObjectOperationAction` has exactly 7 cases (`.mapCreate`, + `.mapSet`, `.mapRemove`, `.counterCreate`, `.counterInc`, `.objectDelete`, `.mapClear`) and + `ObjectsMapSemantics` has only `.lww`. The spec's `action == "MAP_SET"` maps to `.mapSet`, etc. + Unknown wire codes are held internally by `WireEnum.unknown` and never surface publicly. Asserted by + `test_DEV5_object_operation_action_seven_distinct_cases`. +- **DEV-6** — `ObjectData` shape. The public `ObjectData` adds a Swift-only `encoding: String?` (no + wire/Java counterpart), exposes `json` as a raw `String?` (vs Java's parsed `JsonElement`), and uses + non-optional `Double` for `CounterCreate.count` / `CounterInc.number` (`number` on `ObjectData` is + `Double?`). Asserted by `test_ObjectData_holds_typed_values`, `test_PAOOP2_counter_inc_only_relevant_field` + and `test_PAOOP2_counter_create_with_count`. + +**Scope note:** the source spec's cases all drive the wire→public conversion +(`PublicObjectMessage.fromObjectMessage` / `PublicObjectOperation.fromObjectOperation`, i.e. cocoa's +`ProtocolTypes.InboundObjectMessage.toPublicObjectMessage(channelName:)` / +`ProtocolTypes.ObjectOperation.toPublicObjectOperation()`). Both the public-API construction/field +subset (PAOM1/PAOM2/PAOOP1/PAOOP2) and the 13 PAOM3/PAOOP3 conversion cases are ported. The above +deviations (DEV-5/DEV-6) apply equally to the converted public shapes. + +### `objects/unit/instance.md` → `UTS/InstanceUTSTests.swift` + +Cocoa models `Instance` as an **enum** (`.liveMap`/`.liveCounter`/`.primitive`) whose payloads are the +distinct `LiveMapInstance` / `LiveCounterInstance` / `PrimitiveInstance` protocols (AIT-1023), rather +than the spec's base-type + `as*`-cast model (`RTTS9`). Each protocol carries only its applicable +members, so an entire family of the spec's runtime "wrong-type" branches is **compile-time-unrepresentable** +— the tests cannot be written and are recorded here instead of ported: + +- **DEV-1** — Instance enum vs `as*` casts. Unrepresentable spec cases: RTINS3b (`Primitive.id() == null` + — `PrimitiveInstance` has no `id`), RTINS4d (`InternalLiveMap.value() == null` — no `value` on + `LiveMapInstance`), RTINS5d/RTINS6c/RTINS9c (non-map `get`/`entries`/`size` → null), RTINS12d/RTINS13d + (`set`/`remove` on non-map → 92007), RTINS14d/RTINS15d (`increment`/`decrement` on non-counter → + 92007), RTINS16c (`subscribe` on primitive → 92007). The spec's `92007` (`ERR_WRONG_INSTANCE_TYPE`) + code therefore has no cocoa surface at all. +- **DEV-14** — `type` property. `Instance.type` / `PrimitiveInstance.type` (`RTTS8`, non-throwing `ValueType`) + replace the spec's `as*`-cast discrimination; asserted throughout (e.g. the RTINS ports switch on the + enum case). +- **Throwing `value`/`size` properties.** `LiveCounterInstance.value`, `PrimitiveInstance.value` and + `LiveMapInstance.size` are non-optional `get throws(ARTErrorInfo)` (the `throws` is only the RTO25b + access-precondition check), not the spec's nullable returns. + +**Mock-realtime adaptation.** `ObjectsUTSRealtimeObjects` captures `publishAndApply` messages but does +not apply them onto the graph, so the mutation cases (RTINS12/13/14/14a/15/15a) assert the **published +operation** rather than the spec's post-apply value read (`root.get(...).value() == ...`), which needs +the full `InternalDefaultRealtimeObjects` pipeline. Subscribe deliveries (RTINS16/16e2/16f) are driven +by applying an operation to the node directly, the unit stand-in for `mock_ws.send_to_client`. + +### `objects/unit/value_types.md` → `UTS/ValueTypesUTSTests.swift` + +- **DEV-2 / DEV-3** — Primitive collapse. `LiveMap.create` entries and evaluated `MapCreate` values are + the single `LiveMapValue`/`Primitive` enums (the six spec primitive value-types are collapsed; + `ValueTypes.swift` deliberate divergence from `RTTS6c`/`RTTS10c`). A bare `"Alice"`/`30` literal lands + as `.primitive(.string)`/`.primitive(.number)` via the `ExpressibleBy*Literal` conformances. +- **DEV-VT-1** — Evaluation seam. There is no standalone blueprint `evaluate`; cocoa fuses blueprint→ + message generation into `ObjectCreationHelpers` (invoked by `RealtimeObjects.createCounter`/`createMap`). + The ports bridge via `ObjectsUTS.evaluate(counter:)`/`evaluate(map:)`. Consequently the spec's retained + local create (`msg.operation.counterCreate` / `mapCreate`, RTLCV4g5 / RTLMV4j5) is exposed as + `operation.counterCreateWithObjectId.derivedFrom` / `operation.mapCreateWithObjectId.derivedFrom` on + the outbound message; the top-level `counterCreate`/`mapCreate` fields are left nil for creation ops. +- **Compile-time-unrepresentable validation cases.** Swift's typed blueprint API enforces at compile time + what the spec validates at runtime, so these have no runtime test: RTLCV3c/RTLCV4a `create("not_a_number")` + (`initialCount: Double`), RTLMV4a `create(null)` (no null — `create()` vs `create(entries:)`), RTLMV4b + non-String key (`String` keys), and **RTLMV4c / 40013** invalid value / "graph object as map value" + (`LiveMapValue` is a closed enum — a function, or a live non-blueprint map, cannot be constructed as + one). This is **stronger than the spec**, which relies on a runtime 40003/40013 throw. + +### `objects/unit/internal_live_counter_api.md` → `UTS/InternalLiveCounterApiUTSTests.swift` + +- **RTLC12e1 non-finite table.** `increment(amount:)` takes a `Double`, so the `string`/`boolean`/`array`/ + `object`/`null` rows are compile-time-unrepresentable. Only the representable non-finite doubles + (`NaN`/`Infinity`/`-Infinity`) reach the RTLC12e1 finiteness check (`InternalDefaultLiveCounter.increment`, + code 40003); those are ported. `null`-means-omitted maps to the no-argument `increment()` default of 1, + pinned by `InstanceUTSTests.test_RTINS14a...`. +- **Mock-realtime adaptation.** As for InstanceUTSTests: the published COUNTER_INC is asserted; the spec's + post-apply value reads (RTLC12 `value() == 150`, RTLC13 `value() == 85`) need the full pipeline and are + out of unit scope. Remote updates (RTLC11) are simulated by applying an operation to the node. + +### `objects/unit/path_object.md` → `UTS/PathObjectUTSTests.swift` + +Reads are surfaced through a `DefaultLiveMapPathObject` rooted at the empty path, backed by +`ObjectsUTSSeededRealtimeObjects` wrapping the standard tree seeded directly into an `ObjectsPool` +(`ObjectsUTS.standardPool`) — the unit stand-in for `setup_synced_channel`, which materialises the +same tree via OBJECT_SYNC. Mirrors the native `DefaultPathObjectTests`. + +- **DEV-2 (no polymorphic `value()`):** cocoa has no base `PathObject.value()`; a read goes through a + typed cast — `asPrimitive().value()` (`Primitive?`) or `asLiveCounter().value()` (`Double?`). The + spec's `po.value()` maps onto those casts; a map or an unresolved path yields `nil` from either + (the spec's `value() == null`). Applies to RTPO7/RTPO7d/RTPO7e, RTPO3/RTPO3a1/RTPO3c1. +- **`type()` not `getType()`** (RTTS8 discriminator name; consistent with `InstanceUTSTests` DEV-14). +- **RTPO13 / RTPO13c / RTPO13c5 raw `compact()` — no cocoa surface.** Cocoa exposes only + `compactJson()` (JSON-shaped: bytes as base64, cycles as `{objectId}`). The spec's `compact()` + returning raw native values (raw bytes, counters as numbers, cycles reused as the same in-memory + object) has no cocoa method. The JSON-shaped subset is ported as RTPO14 (`compact-json-bytes`, + including the primitive/counter/nested-map coverage of RTPO13, and `compact-json` cycle-as-objectId + seeded directly rather than via `mock_ws.send_to_client`). +- **RTPO5b / RTPO6b compile-time-unrepresentable.** `get(key: String)` / `at(path: String)` take + `String`; a non-string argument (the spec's `get(123)` → 40003) cannot be constructed. + +### `objects/unit/path_object_mutations.md` → `UTS/PathObjectMutationsUTSTests.swift` + +- **DEV-2 (typed casts):** cocoa splits the spec's polymorphic `set/remove/increment/decrement` across + the typed casts (`asLiveMap()`/`asLiveCounter()`). The RTPO15e/16e/17e/18e "wrong type" branch (→ 92007) is exercised by writing through the _mismatched_ cast (e.g. `asLiveMap()` on a counter). +- **Mock-realtime adaptation.** `ObjectsUTSSeededRealtimeObjects` captures `publishAndApply` but does + not apply the op back onto the graph, so the spec's post-apply value reads + (`root.get("name").value() == "Bob"`, `root.get("score").value() == 125`) need the full pipeline and + are out of unit scope — the ports assert the published operation instead. + +### `objects/unit/internal_live_map_api.md` → `UTS/InternalLiveMapApiUTSTests.swift` + +Reads run against the seeded standard pool through the path layer (the `RTPO*` accessors delegate to +`RTLM*`); write messages are captured by the seeded double (`captured_messages[0].state[0]` → +`messages[0].operation`). + +- **Bytes representation.** RTLM20e7f asserts `mapSet.value.bytes == "AQID"` (base64); cocoa's outbound + `ObjectData.bytes` holds **raw `Data`** — base64 is applied at wire (JSON) serialization, below this + layer. The port asserts `Data([1,2,3])`. +- **RTLM20 set-invalid-values-table (40013) compile-time-unrepresentable.** `LiveMapValue` is a closed + enum, so a function / undefined / symbol value cannot be constructed (as for value_types.md RTLMV4c). +- **Mock-realtime adaptation:** `set-applies-locally` post-apply read is out of scope (only the + published MAP_SET is asserted). + +### `objects/unit/path_object_subscribe.md` → `UTS/PathObjectSubscribeUTSTests.swift` + +Path dispatch is owned by the engine's apply path, so these drive the **real** +`InternalDefaultRealtimeObjects`: the standard graph is seeded into the engine's pool via +`testsOnly_setPoolEntry` + `testsOnly_setParentReferences` (entries at POOL_SERIAL `"t:0"` so remote +`"t:1"`+ serials win per-entry LWW), and inbound frames are replayed via `testsOnly_applyObjectMessages` +/ `nosync_handleObjectSyncProtocolMessage` (the unit stand-in for `mock_ws.send_to_client`). Mirrors +the native `PathObjectSubscriptionTests`. + +- **RTO24b2b — event object = chosen candidate path.** `event.object.path` is the most-preferred + covered candidate, not the raw change site. +- **RTLO4b4c3c1 — path subs survive tombstone; RTO4b2a — sync-originated events carry `message == nil`.** + Ported (`event-message-omitted-no-operation` via an OBJECT_SYNC). +- **RTO24b2c throwing-listener unrepresentable.** `PathObjectSubscriptionCallback` is a non-throwing + `@Sendable` closure, so the spec's "listener throws, error caught" branch cannot be written. The + observable part (one listener's dispatch does not stop another's) is ported with two listeners. +- **RTPO19g skipped** (subscribe has no side effects on `channel.state`): needs a real channel/ + connection; the unit fixture has only a fixed `CoreSDK` state. + +### `objects/unit/live_object_subscribe.md` → `UTS/LiveObjectSubscribeUTSTests.swift` + +`Instance#subscribe` (RTINS16) delivery is owned by the engine's apply path, so these drive the **real** +`InternalDefaultRealtimeObjects` (as `PathObjectSubscribeUTSTests` does): the standard graph is seeded into +the engine's pool via `testsOnly_setPoolEntry` + `testsOnly_setParentReferences`, instances are obtained +through the production `root.get(key:).instance()` seam, and inbound frames are replayed via +`testsOnly_applyObjectMessages` (the unit stand-in for `mock_ws.send_to_client`). The enriched +`InstanceSubscriptionEvent.message` is the public `ObjectMessage` the engine derives from the inbound +frame (`toPublicObjectMessage`), so `message.serial`/`siteCode`/`operation` are asserted directly. + +- **DEV-1 (Instance enum):** the spec's `instance.subscribe(...)` is reached by unwrapping the `Instance` + enum to its concrete `.liveCounter` / `.liveMap` payload (`LiveCounterInstance` / `LiveMapInstance`), + which carry `subscribe`. `PrimitiveInstance` has no `subscribe` (RTINS16c is unrepresentable). +- **RTLO4b4c1 noop shape.** The spec models the noop COUNTER_INC as `counterInc: {}` (present but empty). + cocoa's `WireCounterInc.number` is non-optional, so a number-less increment is represented by an + **absent** `counterInc` — the same RTLC9h noop branch (helper `ObjectsUTS.counterIncNoopMessage`). +- **RTLO4b6 (no side effects)** is ported in the weakened form observable in the unit fixture: subscribe + neither throws nor changes the fixed `CoreSDK` channel state (the spec's `channel.state` needs a real + channel/connection, as for `PathObjectSubscribeUTSTests`' RTPO19g). +- **RTLO4b4c3c** (tombstone deregisters instance subs) is ported and confirmed live; note this is the + Instance-layer teardown — path subscriptions survive a tombstone (RTLO4b4c3c1, `PathObjectSubscribeUTSTests`). + +### `objects/unit/realtime_object.md` → `UTS/RealtimeObjectUTSTests.swift` + +The engine-drivable subset of the `channel.object` surface is driven through a real +`InternalDefaultRealtimeObjects` proxied by `PublicDefaultRealtimeObject` (with an `ObjectsUTSCoreSDK` +fixing the channel state); sync is completed by feeding the engine an empty OBJECT_SYNC / ATTACHED. This +mirrors the native `PublicRealtimeObjectTests`. Ported: `get()` (RTO23d/RTO23c/RTO23e-failed/RTO4b), +status events + off() (RTO17/RTO18/RTO18d/RTO19 and the SYNCING/SYNCED sequences), the dispose lifecycle, +and the channel-state access/write preconditions (RTO25b/RTO26b, 90001/400). + +- **DEV-11 (zero-arg status callback).** `on(event:callback:)` takes a `() -> Void`; the event is known + from registration, so RTO18e ("listeners called with no arguments") is the shipped shape. +- **RTL33b implicit attach not implementable.** `ChannelConfigGuards.ensureActiveChannel` can only _read_ + channel state through the plugin API (no attach seam), so RTO23's implicit-attach cases (get-implicit-attach, + RTO23e get-reattaches-detached) are skipped; only the RTL33c FAILED rejection (90001) is exercised. +- **Skipped — mode/echo guards (stubbed, no plugin accessor):** RTO23a, RTO2 mode-enforcement, RTO25a, + RTO26a (40024) and RTO26c (echoMessages, 40000). See `ChannelConfigGuards`' implementability table. +- **Skipped — out of unit scope:** the publish / apply-on-ACK pipeline (RTO15, all RTO20 variants, + echo-dedup, ack-\*) and garbage collection (RTO10/RTO10b1/RTO10c1b1, fake timers) need the mock-WS + OBJECT+ACK path; path-subscription dispatch (RTO24a/RTO24c1) is covered by `PathObjectSubscribeUTSTests`. +- **RTO27** (channel-state data lifecycle) is implemented (DEV-51); its white-box coverage lives in the + native AblyLiveObjects unit suite (`InternalDefaultRealtimeObjectsTests.ChannelStateChangeTests`) — see the + "Failing Tests" section above. + +## Mock Infrastructure Limitations + +The following `objects/unit` cases require the mock-WebSocket harness (`setup_synced_channel` / +`mock_ws.send_to_client` / `install_mock`) and were skipped per the UNIT-only scope — no harness was +built for them: + +- **`instance.md`**: RTINS16g (subscription follows identity after a `MAP_SET` repoints `root.score` — + needs a multi-object graph plus the mock-WS send path) and RTINS16h (subscribe has no side effects on + channel state — needs a real channel/connection). +- **`value_types.md`**: RTLMV4d1/RTLMV4d2/RTLMV4k (nested depth-first evaluation) — nested blueprint + entries are materialised by the async `createMap`/`createCounter` pipeline (concrete + `InternalDefaultRealtimeObjects` + `publishAndApply`), not the pure `ObjectCreationHelpers` seam. Also + RTLCV4a finiteness (NaN/Infinity → 40003): counter initial-value finiteness is validated in + `InternalDefaultRealtimeObjects.createCounter` (RTO12f1), above the pure evaluate seam. +- **`internal_live_counter_api.md`**: the `MockWebSocket`-based variants of RTLC12/RTLC13 (`captured_messages` + via the OBJECT publish path) and RTLC12 `increment-applies-locally` — the published message is asserted + through the `publishAndApply` mock instead; the local-apply value read is out of unit scope. +- **`internal_live_map_api.md`**: RTLM20e7g (`set` with a `LiveCounter` / `LiveMap`) and RTLM20h1 (nested + `LiveMap` containing a `LiveCounter`) — setting a blueprint value materialises it via + `RealtimeObjects.createCounter`/`createMap`, which `DefaultLiveMapInstance` narrows to the concrete + `InternalDefaultRealtimeObjects` (a `preconditionFailure` otherwise); those publish through the + mock-WS OBJECT capture path. The seeded double cannot drive them. Primitive value types + (number/boolean/json/bytes) do not need the pipeline and are ported. + + diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/WireObjectMessageSizeTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/WireObjectMessageSizeTests.swift new file mode 100644 index 000000000..19f4e936e --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/WireObjectMessageSizeTests.swift @@ -0,0 +1,247 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// Tests for the OM3 message-size calculation and the RTO15d publish-size gate. +/// +/// These are ported from ably-java's `ObjectMessageSizeTest`. The size algorithm is implemented on +/// the `ProtocolTypes` (non-wire) `OutboundObjectMessage` — see the explanatory comment in +/// `ObjectMessage.swift` — so the tests exercise it there. +struct WireObjectMessageSizeTests { + // MARK: - OM3 composite size + + // Ported from ably-java `ObjectMessageSizeTest.testObjectMessageSizeWithinLimit`. + // + // A message exercising every size-contributing field, whose expected size is + // clientId(11) + operation(54) + object(46) + extras(26) = 137 bytes. + // @spec OM3 + // @spec OOP4 + // @spec OST3 + @Test + func compositeMessageSize() { + let objectMessage = ProtocolTypes.OutboundObjectMessage( + id: "msg_12345", // Not counted in size calculation + clientId: "test-client", // OM3f: 11 bytes (UTF-8) + connectionId: "conn_98765", // Not counted + // OM3d: JSON serialization length -> 26 bytes ({"count":42,"meta":"data"}) + extras: [ + "meta": "data", + "count": 42, + ], + timestamp: Date(timeIntervalSince1970: 1_699_123_456.789), // Not counted + operation: .init( + action: .known(.mapCreate), + objectId: "obj_54321", // Not counted in operation size + // mapSet contributes 6 (key) + 6 (string value) = 12 + mapSet: .init( + key: "mapKey", // 6 bytes + value: .init( + objectId: "ref_obj", // Not counted (string takes precedence in OD3) + string: "sample", // 6 bytes + ), + ), + // counterInc contributes 8 + counterInc: WireCounterInc(number: NSNumber(value: 10.0)), + // mapCreateWithObjectId.derivedFrom contributes 26 (via OOP4h2) + mapCreateWithObjectId: .init( + initialValue: "{}", // Not counted in derivedFrom size + nonce: "dummy-nonce", // Not counted in derivedFrom size + derivedFrom: .init( + semantics: .known(.lww), // Not counted + entries: [ + // 6 (key) + 6 (string) = 12 + "entry1": .init( + tombstone: false, // Not counted + timeserial: "ts_123", // Not counted + data: .init(string: "value1"), + ), + // 6 (key) + 8 (number) = 14 + "entry2": .init(data: .init(number: NSNumber(value: 42.0))), + ], + ), + ), + // counterCreateWithObjectId.derivedFrom contributes 8 (via OOP4k2) + counterCreateWithObjectId: .init( + initialValue: "{}", // Not counted + nonce: "dummy-nonce", // Not counted + derivedFrom: WireCounterCreate(count: NSNumber(value: 100.0)), + ), + ), + object: .init( + objectId: "state_obj", // Not counted in state size + siteTimeserials: ["site1": "serial1"], // Not counted + tombstone: false, // Not counted + // createOp contributes 9 (key) + 11 (string) = 20 + createOp: .init( + action: .known(.mapSet), + objectId: "create_obj", + mapSet: .init( + key: "createKey", // 9 bytes + value: .init(string: "createValue"), // 11 bytes + ), + ), + // map contributes 8 (key length) + 10 (string) = 18 + map: .init( + semantics: .known(.lww), + entries: [ + "stateKey": .init(data: .init(string: "stateValue")), + ], + ), + // counter contributes 8 + counter: WireObjectsCounter(count: NSNumber(value: 50.0)), + ), + serial: "serial_123", // Not counted + siteCode: "site_abc", // Not counted + ) + + #expect(objectMessage.size == 137) + } + + // Ported from ably-java `ObjectMessageSizeTest.testObjectMessageSizeForUnicodeCharacters`. + // + // Confirms OD3e measures strings in UTF-8 bytes: 你 -> 3 bytes, 😊 -> 4 bytes. + // @spec OD3e + @Test + func unicodeStringSize() { + let objectMessage = ProtocolTypes.OutboundObjectMessage( + operation: .init( + action: .known(.mapSet), + objectId: "", + mapSet: .init( + key: "", + value: .init(string: "你😊"), + ), + ), + ) + + #expect(objectMessage.size == 7) + } + + // MARK: - OD3 leaf branches + + // @spec OD3e + @Test + func objectDataStringSize() { + #expect(ProtocolTypes.ObjectData(string: "hello").size == 5) + // UTF-8 byte length, not character count + #expect(ProtocolTypes.ObjectData(string: "你").size == 3) + } + + // @spec OD3d + @Test + func objectDataNumberSize() { + #expect(ProtocolTypes.ObjectData(number: NSNumber(value: 3.14)).size == 8) + } + + // @spec OD3b + @Test + func objectDataBooleanSize() { + #expect(ProtocolTypes.ObjectData(boolean: true).size == 1) + #expect(ProtocolTypes.ObjectData(boolean: false).size == 1) + } + + // @spec OD3c + @Test + func objectDataBytesSize() { + // Size is the actual binary length, regardless of base64 representation. + let bytes = Data([0x01, 0x02, 0x03, 0x04, 0x05]) + #expect(ProtocolTypes.ObjectData(bytes: bytes).size == 5) + } + + // @spec OD3g + @Test + func objectDataJSONSize() { + // {"k":"v"} is 9 bytes. + #expect(ProtocolTypes.ObjectData(json: .object(["k": "v"])).size == 9) + } + + // @spec OD3f + @Test + func objectDataEmptyIsZero() { + #expect(ProtocolTypes.ObjectData().size == 0) + // objectId does not contribute to the size (it is not one of the OD3 leaves). + #expect(ProtocolTypes.ObjectData(objectId: "obj:1@2").size == 0) + } + + // MARK: - OCN3 counter + + // @spec OCN3a + // @spec OCN3b + @Test + func objectsCounterSize() { + #expect(WireObjectsCounter(count: NSNumber(value: 42.0)).size == 8) + #expect(WireObjectsCounter(count: nil).size == 0) + } + + // MARK: - OM3d extras + + // @spec OM3d + @Test + func extrasSizeIsJSONStringLength() { + let objectMessage = ProtocolTypes.OutboundObjectMessage( + extras: [ + "meta": "data", + "count": 42, + ], + ) + // {"count":42,"meta":"data"} is 26 bytes/UTF-16 code units. + #expect(objectMessage.size == 26) + } + + // MARK: - RTO15d publish-size gate + + private static func createRealtimeObjects(internalQueue: DispatchQueue) -> InternalDefaultRealtimeObjects { + InternalDefaultRealtimeObjects( + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + } + + // Ported from ably-java `ObjectMessageSizeTest.testObjectMessageSizeAboveLimit`. + // + // Two messages of 60 KiB + 5 KiB = 66560 bytes exceed the 64 KiB (65536-byte) default limit, so + // the publish is rejected with a 40009 error before reaching the core SDK. + // @spec RTO15d + @Test + func publishRejectsMessagesExceedingMaxSize() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = Self.createRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // The publish must be rejected before the core SDK is consulted; if the gate fails to fire, + // the mock's unconfigured `nosync_publish` would trap instead of returning an error. + let message1 = ProtocolTypes.OutboundObjectMessage(clientId: String(repeating: "a", count: 60 * 1024)) + #expect(message1.size == 60 * 1024) + let message2 = ProtocolTypes.OutboundObjectMessage(clientId: String(repeating: "b", count: 5 * 1024)) + #expect(message2.size == 5 * 1024) + + let error = try await #require(throws: ARTErrorInfo.self) { + try await realtimeObjects.testsOnly_publish(objectMessages: [message1, message2], coreSDK: coreSDK) + } + + #expect(error.code == 40009) + #expect(error.statusCode == 400) + #expect(error.message == "ObjectMessages size 66560 exceeds maximum allowed size of 65536 bytes") + } + + // A publish whose total size is within the limit is handed to the core SDK and succeeds. + // @spec RTO15d + @Test + func publishAcceptsMessagesWithinMaxSize() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = Self.createRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + coreSDK.setPublishHandler { messages in + PublishResult(serials: messages.map { _ in "serial" }) + } + + // Well within the 65536-byte limit. + let message = ProtocolTypes.OutboundObjectMessage(clientId: String(repeating: "a", count: 1024)) + + try await realtimeObjects.testsOnly_publish(objectMessages: [message], coreSDK: coreSDK) + } +} diff --git a/Package.swift b/Package.swift index d6a5e8a29..ae528b3e3 100644 --- a/Package.swift +++ b/Package.swift @@ -47,7 +47,9 @@ let package = Package( ], path: "LiveObjects/Tests/AblyLiveObjectsTests", exclude: [ - "CLAUDE.md" + "CLAUDE.md", + "UTS/README.md", + "UTS/deviations.md" ] ), // Private API of the core SDK, exposed to Ably-authored plugins. Formerly @@ -112,7 +114,7 @@ let package = Package( name: "UTS", dependencies: [ .byName(name: "Ably"), - .target(name: "_AblyPluginSupportPrivate") + .target(name: "_AblyPluginSupportPrivate"), ], path: "Test/UTS", exclude: [ diff --git a/Test/UTS/deviations.md b/Test/UTS/deviations.md index 3a789383d..f4f6ed97e 100644 --- a/Test/UTS/deviations.md +++ b/Test/UTS/deviations.md @@ -1,8 +1,10 @@ # UTS Deviations (ably-cocoa) -This file records gaps found while running UTS-derived tests against ably-cocoa, per -`uts/docs/writing-derived-tests.md`. Deviation tests assert the **spec-correct** behaviour and are -gated behind the `RUN_DEVIATIONS` environment variable so normal runs stay green: +This file records gaps found while running the UTS-derived tests that live in this shared `UTS` test +target — currently the **rest** and **realtime** tiers (`Test/UTS/Tests/rest/**`, +`Test/UTS/Tests/realtime/**`), per `uts/docs/writing-derived-tests.md`. Deviation tests assert the +**spec-correct** behaviour and are gated behind the `RUN_DEVIATIONS` environment variable so normal +runs stay green: ```swift @Test(.enabled(if: ProcessInfo.processInfo.environment["RUN_DEVIATIONS"] != nil)) @@ -14,14 +16,20 @@ Reproduce a single deviation with: RUN_DEVIATIONS=1 swift test --filter UTS./ ``` +> **Objects (`objects/unit`) deviations moved.** The LiveObjects `objects/unit` UTS ports were +> consolidated into the plugin's own test target for java-parity (all 15 specs under +> `LiveObjects/Tests/AblyLiveObjectsTests/UTS/`). Their deviations now live alongside them in +> `LiveObjects/Tests/AblyLiveObjectsTests/UTS/deviations.md`. + ## Failing Tests (SDK non-compliance, spec-correct test skipped) -_None recorded yet._ +_None currently._ ## Adapted Tests (assert actual SDK behaviour, deviation documented) -_None recorded yet._ +_None currently._ ## Mock Infrastructure Limitations -_None recorded yet._ +_None currently._ + From 0d2cd22060b7192761af3dd26ba0dbdd1aac7d16 Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Fri, 31 Jul 2026 18:01:31 +0530 Subject: [PATCH 2/2] liveobjects: complete the plugin-API bridge and fix defects found by integration testing Implements the remaining core-accessor-gated functionality, ported from ably-java, after enabling the LiveObjects integration test suites: - RTL33 implicit attach: new plugin-API attach primitive (APPluginAPI nosync_attachChannel:) wired through CoreSDK into RealtimeObject.get() - full RTL33a/b/b1/c state handling - Real maxMessageSize on the RTO15d publish size gate (65536 fallback) - Real channel name threaded to the public ObjectMessage (PAOM3) - Channel-config guards fully implemented: object channel modes (40024), echoMessages, connection-active checks - no core-accessor stubs remain - Channel-release SPI (nosync_releaseChannel:) so releasing a channel fails in-flight waiters with a release-specific cause - Fix: engine deinit on the internal queue no longer trips the DispatchQueueMutex precondition (SIGTRAP during ARC-driven teardown) - Fix: siteCode from connectionDetails is now seeded into engines created after CONNECTED, restoring RTO20 local echo All changes are covered by new native tests. Verified end-to-end by the LiveObjects integration suites (17/17 against the Ably sandbox). --- .../Internal/ChannelConfigGuards.swift | 190 +++++++++++++----- .../AblyLiveObjects/Internal/CoreSDK.swift | 68 +++++++ .../Internal/DefaultInternalPlugin.swift | 44 +++- .../InternalDefaultRealtimeObjects.swift | 124 +++++++++--- .../PublicDefaultRealtimeObject.swift | 7 +- .../AblyLiveObjects/Utility/Errors.swift | 23 +++ .../DefaultInternalPluginTests.swift | 156 ++++++++++++++ .../DefaultPathObjectTests.swift | 127 ++++++++++++ .../InternalDefaultRealtimeObjectsTests.swift | 40 ++++ .../Mocks/MockCoreSDK.swift | 88 +++++++- .../PathObjectSubscriptionTests.swift | 21 ++ .../PublicRealtimeObjectTests.swift | 97 +++++++++ .../UTS/ObjectsUTSHelpers.swift | 31 +++ .../WireObjectMessageSizeTests.swift | 44 ++++ Source/ARTPluginAPI.m | 69 +++++++ Source/ARTRealtimeChannels.m | 10 + Test/AblyTests/Tests/PluginAPITests.swift | 6 + .../include/APConnectionDetails.h | 6 + .../include/APLiveObjectsPlugin.h | 7 + .../include/APPluginAPI.h | 42 ++++ 20 files changed, 1106 insertions(+), 94 deletions(-) create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/DefaultInternalPluginTests.swift diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ChannelConfigGuards.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ChannelConfigGuards.swift index 7e931dc7d..6ff06cf05 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/ChannelConfigGuards.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ChannelConfigGuards.swift @@ -13,73 +13,132 @@ import Ably /// | Check | Spec | Implemented? | /// |---|---|---| /// | Channel state (DETACHED/FAILED, +SUSPENDED for writes) | RTO25/RTO26 | ✅ via `CoreSDK.nosync_channelState` | -/// | `object_subscribe` / `object_publish` channel mode | RTO2a2/RTO2b2 (40024) | ❌ no plugin accessor — stubbed | -/// | `echoMessages` client option | RTO26 | ❌ no plugin accessor — stubbed | -/// | Connection `isActive` (publishable state) | RTO26 | ❌ no plugin accessor — stubbed | +/// | `object_subscribe` / `object_publish` channel mode | RTO2a2/RTO2b2 (40024) | ✅ via `CoreSDK.nosync_objectChannelModes` | +/// | `echoMessages` client option | RTO26 | ✅ via `CoreSDK.echoMessages` | +/// | Connection `isActive` (publishable state) | RTO26 | ✅ via `CoreSDK.nosync_connectionStateError` | /// -/// The unimplementable checks carry a `// TODO(core accessor):` marker; landing them needs a -/// `PluginAPI`/core-SDK accessor (a user-gated core change, out of scope per plan §6.6 — the DEV-23 -/// precedent). The channel-state check reuses the exact `CoreSDK.nosync_validateChannelState` the -/// internal engine's node accessors already run (same 90001 code), so no new state check is invented. +/// The channel-state check produces the same 90001 error the internal engine's node accessors run +/// (`CoreSDK.nosync_validateChannelState`), so no new state check is invented. The mode / +/// echo-messages / connection-active checks were formerly stubbed (DEV-38); the `nosync_objectChannelModes`, +/// `echoMessages` and `nosync_connectionStateError` bridge accessors now make them real. @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum ChannelConfigGuards { /// Validates the access (read/subscribe) API preconditions: the channel must be attachable (not /// DETACHED/FAILED) and configured with the `object_subscribe` mode. Spec: RTO25. internal static func throwIfInvalidAccessApiConfiguration(coreSDK: CoreSDK, internalQueue: DispatchQueue) throws(ARTErrorInfo) { - // RTO25b — channel-state check (implementable). Reuses the engine's node-accessor check. - try validateChannelState(coreSDK: coreSDK, internalQueue: internalQueue, notIn: [.detached, .failed], operationDescription: "access API") - // TODO(core accessor): RTO25a / RTO2a2 — throw `channelModeRequired("object_subscribe")` (40024) - // when the channel is not configured with the `object_subscribe` mode. The plugin API exposes - // no channel modes; see the guard-implementability table in this file's doc comment. + // ably-java `Helpers.kt:40` — order: channel state, then `object_subscribe` mode. + let error = internalQueue.ably_syncNoDeadlock { () -> ARTErrorInfo? in + // RTO25b — channel state (reuses the engine's node-accessor check). + nosync_channelStateError(coreSDK: coreSDK, notIn: [.detached, .failed], operationDescription: "access API") + // RTO25a / RTO2a2 — `object_subscribe` mode. + ?? nosync_missingChannelModeError(coreSDK: coreSDK, requiredMode: .objectSubscribe, modeName: "object_subscribe") + } + if let error { + throw error + } } /// Validates only the `object_subscribe` channel mode (no channel-state check), for /// `RealtimeObject.get()` (RTO23a). Unlike the access methods, `get()` delegates channel-state /// handling to the ensure-attached procedure (RTL33), so it must not pre-empt that with a state /// gate. Spec: RTO23a. (Wired in P5's `get()`.) - internal static func throwIfMissingObjectSubscribeMode(coreSDK _: CoreSDK, internalQueue _: DispatchQueue) throws(ARTErrorInfo) { - // TODO(core accessor): RTO2a2 — throw `channelModeRequired("object_subscribe")` (40024) when the - // channel is not configured with the `object_subscribe` mode. No plugin channel-modes accessor. + internal static func throwIfMissingObjectSubscribeMode(coreSDK: CoreSDK, internalQueue: DispatchQueue) throws(ARTErrorInfo) { + // ably-java `Helpers.kt:53` — the `object_subscribe` mode only (no channel-state check). + let error = internalQueue.ably_syncNoDeadlock { () -> ARTErrorInfo? in + nosync_missingChannelModeError(coreSDK: coreSDK, requiredMode: .objectSubscribe, modeName: "object_subscribe") + } + if let error { + throw error + } } /// Validates the write (mutation) API preconditions: message echo must be enabled, the channel /// must be usable (not DETACHED/FAILED/SUSPENDED) and configured with the `object_publish` mode. /// Spec: RTO26. internal static func throwIfInvalidWriteApiConfiguration(coreSDK: CoreSDK, internalQueue: DispatchQueue) throws(ARTErrorInfo) { - // TODO(core accessor): RTO26 — throw a bad-request error when `echoMessages` is disabled. The - // plugin API exposes client options only as an opaque marker protocol (no `echoMessages`). - // RTO26b — channel-state check (implementable). Reuses the engine's node-accessor check. - try validateChannelState(coreSDK: coreSDK, internalQueue: internalQueue, notIn: [.detached, .failed, .suspended], operationDescription: "write API") - // TODO(core accessor): RTO2b2 — throw `channelModeRequired("object_publish")` (40024) when the - // channel is not configured with the `object_publish` mode. No plugin channel-modes accessor. + // ably-java `Helpers.kt:64` — order: echoMessages, then channel state, then `object_publish` mode. + let error = internalQueue.ably_syncNoDeadlock { () -> ARTErrorInfo? in + // RTO26 — `echoMessages` must be enabled. + nosync_echoMessagesDisabledError(coreSDK: coreSDK) + // RTO26b — channel state (reuses the engine's node-accessor check). + ?? nosync_channelStateError(coreSDK: coreSDK, notIn: [.detached, .failed, .suspended], operationDescription: "write API") + // RTO2b2 — `object_publish` mode. + ?? nosync_missingChannelModeError(coreSDK: coreSDK, requiredMode: .objectPublish, modeName: "object_publish") + } + if let error { + throw error + } } /// RTO23e / RTL33 — the *ensure-active-channel* procedure that `RealtimeObject.get()` runs before - /// waiting for sync. ably-java implicitly attaches a DETACHED/INITIALIZED channel (RTL33b) and - /// rejects only FAILED (RTL33c, code 90001). + /// waiting for sync. Mirrors ably-java's `Helpers.kt` `ensureAttached` / `attachAsync`: /// - /// The plugin API (`PluginAPIProtocol` / `_AblyPluginSupportPrivate`) exposes **no way to - /// initiate a channel attach** — only `nosync_stateForChannel` (state read) and the inbound - /// `nosync_onChannelAttached` callback. So the implicit-attach half of RTL33 is not implementable - /// here; we implement the implementable half — reject a FAILED channel (RTL33c) — and leave the - /// attach to the application. When the channel is not yet attached, `get()`'s subsequent - /// `ensureSynced` simply waits until it becomes synced. + /// - **RTL33a** — ATTACHED or SUSPENDED: already usable, no attach performed. + /// - **RTL33b** — INITIALIZED / DETACHED / DETACHING / ATTACHING: implicitly attach (per RTL4, via + /// the `CoreSDK.nosync_attach` plugin bridge) and await completion. A single attach resolves the + /// in-flight ATTACHING/DETACHING cases per RTL4h, so one call covers all four states. The + /// `ARTErrorInfo` that caused an attach failure is propagated (RTL33b1). + /// - **RTL33c** — FAILED (and any unknown state): reject with code 90001, statusCode 400. + /// + /// The state read and the attach initiation happen in a **single** internal-queue block, so no + /// channel-state change can be lost between the read and the attach's callback registration + /// (lost-wakeup discipline). Formerly DEV-46 (implicit attach was not implementable through the + /// plugin API); the `nosync_attach` bridge accessor now makes it real. /// /// Spec: RTO23e, RTL33. - internal static func ensureActiveChannel(coreSDK: CoreSDK, internalQueue: DispatchQueue) throws(ARTErrorInfo) { - // TODO(core accessor): RTL33b — implicitly attach a DETACHED/INITIALIZED channel. The plugin - // API can only read channel state, not initiate an attach; landing this needs a - // PluginAPI/core-SDK accessor (user-gated core change, plan §6.6; DEV-38 precedent). - // RTL33c — reject a FAILED channel (implementable; code 90001). - try validateChannelState(coreSDK: coreSDK, internalQueue: internalQueue, notIn: [.failed], operationDescription: "get") + internal static func ensureActiveChannel(coreSDK: CoreSDK, internalQueue: DispatchQueue) async throws(ARTErrorInfo) { + let result: Result = await withCheckedContinuation { continuation in + internalQueue.async { + let state = coreSDK.nosync_channelState + + // The RTL33c rejection, reused for FAILED and any unknown state. + let invalidStateError = LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "get", + channelState: state, + ) + let invalidStateFailure: Result = .failure(invalidStateError.toARTErrorInfo()) + + switch state { + case .attached, .suspended: + // RTL33a + continuation.resume(returning: .success(())) + case .initialized, .detached, .detaching, .attaching: + // RTL33b — implicit attach; the callback fires on the internal queue (RTL33b1 + // propagates the attach-failure error). + coreSDK.nosync_attach { error in + continuation.resume(returning: error.map { .failure($0) } ?? .success(())) + } + case .failed: + // RTL33c + continuation.resume(returning: invalidStateFailure) + @unknown default: + continuation.resume(returning: invalidStateFailure) + } + } + } + + switch result { + case .success: + return + case let .failure(error): + throw error + } } /// Validates that the channel is in a publishable state (connection active, channel not /// FAILED/SUSPENDED). Spec: RTO26 (publishable-state variant). (Reserved for P5.) internal static func throwIfUnpublishableState(coreSDK: CoreSDK, internalQueue: DispatchQueue) throws(ARTErrorInfo) { - // TODO(core accessor): the connection `isActive` check — the plugin API exposes no connection - // state / manager. Only the channel-state portion below is implementable. - try validateChannelState(coreSDK: coreSDK, internalQueue: internalQueue, notIn: [.failed, .suspended], operationDescription: "publish") + // ably-java `Helpers.kt:110` — order: connection active, then channel state (FAILED/SUSPENDED). + let error = internalQueue.ably_syncNoDeadlock { () -> ARTErrorInfo? in + // The connection must be in a publishable (active) state; if not, surface the connection's + // own state error (DEV-38 note: cocoa returns the connection's `errorReason`, or a synthetic + // 80002 error when it has none, in place of ably-java's `connectionManager.stateErrorInfo`). + coreSDK.nosync_connectionStateError + ?? nosync_channelStateError(coreSDK: coreSDK, notIn: [.failed, .suspended], operationDescription: "publish") + } + if let error { + throw error + } } /// RTPO19c1a / DEV-9 — validates a subscription `depth`. ably-java throws 40003 from the @@ -92,26 +151,51 @@ internal enum ChannelConfigGuards { } } - // MARK: - Private helpers + // MARK: - Private on-queue checks - /// Runs `CoreSDK.nosync_validateChannelState` on the internal queue (the `nosync_` accessor must be - /// invoked there), re-throwing its typed `ARTErrorInfo` to the caller. - private static func validateChannelState( + // The following helpers read `nosync_` core-SDK accessors and so must be called on the internal + // queue (each guard wraps its checks in a single `ably_syncNoDeadlock` hop). Each returns the + // relevant `ARTErrorInfo` if the check fails, or `nil` if it passes — so a guard can chain them + // with `??` to mirror ably-java's short-circuiting check order. + + /// RTO25b/RTO26b — the channel-state check (reuses the engine's node-accessor precondition). + /// ably-java `Helpers.kt:94` `throwIfInChannelState`. + private static func nosync_channelStateError( coreSDK: CoreSDK, - internalQueue: DispatchQueue, notIn invalidStates: [_AblyPluginSupportPrivate.RealtimeChannelState], operationDescription: String, - ) throws(ARTErrorInfo) { - let failure: ARTErrorInfo? = internalQueue.ably_syncNoDeadlock { - do throws(ARTErrorInfo) { - try coreSDK.nosync_validateChannelState(notIn: invalidStates, operationDescription: operationDescription) - return nil - } catch { - return error - } + ) -> ARTErrorInfo? { + let currentState = coreSDK.nosync_channelState + if invalidStates.contains(currentState) { + let error = LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: operationDescription, + channelState: currentState, + ) + return error.toARTErrorInfo() } - if let failure { - throw failure + return nil + } + + /// RTO2a2/RTO2b2 — the required-channel-mode check (40024). ably-java `Helpers.kt:84` + /// `throwIfMissingChannelMode`: throws when the effective modes are absent or do not contain the + /// required mode. + private static func nosync_missingChannelModeError( + coreSDK: CoreSDK, + requiredMode: _AblyPluginSupportPrivate.ChannelMode, + modeName: String, + ) -> ARTErrorInfo? { + if !coreSDK.nosync_objectChannelModes.contains(requiredMode) { + return LiveObjectsError.channelModeRequired(mode: modeName).toARTErrorInfo() + } + return nil + } + + /// RTO26 — the `echoMessages` client-option check. ably-java `Helpers.kt:101` + /// `throwIfEchoMessagesDisabled`. + private static func nosync_echoMessagesDisabledError(coreSDK: CoreSDK) -> ARTErrorInfo? { + if !coreSDK.echoMessages { + return LiveObjectsError.echoMessagesDisabled.toARTErrorInfo() } + return nil } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift index cb0933b90..3efd7a88d 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -19,6 +19,38 @@ internal protocol CoreSDK: AnyObject, Sendable { /// Returns the current state of the Realtime channel that this wraps. var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { get } + + /// The name of the Realtime channel that this wraps (PAOM2e/PAOM3b, DEV-20). Used to populate the + /// `channel` field of a public `ObjectMessage`. The channel name is immutable, so this may be read + /// on any queue. ably-java: `DefaultRealtimeObject.channelName` (DefaultRealtimeObject.kt:35). + var channelName: String { get } + + /// The channel's effective object-related channel modes (RTO2a/RTO2b), used by the RTO2a2/RTO2b2 + /// mode guards. Resolved by the core SDK as the attached modes if present, else the channel-options + /// modes. Must be called on the internal queue. ably-java: `Helpers.kt:76` `getChannelModes`. + var nosync_objectChannelModes: _AblyPluginSupportPrivate.ChannelMode { get } + + /// Whether the client has the `echoMessages` option enabled (RTO26). ably-java: + /// `clientOptions.echoMessages` (Helpers.kt:102). + var echoMessages: Bool { get } + + /// The error that makes the client's connection unpublishable (RTO26), or `nil` if the connection + /// is in a publishable (active) state. Must be called on the internal queue. ably-java: + /// `connectionManager.isActive` / `stateErrorInfo` (Helpers.kt:110-114). + var nosync_connectionStateError: ARTErrorInfo? { get } + + /// RTO15d / DEV-23: The connection's negotiated `maxMessageSize`, read from the latest `CONNECTED` + /// `ProtocolMessage`'s `connectionDetails` (ably-java `Helpers.kt:168`, + /// `connectionManager.maxMessageSize`). `nil` when the core SDK has no connection details yet or the + /// server did not send a limit; callers fall back to the Ably default of 65536 bytes. Must be + /// called on the internal queue. + var nosync_maxMessageSize: Int? { get } + + /// Initiates an implicit attach (RTL33b) on the wrapped Realtime channel, used by the + /// *ensure-active-channel* procedure of `RealtimeObject.get()` (RTO23e / RTL33). The callback + /// receives `nil` on success, or the `ARTErrorInfo` that caused the attach to fail (RTL33b1). + /// Must be called on the internal queue; the callback fires on the internal queue. + func nosync_attach(callback: @escaping @Sendable (ARTErrorInfo?) -> Void) } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) @@ -111,6 +143,42 @@ internal final class DefaultCoreSDK: CoreSDK { internal var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { pluginAPI.nosync_state(for: channel) } + + internal var channelName: String { + pluginAPI.name(for: channel) + } + + internal var nosync_objectChannelModes: _AblyPluginSupportPrivate.ChannelMode { + pluginAPI.nosync_objectChannelModes(for: channel) + } + + internal var echoMessages: Bool { + // ably-java `Helpers.kt:102`. The plugin API exposes client options as an opaque marker + // protocol; cast to the concrete `ARTClientOptions` (the only conformer) to read `echoMessages`. + ARTClientOptions.castPluginPublicClientOptions(pluginAPI.options(for: client)).echoMessages + } + + internal var nosync_connectionStateError: ARTErrorInfo? { + pluginAPI.nosync_connectionStateError(for: client).map { ARTErrorInfo.castPluginPublicErrorInfo($0) } + } + + internal var nosync_maxMessageSize: Int? { + // ably-java Helpers.kt:168 (`connectionManager.maxMessageSize`). The core SDK surfaces the + // limit via the latest CONNECTED ProtocolMessage's connectionDetails; a `0`/absent value means + // the server sent no limit, so we return nil to let the caller fall back to the Ably default. + guard let connectionDetails = pluginAPI.nosync_latestConnectionDetails(for: client) else { + return nil + } + let maxMessageSize = connectionDetails.maxMessageSize + return maxMessageSize > 0 ? maxMessageSize : nil + } + + internal func nosync_attach(callback: @escaping @Sendable (ARTErrorInfo?) -> Void) { + logger.log("nosync_attach()", level: .debug) + pluginAPI.nosync_attach(channel) { error in + callback(error.map { ARTErrorInfo.castPluginPublicErrorInfo($0) }) + } + } } // MARK: - Channel State Validation diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 28d9529c5..d7b8c2ff0 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -20,16 +20,14 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. self.pluginAPI = pluginAPI } - // Dispose lifecycle (matrix #18): Kotlin's `DefaultLiveObjectsPlugin` exposes + // Dispose lifecycle (matrix #18, DEV-47): Kotlin's `DefaultLiveObjectsPlugin` exposes // `dispose(channelName)` / `dispose()`, which ably-java calls from its channel/client teardown to - // dispose the per-channel `DefaultRealtimeObject`. ably-cocoa's plugin SPI - // (`LiveObjectsInternalPluginProtocol`) has **no dispose / channel-release callback** — the core - // SDK never notifies the plugin that a channel or client is being released. So there is no - // analogue to wire here: the per-channel `InternalDefaultRealtimeObjects` is stored as the - // channel's plugin-data value, and its teardown (`InternalDefaultRealtimeObjects.dispose()`) runs - // from its `deinit` when the channel releases that value (ARC). Landing an explicit, - // deterministic plugin-level dispose would need a new plugin-protocol method (a user-gated core - // change; DEV-38 precedent). + // dispose the per-channel `DefaultRealtimeObject`. ably-cocoa's plugin SPI now has a + // channel-release callback — `nosync_releaseChannel(_:)` (see below) — that the core SDK invokes + // from `-[ARTRealtimeChannels release:]`, mirroring `DefaultLiveObjectsPlugin.dispose(channelName)`. + // It disposes the channel's `InternalDefaultRealtimeObjects` with a release-specific cause, + // proactively failing any in-flight operation rather than waiting for the channel's eventual + // deallocation (which would fail waiters with a generic cause via `deinit`). // MARK: - Channel `objects` property @@ -75,9 +73,37 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. internalQueue: internalQueue, userCallbackQueue: callbackQueue, clock: DefaultSimpleClock(), + // DEV-20 / PAOM3b: bind the real channel name so public `ObjectMessage.channel` is populated + // (ably-java `DefaultRealtimeObject.channelName`, DefaultRealtimeObject.kt:35). The name is + // immutable, so reading it here (not on the internal queue) is safe. + channelName: pluginAPI.name(for: channel), garbageCollectionOptions: garbageCollectionOptions, ) pluginAPI.nosync_setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) + + // Seed the RTO20c1 siteCode from the latest connection details at engine creation, through the + // same `nosync_setSiteCode` path that the CONNECTED push (`nosync_onConnected`) uses. The core + // SDK's CONNECTED push (`ARTRealtime.m` `nosync_onConnectedWithConnectionDetails:`) only reaches + // channels that already exist at CONNECTED time; a channel created *after* connect (the normal + // `connect → channels.get(name)` flow) would otherwise never receive a siteCode, leaving + // `publishAndApply` unable to apply local echo (RTO20c1) until the next reconnect. ably-java has + // no such hole because it reads `connectionManager.siteCode` at publish time + // (DefaultRealtimeObject.kt:180); cocoa is push-based, so we seed here and let the CONNECTED push + // keep it fresh. A channel created before connect seeds nil and is covered by the later push. + liveObjects.nosync_setSiteCode(pluginAPI.nosync_latestConnectionDetails(for: client)?.siteCode) + } + + // DEV-47: the core SDK calls this from `-[ARTRealtimeChannels release:]` when a channel is + // released. Disposes the channel's objects engine with a release-specific cause, failing any + // in-flight operation proactively. Mirrors ably-java `DefaultLiveObjectsPlugin.dispose(channelName)` + // (DefaultLiveObjectsPlugin.kt:26). A channel may be released without ever having had its objects + // engine accessed, but `nosync_prepare` runs for every channel at creation, so the plugin data is + // present; we still guard defensively so a teardown race cannot trap. + internal func nosync_release(_ channel: _AblyPluginSupportPrivate.RealtimeChannel) { + guard pluginAPI.nosync_pluginDataValue(forKey: Self.pluginDataKey, channel: channel) != nil else { + return + } + nosync_realtimeObjects(for: channel).nosync_disposeForChannelRelease() } /// Retrieves the internally-typed `objects` property for the channel. diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 7164b68af..7bb808ce0 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -209,9 +209,27 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO deinit { // The full teardown (matrix #18): cancel the GC task, fail any in-flight sync waiters and - // drop all subscriptions. `dispose()` is idempotent, so an earlier explicit `dispose()` call - // makes this a no-op beyond the (already-idempotent) GC cancellation. - dispose() + // drop all subscriptions. `nosync_dispose` is idempotent, so an earlier explicit `dispose()` + // call makes this a no-op beyond the (already-idempotent) GC cancellation. + // + // deinit must NOT reuse the blocking `dispose()`: ARC may run this deinit *on* the internal + // queue (e.g. when the owning `ARTRealtimeChannel` is deallocated during client/channel + // teardown, which happens on that queue), and `dispose()`'s `withSync` asserts + // `.notOnQueue` (`ably_syncNoDeadlock`) — so a blocking teardown from deinit traps (SIGTRAP). + // Mirror ably-java's non-blocking `DefaultRealtimeObject.dispose` (which cancels via + // `sequentialScope.cancelChildren`, never sync-hopping its own queue): cancel the GC task + // (thread-safe from any thread), then hop the queue-confined cleanup onto the internal queue + // with `async`, capturing ONLY the mutex — never `self`, which is being deallocated. The + // async hop fails any detached-continuation waiters slightly later than an explicit + // `dispose()` would, but still fails (never drops) them. Extends DEV-47 (deinit-on-queue + // hazard is cocoa-specific; absent in GC'd Kotlin). Spec: matrix #18. + garbageCollectionTask.cancel() + let mutex = mutableStateMutex + mutex.dispatchQueue.async { + mutex.withoutSync { mutableState in + Self.nosync_dispose(&mutableState) + } + } } /// The channel objects engine's internal serial queue (Kotlin's `sequentialScope`). Shared out @@ -242,18 +260,53 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO /// whose only long-lived resource — the GC `Task` — is owned here and cancelled above. internal func dispose() { garbageCollectionTask.cancel() + // Off-queue callers (the explicit teardown, e.g. `PublicRealtimeObjectTests`) block on the + // internal queue via `withSync`; `deinit` uses a non-blocking `async` hop instead (see the + // `deinit` note above). Both run the same queue-confined `nosync_dispose`. mutableStateMutex.withSync { mutableState in - // Fail any pending publishAndApply / get() sync waiters (RTO20e1 path). - mutableState.nosync_drainPublishAndApplySyncWaiters( - outcome: .channelStateFailed(state: .failed, reason: nil), - ) - // Drop all path subscriptions. - mutableState.pathObjectSubscriptionRegister.nosync_dispose() - // Drop all status-event subscriptions. - mutableState.offAll() + Self.nosync_dispose(&mutableState) + } + } + + /// DEV-47: Disposes the engine in response to a channel release (`channels.release()`), failing any + /// in-flight sync waiters with a **release-specific** cause (a 40000 "Channel has been released" + /// error) rather than the generic `.channelStateFailed` reason used by `deinit`/`dispose()`. Called + /// on the internal queue by the core SDK's channel-release path (via the plugin's + /// `nosync_releaseChannel:` hook → `DefaultInternalPlugin`). Mirrors ably-java + /// `DefaultLiveObjectsPlugin.dispose(channelName)` → `DefaultRealtimeObject.dispose(cause)` + /// (DefaultLiveObjectsPlugin.kt:26, DefaultRealtimeObject.kt:319). Idempotent. + /// + /// Like `dispose()`, this keeps the instance usable (the sync state and pool are untouched), so a + /// later `get()` after a re-attach still works, matching Kotlin's scope-survives-dispose behaviour. + internal func nosync_disposeForChannelRelease() { + garbageCollectionTask.cancel() + let releaseCause = LiveObjectsError.channelReleased.toARTErrorInfo() + mutableStateMutex.withoutSync { mutableState in + Self.nosync_dispose(&mutableState, reason: releaseCause) } } + /// The queue-confined teardown shared by the blocking `dispose()` (off-queue caller, via + /// `withSync`), `deinit` (which hops onto the internal queue via `async` + `withoutSync`) and the + /// DEV-47 channel-release path (`nosync_disposeForChannelRelease`). Operates only on the passed-in + /// `mutableState`, so it never touches — or resurrects — `self`, which is essential when it runs + /// from `deinit`. Idempotent (a prior dispose leaves the waiter set, subscription register and + /// status emitter already drained). Spec: matrix #18. + /// + /// - Parameter reason: the cause attached to the RTO20e1 failure delivered to in-flight sync + /// waiters. `nil` (the default, used by `deinit`/`dispose()`) fails them with no specific cause; + /// the channel-release path passes a release-specific error (DEV-47). + private static func nosync_dispose(_ mutableState: inout MutableState, reason: ARTErrorInfo? = nil) { + // Fail any pending publishAndApply / get() sync waiters (RTO20e1 path). + mutableState.nosync_drainPublishAndApplySyncWaiters( + outcome: .channelStateFailed(state: .failed, reason: reason), + ) + // Drop all path subscriptions. + mutableState.pathObjectSubscriptionRegister.nosync_dispose() + // Drop all status-event subscriptions. + mutableState.offAll() + } + // MARK: - LiveMapObjectsPoolDelegate internal var nosync_objectsPool: ObjectsPool { @@ -582,22 +635,23 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO // MARK: - Sending `OBJECT` ProtocolMessage - /// The maximum allowed total size, in bytes, for a set of `ObjectMessage`s published in one go. - /// - /// TODO: This should be the connection's negotiated `maxMessageSize` (TO3l8 / - /// `ConnectionDetails.maxMessageSize`), but the core SDK does not currently expose that value to - /// plugins via `_AblyPluginSupportPrivate` (`ConnectionDetailsProtocol` only surfaces - /// `objectsGCGracePeriod` and `siteCode`). We therefore fall back to the Ably default of 65536 - /// bytes (`ARTDefault.maxMessageSize`), matching `Defaults.maxMessageSize` in ably-java. When the - /// plugin API exposes the connection's limit, source it from there instead. - /// See https://github.com/ably/ably-liveobjects-swift-plugin/issues/13. + /// The Ably default maximum message size, in bytes, used as a fallback when the connection has not + /// negotiated one. Mirrors `Defaults.maxMessageSize` in ably-java (and `ARTDefault.maxMessageSize`). private static let defaultMaxMessageSize = 65536 /// RTO15d: Validates that the total size of `objectMessages` (each calculated per OM3) does not /// exceed the connection's `maxMessageSize`. If it does, the publish is rejected with an /// `ErrorInfo` of `statusCode` 400 and `code` 40009. - private static func ensureMessageSizeWithinLimit(_ objectMessages: [ProtocolTypes.OutboundObjectMessage]) throws(ARTErrorInfo) { - let maximumAllowedSize = defaultMaxMessageSize + /// + /// The effective limit is the connection's negotiated `maxMessageSize` (DEV-23), read from the + /// latest `CONNECTED` `ProtocolMessage`'s `connectionDetails` via `CoreSDK.nosync_maxMessageSize`; + /// we fall back to ``defaultMaxMessageSize`` when the core SDK has no connection details yet or the + /// server did not send a limit. Mirrors ably-java `Helpers.kt:167` `ensureMessageSizeWithinLimit` + /// (`connectionManager.maxMessageSize`). + /// + /// Must be called on the internal queue (it reads the `nosync_` connection-details accessor). + private static func ensureMessageSizeWithinLimit(_ objectMessages: [ProtocolTypes.OutboundObjectMessage], coreSDK: CoreSDK) throws(ARTErrorInfo) { + let maximumAllowedSize = coreSDK.nosync_maxMessageSize ?? defaultMaxMessageSize let totalSize = objectMessages.reduce(0) { $0 + $1.size } if totalSize > maximumAllowedSize { throw LiveObjectsError.maxMessageSizeExceeded(size: totalSize, maxSize: maximumAllowedSize).toARTErrorInfo() @@ -606,10 +660,17 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. internal func testsOnly_publish(objectMessages: [ProtocolTypes.OutboundObjectMessage], coreSDK: CoreSDK) async throws(ARTErrorInfo) { - // RTO15d: reject the publish if the total ObjectMessage size exceeds maxMessageSize. - try Self.ensureMessageSizeWithinLimit(objectMessages) try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in mutableStateMutex.withSync { _ in + // RTO15d: reject the publish if the total ObjectMessage size exceeds maxMessageSize. + // The check reads the connection's negotiated limit (a `nosync_` accessor), so it must + // run on the internal queue. + do throws(ARTErrorInfo) { + try Self.ensureMessageSizeWithinLimit(objectMessages, coreSDK: coreSDK) + } catch { + continuation.resume(returning: .failure(error)) + return + } coreSDK.nosync_publish(objectMessages: objectMessages) { result in continuation.resume(returning: result.map { _ in }) } @@ -647,7 +708,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO // RTO15d: Reject the publish before contacting the core SDK if the total ObjectMessage size // exceeds maxMessageSize. do throws(ARTErrorInfo) { - try Self.ensureMessageSizeWithinLimit(objectMessages) + try Self.ensureMessageSizeWithinLimit(objectMessages, coreSDK: coreSDK) } catch { mutableStateMutex.withoutSync { mutableState in mutableStateCallback(&mutableState, .failure(error)) @@ -780,6 +841,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } } + internal var testsOnly_siteCode: String? { + mutableStateMutex.withSync { mutableState in + mutableState.siteCode + } + } + /// Sets the garbage collection grace period. /// /// Call this upon receiving a `CONNECTED` `ProtocolMessage`, per RTO10b2. @@ -835,9 +902,10 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO /// The name of the channel these objects belong to. Used to populate the `channel` field of /// the public ``ObjectMessage`` produced at emission (PAOM2e/PAOM3b). /// - /// - Note: `_AblyPluginSupportPrivate` currently exposes no channel-name accessor, so in - /// production this is presently `""` (see the `channelName` init parameter). The message is - /// not consumed by any public API until P3/P4; tests supply a real name directly. + /// In production this is bound to the real channel name at construction (DEV-20): the + /// `channelName` init parameter is supplied by `DefaultInternalPlugin.nosync_prepare` via the + /// `nameForChannel:` plugin bridge (ably-java `DefaultRealtimeObject.channelName`, + /// DefaultRealtimeObject.kt:35). internal var channelName: String = "" internal var onChannelAttachedHasObjects: Bool? diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift index 867f16c8f..ded0d9fe6 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift @@ -36,9 +36,10 @@ internal final class PublicDefaultRealtimeObject: RealtimeObject { // RTO23a — object_subscribe mode guard (currently a stub; see ChannelConfigGuards for the // plugin-API limitation). try ChannelConfigGuards.throwIfMissingObjectSubscribeMode(coreSDK: coreSDK, internalQueue: proxied.internalQueue) - // RTO23e / RTL33 — ensure the channel is usable (reject FAILED; implicit attach is not - // available through the plugin API — see ChannelConfigGuards.ensureActiveChannel). - try ChannelConfigGuards.ensureActiveChannel(coreSDK: coreSDK, internalQueue: proxied.internalQueue) + // RTO23e / RTL33 — ensure the channel is usable: RTL33a (already ATTACHED/SUSPENDED), + // RTL33b (implicit attach for INITIALIZED/DETACHED/DETACHING/ATTACHING, awaiting ATTACHED), + // RTL33c (reject FAILED). See ChannelConfigGuards.ensureActiveChannel. + try await ChannelConfigGuards.ensureActiveChannel(coreSDK: coreSDK, internalQueue: proxied.internalQueue) // RTO23c — wait for the initial sync to complete (atomic state-check + waiter registration, // 92008 if the channel leaves a usable state while waiting). try await proxied.ensureSynced() diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift index 5cc894e62..c0ac213d6 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift @@ -25,6 +25,13 @@ internal enum LiveObjectsError { /// RTO2a2/RTO2b2: The channel is missing a required channel mode (`object_subscribe` for reads, /// `object_publish` for writes). Code 40024. case channelModeRequired(mode: String) + /// RTO26: A write (mutation) operation was attempted while the client's `echoMessages` option is + /// disabled. Code 40000 (ably-java `ObjectErrorCode.BadRequest`). + case echoMessagesDisabled + /// DEV-47: The channel was released via `channels.release()`, so any in-flight objects operation is + /// failed with this as the cause. Code 40000 (ably-java `clientError`, + /// `DefaultLiveObjectsPlugin.dispose(channelName)`). + case channelReleased /// RTO/DEV-7: The LiveObjects plugin is not configured on the client. Code 40019. (Reserved for /// P5's plugin-missing decision; the code is landed here with the rest of the path-API error model.) case pluginUnavailable @@ -51,6 +58,8 @@ internal enum LiveObjectsError { case .pathNotResolved, .pathTypeMismatch, .channelModeRequired, + .echoMessagesDisabled, + .channelReleased, .pluginUnavailable, .invalidInput, .other: @@ -72,6 +81,10 @@ internal enum LiveObjectsError { 92007 // RTTS5d2/RTTS9d case .channelModeRequired: 40024 // RTO2a2/RTO2b2 + case .echoMessagesDisabled: + 40000 // RTO26 (ably-java ObjectErrorCode.BadRequest) + case .channelReleased: + 40000 // DEV-47 (ably-java clientError / ObjectErrorCode.BadRequest) case .pluginUnavailable: 40019 // DEV-7 case .invalidInput: @@ -98,6 +111,8 @@ internal enum LiveObjectsError { .pathNotResolved, .pathTypeMismatch, .channelModeRequired, + .echoMessagesDisabled, + .channelReleased, .pluginUnavailable, .invalidInput, .other: @@ -133,6 +148,12 @@ internal enum LiveObjectsError { case let .channelModeRequired(mode: mode): // RTO2a2/RTO2b2 "\"\(mode)\" channel mode must be set for this operation" + case .echoMessagesDisabled: + // RTO26 - matches ably-java's message verbatim (Helpers.kt:104) + "\"echoMessages\" client option must be enabled for this operation" + case .channelReleased: + // DEV-47 - matches ably-java's message verbatim (DefaultLiveObjectsPlugin.kt:27) + "Channel has been released using channels.release()" case .pluginUnavailable: // DEV-7 "The LiveObjects plugin is not configured on this client" @@ -157,6 +178,8 @@ internal enum LiveObjectsError { .pathNotResolved, .pathTypeMismatch, .channelModeRequired, + .echoMessagesDisabled, + .channelReleased, .pluginUnavailable, .invalidInput, .other: diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/DefaultInternalPluginTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/DefaultInternalPluginTests.swift new file mode 100644 index 000000000..e8ce98440 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/DefaultInternalPluginTests.swift @@ -0,0 +1,156 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +/// Tests for `DefaultInternalPlugin`'s engine setup, in particular the DEV-23-adjacent siteCode +/// seeding at channel preparation (Item 6). +struct DefaultInternalPluginTests { + // MARK: - Test doubles + + /// An empty marker conformer for `RealtimeChannel`. + final class StubChannel: NSObject, _AblyPluginSupportPrivate.RealtimeChannel {} + /// An empty marker conformer for `RealtimeClient`. + final class StubClient: NSObject, _AblyPluginSupportPrivate.RealtimeClient {} + /// An empty marker conformer for `Logger`. + final class StubLogger: NSObject, _AblyPluginSupportPrivate.Logger {} + + /// A minimal `ConnectionDetailsProtocol` stub carrying just the fields the plugin reads. + final class StubConnectionDetails: NSObject, _AblyPluginSupportPrivate.ConnectionDetailsProtocol, @unchecked Sendable { + let objectsGCGracePeriod: NSNumber? + let siteCode: String? + let maxMessageSize: Int + + init(siteCode: String?, objectsGCGracePeriod: NSNumber? = nil, maxMessageSize: Int = 0) { + self.siteCode = siteCode + self.objectsGCGracePeriod = objectsGCGracePeriod + self.maxMessageSize = maxMessageSize + } + } + + /// A minimal `PluginAPIProtocol` mock exercising only what `nosync_prepare` needs; the rest trap. + final class MockPluginAPI: NSObject, _AblyPluginSupportPrivate.PluginAPIProtocol, @unchecked Sendable { + let internalQueue: DispatchQueue + let callbackQueue: DispatchQueue + let clientOptions: ARTClientOptions + let connectionDetails: (any _AblyPluginSupportPrivate.ConnectionDetailsProtocol)? + let channelName: String + + private let lock = NSLock() + private var pluginData: [String: Any] = [:] + + init( + internalQueue: DispatchQueue, + callbackQueue: DispatchQueue, + clientOptions: ARTClientOptions = ARTClientOptions(), + connectionDetails: (any _AblyPluginSupportPrivate.ConnectionDetailsProtocol)?, + channelName: String = "test-channel" + ) { + self.internalQueue = internalQueue + self.callbackQueue = callbackQueue + self.clientOptions = clientOptions + self.connectionDetails = connectionDetails + self.channelName = channelName + } + + var usesLiveObjectsProtocolV6: Bool { true } + + func internalQueue(for _: any _AblyPluginSupportPrivate.RealtimeClient) -> DispatchQueue { internalQueue } + func callbackQueue(for _: any _AblyPluginSupportPrivate.RealtimeClient) -> DispatchQueue { callbackQueue } + func options(for _: any _AblyPluginSupportPrivate.RealtimeClient) -> any _AblyPluginSupportPrivate.PublicClientOptions { clientOptions.asPluginPublicClientOptions } + func logger(for _: any _AblyPluginSupportPrivate.RealtimeChannel) -> any _AblyPluginSupportPrivate.Logger { StubLogger() } + func name(for _: any _AblyPluginSupportPrivate.RealtimeChannel) -> String { channelName } + func nosync_latestConnectionDetails(for _: any _AblyPluginSupportPrivate.RealtimeClient) -> (any _AblyPluginSupportPrivate.ConnectionDetailsProtocol)? { connectionDetails } + + func nosync_setPluginDataValue(_ value: Any, forKey key: String, channel _: any _AblyPluginSupportPrivate.RealtimeChannel) { + lock.withLock { pluginData[key] = value } + } + + func nosync_pluginDataValue(forKey key: String, channel _: any _AblyPluginSupportPrivate.RealtimeChannel) -> Any? { + lock.withLock { pluginData[key] } + } + + func log(_: String, with _: _AblyPluginSupportPrivate.LogLevel, file _: UnsafePointer, line _: Int, logger _: any _AblyPluginSupportPrivate.Logger) { + // no-op + } + + // MARK: Unused by these tests + + func underlyingObjects(for _: any _AblyPluginSupportPrivate.PublicRealtimeChannel) -> any _AblyPluginSupportPrivate.PublicRealtimeChannelUnderlyingObjects { + fatalError("not used") + } + + func setPluginOptionsValue(_: Any, forKey _: String, clientOptions _: any _AblyPluginSupportPrivate.PublicClientOptions) { + fatalError("not used") + } + + func pluginOptionsValue(forKey _: String, clientOptions _: any _AblyPluginSupportPrivate.PublicClientOptions) -> Any? { + fatalError("not used") + } + + func nosync_sendObject(withObjectMessages _: [any _AblyPluginSupportPrivate.ObjectMessageProtocol], channel _: any _AblyPluginSupportPrivate.RealtimeChannel, completion _: (((any _AblyPluginSupportPrivate.PublishResultProtocol)?, (any _AblyPluginSupportPrivate.PublicErrorInfo)?) -> Void)?) { + fatalError("not used") + } + + func nosync_state(for _: any _AblyPluginSupportPrivate.RealtimeChannel) -> _AblyPluginSupportPrivate.RealtimeChannelState { + fatalError("not used") + } + + func nosync_objectChannelModes(for _: any _AblyPluginSupportPrivate.RealtimeChannel) -> _AblyPluginSupportPrivate.ChannelMode { + fatalError("not used") + } + + func nosync_connectionStateError(for _: any _AblyPluginSupportPrivate.RealtimeClient) -> (any _AblyPluginSupportPrivate.PublicErrorInfo)? { + fatalError("not used") + } + + func nosync_attach(_: any _AblyPluginSupportPrivate.RealtimeChannel, completion _: (((any _AblyPluginSupportPrivate.PublicErrorInfo)?) -> Void)?) { + fatalError("not used") + } + + func nosync_fetchServerTime(for _: any _AblyPluginSupportPrivate.RealtimeClient, completion _: ((Date?, (any _AblyPluginSupportPrivate.PublicErrorInfo)?) -> Void)?) { + fatalError("not used") + } + } + + // MARK: - siteCode seeding (Item 6) + + /// Prepares a channel and returns the engine the plugin stored for it. + private static func prepareEngine(connectionDetails: (any _AblyPluginSupportPrivate.ConnectionDetailsProtocol)?) -> InternalDefaultRealtimeObjects { + let internalQueue = DispatchQueue(label: "DefaultInternalPluginTests.internal") + let pluginAPI = MockPluginAPI( + internalQueue: internalQueue, + callbackQueue: .main, + connectionDetails: connectionDetails, + ) + let plugin = DefaultInternalPlugin(pluginAPI: pluginAPI) + let channel = StubChannel() + let client = StubClient() + + internalQueue.sync { + plugin.nosync_prepare(channel, client: client) + } + return internalQueue.sync { + DefaultInternalPlugin.nosync_realtimeObjects(for: channel, pluginAPI: pluginAPI) + } + } + + // When the latest connection details carry a siteCode, the engine is seeded with it at prepare time + // (so a channel created after CONNECTED — which never receives the CONNECTED push — can apply local + // echo per RTO20c1). DEV-23-adjacent (siteCode seeding hole). + @Test + func seedsSiteCodeFromConnectionDetailsAtPrepare() { + let engine = Self.prepareEngine(connectionDetails: StubConnectionDetails(siteCode: "site42")) + #expect(engine.testsOnly_siteCode == "site42") + } + + // When there are no connection details (channel created before CONNECTED), the seed is nil; the + // later CONNECTED push (nosync_onConnected) will supply the siteCode. The current skip-with-warning + // behaviour on publish is preserved. + @Test + func seedsNilSiteCodeWhenNoConnectionDetails() { + let engine = Self.prepareEngine(connectionDetails: nil) + #expect(engine.testsOnly_siteCode == nil) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/DefaultPathObjectTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/DefaultPathObjectTests.swift index 22689979b..0fd754344 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/DefaultPathObjectTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/DefaultPathObjectTests.swift @@ -323,6 +323,133 @@ struct DefaultPathObjectTests { } } + // MARK: - Channel-mode guards (RTO2a2 / RTO2b2, DEV-38) + + // @spec RTO2a2 - a read on a channel without the `object_subscribe` mode throws 40024 + @Test + func readWithoutObjectSubscribeModeThrows() throws { + let fixture = Self.makeFixture() + // Channel configured only with object_publish — missing object_subscribe. + fixture.coreSDK.setObjectChannelModes([.objectPublish]) + let root = Self.rootPathObject(fixture) + + let error = try #require(throws: ARTErrorInfo.self) { + _ = try root.exists() + } + #expect(error.code == 40024) + #expect(error.statusCode == 400) + #expect(error.message == "\"object_subscribe\" channel mode must be set for this operation") + } + + // @spec RTO2b2 - a write on a channel without the `object_publish` mode throws 40024 + @Test + func writeWithoutObjectPublishModeThrows() async throws { + let fixture = Self.makeFixture() + // Channel configured only with object_subscribe — missing object_publish. + fixture.coreSDK.setObjectChannelModes([.objectSubscribe]) + let root = Self.rootPathObject(fixture) + + let error = try await #require(throws: ARTErrorInfo.self) { + try await root.set(key: "k", value: .primitive(.string("v"))) + } + #expect(error.code == 40024) + #expect(error.message == "\"object_publish\" channel mode must be set for this operation") + } + + // @spec RTO2a2 - when no modes are configured at all, the access guard also throws 40024 + @Test + func readWithNoModesThrows() throws { + let fixture = Self.makeFixture() + fixture.coreSDK.setObjectChannelModes([]) + let root = Self.rootPathObject(fixture) + + let error = try #require(throws: ARTErrorInfo.self) { + _ = try root.get(key: "s").type() + } + #expect(error.code == 40024) + } + + // @spec RTO2a2 - the get() mode guard (throwIfMissingObjectSubscribeMode) throws 40024 when the + // `object_subscribe` mode is absent, and passes when present (no channel-state check). + @Test + func objectSubscribeModeGuardForGet() throws { + let internalQueue = TestFactories.createInternalQueue() + + // Present (even on a DETACHED channel — get() delegates state handling to ensure-active). + let withMode = MockCoreSDK(channelState: .detached, objectChannelModes: [.objectSubscribe], internalQueue: internalQueue) + #expect(throws: Never.self) { + try ChannelConfigGuards.throwIfMissingObjectSubscribeMode(coreSDK: withMode, internalQueue: internalQueue) + } + + // Absent: throws 40024. + let withoutMode = MockCoreSDK(channelState: .attached, objectChannelModes: [.objectPublish], internalQueue: internalQueue) + let error = try #require(throws: ARTErrorInfo.self) { + try ChannelConfigGuards.throwIfMissingObjectSubscribeMode(coreSDK: withoutMode, internalQueue: internalQueue) + } + #expect(error.code == 40024) + } + + // MARK: - echoMessages write guard (RTO26, DEV-38) + + // @spec RTO26 - a write with the `echoMessages` option disabled throws 40000 + @Test + func writeWithEchoMessagesDisabledThrows() async throws { + let fixture = Self.makeFixture() + fixture.coreSDK.setEchoMessages(false) + let root = Self.rootPathObject(fixture) + + let error = try await #require(throws: ARTErrorInfo.self) { + try await root.set(key: "k", value: .primitive(.string("v"))) + } + #expect(error.code == 40000) + #expect(error.statusCode == 400) + #expect(error.message == "\"echoMessages\" client option must be enabled for this operation") + } + + // The write guard checks echoMessages before channel state (ably-java Helpers.kt:64 order): with + // echo disabled AND the channel SUSPENDED, the echoMessages error (40000) is the one surfaced. + // @spec RTO26 + @Test + func writeGuardChecksEchoMessagesBeforeChannelState() async throws { + let fixture = Self.makeFixture(channelState: .suspended) + fixture.coreSDK.setEchoMessages(false) + let root = Self.rootPathObject(fixture) + + let error = try await #require(throws: ARTErrorInfo.self) { + try await root.set(key: "k", value: .primitive(.string("v"))) + } + #expect(error.code == 40000) + } + + // MARK: - throwIfUnpublishableState connection guard (RTO26, DEV-38) + + // @spec RTO26 - an inactive connection surfaces the connection's state error; the channel-state + // portion is checked only when the connection is active. + @Test + func unpublishableStateGuardSurfacesConnectionError() throws { + let internalQueue = TestFactories.createInternalQueue() + + // Active connection, usable channel: passes. + let active = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + #expect(throws: Never.self) { + try ChannelConfigGuards.throwIfUnpublishableState(coreSDK: active, internalQueue: internalQueue) + } + + // Inactive connection: the connection's own error is surfaced (before the channel-state check). + let connectionError = ARTErrorInfo.create(withCode: 80002, status: 400, message: "Connection is suspended") + let inactive = MockCoreSDK(channelState: .attached, connectionStateError: connectionError, internalQueue: internalQueue) + let error = try #require(throws: ARTErrorInfo.self) { + try ChannelConfigGuards.throwIfUnpublishableState(coreSDK: inactive, internalQueue: internalQueue) + } + #expect(error.code == 80002) + + // Active connection but FAILED channel: the channel-state check fires. + let failedChannel = MockCoreSDK(channelState: .failed, internalQueue: internalQueue) + #expect(throws: ARTErrorInfo.self) { + try ChannelConfigGuards.throwIfUnpublishableState(coreSDK: failedChannel, internalQueue: internalQueue) + } + } + // MARK: - Depth validation helper (DEV-9 / RTPO19c1a) // @spec RTPO19c1a - depth <= 0 is rejected with 40003; the check lives in ChannelConfigGuards for part 2 diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index a59a59aad..8a5e528f1 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -2279,4 +2279,44 @@ struct InternalDefaultRealtimeObjectsTests { #expect(try #require(poolAfter.entries["counter:child@2"]?.counterValue).testsOnly_data == 99) } } + + /// Teardown from `deinit` (matrix #18). + /// + /// Regression test for the deinit-on-internal-queue crash found by the objects UTS integration + /// suites (ably/ably-cocoa#2226): ARC can run the engine's `deinit` *on* the internal queue — + /// e.g. when the owning `ARTRealtimeChannel` is deallocated during client/channel teardown, + /// which happens on that queue. The previous blocking `deinit { dispose() }` then tripped + /// `ably_syncNoDeadlock`'s `.notOnQueue` precondition and crashed (SIGTRAP / EXC_BREAKPOINT). + /// `deinit` now hops the queue-confined cleanup asynchronously (never sync-blocking its own + /// queue), mirroring ably-java's non-blocking `DefaultRealtimeObject.dispose`. Extends DEV-47 + /// (the deinit-on-queue hazard is cocoa-specific; absent in GC'd Kotlin). + struct DisposeTests { + @Test + func deallocatingOnInternalQueueDoesNotCrash() async throws { + let internalQueue = TestFactories.createInternalQueue() + var objects: InternalDefaultRealtimeObjects? = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects( + internalQueue: internalQueue, + ) + // Sanity: it exists before we drop it (also keeps `objects` a genuine last strong ref). + #expect(objects != nil) + + // Release the last strong reference from *within* the internal queue, reproducing exactly + // what ARC does when the owning channel is torn down on that queue. Before the fix, the + // resulting `deinit` → `dispose()` → `withSync` trapped here. + internalQueue.sync { + objects = nil + } + + // Flush the queue so `deinit`'s asynchronous teardown hop has run, then confirm we reached + // here without crashing. (Waiter-draining semantics are unchanged — `deinit` runs the same + // `nosync_dispose` as the explicit `dispose()` path, covered by the dispose tests — so this + // test targets only the reproduction of the crash.) + await withCheckedContinuation { (continuation: CheckedContinuation) in + internalQueue.async { + continuation.resume() + } + } + #expect(objects == nil) + } + } } diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 3e6309d48..ffeefc8be 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -7,13 +7,75 @@ final class MockCoreSDK: CoreSDK { private let mutex = NSLock() private nonisolated(unsafe) var _publishHandler: (([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult)? private nonisolated(unsafe) var _publishCallbackHandler: (([ProtocolTypes.OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void)? + /// Custom handler for `nosync_attach` (RTL33b implicit attach). If unset, `nosync_attach` + /// simulates a successful attach that transitions the channel to ATTACHED. + private nonisolated(unsafe) var _attachHandler: ((@escaping @Sendable (ARTErrorInfo?) -> Void) -> Void)? private let channelStateMutex: DispatchQueueMutex<_AblyPluginSupportPrivate.RealtimeChannelState> private let serverTime: Date + let channelName: String - init(channelState: _AblyPluginSupportPrivate.RealtimeChannelState, serverTime: Date = .init(), internalQueue: DispatchQueue) { + /// The value returned by ``nosync_maxMessageSize`` (RTO15d). `nil` (the default) simulates a + /// connection with no negotiated limit, so the RTO15d gate falls back to the Ably default. + private nonisolated(unsafe) var _maxMessageSize: Int? + + /// The value returned by ``nosync_objectChannelModes`` (RTO2a2/RTO2b2). + private nonisolated(unsafe) var _objectChannelModes: _AblyPluginSupportPrivate.ChannelMode + /// The value returned by ``echoMessages`` (RTO26). + private nonisolated(unsafe) var _echoMessages: Bool + /// The value returned by ``nosync_connectionStateError`` (RTO26); `nil` = connection is active. + private nonisolated(unsafe) var _connectionStateError: ARTErrorInfo? + + init( + channelState: _AblyPluginSupportPrivate.RealtimeChannelState, + serverTime: Date = .init(), + maxMessageSize: Int? = nil, + channelName: String = "", + objectChannelModes: _AblyPluginSupportPrivate.ChannelMode = [.objectSubscribe, .objectPublish], + echoMessages: Bool = true, + connectionStateError: ARTErrorInfo? = nil, + internalQueue: DispatchQueue, + ) { channelStateMutex = DispatchQueueMutex(dispatchQueue: internalQueue, initialValue: channelState) self.serverTime = serverTime + _maxMessageSize = maxMessageSize + self.channelName = channelName + _objectChannelModes = objectChannelModes + _echoMessages = echoMessages + _connectionStateError = connectionStateError + } + + var nosync_objectChannelModes: _AblyPluginSupportPrivate.ChannelMode { + mutex.withLock { _objectChannelModes } + } + + func setObjectChannelModes(_ modes: _AblyPluginSupportPrivate.ChannelMode) { + mutex.withLock { _objectChannelModes = modes } + } + + var echoMessages: Bool { + mutex.withLock { _echoMessages } + } + + func setEchoMessages(_ enabled: Bool) { + mutex.withLock { _echoMessages = enabled } + } + + var nosync_connectionStateError: ARTErrorInfo? { + mutex.withLock { _connectionStateError } + } + + func setConnectionStateError(_ error: ARTErrorInfo?) { + mutex.withLock { _connectionStateError = error } + } + + var nosync_maxMessageSize: Int? { + mutex.withLock { _maxMessageSize } + } + + /// Sets the value returned by ``nosync_maxMessageSize`` (RTO15d). + func setMaxMessageSize(_ maxMessageSize: Int?) { + mutex.withLock { _maxMessageSize = maxMessageSize } } func nosync_publish(objectMessages: [ProtocolTypes.OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { @@ -50,6 +112,30 @@ final class MockCoreSDK: CoreSDK { channelStateMutex.withoutSync { $0 } } + func nosync_attach(callback: @escaping @Sendable (ARTErrorInfo?) -> Void) { + var handler: ((@escaping @Sendable (ARTErrorInfo?) -> Void) -> Void)? + mutex.withLock { handler = _attachHandler } + if let handler { + handler(callback) + } else { + // Default: simulate a successful attach that transitions the channel to ATTACHED + // (invoked on the internal queue by `ensureActiveChannel`, so `withoutSync` is valid). + channelStateMutex.withoutSync { $0 = .attached } + callback(nil) + } + } + + /// Sets a custom `nosync_attach` handler (RTL33b), e.g. to inject an attach failure or a state + /// transition. The handler is invoked on the internal queue and must call the supplied callback. + func setAttachHandler(_ handler: @escaping (@escaping @Sendable (ARTErrorInfo?) -> Void) -> Void) { + mutex.withLock { _attachHandler = handler } + } + + /// Overwrites the mock channel state (invoked on the internal queue). + func nosync_setChannelState(_ state: _AblyPluginSupportPrivate.RealtimeChannelState) { + channelStateMutex.withoutSync { $0 = state } + } + /// Sets a custom publish handler for testing. /// /// - Precondition: ``setPublishCallbackHandler(_:)`` must not have been called. diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/PathObjectSubscriptionTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/PathObjectSubscriptionTests.swift index a4a982feb..ae541c36f 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/PathObjectSubscriptionTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/PathObjectSubscriptionTests.swift @@ -134,6 +134,27 @@ struct PathObjectSubscriptionTests { #expect(event.message == message.toPublicObjectMessage(channelName: Self.channelName)) } + // DEV-20 / PAOM3b: the public `ObjectMessage.channel` reflects the real channel name that the + // engine was constructed with (in production, bound from the `nameForChannel:` plugin bridge in + // `DefaultInternalPlugin.nosync_prepare`) — no longer the former `""` placeholder. + // @spec PAOM3b + @Test + func deliveredMessageChannelReflectsRealChannelName() throws { + let fixture = Self.makeFixture() + let collector = EventCollector() + + let subscription = try Self.rootPath(fixture).get(key: "k").subscribe { collector.record($0) } + defer { subscription.unsubscribe() } + + let message = TestFactories.mapSetOperationMessage(objectId: ObjectsPool.rootKey, key: "k", value: "v", serial: "ts1", siteCode: "site1") + Self.applyAndDrain([message], fixture) + + let event = try #require(collector.events.first) + #expect(event.message?.channel == Self.channelName) + // Guards against a regression to the empty-string placeholder. + #expect(event.message?.channel.isEmpty == false) + } + // @spec RTO24b2a1 - a subscription at the updated object's own path is the most-preferred candidate @Test func subscribeOnRootReceivesObjectOwnPath() throws { diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/PublicRealtimeObjectTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/PublicRealtimeObjectTests.swift index ed6babb23..ea46c2f4c 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/PublicRealtimeObjectTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/PublicRealtimeObjectTests.swift @@ -133,6 +133,68 @@ struct PublicRealtimeObjectTests { } } + // @spec RTL33b - get() on a not-yet-attached channel implicitly attaches (RTL33b), then resolves + // after sync. Mirrors ably-java Helpers.kt `ensureAttached`/`attachAsync`. + @Test + func getImplicitlyAttachesNonAttachedChannelThenResolves() async throws { + let (publicObject, proxied, coreSDK, internalQueue) = Self.makePublicObject(channelState: .initialized) + + // Record that the RTL33b implicit attach is initiated (and resolve it successfully). + let attachCalled = CallCounter() + coreSDK.setAttachHandler { callback in + attachCalled.increment() + callback(nil) + } + + // get() should implicitly attach, then wait for sync. + async let getTask = publicObject.get() + _ = try #require(await proxied.testsOnly_waitingForSyncEvents.first { _ in true }) + + // The implicit attach was initiated (RTL33b) before the sync wait. + #expect(!attachCalled.isEmpty) + + // Completing the sync resolves the waiting get(). + Self.driveToSynced(proxied, on: internalQueue) + let root = try await getTask + #expect(root.path.isEmpty) + } + + // @spec RTL33a - get() on an already-ATTACHED channel performs no implicit attach. + @Test + func getOnAttachedChannelDoesNotImplicitlyAttach() async throws { + let (publicObject, proxied, coreSDK, internalQueue) = Self.makePublicObject(channelState: .attached) + + let attachCalled = CallCounter() + coreSDK.setAttachHandler { callback in + attachCalled.increment() + callback(nil) + } + + Self.driveToSynced(proxied, on: internalQueue) + _ = try await publicObject.get() + + // RTL33a: an ATTACHED channel is already usable, so no attach is performed. + #expect(attachCalled.isEmpty) + } + + // @spec RTL33b1 - a failed implicit attach propagates the attach error out of get(). + @Test + func getPropagatesImplicitAttachFailure() async throws { + let (publicObject, _, coreSDK, _) = Self.makePublicObject(channelState: .detached) + + let attachError = ARTErrorInfo.create(withCode: 90000, message: "attach failed") + coreSDK.setAttachHandler { callback in + callback(attachError) + } + + do { + _ = try await publicObject.get() + Issue.record("Expected get() to rethrow the implicit-attach failure (RTL33b1)") + } catch { + #expect((error as? ARTErrorInfo)?.code == 90000) + } + } + // MARK: - on(event:callback:) / StatusSubscription (RTO18) // @spec RTO18 - on(.synced) fires when the sync completes @@ -206,6 +268,41 @@ struct PublicRealtimeObjectTests { #expect(root.path.isEmpty) } + // DEV-47: a channel release (`channels.release()`, routed through the plugin's + // `nosync_releaseChannel:` hook → `nosync_disposeForChannelRelease`) fails a get() waiting for sync + // with 92008 whose *cause* is the release-specific 40000 error — distinct from `deinit`/`dispose()`, + // which fail waiters with no cause. The instance stays usable for a later get() (scope-survives). + @Test + func channelReleaseFailsWaitingGetWithReleaseCause() async throws { + let (publicObject, proxied, _, internalQueue) = Self.makePublicObject() + + // Start get() - it waits (not yet synced). + async let getTask = publicObject.get() + _ = try #require(await proxied.testsOnly_waitingForSyncEvents.first { _ in true }) + + // Simulate the core SDK's channel-release notification (on the internal queue, as the core does). + internalQueue.ably_syncNoDeadlock { + proxied.nosync_disposeForChannelRelease() + } + + do { + _ = try await getTask + Issue.record("Expected the in-flight get() to be failed by the channel release") + } catch { + let artError = try #require(error as? ARTErrorInfo) + #expect(artError.code == 92008) + // The release-specific cause distinguishes this from a plain dispose()/deinit teardown. + let cause = try #require(artError.cause) + #expect(cause.code == 40000) + #expect(cause.message == "Channel has been released using channels.release()") + } + + // The instance stays usable: complete a sync and a fresh get() resolves. + Self.driveToSynced(proxied, on: internalQueue) + let root = try await publicObject.get() + #expect(root.path.isEmpty) + } + // dispose() drops status subscriptions; a later on()/off() still works (instance usable) @Test func disposeDropsStatusSubscriptionsButOnStillWorks() async throws { diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectsUTSHelpers.swift b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectsUTSHelpers.swift index bdea222b9..0502242b7 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectsUTSHelpers.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/UTS/ObjectsUTSHelpers.swift @@ -49,6 +49,37 @@ final class ObjectsUTSCoreSDK: CoreSDK { var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { channelState } + + var channelName: String { + // The unit ports do not assert the public ObjectMessage channel name through this CoreSDK. + "" + } + + // The unit ports run against a fully-configured channel (the RTO2a2/RTO2b2/RTO26 guards must pass), + // matching the previously-stubbed always-pass behaviour. + var nosync_objectChannelModes: _AblyPluginSupportPrivate.ChannelMode { + [.objectSubscribe, .objectPublish] + } + + var echoMessages: Bool { + true + } + + var nosync_connectionStateError: ARTErrorInfo? { + nil + } + + var nosync_maxMessageSize: Int? { + // The unit ports do not publish through this CoreSDK, so the RTO15d gate is never consulted; + // nil falls back to the Ably default if it ever were. + nil + } + + func nosync_attach(callback: @escaping @Sendable (ARTErrorInfo?) -> Void) { + // The unit ports drive get() on an ATTACHED channel (RTL33a), so the RTL33b implicit attach + // is never reached; succeed for completeness. + callback(nil) + } } // MARK: - RealtimeObjects diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/WireObjectMessageSizeTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/WireObjectMessageSizeTests.swift index 19f4e936e..7d686c903 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/WireObjectMessageSizeTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/WireObjectMessageSizeTests.swift @@ -244,4 +244,48 @@ struct WireObjectMessageSizeTests { try await realtimeObjects.testsOnly_publish(objectMessages: [message], coreSDK: coreSDK) } + + // DEV-23 / RTO15d: the gate reads the connection's negotiated `maxMessageSize` (from the latest + // CONNECTED ProtocolMessage's connectionDetails, via `CoreSDK.nosync_maxMessageSize`). A message + // within the 65536 default but above the smaller negotiated limit is rejected against that limit. + // @spec RTO15d + @Test + func publishUsesConnectionMaxMessageSize() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = Self.createRealtimeObjects(internalQueue: internalQueue) + // Negotiated limit of 2 KiB, well below the 65536 default. + let coreSDK = MockCoreSDK(channelState: .attached, maxMessageSize: 2 * 1024, internalQueue: internalQueue) + + // 3 KiB: within the default fallback but over the negotiated 2 KiB limit. + let message = ProtocolTypes.OutboundObjectMessage(clientId: String(repeating: "a", count: 3 * 1024)) + #expect(message.size == 3 * 1024) + + let error = try await #require(throws: ARTErrorInfo.self) { + try await realtimeObjects.testsOnly_publish(objectMessages: [message], coreSDK: coreSDK) + } + + #expect(error.code == 40009) + #expect(error.statusCode == 400) + #expect(error.message == "ObjectMessages size 3072 exceeds maximum allowed size of 2048 bytes") + } + + // DEV-23 / RTO15d: when the core SDK exposes no negotiated `maxMessageSize` (nil — no connection + // details, or the server sent no limit), the gate falls back to the 65536 Ably default, so a + // message that fits the default is accepted. + // @spec RTO15d + @Test + func publishFallsBackToDefaultMaxMessageSizeWhenUnset() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = Self.createRealtimeObjects(internalQueue: internalQueue) + // nil maxMessageSize → fall back to the 65536 default. + let coreSDK = MockCoreSDK(channelState: .attached, maxMessageSize: nil, internalQueue: internalQueue) + coreSDK.setPublishHandler { messages in + PublishResult(serials: messages.map { _ in "serial" }) + } + + // 60 KiB: over any smaller negotiated limit but within the 65536 default. + let message = ProtocolTypes.OutboundObjectMessage(clientId: String(repeating: "a", count: 60 * 1024)) + + try await realtimeObjects.testsOnly_publish(objectMessages: [message], coreSDK: coreSDK) + } } diff --git a/Source/ARTPluginAPI.m b/Source/ARTPluginAPI.m index 85a39c474..ec728b0eb 100644 --- a/Source/ARTPluginAPI.m +++ b/Source/ARTPluginAPI.m @@ -10,6 +10,8 @@ #import "ARTConnectionDetails+Private.h" #import "ARTPublishResult+Private.h" #import "ARTPublishResultSerial+Private.h" +#import "ARTConnection+Private.h" +#import "ARTRealtimeChannelOptions.h" static ARTErrorInfo *_ourPublicErrorInfo(id pluginPublicErrorInfo) { if (![pluginPublicErrorInfo isKindOfClass:[ARTErrorInfo class]]) { @@ -135,6 +137,14 @@ - (nullable id)nosync_pluginDataValueForKey:(nonnull NSString *)key return _internalRealtimeChannel(channel).logger; } +- (NSString *)nameForChannel:(id)channel { + // ably-java binds the channel name via `AblyClientAdapter.getChannelName` / + // `DefaultRealtimeObject.channelName` (DefaultRealtimeObject.kt:35), threaded into + // `WireObjectMessage.toPublicMessage(channelName)` (message/DefaultObjectMessage.kt:17). The name is + // immutable (set at channel creation), so no queue assertion is needed. + return _internalRealtimeChannel(channel).name; +} + - (void)setPluginOptionsValue:(id)value forKey:(NSString *)key clientOptions:(id)options { [_ourPublicClientOptions(options) setPluginOptionsValue:value forKey:key]; } @@ -176,6 +186,65 @@ - (APRealtimeChannelState)nosync_stateForChannel:(id)channel return ARTConvertToPluginChannelState(internalChannel.state_nosync); } +- (APChannelMode)nosync_objectChannelModesForChannel:(id)channel { + ARTRealtimeChannelInternal *internalChannel = _internalRealtimeChannel(channel); + dispatch_assert_queue(internalChannel.queue); + + // RTO2a: use the attached modes if present (non-empty); RTO2b: otherwise fall back to the + // user-provided channel-options modes. Mirrors ably-java `Helpers.kt:76` `getChannelModes`. + ARTChannelMode effectiveModes = internalChannel.modes_nosync; + if (effectiveModes == 0) { + ARTRealtimeChannelOptions *options = internalChannel.options_nosync; + if (options) { + effectiveModes = options.modes; + } + } + + APChannelMode result = 0; + if (effectiveModes & ARTChannelModeObjectSubscribe) { + result |= APChannelModeObjectSubscribe; + } + if (effectiveModes & ARTChannelModeObjectPublish) { + result |= APChannelModeObjectPublish; + } + return result; +} + +- (id)nosync_connectionStateErrorForClient:(id)client { + ARTRealtimeInternal *internalRealtimeClient = _internalRealtimeClient(client); + dispatch_assert_queue(internalRealtimeClient.queue); + + // ably-java `Helpers.kt:110-114`: `if (!connectionManager.isActive) throw ablyException(stateErrorInfo)`. + ARTConnectionInternal *connection = internalRealtimeClient.connection; + if ([connection isActive_nosync]) { + return nil; + } + + ARTErrorInfo *errorReason = [connection errorReason_nosync]; + if (errorReason) { + return errorReason; + } + // The connection is not active but carries no error reason (e.g. INITIALIZED/CONNECTING before any + // failure). Synthesise a generic disconnected error so the caller still receives an `ErrorInfo`. + return [ARTErrorInfo createWithCode:ARTErrorConnectionSuspended + status:400 + message:@"Connection is not in an active state"]; +} + +- (void)nosync_attachChannel:(id)channel + completion:(void (^)(id _Nullable error))completion { + ARTRealtimeChannelInternal *internalChannel = _internalRealtimeChannel(channel); + dispatch_assert_queue(internalChannel.queue); + + // `-_attach:` is the internal (already-on-queue) form of the public `-[ARTRealtimeChannel attach:]`; + // it invokes its callback on the channel's internal queue when the attach resolves. + [internalChannel _attach:^(ARTErrorInfo *_Nullable error) { + if (completion) { + completion(error); + } + }]; +} + - (void)nosync_fetchServerTimeForClient:(id)client completion:(void (^)(NSDate * _Nullable, id _Nullable))completion { ARTRealtimeInternal *internalRealtimeClient = _internalRealtimeClient(client); diff --git a/Source/ARTRealtimeChannels.m b/Source/ARTRealtimeChannels.m index cab70f67a..61c315d59 100644 --- a/Source/ARTRealtimeChannels.m +++ b/Source/ARTRealtimeChannels.m @@ -3,6 +3,7 @@ #import "ARTChannels+Private.h" #import "ARTRealtimeChannel+Private.h" #import "ARTRealtime+Private.h" +#import "ARTClientOptions+Private.h" #import "ARTRealtimePresence+Private.h" #import "ARTClientOptions+TestConfiguration.h" #import "ARTTestClientOptions.h" @@ -126,6 +127,15 @@ - (void)release:(NSString *)name callback:(ARTCallback)cb { // one and the second call's detach callback is called, can be // released unwillingly. if ([self->_channels _exists:name] && [self->_channels _get:name] == channel) { +#ifdef ABLY_SUPPORTS_PLUGINS + // Notify the LiveObjects plugin (if any) that this channel is being released, so it can + // dispose of its per-channel resources and fail any in-flight operations with a + // release-specific cause rather than waiting for the channel's eventual deallocation. This + // callback runs on the internal queue (the `_detach:` callback queue). Mirrors ably-java's + // `DefaultLiveObjectsPlugin.dispose(channelName)` (DefaultLiveObjectsPlugin.kt:26). + [self.realtime.options.liveObjectsPlugin nosync_releaseChannel:channel]; +#endif + [self->_channels _release:name]; } diff --git a/Test/AblyTests/Tests/PluginAPITests.swift b/Test/AblyTests/Tests/PluginAPITests.swift index 598075d53..a5efd4d74 100644 --- a/Test/AblyTests/Tests/PluginAPITests.swift +++ b/Test/AblyTests/Tests/PluginAPITests.swift @@ -22,12 +22,14 @@ class PluginAPITests: XCTestCase { internal var receivedConnectionDetails: [(connectionDetails: (any ConnectionDetailsProtocol)?, channel: any RealtimeChannel)] = [] internal var receivedChannelAttached: [(channel: any RealtimeChannel, hasObjects: Bool)] = [] internal var receivedStateChanges: [(channel: any RealtimeChannel, state: RealtimeChannelState, reason: (any PublicErrorInfo)?)] = [] + internal var receivedChannelReleased: [any RealtimeChannel] = [] /// Resets all recorded mock state. Called in `setUp()` since this is a shared static instance. func clearState() { receivedConnectionDetails.removeAll() receivedChannelAttached.removeAll() receivedStateChanges.removeAll() + receivedChannelReleased.removeAll() } var compatibleWithProtocolV6: Bool { true } @@ -36,6 +38,10 @@ class PluginAPITests: XCTestCase { // no-op } + func nosync_release(_ channel: any RealtimeChannel) { + receivedChannelReleased.append(channel) + } + func decodeObjectMessage(_ serialized: [String : Any], context: any DecodingContextProtocol, format: EncodingFormat, error: AutoreleasingUnsafeMutablePointer<(any PublicErrorInfo)?>?) -> (any ObjectMessageProtocol)? { return MockObjectMessage() } diff --git a/_AblyPluginSupportPrivate/include/APConnectionDetails.h b/_AblyPluginSupportPrivate/include/APConnectionDetails.h index e45056ec9..e193f30ea 100644 --- a/_AblyPluginSupportPrivate/include/APConnectionDetails.h +++ b/_AblyPluginSupportPrivate/include/APConnectionDetails.h @@ -10,6 +10,12 @@ NS_SWIFT_SENDABLE /// Wraps an `NSTimeInterval` containing the `objectsGCGracePeriod`, if any, in seconds. @property (nonatomic, readonly, nullable) NSNumber *objectsGCGracePeriod; +/// The maximum message size allowed by the Ably account this connection is using (CD2c). A value of +/// `0` indicates that the server did not send a limit, in which case the plugin should fall back to +/// the Ably default. Used by the RTO15d publish-size gate (see ably-java `Helpers.kt:168`, +/// `connectionManager.maxMessageSize`). +@property (nonatomic, readonly) NSInteger maxMessageSize; + /// The site code of the server that the client is connected to (CD2j). /// /// May be absent if the server does not provide it. diff --git a/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h b/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h index d0db6082d..86122065e 100644 --- a/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h +++ b/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h @@ -48,6 +48,13 @@ NS_SWIFT_SENDABLE /// The plugin can use this as an opportunity to perform any initial setup of LiveObjects functionality for this channel. - (void)nosync_prepareChannel:(id)channel client:(id)client; +/// ably-cocoa will call this method when a channel is released via `-[ARTRealtimeChannels release:]`. +/// +/// The plugin can use this as an opportunity to dispose of any LiveObjects resources it set up for the +/// channel in `-nosync_prepareChannel:client:`, and to fail any in-flight operations with a +/// release-specific cause (rather than relying on the channel's eventual deallocation). +- (void)nosync_releaseChannel:(id)channel; + /// Decodes an `ObjectMessage` received over the wire. /// /// Parameters: diff --git a/_AblyPluginSupportPrivate/include/APPluginAPI.h b/_AblyPluginSupportPrivate/include/APPluginAPI.h index 9e64bcf46..ab90397f9 100644 --- a/_AblyPluginSupportPrivate/include/APPluginAPI.h +++ b/_AblyPluginSupportPrivate/include/APPluginAPI.h @@ -3,6 +3,14 @@ NS_ASSUME_NONNULL_BEGIN +/// A copy of the object-related subset of ably-cocoa's `ARTChannelMode`. The raw values match +/// `ARTChannelMode` (`ARTChannelModeObjectSubscribe`/`ARTChannelModeObjectPublish`) so the two can be +/// bridged bit-for-bit. Used by the RTO2a2/RTO2b2 channel-mode guards of the LiveObjects plugin. +typedef NS_OPTIONS(NSUInteger, APChannelMode) { + APChannelModeObjectSubscribe = 1 << 24, + APChannelModeObjectPublish = 1 << 25, +} NS_SWIFT_NAME(ChannelMode); + @protocol APLogger; @protocol APObjectMessageProtocol; @protocol APRealtimeChannel; @@ -67,6 +75,13 @@ NS_SWIFT_SENDABLE /// - Parameter channel: The channel whose logger the returned logger should wrap. - (id)loggerForChannel:(id)channel; +/// The name of a realtime channel. +/// +/// Used by the LiveObjects plugin to populate the `channel` field of a public `ObjectMessage` +/// (PAOM2e/PAOM3b). The channel name is immutable (set at channel creation), so this may be read on +/// any queue. +- (NSString *)nameForChannel:(id)channel; + /// Provides plugins with the queue on which all user callbacks for a given client should be called. - (dispatch_queue_t)callbackQueueForClient:(id)client; @@ -96,6 +111,33 @@ NS_SWIFT_SENDABLE /// Returns a realtime channel's current state. - (APRealtimeChannelState)nosync_stateForChannel:(id)channel; +/// The channel's effective object-related channel modes, resolved per RTO2a/RTO2b: the attached modes +/// if present (RTO2a), otherwise the user-provided channel-options modes (RTO2b). Only the +/// `ObjectSubscribe`/`ObjectPublish` bits are reported. Used by the RTO2a2/RTO2b2 channel-mode guards. +/// +/// Mirrors ably-java's `Helpers.kt` `getChannelModes` (Helpers.kt:76). +- (APChannelMode)nosync_objectChannelModesForChannel:(id)channel; + +/// The error that makes the client's connection unpublishable (RTO26; ably-java +/// `connectionManager.isActive` / `stateErrorInfo`, Helpers.kt:110-114), or `nil` if the connection is +/// in a state from which messages can be published. When not active, returns the connection's current +/// error reason if it has one. +- (nullable id)nosync_connectionStateErrorForClient:(id)client; + +/// Initiates an attach on a realtime channel, per the RTL33b implicit-attach used by a plugin's +/// *ensure-active-channel* procedure (RTL33 / RTO23e). This is a thin bridge onto the channel's +/// existing attach (equivalent to the public `-[ARTRealtimeChannel attach:]`): if the channel is +/// already attached (or an attach/detach is in flight) it resolves per RTL4h without starting a +/// redundant attach. +/// +/// The completion handler is called with a `nil` error on success, or the `ErrorInfo` that caused +/// the attach to fail (RTL33b1). +/// +/// The completion handler will be called on the client's internal queue (see +/// `-internalQueueForClient:`). +- (void)nosync_attachChannel:(id)channel + completion:(void (^ _Nullable)(_Nullable id error))completion; + /// Fetches the Ably server time from the REST API, per RTO16. /// /// Per RTO16a, if the client knows the local clock's offset from the server time, then the server time will be calculated without making a request.