From e6fa32e05004d572d3016e8914c2c06afb447621 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 9 Jul 2026 23:08:07 -0300 Subject: [PATCH 01/21] Add script for importing another repo's contents Adds Scripts/import-repo-contents.sh, which extracts the tracked files of another git repository at a given commit into a directory of this repository. This will be used by upcoming commits that import the contents of the ably-cocoa-plugin-support and ably-liveobjects-swift-plugin repos into this repo (see AIT-1027). Putting the import behind a script keeps those commits reproducible: to swap in a newer version of an imported repo, drop the import commit, rerun the script at the new SHA, and recommit. Co-Authored-By: Claude Fable 5 --- Scripts/import-repo-contents.sh | 38 +++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) create mode 100755 Scripts/import-repo-contents.sh diff --git a/Scripts/import-repo-contents.sh b/Scripts/import-repo-contents.sh new file mode 100755 index 000000000..43cf1aff3 --- /dev/null +++ b/Scripts/import-repo-contents.sh @@ -0,0 +1,38 @@ +#!/usr/bin/env bash + +# Imports the contents of another git repository, at a given commit, into a +# directory of this repository. +# +# Usage: Scripts/import-repo-contents.sh +# +# Only files tracked by git at the given commit are imported (via +# `git archive`); submodule paths come across as empty directories, not +# content. The commit SHA must be a full SHA (fetching an abbreviated SHA is +# not supported by all servers). +# +# This exists so that "import the contents of repo X at commit Y" commits are +# reproducible: to swap in a newer version of the imported repo, drop the +# import commit, rerun this script at the new SHA, and recommit. + +set -euo pipefail + +if [ "$#" -ne 3 ]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +url=$1 +sha=$2 +dest=$3 + +tmp=$(mktemp -d) +trap 'rm -rf "$tmp"' EXIT + +git init --quiet "$tmp" +git -C "$tmp" remote add origin "$url" +git -C "$tmp" fetch --quiet --depth 1 origin "$sha" + +mkdir -p "$dest" +git -C "$tmp" archive "$sha" | tar -x -C "$dest" + +echo "Imported $url @ $sha into $dest" From 902c8d502d65f2c0f42623ff2407cde2469f93ae Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 9 Jul 2026 23:08:31 -0300 Subject: [PATCH 02/21] Import ably-cocoa-plugin-support at a290b89 This is the verbatim contents of the ably-cocoa-plugin-support repo [1] at commit a290b89, imported by running: Scripts/import-repo-contents.sh \ https://github.com/ably/ably-cocoa-plugin-support.git \ a290b8942086ffb6e21e4805d9319143669d9414 \ PluginSupport No modifications have been made to the imported files; subsequent commits will integrate them into this repo. This is part of merging the plugin-support and LiveObjects repos into ably-cocoa (AIT-1027), so that there is a single repository for ably-cocoa and its plugins. The _AblyPluginSupportPrivate library, which exposes ably-cocoa private API to Ably-authored plugins, only needed to live in a separate repo while its consumers (ably-cocoa and the LiveObjects plugin) lived in separate repos; it now becomes an implementation detail of this repo. [1] https://github.com/ably/ably-cocoa-plugin-support Co-Authored-By: Claude Fable 5 --- PluginSupport/.gitignore | 11 ++ PluginSupport/COPYRIGHT | 1 + PluginSupport/LICENSE | 176 ++++++++++++++++++ PluginSupport/Package.swift | 22 +++ PluginSupport/README.md | 136 ++++++++++++++ .../APDependencyStore.m | 35 ++++ .../include/APConnectionDetails.h | 20 ++ .../include/APDecodingContext.h | 24 +++ .../include/APDependencyStore.h | 43 +++++ .../include/APEncodingFormat.h | 16 ++ .../include/APLiveObjectsPlugin.h | 130 +++++++++++++ .../include/APLogLevel.h | 9 + .../include/APLogger.h | 11 ++ .../include/APPluginAPI.h | 119 ++++++++++++ .../include/APPublicClientOptions.h | 16 ++ .../include/APPublicErrorInfo.h | 16 ++ .../include/APPublicRealtimeChannel.h | 16 ++ ...APPublicRealtimeChannelUnderlyingObjects.h | 18 ++ .../include/APPublishResult.h | 25 +++ .../include/APRealtimeChannel.h | 15 ++ .../include/APRealtimeChannelState.h | 10 + .../include/APRealtimeClient.h | 15 ++ 22 files changed, 884 insertions(+) create mode 100644 PluginSupport/.gitignore create mode 100644 PluginSupport/COPYRIGHT create mode 100644 PluginSupport/LICENSE create mode 100644 PluginSupport/Package.swift create mode 100644 PluginSupport/README.md create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/APDependencyStore.m create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLogger.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h create mode 100644 PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h diff --git a/PluginSupport/.gitignore b/PluginSupport/.gitignore new file mode 100644 index 000000000..f761f995d --- /dev/null +++ b/PluginSupport/.gitignore @@ -0,0 +1,11 @@ +# Start of .gitignore created by Swift Package Manager +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +docs-coverage-report +# End of .gitignore created by Swift Package Manager diff --git a/PluginSupport/COPYRIGHT b/PluginSupport/COPYRIGHT new file mode 100644 index 000000000..165bec7ac --- /dev/null +++ b/PluginSupport/COPYRIGHT @@ -0,0 +1 @@ +Copyright 2025 Ably Real-time Ltd (ably.com) diff --git a/PluginSupport/LICENSE b/PluginSupport/LICENSE new file mode 100644 index 000000000..d9a10c0d8 --- /dev/null +++ b/PluginSupport/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/PluginSupport/Package.swift b/PluginSupport/Package.swift new file mode 100644 index 000000000..787d8e52c --- /dev/null +++ b/PluginSupport/Package.swift @@ -0,0 +1,22 @@ +// swift-tools-version:5.3.0 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +// The swift-tools-version should not be increased above that of any of the packages that depend on this package (at the time of writing that is ably-cocoa and the LiveObjects plugin), so as not to inadvertently increase the required tooling version of those packages. + +import PackageDescription + +let package = Package( + name: "ably-cocoa-plugin-support", + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "_AblyPluginSupportPrivate", + targets: ["_AblyPluginSupportPrivate"]), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "_AblyPluginSupportPrivate"), + ] +) diff --git a/PluginSupport/README.md b/PluginSupport/README.md new file mode 100644 index 000000000..91559f098 --- /dev/null +++ b/PluginSupport/README.md @@ -0,0 +1,136 @@ +# Ably Cocoa SDK Plugin Support Library + +This is an Ably-internal library that defines the private APIs that the [Ably Pub/Sub Cocoa SDK](https://github.com/ably/ably-cocoa) uses to communicate with its plugins. + +> [!note] +> This library is an implementation detail of the Ably SDK and should not be used directly by end users of the SDK. + +Further documentation will be added in the future. + +## Conventions + +- Methods whose names begin with `nosync_` must always be called on the client's internal queue. + +## Dependency setup + +The intention is that: + +- ably-cocoa's `Package.swift` declares a dependency on ably-cocoa-plugin-support +- each plugin's `Package.swift` declares a dependency on ably-cocoa-plugin-support +- each plugin's `Package.swift` also declares a direct dependency on ably-cocoa + +This means that: + +- a given version of the plugin is able to specify a _minimum_ version of ably-cocoa +- a given version of ably-cocoa is expected to work with all plugin versions whose minimum it satisfies — i.e. ably-cocoa is _not_ able to specify a minimum version of a plugin + +The only way that ably-cocoa can exclude certain plugin versions is by creating a clash in the major version of the ably-cocoa-plugin-support dependency; we avoid using this mechanism where possible — as it can cause confusing dependency resolution error messages for users and complicates the release process — but reserve it, for example, when [introducing breaking Realtime protocol changes](#breaking-realtime-protocol-version-changes). + +## Playbook for making changes + +This section describes how to make some common changes that require changes to all three repositories (ably-cocoa, the plugin, and this repo). + +### Adding new capabilities + +In this section we describe how to make _non-breaking plugin-support changes_ (i.e. a minor plugin-support version bump) that either introduce functionality only available in a new version of ably-cocoa, or introduce functionality only available in a new version of the plugin. + +The key point is that both of the following are breaking plugin-support changes and thus must be avoided: + +- adding a new required method to an Objective-C protocol (breaks implementers of that protocol) +- removing a required method from an Objective-C protocol (breaks consumers of that protocol) + +#### Introducing new functionality only available in a new version of ably-cocoa + +1. Add a new `@optional` method or property. +2. Increase the plugin's minimum ably-cocoa version so that this method is guaranteed to be implemented, and perform a runtime precondition failure if it is not. + +> [!NOTE] +> Avoid introducing new `@optional` properties with a nullable type, since in this case Swift does not provide a mechanism for differentiating between "property not implemented" and "property implemented and its value is `nil`". Use a getter method in this situation instead. + +#### Introducing new functionality only available in a new version of the plugin + +1. Add a new `@optional` method or property. +2. Perform a `-respondsToSelector:` check in ably-cocoa to decide whether this new API can be called. ably-cocoa **must** still behave correctly in the case where it cannot (i.e. where an old version of the plugin is being used). + +### Removing capabilities + +In this section we describe how to make _non-breaking plugin-support changes_ (i.e. a minor plugin-support version bump) that either remove functionality from ably-cocoa, or remove functionality from the plugin. + +This also covers the case where, in combination with [adding a new capability](#adding-new-capabilities), we are replacing a method with a new variant. + +The key point is that it is a breaking plugin-support change to remove a method from an Objective-C protocol (breaks consumers of that protocol) and this must thus be avoided. + +#### Removing functionality from the plugin + +Since the plugin can exclude older versions of ably-cocoa, the plugin is able to choose to throw a runtime exception in methods that it no longer wishes to implement. This should be combined with increasing the plugin's minimum ably-cocoa version to one that no longer calls the method, so that the exception is never triggered in practice. + +#### Removing functionality from ably-cocoa + +Since new versions of ably-cocoa can be used with old versions of the plugin, ably-cocoa must continue to provide functioning implementations of all methods. + +### Breaking Realtime protocol version changes + +In this section we describe how to make changes that require incrementing the [CSV2](https://sdk.ably.com/builds/ably/specification/main/features/#CSV2) protocol version number. + +The key outcome that we wish to achieve is the following: + +- a given version of ably-cocoa only needs to support a single protocol version +- a given version of the plugin only needs to support a single protocol version + +— that is, we do not wish to try and implement negotiation of protocol version between ably-cocoa and the plugin. + +In order to do this, whenever we increment the CSV2 protocol number in such a way as could cause incompatibility with a plugin, this repo (plugin-support) will make a **breaking API change**, and thus **bump its major version**. + +#### Properties to replace + +The aforementioned breaking plugin-support change is implemented by replacing the two required protocol properties shown below with equivalents that refer to the new protocol version (e.g. when moving from v6 to v7, rename `usesLiveObjectsProtocolV6` to `usesLiveObjectsProtocolV7`, and similarly rename `compatibleWithProtocolV6` to `compatibleWithProtocolV7`). Note that only one property is needed in order to create a breaking API change, but we provide both so that both parties can check the other's conformance even if package dependencies are not configured correctly. + +The current properties are: + +In `APPluginAPIProtocol`: + +```objc +/// Whether the SDK uses a protocol version which, as far as a LiveObjects +/// plugin is concerned, is equivalent to protocol v6. +/// +/// If the SDK uses a protocol version which is higher than 6 but whose +/// breaking changes compared to v6 do not affect LiveObjects plugins, this +/// property may still return `YES`. +/// +/// This property **must** return `YES`. If you wish to introduce a protocol +/// version change, see the "Breaking Realtime protocol version changes" +/// section in the Readme. +@property (nonatomic, readonly) BOOL usesLiveObjectsProtocolV6; +``` + +> [!NOTE] +> The plugin should perform a runtime assertion that this property returns `YES`, to catch any scenario where package dependencies are incorrectly configured. + +In `APLiveObjectsInternalPluginProtocol`: + +```objc +/// Whether this plugin is compatible with protocol v6. +/// +/// If this property returns `YES`, it indicates that this plugin can be used +/// with a version of ably-cocoa which uses protocol v6, or which uses a +/// protocol version higher than v6 whose breaking changes compared to v6 do +/// not affect LiveObjects plugins. +/// +/// This property **must** return `YES`. If you wish to introduce a protocol +/// version change, see the "Breaking Realtime protocol version changes" +/// section in the Readme. +@property (nonatomic, readonly) BOOL compatibleWithProtocolV6; +``` + +> [!NOTE] +> ably-cocoa should perform a runtime assertion that this property returns `YES`, to catch any scenario where package dependencies are incorrectly configured. + +## Release process for breaking plugin-support changes + +The release process in such a scenario is not very smooth; there will be a brief period in which there exists a version of ably-cocoa without a plugin version that is compatible with it. During this window, a user who explicitly updates their `Package.swift` to require the new ably-cocoa version while also depending on the plugin will get a dependency resolution error. To minimise this window, have the plugin release PR reviewed and ready to merge before releasing ably-cocoa, so that the only remaining step is updating the ably-cocoa dependency from a commit pin to the released version tag. + +1. Prepare a release PR of plugin version `v_p`, with dependency on the new major version of plugin-support, but still pinned to an ably-cocoa commit. Do not release it. +2. Prepare a release PR of ably-cocoa version `v_c`, with dependency on the new major version of plugin-support. Mention in the release message that it is compatible with version `v_p` of the plugin, which will be released shortly. +3. Release ably-cocoa version `v_c`. +4. Update the release PR of plugin version `v_p` to now point to ably-cocoa version `v_c`. +5. Release plugin version `v_p`. diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/APDependencyStore.m b/PluginSupport/Sources/_AblyPluginSupportPrivate/APDependencyStore.m new file mode 100644 index 000000000..9d4e2d2a3 --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/APDependencyStore.m @@ -0,0 +1,35 @@ +#import "APDependencyStore.h" + +@interface APDependencyStore () + +@property (nullable, atomic) id pluginAPI; + +@end + +@implementation APDependencyStore + ++ (APDependencyStore *)sharedInstance { + static APDependencyStore *sharedInstance; + static dispatch_once_t onceToken; + dispatch_once(&onceToken, ^{ + sharedInstance = [[APDependencyStore alloc] init]; + }); + + return sharedInstance; +} + +- (void)registerPluginAPI:(id)pluginAPI { + self.pluginAPI = pluginAPI; +} + +- (id)fetchPluginAPI { + id pluginAPI = self.pluginAPI; + + if (!pluginAPI) { + [NSException raise:NSInternalInconsistencyException format:@"-fetchPluginAPI called before -registerPluginAPI:"]; + } + + return pluginAPI; +} + +@end diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h new file mode 100644 index 000000000..e45056ec9 --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h @@ -0,0 +1,20 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +NS_SWIFT_NAME(ConnectionDetailsProtocol) +NS_SWIFT_SENDABLE +/// The contents of a `CONNECTED` `ProtocolMessage`'s `connectionDetails`. +@protocol APConnectionDetailsProtocol + +/// Wraps an `NSTimeInterval` containing the `objectsGCGracePeriod`, if any, in seconds. +@property (nonatomic, readonly, nullable) NSNumber *objectsGCGracePeriod; + +/// The site code of the server that the client is connected to (CD2j). +/// +/// May be absent if the server does not provide it. +@property (nonatomic, readonly, nullable) NSString *siteCode; + +@end + +NS_ASSUME_NONNULL_END diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h new file mode 100644 index 000000000..77b3a84d3 --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h @@ -0,0 +1,24 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +NS_SWIFT_NAME(DecodingContextProtocol) +NS_SWIFT_SENDABLE +/// Contains contextual information that may be needed when decoding an object contained within a `ProtocolMessage`. +@protocol APDecodingContextProtocol + +/// The `id` of the `ProtocolMessage` that contains the object that is being decoded. +@property (nonatomic, readonly, nullable) NSString *parentID; + +/// The `connectionId` of the `ProtocolMessage` that contains the object that is being decoded. +@property (nonatomic, readonly, nullable) NSString *parentConnectionID; + +/// The `timestamp` of the `ProtocolMessage` that contains the object that is being decoded. +@property (nonatomic, readonly, nullable) NSDate *parentTimestamp; + +/// The index, inside the array in the `ProtocolMessage` that contains the object that is being decoded, of the object that is being decoded. +@property (nonatomic, readonly) NSInteger indexInParent; + +@end + +NS_ASSUME_NONNULL_END diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h new file mode 100644 index 000000000..1c7a95008 --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h @@ -0,0 +1,43 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +@protocol APPluginAPIProtocol; + +/// `APDependencyStoreProtocol` allows ably-cocoa to register an instance of `APPluginAPI` (that is, the interface through which Ably-authored plugins access ably-cocoa's internals) to be subsequently fetched by a plugin. +/// +/// The canonical implementation of this protocol is that returned by `+[APDependencyStore sharedInstance]`. +NS_SWIFT_NAME(DependencyStoreProtocol) +NS_SWIFT_SENDABLE +@protocol APDependencyStoreProtocol + +/// Stores an instance of `APPluginAPI` to be subsequently retrieved by `-fetchPluginAPI`. +/// +/// This method should be used by ably-cocoa. +/// +/// - Note: This method can be called from any thread. +- (void)registerPluginAPI:(id)pluginAPI; + +/// Retrieves an instance of `APPluginAPI` that was previously stored by `-registerPluginAPI:`. +/// +/// This method should be used by plugins. It is a programmer error to call it if `-registerPluginAPI:` has not yet been called. +/// +/// - Note: This method can be called from any thread. +- (id)fetchPluginAPI; + +@end + +/// Provides the canonical implementation of `APDependencyStoreProtocol`. +NS_SWIFT_NAME(DependencyStore) +NS_SWIFT_SENDABLE +@interface APDependencyStore: NSObject + +/// Returns the singleton instance of this class. ++ (APDependencyStore *)sharedInstance; + +// Use `+sharedInstance` instead. +- (instancetype)init NS_UNAVAILABLE; + +@end + +NS_ASSUME_NONNULL_END diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h new file mode 100644 index 000000000..f8b3da1ea --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h @@ -0,0 +1,16 @@ +/// The wire format to which a plugin object should encode an object or from which a plugin should be decode an object. +/// +/// The requirements for encoding to and decoding from each format are as follows: +/// +/// - `APEncodingFormatJSON`: +/// - Encoding: Plugins should generate an object that ably-cocoa can pass to Foundation's `JSONSerialization.data(withJSONObject:,…)`. +/// - Decoding: Plugins should expect to find the same types of values as you would in the output of Foundation's `JSONSerialization`. +/// - `APEncodingFormatMessagePack`: +/// - Encoding: As for `APEncodingFormatJSON`, but arrays and dictionaries are allowed to additionally contain `NSData` values. +/// - Decoding: As for `APEncodingFormatJSON`, but arrays and dictionaries may also additionally contain `NSData` values. +/// +/// We intentionally use a closed enum so that plugins do not have to include an `@unknown default` (since there is no sensible default for them to use; if we introduce a new encoding format in the future then we will have to define new logic — and since it's a closed enum, a new enum). +typedef NS_CLOSED_ENUM(NSInteger, APEncodingFormat) { + APEncodingFormatJSON NS_SWIFT_NAME(json), + APEncodingFormatMessagePack +} NS_SWIFT_NAME(EncodingFormat); diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h new file mode 100644 index 000000000..d0db6082d --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h @@ -0,0 +1,130 @@ +#import +#import "APEncodingFormat.h" +#import "APRealtimeChannelState.h" + +@protocol APLiveObjectsInternalPluginProtocol; +@protocol APObjectMessageProtocol; +@protocol APDecodingContextProtocol; +@protocol APRealtimeChannel; +@protocol APRealtimeClient; +@protocol APPublicErrorInfo; +@protocol APConnectionDetailsProtocol; + +NS_ASSUME_NONNULL_BEGIN + +/// The entrypoint by which ably-cocoa accesses the functionality provided by the LiveObjects plugin. +/// +/// The value that the user provides for the `ARTPluginNameLiveObjects` key in the `-[ARTClientOptions plugins]` client option must _informally_ conform to this protocol; that is, it must implement its methods but not declare itself as conforming to the protocol. (We use informal conformance so that the LiveObjects plugin does not need to expose its usage of the `ARTPlugin` library to the user.) +NS_SWIFT_NAME(LiveObjectsPluginProtocol) +NS_SWIFT_SENDABLE +@protocol APLiveObjectsPluginProtocol + +/// Provides ably-cocoa with an implementation of `APLiveObjectsInternalPluginProtocol`. ++ (id)internalPlugin; + +@end + +/// The interface that ably-cocoa uses to access the functionality provided by the LiveObjects plugin. +/// +/// This protocol can be more complex than `APLiveObjectsPluginProtocol`, because, since its implementation will be internal to the LiveObjects plugin library, then, unlike the informal conformance that we have to use for `APLiveObjectsPluginProtocol`, the implementation can declare itself as conforming to this protocol and will receive compiler checking that it does indeed conform. +NS_SWIFT_NAME(LiveObjectsInternalPluginProtocol) +NS_SWIFT_SENDABLE +@protocol APLiveObjectsInternalPluginProtocol + +/// Whether this plugin is compatible with protocol v6. +/// +/// If this property returns `YES`, it indicates that this plugin can be used +/// with a version of ably-cocoa which uses protocol v6, or which uses a +/// protocol version higher than v6 whose breaking changes compared to v6 do +/// not affect LiveObjects plugins. +/// +/// This property **must** return `YES`. If you wish to introduce a protocol +/// version change, see the "Breaking Realtime protocol version changes" +/// section in the Readme. +@property (nonatomic, readonly) BOOL compatibleWithProtocolV6; + +/// ably-cocoa will call this method when initializing an `ARTRealtimeChannel` instance. +/// +/// 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; + +/// Decodes an `ObjectMessage` received over the wire. +/// +/// Parameters: +/// - serialized: A dictionary that contains the representation of the `ObjectMessage` received over the wire. To find out what kinds of values you should expect to find here for a given `format`, see the decoding rules described in `APEncodingFormat`. +/// - context: Contains information that may be needed in the decoding, such as information about the containing `ProtocolMessage`. +/// - format: The format that was used to create `serialized`, and whose rules should be used when decoding the `ObjectMessage`. +/// +/// Returns: A `ObjectMessageProtocol` object that ably-cocoa can later pass to this plugin's `-handleObjectProtocolMessageWithObjectMessages:channel:` method, or `nil` if decoding fails (in which case `error` must be populated). +- (nullable id)decodeObjectMessage:(NSDictionary *)serialized + context:(id)context + format:(APEncodingFormat)format + error:(_Nullable id *_Nullable)error; + +/// Encodes an `ObjectMessage` to be sent over the wire. +/// +/// Parameters: +/// - objectMessage: An `ObjectMessage` that this plugin earlier passed to `APPluginAPI`'s `-sendObjectWithObjectMessages:channel:completion:`. +/// - format: The format whose rules should be used when encoding the `ObjectMessage`. +/// +/// Returns: See the encoding rules described in `APEncodingFormat`. +- (NSDictionary *)encodeObjectMessage:(id)objectMessage + format:(APEncodingFormat)format; + +/// Called when a channel received an `ATTACHED` `ProtocolMessage`. (This is copied from ably-js, will document this method properly once exact meaning decided.) +/// +/// Parameters: +/// - channel: The channel that received the `ProtocolMessage`. +/// - hasObjects: Whether the `ProtocolMessage` has the `HAS_OBJECTS` flag set. +- (void)nosync_onChannelAttached:(id)channel + hasObjects:(BOOL)hasObjects + NS_SWIFT_NAME(nosync_onChannelAttached(_:hasObjects:)); + +/// Processes a received `OBJECT` `ProtocolMessage`. +/// +/// Parameters: +/// - objectMessages: The contents of the `ProtocolMessage`'s `state` property. +/// - channel: The channel on which the `ProtocolMessage` was received. +- (void)nosync_handleObjectProtocolMessageWithObjectMessages:(NSArray> *)objectMessages + channel:(id)channel; + +/// Processes a received `OBJECT_SYNC` `ProtocolMessage`. +/// +/// Parameters: +/// - objectMessages: The contents of the `ProtocolMessage`'s `state` property. +/// - channel: The channel on which the `ProtocolMessage` was received. +- (void)nosync_handleObjectSyncProtocolMessageWithObjectMessages:(NSArray> *)objectMessages + protocolMessageChannelSerial:(nullable NSString *)protocolMessageChannelSerial + channel:(id)channel; + +/// Called whenever the client receives a `CONNECTED` `ProtocolMessage`, passing its `connectionDetails` (if any). +/// +/// Parameters: +/// - channel: The channel that should be informed about the connection details. +- (void)nosync_onConnectedWithConnectionDetails:(nullable id)connectionDetails + channel:(id)channel; + +/// Called when the channel undergoes a state transition. +/// +/// Parameters: +/// - channel: The channel whose state changed. +/// - state: The new channel state. +/// - reason: The error reason associated with the state change, if any. Corresponds to `ARTRealtimeChannel.errorReason`. +- (void)nosync_onChannelStateChanged:(id)channel + toState:(APRealtimeChannelState)state + reason:(nullable id)reason + NS_SWIFT_NAME(nosync_onChannelStateChanged(_:toState:reason:)); + +@end + +/// An `ObjectMessage`, as found in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. +/// +/// This protocol is empty because ably-cocoa does not need to interact with the contents of an `ObjectMessage`. +/// +/// An instance of `APLiveObjectsInternalPluginProtocol` is expected to be able to handle any `APObjectMessageProtocol` instance that it creates. +NS_SWIFT_NAME(ObjectMessageProtocol) +NS_SWIFT_SENDABLE +@protocol APObjectMessageProtocol +@end + +NS_ASSUME_NONNULL_END diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h new file mode 100644 index 000000000..26c8322cb --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h @@ -0,0 +1,9 @@ +/// A copy of ably-cocoa's `ARTLogLevel`. +typedef NS_CLOSED_ENUM(NSUInteger, APLogLevel) { + APLogLevelVerbose, + APLogLevelDebug, + APLogLevelInfo, + APLogLevelWarn, + APLogLevelError, + APLogLevelNone +} NS_SWIFT_NAME(LogLevel); diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLogger.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLogger.h new file mode 100644 index 000000000..4d46a9d3a --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLogger.h @@ -0,0 +1,11 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +/// A logger to be used by plugins. +NS_SWIFT_NAME(Logger) +NS_SWIFT_SENDABLE +@protocol APLogger +@end + +NS_ASSUME_NONNULL_END diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h new file mode 100644 index 000000000..9e64bcf46 --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h @@ -0,0 +1,119 @@ +@import Foundation; +#import "APRealtimeChannelState.h" + +NS_ASSUME_NONNULL_BEGIN + +@protocol APLogger; +@protocol APObjectMessageProtocol; +@protocol APRealtimeChannel; +@protocol APRealtimeClient; +@protocol APPublicRealtimeChannelUnderlyingObjects; +@protocol APPublicClientOptions; +@protocol APPublicRealtimeChannel; +@protocol APPublicErrorInfo; +@protocol APPublishResultProtocol; + +/// `APPluginAPIProtocol` provides a stable API for Ably-authored plugins to access certain private functionality of ably-cocoa. +NS_SWIFT_NAME(PluginAPIProtocol) +NS_SWIFT_SENDABLE +@protocol APPluginAPIProtocol + +/// Whether the SDK uses a protocol version which, as far as a LiveObjects +/// plugin is concerned, is equivalent to protocol v6. +/// +/// If the SDK uses a protocol version which is higher than 6 but whose +/// breaking changes compared to v6 do not affect LiveObjects plugins, this +/// property may still return `YES`. +/// +/// This property **must** return `YES`. If you wish to introduce a protocol +/// version change, see the "Breaking Realtime protocol version changes" +/// section in the Readme. +@property (nonatomic, readonly) BOOL usesLiveObjectsProtocolV6; + +/// Returns the internal objects that correspond to a public `ARTRealtimeChannel`. +/// +/// Plugins should, in general, not make use of `ARTRealtimeChannel` internally, and instead use `APRealtimeChannel`. This method is intended only to be used in plugin-authored extensions of `ARTRealtimeChannel`. +- (id)underlyingObjectsForPublicRealtimeChannel:(id)channel; + +/// Allows a plugin to store arbitrary key-value data on a channel. +/// +/// The channel stores a strong reference to `value`. +- (void)nosync_setPluginDataValue:(id)value + forKey:(NSString *)key + channel:(id)channel; + +/// Allows a plugin to retrieve arbitrary key-value data that was previously stored on a channel using `-setPluginDataValue:forKey:channel:`. +- (nullable id)nosync_pluginDataValueForKey:(NSString *)key + channel:(id)channel; + +/// Allows a plugin to store arbitrary key-value data in an `ARTClientOptions`. This allows a plugin to define its own client options. +/// +/// You would usually call this from within a plugin-defined extension of `ARTClientOptions`. +- (void)setPluginOptionsValue:(id)value + forKey:(NSString *)key + clientOptions:(id)options; + +/// Allows a plugin to retrieve arbitrary key-value data that was previously stored on an `ARTClientOptions` using `-setPluginOptionsValue:forKey:clientOptions:`. +/// +/// You would usually call this from within a plugin-defined extension of `ARTClientOptions`. +- (nullable id)pluginOptionsValueForKey:(NSString *)key + clientOptions:(id)options; + +/// Retrieves a copy of the options for a client. +- (id)optionsForClient:(id)client; + +/// Provides plugins with access to ably-cocoa's logging functionality. +/// +/// - Parameter channel: The channel whose logger the returned logger should wrap. +- (id)loggerForChannel:(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; + +/// Provides plugins with the queue which a given client uses to synchronize its internal state. +/// +/// All `_AblyPluginSupportPrivate` methods whose names begin with `nosync_` must be called on this queue. +- (dispatch_queue_t)internalQueueForClient:(id)client; + +/// Attempts to submit an `OBJECT` `ProtocolMessage` for best-effort delivery to Ably per RTO15. +/// +/// This enables the channel message publishing behaviour described in RTL6c: +/// +/// - If the channel's state is neither SUSPENDED nor FAILED then the message will be submitted to the connection for further checks per RTL6c1 and RTL6c2. Note that these checks may cause the connection to immediately reject the message per RTL6c4. +/// - If the channel's state is SUSPENDED or FAILED then the callback will be called immediately with an error per RTL6c4. +/// +/// If the message ends up being sent on the transport then the completion handler will be called to indicate the result of waiting for an `ACK` or `NACK`, or when the connection gives up on trying to send the message. The completion handler receives an `APPublishResultProtocol` containing the serials assigned to the published messages by the server. +/// +/// The completion handler will be called on the client's internal queue (see `-internalQueueForClient:`). +/// +/// This method will call ``APLiveObjectsPlugin/encodeObjectMessage:format:`` to encode the `ObjectMessage`s to be sent over the wire, per RTO15c. +/// +/// - Note: This method does not currently implement the RTO15d message size checks; this will come in https://github.com/ably/ably-liveobjects-swift-plugin/issues/13. +- (void)nosync_sendObjectWithObjectMessages:(NSArray> *)objectMessages + channel:(id)channel + completion:(void (^ _Nullable)(_Nullable id publishResult, _Nullable id error))completion; + +/// Returns a realtime channel's current state. +- (APRealtimeChannelState)nosync_stateForChannel:(id)channel; + +/// 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. +/// +/// The completion handler will be called on the client's internal queue (see `-internalQueueForClient:`). +- (void)nosync_fetchServerTimeForClient:(id)client + completion:(void (^ _Nullable)(NSDate *_Nullable serverTime, _Nullable id error))completion; + +/// The `connectionDetails` from the latest `CONNECTED` `ProtocolMessage` that the client received (`nil` if it did not contain a `connectionDetails`). +- (nullable id)nosync_latestConnectionDetailsForClient:(id)client; + +/// Logs a message to a logger. +- (void)log:(NSString *)message + withLevel:(APLogLevel)level + file:(const char *)fileName + line:(NSInteger)line + logger:(id)logger; + +@end + +NS_ASSUME_NONNULL_END diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h new file mode 100644 index 000000000..ed87ad31d --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h @@ -0,0 +1,16 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +/// A marker protocol that this library uses to represent an instance of ably-cocoa's `ARTClientOptions`. +/// +/// Both ably-cocoa and it plugins can treat this type interchangeably with `ARTClientOptions`; that is, they can assume that the only class that conforms to this protocol is `ARTClientOptions`, casting between the two as they wish. +/// +/// The word "public" in this type's name indicates that it corresponds to a type that is public in ably-cocoa. +/// +/// - Note: `ARTClientOptions`'s runtime conformance to this protocol is implemented by ably-cocoa (but it is not able to declare this conformance publicly). +NS_SWIFT_NAME(PublicClientOptions) +@protocol APPublicClientOptions +@end + +NS_ASSUME_NONNULL_END diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h new file mode 100644 index 000000000..b9911ffb4 --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h @@ -0,0 +1,16 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +/// A marker protocol that this library uses to represent an instance of ably-cocoa's `ARTErrorInfo`. +/// +/// Both ably-cocoa and it plugins can treat this type interchangeably with `ARTErrorInfo`; that is, they can assume that the only class that conforms to this protocol is `ARTErrorInfo`, casting between the two as they wish. +/// +/// The word "public" in this type's name indicates that it corresponds to a type that is public in ably-cocoa. +/// +/// - Note: `ARTErrorInfo`'s runtime conformance to this protocol is implemented by ably-cocoa (but it is not able to declare this conformance publicly). +NS_SWIFT_NAME(PublicErrorInfo) +@protocol APPublicErrorInfo +@end + +NS_ASSUME_NONNULL_END diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h new file mode 100644 index 000000000..9588e8143 --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h @@ -0,0 +1,16 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +/// A marker protocol that this library uses to represent an instance of ably-cocoa's `ARTRealtimeChannel`. +/// +/// Both ably-cocoa and it plugins can treat this type interchangeably with `ARTRealtimeChannel`; that is, they can assume that the only class that conforms to this protocol is `ARTRealtimeChannel`, casting between the two as they wish. +/// +/// The word "public" in this type's name indicates that it corresponds to a type that is public in ably-cocoa. +/// +/// - Note: `ARTRealtimeChannel`'s runtime conformance to this protocol is implemented by ably-cocoa (but it is not able to declare this conformance publicly). +NS_SWIFT_NAME(PublicRealtimeChannel) +@protocol APPublicRealtimeChannel +@end + +NS_ASSUME_NONNULL_END diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h new file mode 100644 index 000000000..a6ef97e37 --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h @@ -0,0 +1,18 @@ +@import Foundation; + +@protocol APRealtimeClient; +@protocol APRealtimeChannel; + +NS_ASSUME_NONNULL_BEGIN + +NS_SWIFT_NAME(PublicRealtimeChannelUnderlyingObjects) +NS_SWIFT_SENDABLE +/// The `_AblyPluginPrivate` objects that back an `ARTRealtimeChannel` instance. +@protocol APPublicRealtimeChannelUnderlyingObjects + +@property (nonatomic, readonly) id client; +@property (nonatomic, readonly) id channel; + +@end + +NS_ASSUME_NONNULL_END diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h new file mode 100644 index 000000000..9050f5d62 --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h @@ -0,0 +1,25 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +/// The serial for a single published message. +NS_SWIFT_SENDABLE +NS_SWIFT_NAME(PublishResultSerialProtocol) +@protocol APPublishResultSerialProtocol + +/// The message serial of the published message, or `nil` if the message was discarded due to a configured conflation rule. +@property (nullable, nonatomic, readonly) NSString *value; + +@end + +/// Contains the result of a publish operation. +NS_SWIFT_SENDABLE +NS_SWIFT_NAME(PublishResultProtocol) +@protocol APPublishResultProtocol + +/// An array of serials corresponding 1:1 to the messages that were published. +@property (nonatomic, readonly) NSArray> *serials; + +@end + +NS_ASSUME_NONNULL_END diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h new file mode 100644 index 000000000..4ee93fd26 --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h @@ -0,0 +1,15 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +/// The interface that plugins use to interact with a realtime channel. +/// +/// This exists so that plugins do not need to make use of the public `ARTRealtimeChannel` class, which allows the internal components of ably-cocoa to continue the (existing before we introduced plugins) pattern of also not making use of this public class. +/// +/// Note that `_AblyPluginSupportPrivate` does not allow you to pass it arbitrary objects that conform to this protocol; rather you must pass it an object which it previously passed to the plugin (e.g. via `prepareChannel:`). +NS_SWIFT_NAME(RealtimeChannel) +NS_SWIFT_SENDABLE +@protocol APRealtimeChannel +@end + +NS_ASSUME_NONNULL_END diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h new file mode 100644 index 000000000..619a48055 --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h @@ -0,0 +1,10 @@ +/// A copy of ably-cocoa's `ARTRealtimeChannelState`. +typedef NS_CLOSED_ENUM(NSUInteger, APRealtimeChannelState) { + APRealtimeChannelStateInitialized, + APRealtimeChannelStateAttaching, + APRealtimeChannelStateAttached, + APRealtimeChannelStateDetaching, + APRealtimeChannelStateDetached, + APRealtimeChannelStateSuspended, + APRealtimeChannelStateFailed +} NS_SWIFT_NAME(RealtimeChannelState); diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h new file mode 100644 index 000000000..70a3974ff --- /dev/null +++ b/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h @@ -0,0 +1,15 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +/// The interface that plugins use to interact with a realtime client. +/// +/// This exists so that plugins do not need to make use of the public `ARTRealtime` class, which allows the internal components of ably-cocoa to continue the (existing before we introduced plugins) pattern of also not making use of this public class. +/// +/// Note that `_AblyPluginSupportPrivate` does not allow you to pass it arbitrary objects that conform to this protocol; rather you must pass it an object which it previously passed to the plugin (e.g. via TODO we don't have an example yet; will come later). +NS_SWIFT_NAME(RealtimeClient) +NS_SWIFT_SENDABLE +@protocol APRealtimeClient +@end + +NS_ASSUME_NONNULL_END From b5b3afcd24680b3e4093359159b1b80e1b4fad3a Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 9 Jul 2026 23:08:46 -0300 Subject: [PATCH 03/21] Move plugin-support sources into Sources/ Pure renames, no content changes: - PluginSupport/Sources/_AblyPluginSupportPrivate -> Sources/_AblyPluginSupportPrivate - PluginSupport/README.md -> Sources/_AblyPluginSupportPrivate/README.md This is where the _AblyPluginSupportPrivate target will live as an ordinary target of the ably-cocoa package (a subsequent commit updates Package.swift). The README will be revised for its new home in a later commit. Co-Authored-By: Claude Fable 5 --- .../_AblyPluginSupportPrivate/APDependencyStore.m | 0 {PluginSupport => Sources/_AblyPluginSupportPrivate}/README.md | 0 .../_AblyPluginSupportPrivate/include/APConnectionDetails.h | 0 .../_AblyPluginSupportPrivate/include/APDecodingContext.h | 0 .../_AblyPluginSupportPrivate/include/APDependencyStore.h | 0 .../_AblyPluginSupportPrivate/include/APEncodingFormat.h | 0 .../_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h | 0 .../_AblyPluginSupportPrivate/include/APLogLevel.h | 0 .../_AblyPluginSupportPrivate/include/APLogger.h | 0 .../_AblyPluginSupportPrivate/include/APPluginAPI.h | 0 .../_AblyPluginSupportPrivate/include/APPublicClientOptions.h | 0 .../_AblyPluginSupportPrivate/include/APPublicErrorInfo.h | 0 .../_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h | 0 .../include/APPublicRealtimeChannelUnderlyingObjects.h | 0 .../_AblyPluginSupportPrivate/include/APPublishResult.h | 0 .../_AblyPluginSupportPrivate/include/APRealtimeChannel.h | 0 .../_AblyPluginSupportPrivate/include/APRealtimeChannelState.h | 0 .../_AblyPluginSupportPrivate/include/APRealtimeClient.h | 0 18 files changed, 0 insertions(+), 0 deletions(-) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/APDependencyStore.m (100%) rename {PluginSupport => Sources/_AblyPluginSupportPrivate}/README.md (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APConnectionDetails.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APDecodingContext.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APDependencyStore.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APEncodingFormat.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APLogLevel.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APLogger.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APPluginAPI.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APPublicClientOptions.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APPublishResult.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APRealtimeChannel.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h (100%) rename {PluginSupport/Sources => Sources}/_AblyPluginSupportPrivate/include/APRealtimeClient.h (100%) diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/APDependencyStore.m b/Sources/_AblyPluginSupportPrivate/APDependencyStore.m similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/APDependencyStore.m rename to Sources/_AblyPluginSupportPrivate/APDependencyStore.m diff --git a/PluginSupport/README.md b/Sources/_AblyPluginSupportPrivate/README.md similarity index 100% rename from PluginSupport/README.md rename to Sources/_AblyPluginSupportPrivate/README.md diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h b/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h rename to Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h b/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h rename to Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h b/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h rename to Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h b/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h rename to Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h rename to Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h b/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h rename to Sources/_AblyPluginSupportPrivate/include/APLogLevel.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLogger.h b/Sources/_AblyPluginSupportPrivate/include/APLogger.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APLogger.h rename to Sources/_AblyPluginSupportPrivate/include/APLogger.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h rename to Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h b/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h rename to Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h b/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h rename to Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h b/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h rename to Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h b/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h rename to Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h b/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h rename to Sources/_AblyPluginSupportPrivate/include/APPublishResult.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h b/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h rename to Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h b/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h rename to Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h diff --git a/PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h b/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h similarity index 100% rename from PluginSupport/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h rename to Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h From a0a20ff20497780e1829de3744a655253acac307 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 9 Jul 2026 23:08:58 -0300 Subject: [PATCH 04/21] Remove imported plugin-support scaffolding Removes the remaining files imported from the ably-cocoa-plugin-support repo, which are superseded now that its source code is part of this repo: - PluginSupport/Package.swift: the library becomes a target of this package (next commit) rather than a package of its own - PluginSupport/LICENSE, PluginSupport/COPYRIGHT: this repo's LICENSE and COPYRIGHT (identical Apache 2.0 licence) now cover the code - PluginSupport/.gitignore: only ignored build artefacts of the standalone package (.build etc.), which this repo's .gitignore already covers Co-Authored-By: Claude Fable 5 --- PluginSupport/.gitignore | 11 --- PluginSupport/COPYRIGHT | 1 - PluginSupport/LICENSE | 176 ------------------------------------ PluginSupport/Package.swift | 22 ----- 4 files changed, 210 deletions(-) delete mode 100644 PluginSupport/.gitignore delete mode 100644 PluginSupport/COPYRIGHT delete mode 100644 PluginSupport/LICENSE delete mode 100644 PluginSupport/Package.swift diff --git a/PluginSupport/.gitignore b/PluginSupport/.gitignore deleted file mode 100644 index f761f995d..000000000 --- a/PluginSupport/.gitignore +++ /dev/null @@ -1,11 +0,0 @@ -# Start of .gitignore created by Swift Package Manager -.DS_Store -/.build -/Packages -xcuserdata/ -DerivedData/ -.swiftpm/configuration/registries.json -.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata -.netrc -docs-coverage-report -# End of .gitignore created by Swift Package Manager diff --git a/PluginSupport/COPYRIGHT b/PluginSupport/COPYRIGHT deleted file mode 100644 index 165bec7ac..000000000 --- a/PluginSupport/COPYRIGHT +++ /dev/null @@ -1 +0,0 @@ -Copyright 2025 Ably Real-time Ltd (ably.com) diff --git a/PluginSupport/LICENSE b/PluginSupport/LICENSE deleted file mode 100644 index d9a10c0d8..000000000 --- a/PluginSupport/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS diff --git a/PluginSupport/Package.swift b/PluginSupport/Package.swift deleted file mode 100644 index 787d8e52c..000000000 --- a/PluginSupport/Package.swift +++ /dev/null @@ -1,22 +0,0 @@ -// swift-tools-version:5.3.0 -// The swift-tools-version declares the minimum version of Swift required to build this package. - -// The swift-tools-version should not be increased above that of any of the packages that depend on this package (at the time of writing that is ably-cocoa and the LiveObjects plugin), so as not to inadvertently increase the required tooling version of those packages. - -import PackageDescription - -let package = Package( - name: "ably-cocoa-plugin-support", - products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. - .library( - name: "_AblyPluginSupportPrivate", - targets: ["_AblyPluginSupportPrivate"]), - ], - targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. - .target( - name: "_AblyPluginSupportPrivate"), - ] -) From cbe4a6926794aab5219c90f431cfa6f576110c98 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 9 Jul 2026 23:10:38 -0300 Subject: [PATCH 05/21] Use the in-repo _AblyPluginSupportPrivate target Replaces the ably-cocoa-plugin-support package dependency with a target built from the sources imported in the preceding commits. The target is deliberately not vended as a product: it only ever existed to expose ably-cocoa private API to Ably-authored plugins, and its only consumers (ably-cocoa and the LiveObjects plugin, which a later commit brings into this repo) now live here. Note for the transition period: a dependency graph that contains both this version of ably-cocoa and the standalone LiveObjects plugin (which depends on the ably-cocoa-plugin-support package) will fail to resolve with a duplicate target name error, since both provide a target named _AblyPluginSupportPrivate. That combination could never work anyway; plugin users should migrate to the AblyLiveObjects product introduced later in this branch. A final release of the standalone plugin will cap its ably-cocoa dependency so that existing users' `swift package update` keeps resolving a working (old) pair. CocoaPods and Carthage are unaffected: plugin support is SPM-only (ABLY_SUPPORTS_PLUGINS is only defined in Package.swift), and neither the podspec nor the Xcode project referenced the removed package. Co-Authored-By: Claude Fable 5 --- Package.resolved | 9 --------- Package.swift | 16 +++++++++++----- 2 files changed, 11 insertions(+), 14 deletions(-) diff --git a/Package.resolved b/Package.resolved index aba9e456a..fec99d1ef 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,15 +1,6 @@ { "object": { "pins": [ - { - "package": "ably-cocoa-plugin-support", - "repositoryURL": "https://github.com/ably/ably-cocoa-plugin-support.git", - "state": { - "branch": null, - "revision": "a290b8942086ffb6e21e4805d9319143669d9414", - "version": "2.0.0" - } - }, { "package": "CwlCatchException", "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git", diff --git a/Package.swift b/Package.swift index 4a77d58bf..2dbb7d867 100644 --- a/Package.swift +++ b/Package.swift @@ -18,16 +18,22 @@ let package = Package( dependencies: [ .package(name: "msgpack", url: "https://github.com/rvi/msgpack-objective-C", from: "0.4.0"), .package(name: "AblyDeltaCodec", url: "https://github.com/ably/delta-codec-cocoa", from: "1.3.5"), - .package(name: "Nimble", url: "https://github.com/quick/nimble", from: "11.2.2"), - .package(name: "ably-cocoa-plugin-support", url: "https://github.com/ably/ably-cocoa-plugin-support.git", from: "2.0.0") + .package(name: "Nimble", url: "https://github.com/quick/nimble", from: "11.2.2") ], targets: [ + // Private API of the core SDK, exposed to Ably-authored plugins. Formerly + // the separate ably-cocoa-plugin-support repository; deliberately not + // vended as a product. + .target( + name: "_AblyPluginSupportPrivate", + path: "Sources/_AblyPluginSupportPrivate" + ), .target( name: "Ably", dependencies: [ .byName(name: "msgpack"), .byName(name: "AblyDeltaCodec"), - .product(name: "_AblyPluginSupportPrivate", package: "ably-cocoa-plugin-support") + .target(name: "_AblyPluginSupportPrivate") ], path: "Source", exclude: [ @@ -59,7 +65,7 @@ let package = Package( .byName(name: "AblyTesting"), .byName(name: "AblyTestingObjC"), .byName(name: "Nimble"), - .product(name: "_AblyPluginSupportPrivate", package: "ably-cocoa-plugin-support") + .target(name: "_AblyPluginSupportPrivate") ], path: "Test/AblyTests", resources: [ @@ -73,7 +79,7 @@ let package = Package( name: "UTS", dependencies: [ .byName(name: "Ably"), - .product(name: "_AblyPluginSupportPrivate", package: "ably-cocoa-plugin-support") + .target(name: "_AblyPluginSupportPrivate") ], path: "Test/UTS", exclude: [ From 587e8963afaf902103103c2918230821af725305 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 9 Jul 2026 23:12:30 -0300 Subject: [PATCH 06/21] Raise swift-tools-version from 5.3 to 6.1 Needed so that the AblyLiveObjects target (added in a later commit) can use the Swift 6 language mode and internal imports, which require per-target .swiftLanguageMode settings (tools 6.0+). Consequences for users: - SPM consumers now need Xcode 16.3 or later (April 2025) to resolve this package. CocoaPods and Carthage consumers are unaffected (neither reads Package.swift). - This does not change the OS versions the SDK supports: the declared platforms are unchanged, and deployment-target support is a property of the consumer's Xcode, not of the tools version. Since 28 April 2026 the App Store requires apps to be built with Xcode 26 [1], so in practice App Store developers already exceed this requirement. - Consumers on older toolchains resolving with a `from:` range will stay pinned to the last tools-5.3 release (1.2.x) rather than failing, since SwiftPM excludes versions whose manifest requires newer tools. Escape hatch: if consumers who cannot upgrade Xcode (e.g. enterprise certificate distribution) report breakage, this is fixable in a patch release by renaming this manifest to Package@swift-6.1.swift and reintroducing a tools-5.3 Package.swift vending only the Ably product; SwiftPM serves each consumer the newest manifest their tools support. Mechanical changes required by the new tools version: - Test targets that predate the Swift 6 language mode (AblyTests, AblyTesting) explicitly opt in to .swiftLanguageMode(.v5), since tools 6.x defaults targets to the Swift 6 mode. - The UTS test target's unsafeFlags(["-swift-version", "6"]) workaround becomes a proper .swiftLanguageMode(.v6). - The deprecated .package(name:url:from:) dependency forms become .package(url:from:), which in turn requires the explicit .product(name:package:) form for cross-package target dependencies. [1] https://developer.apple.com/news/upcoming-requirements/?id=02032026a Co-Authored-By: Claude Fable 5 --- Package.swift | 31 +++++++++++++++++++------------ 1 file changed, 19 insertions(+), 12 deletions(-) diff --git a/Package.swift b/Package.swift index 2dbb7d867..f83942dbf 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3.0 +// swift-tools-version: 6.1 import PackageDescription @@ -16,9 +16,9 @@ let package = Package( ), ], dependencies: [ - .package(name: "msgpack", url: "https://github.com/rvi/msgpack-objective-C", from: "0.4.0"), - .package(name: "AblyDeltaCodec", url: "https://github.com/ably/delta-codec-cocoa", from: "1.3.5"), - .package(name: "Nimble", url: "https://github.com/quick/nimble", from: "11.2.2") + .package(url: "https://github.com/rvi/msgpack-objective-C", from: "0.4.0"), + .package(url: "https://github.com/ably/delta-codec-cocoa", from: "1.3.5"), + .package(url: "https://github.com/quick/nimble", from: "11.2.2") ], targets: [ // Private API of the core SDK, exposed to Ably-authored plugins. Formerly @@ -31,8 +31,8 @@ let package = Package( .target( name: "Ably", dependencies: [ - .byName(name: "msgpack"), - .byName(name: "AblyDeltaCodec"), + .product(name: "msgpack", package: "msgpack-objective-C"), + .product(name: "AblyDeltaCodec", package: "delta-codec-cocoa"), .target(name: "_AblyPluginSupportPrivate") ], path: "Source", @@ -64,12 +64,16 @@ let package = Package( .byName(name: "Ably"), .byName(name: "AblyTesting"), .byName(name: "AblyTestingObjC"), - .byName(name: "Nimble"), + .product(name: "Nimble", package: "nimble"), .target(name: "_AblyPluginSupportPrivate") ], path: "Test/AblyTests", resources: [ .copy("ably-common") + ], + swiftSettings: [ + // This test code predates the Swift 6 language mode. + .swiftLanguageMode(.v5) ] ), // Universal Test Suite (UTS) @@ -88,10 +92,9 @@ let package = Package( ], swiftSettings: [ // Build the UTS suite in the Swift 6 language mode (strict concurrency checking) so the - // compiler catches data races in the harness/tests. The package manifest is still - // swift-tools-version 5.3, which predates `.swiftLanguageMode`, so this is applied via - // the compiler flag. Only affects this test target (not the shipped product). - .unsafeFlags(["-swift-version", "6"]) + // compiler catches data races in the harness/tests. Only affects this test target (not + // the shipped product). + .swiftLanguageMode(.v6) ] ), // A handful of tests written in Objective-C (they can't be part of AblyTests because SPM doesn't allow mixed-language targets). @@ -110,7 +113,11 @@ let package = Package( dependencies: [ .byName(name: "Ably"), ], - path: "Test/AblyTesting" + path: "Test/AblyTesting", + swiftSettings: [ + // This test code predates the Swift 6 language mode. + .swiftLanguageMode(.v5) + ] ), // Provides test helpers written in Objective-C (they can't be part of AblyTests because SPM doesn't allow mixed-language targets). .target( From e4804e6912b17632684943c82150c5502b1314f8 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 9 Jul 2026 23:13:04 -0300 Subject: [PATCH 07/21] Import ably-liveobjects-swift-plugin at a3bf63d This is the verbatim contents of the ably-liveobjects-swift-plugin repo [1] at commit a3bf63d, imported by running: Scripts/import-repo-contents.sh \ https://github.com/ably/ably-liveobjects-swift-plugin.git \ a3bf63d697ebf8b1c0da6d6491df504728fa4c03 \ LiveObjects No modifications have been made to the imported files; subsequent commits integrate them into this repo. Two notes on the import mechanics: - The plugin repo's Tests/AblyLiveObjectsTests/ably-common submodule is not imported (git archive emits submodules as empty directories). A later commit points the affected test helper at this repo's existing ably-common submodule instead. - If a newer version of the plugin needs to be swapped in before this branch lands, drop this commit, rerun the script at the new SHA and recommit; the subsequent commits are either outside LiveObjects/, small itemised deletions, or regenerated by script. This is the main step of merging the LiveObjects plugin into ably-cocoa (AIT-1027), giving a single repository for ably-cocoa and its plugins, as with ably-js and ably-java. LiveObjects/ is the permanent home of this code, not a staging area: the plugin's development tooling, example app, docs and configuration continue to live and work in this directory. [1] https://github.com/ably/ably-liveobjects-swift-plugin Co-Authored-By: Claude Fable 5 --- LiveObjects/.github/workflows/check.yaml | 343 + LiveObjects/.gitignore | 19 + LiveObjects/.gitmodules | 3 + LiveObjects/.prettierignore | 6 + LiveObjects/.prettierrc | 1 + LiveObjects/.swift-version | 1 + LiveObjects/.swiftformat | 2 + LiveObjects/.swiftlint.yml | 114 + .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../AblyLiveObjects-Package.xcscheme | 110 + .../xcschemes/AblyLiveObjects.xcscheme | 87 + .../xcshareddata/xcschemes/BuildTool.xcscheme | 78 + .../contents.xcworkspacedata | 13 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 96 + LiveObjects/CHANGELOG.md | 58 + LiveObjects/CLAUDE.md | 59 + LiveObjects/CONTRIBUTING.md | 167 + LiveObjects/COPYRIGHT | 1 + .../project.pbxproj | 394 ++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 24 + .../AblyLiveObjectsExample.entitlements | 12 + .../AblyLiveObjectsExampleApp.swift | 26 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 63 + .../Assets.xcassets/Contents.json | 6 + .../AblyLiveObjectsExample/ContentView.swift | 51 + .../Helpers/ARTRealtimeChannel+Async.swift | 27 + .../Preview Assets.xcassets/Contents.json | 6 + .../Secrets.example.swift | 9 + .../ViewModels/LiveCounterViewModel.swift | 218 + .../ViewModels/TaskBoardViewModel.swift | 180 + .../Views/LiveCounterView.swift | 117 + .../Views/TaskBoardView.swift | 177 + LiveObjects/LICENSE | 176 + LiveObjects/MAINTAINERS.md | 1 + LiveObjects/Mintfile | 2 + LiveObjects/Package.resolved | 96 + LiveObjects/Package.swift | 98 + LiveObjects/README.md | 73 + .../Sources/AblyLiveObjects/.swiftformat | 2 + .../Sources/AblyLiveObjects/.swiftlint.yml | 3 + .../Internal/ARTClientOptions+Objects.swift | 46 + .../AblyLiveObjects/Internal/CoreSDK.swift | 137 + .../Internal/DefaultInternalPlugin.swift | 197 + .../Internal/DefaultLiveCounterUpdate.swift | 3 + .../Internal/DefaultLiveMapUpdate.swift | 3 + .../Internal/InternalDefaultLiveCounter.swift | 502 ++ .../Internal/InternalDefaultLiveMap.swift | 1120 ++++ .../InternalDefaultRealtimeObjects.swift | 1083 ++++ .../Internal/InternalLiveMapValue.swift | 165 + .../Internal/InternalLiveObject.swift | 58 + .../Internal/InternalObjectsMapEntry.swift | 21 + .../Internal/LiveObjectMutableState.swift | 152 + .../Internal/LiveObjectUpdate.swift | 26 + .../Internal/ObjectCreationHelpers.swift | 205 + .../Internal/ObjectDiffHelpers.swift | 57 + .../Internal/ObjectsOperationSource.swift | 7 + .../Internal/ObjectsPool.swift | 453 ++ .../Internal/ObjectsSyncState.swift | 18 + .../Internal/PublishResult.swift | 14 + .../Internal/SimpleClock.swift | 19 + .../Internal/SubscriptionStorage.swift | 91 + .../Internal/SyncObjectsPool.swift | 86 + .../InboundObjectMessage+Synthetic.swift | 19 + .../Protocol/ObjectMessage.swift | 648 ++ .../AblyLiveObjects/Protocol/SyncCursor.swift | 39 + .../AblyLiveObjects/Protocol/WireEnum.swift | 25 + .../Protocol/WireObjectMessage.swift | 798 +++ .../Public/ARTRealtimeChannel+Objects.swift | 41 + .../AblyLiveObjects/Public/Plugin.swift | 46 + .../InternalLiveMapValue+ToPublic.swift | 51 + .../PublicDefaultLiveCounter.swift | 54 + .../PublicDefaultLiveMap.swift | 103 + .../PublicDefaultRealtimeObjects.swift | 131 + .../PublicObjectsStore.swift | 160 + .../AblyLiveObjects/Public/PublicTypes.swift | 389 ++ .../AblyLiveObjects/Utility/Assertions.swift | 7 + .../Utility/Data+Extensions.swift | 19 + .../Utility/Dictionary+Extensions.swift | 8 + .../Utility/DispatchQueue+Extensions.swift | 15 + .../Utility/DispatchQueueMutex.swift | 48 + .../AblyLiveObjects/Utility/Errors.swift | 182 + .../Utility/ExtendedJSONValue.swift | 93 + .../AblyLiveObjects/Utility/JSONValue.swift | 309 + .../AblyLiveObjects/Utility/Logger.swift | 41 + .../Utility/LoggingUtilities.swift | 14 + .../Utility/MarkerProtocolHelpers.swift | 54 + .../Utility/NSLock+Extensions.swift | 10 + .../AblyLiveObjects/Utility/WeakRef.swift | 8 + .../AblyLiveObjects/Utility/WireCodable.swift | 390 ++ .../AblyLiveObjects/Utility/WireValue.swift | 250 + LiveObjects/Sources/BuildTool/BuildTool.swift | 845 +++ .../Sources/BuildTool/Configuration.swift | 12 + .../BuildTool/DestinationFetcher.swift | 41 + .../BuildTool/DestinationPredicate.swift | 7 + .../BuildTool/DestinationSpecifier.swift | 15 + .../BuildTool/DestinationStrategy.swift | 6 + LiveObjects/Sources/BuildTool/Error.swift | 6 + LiveObjects/Sources/BuildTool/Platform.swift | 38 + .../Sources/BuildTool/ProcessRunner.swift | 79 + .../Sources/BuildTool/String+Decoding.swift | 16 + .../Sources/BuildTool/XcodeRunner.swift | 69 + LiveObjects/TestPlans/AllTests.xctestplan | 24 + LiveObjects/TestPlans/UnitTests.xctestplan | 29 + .../Tests/AblyLiveObjectsTests/.swiftformat | 2 + .../Tests/AblyLiveObjectsTests/.swiftlint.yml | 3 + .../AblyLiveObjectsTests.swift | 191 + .../Tests/AblyLiveObjectsTests/CLAUDE.md | 15 + .../Helpers/Ably+Concurrency.swift | 61 + .../Helpers/Assertions.swift | 7 + .../Helpers/ClientHelper.swift | 80 + .../Helpers/Sandbox.swift | 92 + .../Helpers/Subscriber.swift | 89 + .../Helpers/Tag+Integration.swift | 6 + .../Helpers/TestFactories.swift | 719 +++ .../Helpers/TestLogger.swift | 41 + .../InternalDefaultLiveCounterTests.swift | 750 +++ .../InternalDefaultLiveMapTests.swift | 2018 ++++++ .../InternalDefaultRealtimeObjectsTests.swift | 2091 ++++++ .../JS Integration Tests/ObjectsHelper.swift | 595 ++ .../ObjectsIntegrationTests.swift | 5684 +++++++++++++++++ .../TestProxyTransport.swift | 456 ++ .../AblyLiveObjectsTests/JSONValueTests.swift | 157 + .../LiveObjectMutableStateTests.swift | 332 + .../Mocks/MockCoreSDK.swift | 86 + .../MockLiveMapObjectsPoolDelegate.swift | 41 + .../Mocks/MockRealtimeObjects.swift | 45 + .../Mocks/MockSimpleClock.swift | 39 + .../ObjectCreationHelpersTests.swift | 215 + .../ObjectDiffHelpersTests.swift | 181 + .../ObjectLifetimesTests.swift | 238 + .../ObjectMessageTests.swift | 472 ++ .../ObjectsPoolTests.swift | 403 ++ .../SyncCursorTests.swift | 105 + .../SyncObjectsPoolTests.swift | 203 + .../WireObjectMessageTests.swift | 764 +++ .../AblyLiveObjectsTests/WireValueTests.swift | 379 ++ .../images/SwiftSDK-LiveObjects-github.png | Bin 0 -> 935881 bytes .../unit-tests-test-plan-screenshot.png | Bin 0 -> 118545 bytes LiveObjects/package-lock.json | 30 + LiveObjects/package.json | 12 + 144 files changed, 29165 insertions(+) create mode 100644 LiveObjects/.github/workflows/check.yaml create mode 100644 LiveObjects/.gitignore create mode 100644 LiveObjects/.gitmodules create mode 100644 LiveObjects/.prettierignore create mode 100644 LiveObjects/.prettierrc create mode 100644 LiveObjects/.swift-version create mode 100644 LiveObjects/.swiftformat create mode 100644 LiveObjects/.swiftlint.yml create mode 100644 LiveObjects/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme create mode 100644 LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme create mode 100644 LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme create mode 100644 LiveObjects/AblyLiveObjects.xcworkspace/contents.xcworkspacedata create mode 100644 LiveObjects/AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 LiveObjects/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 LiveObjects/CHANGELOG.md create mode 100644 LiveObjects/CLAUDE.md create mode 100644 LiveObjects/CONTRIBUTING.md create mode 100644 LiveObjects/COPYRIGHT create mode 100644 LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj create mode 100644 LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 LiveObjects/Example/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements create mode 100644 LiveObjects/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift create mode 100644 LiveObjects/Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 LiveObjects/Example/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 LiveObjects/Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json create mode 100644 LiveObjects/Example/AblyLiveObjectsExample/ContentView.swift create mode 100644 LiveObjects/Example/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift create mode 100644 LiveObjects/Example/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 LiveObjects/Example/AblyLiveObjectsExample/Secrets.example.swift create mode 100644 LiveObjects/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift create mode 100644 LiveObjects/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift create mode 100644 LiveObjects/Example/AblyLiveObjectsExample/Views/LiveCounterView.swift create mode 100644 LiveObjects/Example/AblyLiveObjectsExample/Views/TaskBoardView.swift create mode 100644 LiveObjects/LICENSE create mode 100644 LiveObjects/MAINTAINERS.md create mode 100644 LiveObjects/Mintfile create mode 100644 LiveObjects/Package.resolved create mode 100644 LiveObjects/Package.swift create mode 100644 LiveObjects/README.md create mode 100644 LiveObjects/Sources/AblyLiveObjects/.swiftformat create mode 100644 LiveObjects/Sources/AblyLiveObjects/.swiftlint.yml create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/PublishResult.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/SimpleClock.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Protocol/SyncCursor.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Protocol/WireEnum.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/Plugin.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/PublicTypes.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/Assertions.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/Data+Extensions.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/JSONValue.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/Logger.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/WeakRef.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/WireCodable.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Utility/WireValue.swift create mode 100644 LiveObjects/Sources/BuildTool/BuildTool.swift create mode 100644 LiveObjects/Sources/BuildTool/Configuration.swift create mode 100644 LiveObjects/Sources/BuildTool/DestinationFetcher.swift create mode 100644 LiveObjects/Sources/BuildTool/DestinationPredicate.swift create mode 100644 LiveObjects/Sources/BuildTool/DestinationSpecifier.swift create mode 100644 LiveObjects/Sources/BuildTool/DestinationStrategy.swift create mode 100644 LiveObjects/Sources/BuildTool/Error.swift create mode 100644 LiveObjects/Sources/BuildTool/Platform.swift create mode 100644 LiveObjects/Sources/BuildTool/ProcessRunner.swift create mode 100644 LiveObjects/Sources/BuildTool/String+Decoding.swift create mode 100644 LiveObjects/Sources/BuildTool/XcodeRunner.swift create mode 100644 LiveObjects/TestPlans/AllTests.xctestplan create mode 100644 LiveObjects/TestPlans/UnitTests.xctestplan create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/.swiftformat create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/.swiftlint.yml create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/CLAUDE.md create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/JSONValueTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/SyncCursorTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift create mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/WireValueTests.swift create mode 100644 LiveObjects/images/SwiftSDK-LiveObjects-github.png create mode 100644 LiveObjects/images/unit-tests-test-plan-screenshot.png create mode 100644 LiveObjects/package-lock.json create mode 100644 LiveObjects/package.json diff --git a/LiveObjects/.github/workflows/check.yaml b/LiveObjects/.github/workflows/check.yaml new file mode 100644 index 000000000..b50e21fed --- /dev/null +++ b/LiveObjects/.github/workflows/check.yaml @@ -0,0 +1,343 @@ +name: Check + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +permissions: {} + +jobs: + lint: + runs-on: macos-15 + + permissions: + contents: read + + # From actions/cache documentation linked to below + env: + MINT_PATH: .mint/lib + MINT_LINK_PATH: .mint/bin + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + + # This step can be removed once the runners' default version of Xcode is 16.4 or above + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: 16.4 + + # We use caching for Mint because at the time of writing SwiftLint took about 5 minutes to build in CI, which is unacceptably slow. + # https://github.com/actions/cache/blob/40c3b67b2955d93d83b27ed164edd0756bc24049/examples.md#swift---mint + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: .mint + key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }} + restore-keys: | + ${{ runner.os }}-mint- + + - run: npm ci + - run: brew install mint + - run: mint bootstrap + + - run: swift run BuildTool lint + + # TODO: Restore in https://github.com/ably/ably-liveobjects-swift-plugin/issues/2 once we've seen what form the LiveObjects spec takes + # + # spec-coverage: + # runs-on: macos-15 + # steps: + # - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + # with: + # persist-credentials: false + # submodules: true + # + # # This step can be removed once the runners' default version of Xcode is 16.4 or above + # - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + # with: + # xcode-version: 16.4 + # + # - name: Spec coverage + # run: swift run BuildTool spec-coverage --spec-commit-sha 2f88b1b + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + generate-matrices: + runs-on: macos-15 + + permissions: + contents: read + + outputs: + matrix: ${{ steps.generation-step.outputs.matrix }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + + # This step can be removed once the runners' default version of Xcode is 16.4 or above + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: 16.4 + + - id: generation-step + run: swift run BuildTool generate-matrices >> $GITHUB_OUTPUT + + build-and-test-spm: + name: SPM (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withoutPlatform }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + # https://forums.swift.org/t/warnings-as-errors-for-libraries-frameworks/58393/2 + - run: swift build -Xswiftc -warnings-as-errors + - run: swift test -Xswiftc -warnings-as-errors + + build-release-configuration-spm: + name: SPM, `release` configuration (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withoutPlatform }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + # https://forums.swift.org/t/warnings-as-errors-for-libraries-frameworks/58393/2 + - run: swift build -Xswiftc -warnings-as-errors --configuration release + + build-and-test-xcode: + name: Xcode, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + # We run these as two separate steps so that we can easily see the execution time of each step. + + - name: Build for testing + env: + PLATFORM: ${{ matrix.platform }} + run: swift run BuildTool build-library-for-testing --platform "$PLATFORM" + + - name: Run tests + env: + PLATFORM: ${{ matrix.platform }} + run: swift run BuildTool test-library --platform "$PLATFORM" --without-building + + code-coverage: + name: Generate code coverage + runs-on: macos-15 + + permissions: + contents: read + checks: write + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + + # This step can be removed once the runners' default version of Xcode is 16.4 or above + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: 16.4 + + - run: swift run BuildTool generate-code-coverage --result-bundle-path CodeCoverage.xcresult + + # Generate a Markdown report of the code coverage information and add it to the workflow run. + # + # This tool is the best option that I could find after a brief look at the options. There are a few things that I wish it could do: + # + # - post a message on the pull request, like they do on Kotlin + # - offer more fine-grained configuration about which data to include in the report (I only care about code coverage, not test results, and I don't care about code coverage of the AblyLiveObjectsTests target) + # + # but it'll do for now (we can always fork or have another look for tooling later). + - uses: slidoapp/xcresulttool@9c51e32ae0320e0ca9b63240e4da8e974ac573e3 # v3.1.0 + with: + path: CodeCoverage.xcresult + # This title will be used for the sidebar item that this job adds to GitHub results page for this workflow + title: Code coverage results + # Turning off as much non-code-coverage information as it lets me + show-passed-tests: false + if: success() || failure() + + build-release-configuration-xcode: + name: Xcode, `release` configuration, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + - name: Build library + env: + PLATFORM: ${{ matrix.platform }} + run: swift run BuildTool build-library --platform "$PLATFORM" --configuration release + + check-example-app: + name: Example app, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + - name: Build example app + env: + PLATFORM: ${{ matrix.platform }} + run: swift run BuildTool build-example-app --platform "$PLATFORM" + + check-documentation: + runs-on: macos-15 + + permissions: + deployments: write + id-token: write + contents: read + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + + # This step can be removed once the runners' default version of Xcode is 16.4 or above + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: 16.4 + + # Dry run upload-action to get base-path url + - name: Dry-Run Upload (to get url) + id: preupload + uses: ably/sdk-upload-action@4e694297f208b72b5a9f6b1248a1556f19f821d6 # v2 + with: + mode: preempt + sourcePath: .build/plugins/Swift-DocC/outputs/AblyLiveObjects.doccarchive # Path to the Swift DocC output folder + githubToken: ${{ secrets.GITHUB_TOKEN }} + artifactName: AblyLiveObjects + + # Build the documentation using Swift DocC + - name: Build documentation + env: + HOSTING_BASE_PATH: ${{ steps.preupload.outputs.base-path }} + run: | + swift package generate-documentation --target AblyLiveObjects --disable-indexing \ + --hosting-base-path "$HOSTING_BASE_PATH" \ + --transform-for-static-hosting + working-directory: ${{ github.workspace }} + + # Configure AWS credentials for uploading documentation + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4 + with: + aws-region: eu-west-2 + role-to-assume: arn:aws:iam::${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}:role/ably-sdk-builds-ably-liveobjects-swift-plugin + role-session-name: "${{ github.run_id }}-${{ github.run_number }}" + + # Upload the generated documentation + - name: Upload Documentation + uses: ably/sdk-upload-action@4e694297f208b72b5a9f6b1248a1556f19f821d6 # v2 + with: + sourcePath: .build/plugins/Swift-DocC/outputs/AblyLiveObjects.doccarchive # Path to the Swift DocC output folder + githubToken: ${{ secrets.GITHUB_TOKEN }} + artifactName: AblyLiveObjects # Optional root-level directory name + landingPagePath: documentation/ablyliveobjects + + # We use this job as a marker that all of the required checks have completed. + # This allows us to configure a single required status check in our branch + # protection rules instead of having to type loads of different check names + # into the branch protection web UI (and keep this list up to date as we + # tweak the matrices). + all-checks-completed: + runs-on: ubuntu-latest + + permissions: {} + + needs: + - lint + # - spec-coverage + - build-and-test-spm + - build-release-configuration-spm + - build-and-test-xcode + - build-release-configuration-xcode + - check-example-app + - check-documentation + - code-coverage + + steps: + - name: No-op + run: "true" diff --git a/LiveObjects/.gitignore b/LiveObjects/.gitignore new file mode 100644 index 000000000..9383ec368 --- /dev/null +++ b/LiveObjects/.gitignore @@ -0,0 +1,19 @@ +# Start of .gitignore created by Swift Package Manager +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc +# End of .gitignore created by Swift Package Manager + +/node_modules +/.mint + +# User-created file that contains their Ably API key +/Example/AblyLiveObjectsExample/Secrets.swift + +# Stuff that Cursor checks out to use locally (this is my naming, not any special Cursor thing) +/cursor-support diff --git a/LiveObjects/.gitmodules b/LiveObjects/.gitmodules new file mode 100644 index 000000000..aecc76733 --- /dev/null +++ b/LiveObjects/.gitmodules @@ -0,0 +1,3 @@ +[submodule "Tests/AblyLiveObjectsTests/ably-common"] + path = Tests/AblyLiveObjectsTests/ably-common + url = https://github.com/ably/ably-common diff --git a/LiveObjects/.prettierignore b/LiveObjects/.prettierignore new file mode 100644 index 000000000..e2e73d508 --- /dev/null +++ b/LiveObjects/.prettierignore @@ -0,0 +1,6 @@ +# Don't try and format the asset catalogue JSON files, which are managed by Xcode +*.xcassets/ + +# Submodules +Tests/AblyLiveObjectsTests/ably-common +ably-cocoa diff --git a/LiveObjects/.prettierrc b/LiveObjects/.prettierrc new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/LiveObjects/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/LiveObjects/.swift-version b/LiveObjects/.swift-version new file mode 100644 index 000000000..a435f5a56 --- /dev/null +++ b/LiveObjects/.swift-version @@ -0,0 +1 @@ +6.1 diff --git a/LiveObjects/.swiftformat b/LiveObjects/.swiftformat new file mode 100644 index 000000000..fe6b3ba66 --- /dev/null +++ b/LiveObjects/.swiftformat @@ -0,0 +1,2 @@ +# Submodules +--exclude ably-cocoa diff --git a/LiveObjects/.swiftlint.yml b/LiveObjects/.swiftlint.yml new file mode 100644 index 000000000..f6d10a1e2 --- /dev/null +++ b/LiveObjects/.swiftlint.yml @@ -0,0 +1,114 @@ +excluded: + - .build + # Submodules + - ably-cocoa + +strict: true + +disabled_rules: + # All of the default rules of type "metrics". We have no reason to believe that the arbitrary defaults picked by SwiftLint are helpful. + - cyclomatic_complexity + - file_length + - function_body_length + - function_parameter_count + - large_tuple + - line_length + - nesting + - type_body_length + + # Rules of type "lint" that we've decided we don't want: + - todo # We frequently use TODOs accompanied by a GitHub issue reference + +opt_in_rules: + # All of the opt-in rules of type "performance": + - contains_over_filter_count + - contains_over_filter_is_empty + - contains_over_first_not_nil + - contains_over_range_nil_comparison + - empty_collection_literal + - empty_count + - empty_string + - first_where + - flatmap_over_map_reduce + - last_where + - reduce_into + - sorted_first_last + + # Opt-in rules of type "style" that we've decided we want: + - attributes + - closure_end_indentation + - closure_spacing + - collection_alignment + - comma_inheritance + - conditional_returns_on_newline + - file_header + - implicit_return + - literal_expression_end_indentation + - modifier_order + - multiline_arguments + - multiline_arguments_brackets + - multiline_function_chains + - multiline_literal_brackets + - multiline_parameters + - multiline_parameters_brackets + - operator_usage_whitespace + - prefer_self_type_over_type_of_self + - self_binding + - single_test_class + - sorted_imports + - switch_case_on_newline + - trailing_closure + - trailing_newline + - unneeded_parentheses_in_closure_argument + - vertical_parameter_alignment_on_call + - vertical_whitespace_closing_braces + - vertical_whitespace_opening_braces + + # Opt-in rules of type "idiomatic" that we've decided we want: + - anonymous_argument_in_multiline_closure + - convenience_type + - fallthrough + - fatal_error_message + - pattern_matching_keywords + - redundant_type_annotation + - shorthand_optional_binding + - static_operator + - toggle_bool + - xct_specific_matcher + + # Opt-in rules of type "lint" that we've decided we want: + - array_init + - empty_xctest_method + - override_in_extension + - yoda_condition + - private_swiftui_state + +file_header: + # The aim of this rule is to make sure that we delete the Xcode-generated boilerplate comment, which looks like + # + # // + # // File.swift + # // AblyLiveObjects + # // + # // Created by Lawrence Forooghian on 15/08/2024. + # // + forbidden_pattern: // Created by + +type_name: + &no_length_checks # We disable the length checks, for the same reason we disable the rules of type "metrics". + min_length: + warning: 1 + max_length: + warning: 10000 + +generic_type_name: *no_length_checks + +identifier_name: + <<: *no_length_checks + allowed_symbols: + # Allow underscore; it can be handy for adding a prefix to another identifier (e.g. our testsOnly_ convention) + - _ + +# For compatibility with SwiftFormat +trailing_comma: + mandatory_comma: true diff --git a/LiveObjects/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/LiveObjects/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..54782e32f --- /dev/null +++ b/LiveObjects/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + + diff --git a/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme b/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme new file mode 100644 index 000000000..5c088be7a --- /dev/null +++ b/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme b/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme new file mode 100644 index 000000000..007419881 --- /dev/null +++ b/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme b/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme new file mode 100644 index 000000000..dac481de7 --- /dev/null +++ b/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/LiveObjects/AblyLiveObjects.xcworkspace/contents.xcworkspacedata b/LiveObjects/AblyLiveObjects.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..82a77443a --- /dev/null +++ b/LiveObjects/AblyLiveObjects.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/LiveObjects/AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/LiveObjects/AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/LiveObjects/AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/LiveObjects/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LiveObjects/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..a45cc6840 --- /dev/null +++ b/LiveObjects/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,96 @@ +{ + "originHash" : "882ea3086372221060f5fc4b93d0254adddc2a879699f35a91adee80d3ba15ce", + "pins" : [ + { + "identity" : "ably-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/ably-cocoa.git", + "state" : { + "revision" : "83bb5366b09408723446d8ba8a146938eeba829e", + "version" : "1.2.59" + } + }, + { + "identity" : "ably-cocoa-plugin-support", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/ably-cocoa-plugin-support", + "state" : { + "revision" : "a290b8942086ffb6e21e4805d9319143669d9414", + "version" : "2.0.0" + } + }, + { + "identity" : "delta-codec-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/delta-codec-cocoa", + "state" : { + "revision" : "d53eec08f9443c6160d941327a6f9d8bbb93cea2", + "version" : "1.3.5" + } + }, + { + "identity" : "msgpack-objective-c", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rvi/msgpack-objective-C", + "state" : { + "revision" : "3e36b48e04ecd756cb927bd5f5b9bf6d45e475f9", + "version" : "0.4.0" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", + "version" : "1.0.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", + "version" : "1.4.3" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, + { + "identity" : "table", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JanGorman/Table.git", + "state" : { + "revision" : "7b8521c3b1078ba0b347964aec7c503cd6f1ff40", + "version" : "1.1.1" + } + } + ], + "version" : 3 +} diff --git a/LiveObjects/CHANGELOG.md b/LiveObjects/CHANGELOG.md new file mode 100644 index 000000000..c51b74820 --- /dev/null +++ b/LiveObjects/CHANGELOG.md @@ -0,0 +1,58 @@ +# Change Log + +## [0.4.0](https://github.com/ably/ably-liveobjects-swift-plugin/tree/0.4.0) + +### Operations are now applied on acknowledgement + +When you call a mutation method (e.g. `map.set()`), the SDK now applies the effects of this operation to the local LiveObjects data as soon as it receives the server's acknowledgement of the operation. This is an improvement over earlier versions, in which the SDK did not apply such an operation until receiving the operation's echo. + +Concretely, this means that in the following example, `fetchedValue` is now guaranteed to be `myValue`: + +```swift +try await map.set(key: "myKey", value: "myValue") +let fetchedValue = try map.get(key: "myKey") +``` + +### Other changes + +These changes do not affect the plugin's public API. + +- Buffer operations whilst `SYNCING` per updated RTO8a (https://github.com/ably/ably-liveobjects-swift-plugin/pull/109) +- Fix events emitted upon sync (https://github.com/ably/ably-liveobjects-swift-plugin/pull/111) +- Support the protocol v6 `ObjectMessage` structure (https://github.com/ably/ably-liveobjects-swift-plugin/pull/114) +- Implement new rules for discarding ops buffered during sync (https://github.com/ably/ably-liveobjects-swift-plugin/pull/121) +- Partial object sync (https://github.com/ably/ably-liveobjects-swift-plugin/pull/117) +- Implement `MAP_CLEAR` operation (https://github.com/ably/ably-liveobjects-swift-plugin/pull/122) + +**Full Changelog**: https://github.com/ably/ably-liveobjects-swift-plugin/compare/0.3.0...0.4.0 + +## [0.3.0](https://github.com/ably/ably-liveobjects-swift-plugin/tree/0.3.0) + +## What's Changed + +No public API changes. Some internal improvements: + +- Use server time for object ID ([#98](https://github.com/ably/ably-liveobjects-swift-plugin/pull/98)) +- Use server-sent GC grace period ([#99](https://github.com/ably/ably-liveobjects-swift-plugin/pull/99)) +- Always transition to `SYNCING` on receipt of `ATTACHED` ([#104](https://github.com/ably/ably-liveobjects-swift-plugin/pull/104)) + +**Full Changelog**: https://github.com/ably/ably-liveobjects-swift-plugin/compare/0.2.0...0.3.0 + +## [0.2.0](https://github.com/ably/ably-liveobjects-swift-plugin/tree/0.2.0) + +## What's Changed + +- Fixes an issue with SPM dependency specification that caused compliation errors. ([#93](https://github.com/ably/ably-liveobjects-swift-plugin/issues/93)) +- Changes `JSONValue`'s `number` associated value from `NSNumber` to `Double`. ([#91](https://github.com/ably/ably-liveobjects-swift-plugin/pull/91)) + +**Full Changelog**: https://github.com/ably/ably-liveobjects-swift-plugin/compare/0.1.0...0.2.0 + +## [0.1.0](https://github.com/ably/ably-liveobjects-swift-plugin/tree/0.1.0) + +## What's New + +- Our first release! LiveObjects provides a simple way to build collaborative applications with synchronized state across multiple clients in real-time. + +Learn [about Ably LiveObjects.](https://ably.com/docs/liveobjects) + +[Getting started with LiveObjects in Swift.](https://ably.com/docs/liveobjects/quickstart/swift) diff --git a/LiveObjects/CLAUDE.md b/LiveObjects/CLAUDE.md new file mode 100644 index 000000000..b1b9c7324 --- /dev/null +++ b/LiveObjects/CLAUDE.md @@ -0,0 +1,59 @@ +# CLAUDE.md + +## Build + +```sh +swift build +``` + +## Test + +When verifying changes, always run the unit tests first: + +```sh +swift run BuildTool test-library --platform macOS --only-unit-tests +``` + +This is fast (a few seconds) and excludes integration tests. Only run the full test suite if explicitly asked. + +## Lint + +```sh +swift run BuildTool lint +``` + +Use `--fix` to auto-fix where possible. + +## Specification Document + +The _Specification Document_ (or simply "the specification" or "the spec") specifies much of the behaviour of this codebase. It lives in the `specification` repository. If you need to consult it and you haven't been told where to find a local copy, ask. + +- The specification is structured as a list of specification points, each with an identifier such as `OD1`. Some specification points have subpoints, e.g. `REC2a`, which in turn can have subpoints like `REC2a1`. In the specification's Markdown source, the start of specification point `OD1` is represented by `` `(OD1)` ``. +- The LiveObjects functionality is referred to in the specification simply as "Objects". The LiveObjects-specific spec points are in a separate file, `specifications/objects-features.md`. +- If you are given a task that requires knowledge of the specification, you must consult it before proceeding. Never guess its contents. +- If the specification is unclear, mention this. + +## Swift style + +- Satisfy SwiftLint's `explicit_acl` rule (all declarations must specify access control level keywords explicitly). + - When writing an `extension` of a type, prefer placing the access level on the extension declaration rather than on each individual member. + - This does not apply to test code. +- When the type being initialised can be inferred, use the implicit `.init(…)` form instead of writing the type name explicitly. +- When the enum type can be inferred, use the implicit `.caseName` form instead of writing the type name explicitly. +- When writing `JSONValue` or `WireValue` types, use the literal syntax enabled by their `ExpressibleBy*Literal` conformances where possible. +- When writing a JSON string, use Swift raw string literals instead of escaping double quotes. +- When importing these modules in library (non-test) code: + - Ably: `import Ably` + - `_AblyPluginSupportPrivate`: `internal import _AblyPluginSupportPrivate` +- When writing an array literal that starts with an initialiser expression, start the initialiser on the line after the opening square bracket: + + ```swift + // Do this: + objectMessages: [ + InboundObjectMessage( + id: nil, + + // Not this: + objectMessages: [InboundObjectMessage( + id: nil, + ``` diff --git a/LiveObjects/CONTRIBUTING.md b/LiveObjects/CONTRIBUTING.md new file mode 100644 index 000000000..b99ee9f7b --- /dev/null +++ b/LiveObjects/CONTRIBUTING.md @@ -0,0 +1,167 @@ +# Contributing + +## Requirements + +- The Xcode version [mentioned in the README](./README.md#requirements) +- [Mint](https://github.com/yonaskolb/Mint) package manager +- Node.js (any recent version should be fine) + +## Setup + +1. `git submodule update --init` +2. `mint bootstrap` — this will take quite a long time (~5 minutes on my machine) the first time you run it +3. `npm install` + +## Running the tests + +Either: + +- `swift test`, or +- open `AblyLiveObjects.xcworkspace` in Xcode and test the `AblyLiveObjects` scheme + +### Running only the unit tests + +There is a test plan called `UnitTests` which excludes tests tagged with `.integration`. These tests are very quick to execute, so it's a useful option to have for quick feedback when developing. + +From the command line: + +```sh +swift run BuildTool test-library --platform macOS --only-unit-tests +``` + +Or in Xcode, set the `UnitTests` test plan as the _active test plan_ (the test plan which ⌘U will run): + +![Screenshot showing how to activate the UnitTests test plan](/images/unit-tests-test-plan-screenshot.png) + +## Linting + +To check formatting and code quality, run `swift run BuildTool lint`. Run with `--fix` to first automatically fix things where possible. + +## Development guidelines + +- The aim of the [example app](README.md#example-app) is that it demonstrate all of the core functionality of the SDK. So if you add a new feature, try to add something to the example app to demonstrate this feature. +- If you add a new feature, try to extend the `IntegrationTests` tests to perform a smoke test of its core functionality. +- We should aim to make it easy for consumers of the SDK to be able to mock out the SDK in the tests for their own code. A couple of things that will aid with this: + - Describe the SDK's functionality via protocols (when doing so would still be sufficiently idiomatic to Swift). + - When defining a `struct` that is emitted by the public API of the library, make sure to define a public memberwise initializer so that users can create one to be emitted by their mocks. (There is no way to make Swift's autogenerated memberwise initializer public, so you will need to write one yourself. In Xcode, you can do this by clicking at the start of the type declaration and doing Editor → Refactor → Generate Memberwise Initializer.) +- When writing code that implements behaviour specified by the LiveObjects features spec, add a comment that references the identifier of the relevant spec item. +- When writing methods that accept one of the public callback types (e.g. `LiveObjectUpdateCallback`), use the typealias name instead of the resolved type that Xcode fills in autocomplete; that is, write `LiveObjectUpdateCallback` instead of autocomplete's `(any LiveCounterUpdate) -> Void`. + +### Throwing errors + +- The public API of the SDK should use typed throws, and the thrown errors should be of type `ARTErrorInfo`. +- Some platform methods do not support typed throws. In these cases, we have our own extension which does; use this instead. They are: + - `Dictionary.mapValues`; use `ablyLiveObjects_mapValuesWithTypedThrow`. + - `NSLock.withLock`; use `ablyLiveObjects_withLockWithTypedThrow`. + +### Memory management + +We follow an approach to memory management that is broadly similar to that of ably-cocoa: we keep all of the internal components of the SDK alive as long as the user is holding a strong reference to any object vended by the public API of the SDK. This means that, for example, a user can use LiveObjects functionality by maintaining only a reference to the root `LiveMap`; even if they relinquish their references to, say, the `ARTRealtime` or `ARTRealtimeChannel` instance, they will continue to receive events from the `LiveMap` and they will be able to use its methods. + +We achieve this by vending a set of public types which maintain strong references to all of the internal components of the SDK which are needed in order for the public type to function correctly. For example, the public `PublicDefaultLiveMap` type wraps an `InternalDefaultLiveMap`, and holds strong references to the `CoreSDK` object, which in turn holds the following sequence of strong references: `CoreSDK` → `RealtimeClient` → `RealtimeChannel` → `InternalDefaultRealtimeObjects`, thus ensuring that: + +1. the `InternalDefaultLiveMap` can always perform actions on these dependencies in response to a user action +2. these dependencies, which deliver events to the `InternalDefaultLiveMap`, remain alive and thus remain delivering events + +The key rules that must be followed in order to avoid a strong reference cycle are that the SDK's _internal_ classes (that is `InternalDefaultLiveMap` etc) _must never hold a strong reference to_: + +- any of the corresponding public types (e.g. `PublicDefaultLiveMap`) +- any of the ably-cocoa components that hold a strong reference to these internal components (that is, the realtime client or channel); thus, they must never hold a strong reference to a `CoreSDK` object + +Note that, unlike ably-cocoa, the internal components do not even hold weak references to their dependencies; rather, these dependencies are passed in by the public object when the user performs an action that requires one of these dependencies. (There may turn out to be limitations to this approach, but haven't found them yet.) + +Also note that, unlike ably-cocoa, we aim to provide a stable pointer identity for the public objects vended by the SDK, instead of creating a new object each time the user requests one. See the `PublicObjectsStore` class for more details. + +The `Public…` classes all follow the same pattern and are not very interesting; the business logic should be in the `Internal…` classes and those should be where we focus our unit testing effort. + +`ObjectLifetimesTests.swift` contains tests of the behaviour described in this section. + +### Threading + +Since this is an extension of ably-cocoa, we follow the same threading approach: + +1. The public API can be interacted with from any thread, including synchronous methods such as getters +2. Callbacks passed to the public API are invoked on the same queue as used by the `ARTRealtime` instance (the `dispatchQueue` client option) +3. Synchronisation of mutable state is performed using the same internal serial dispatch queue as is used by the `ARTRealtime` instance (the `internalDispatchQueue` client option) + +We follow the same naming convention as in ably-cocoa whereby if a method's name contains `nosync` then it must be called on the internal dispatch queue. This allows us to avoid deadlocks that would result from attempting to call `DispatchQueue.sync { … }` when already on the internal queue. + +We have an extension on `DispatchQueue`, `ably_syncNoDeadlock(execute:)`, which behaves the same as `sync(execute:)` but with a runtime precondition that we are not already on the queue; favour our extension in order to avoid deadlock. + +### Testing guidelines + +#### Attributing tests to a spec point + +When writing a test that relates to a spec point from the LiveObjects features spec, add a comment that contains one of the following tags: + +- `@spec ` — The test case directly tests all the functionality documented in the spec item. +- `@specOneOf(m/n) ` — The test case is the mth of n test cases which, together, test all the functionality documented in the spec item. +- `@specPartial ` — The test case tests some, but not all, of the functionality documented in the spec item. This is different to `@specOneOf` in that it implies that the test suite does not fully test this spec item. + +The `` parameter should be a spec item identifier such as `CHA-RL3g`. + +Each of the above tags can optionally be followed by a hyphen and an explanation of how the test relates to the given spec item. + +Examples: + +```swift +// @spec CHA-EX3f +func test1 { … } +``` + +```swift +// @specOneOf(1/2) CHA-EX2h — Tests the case where the room is FAILED +func test2 { … } + +// @specOneOf(2/2) CHA-EX2h — Tests the case where the room is SUSPENDED +func test3 { … } +``` + +```swift +// @specPartial CHA-EX1h4 - Tests that we retry, but not the retry attempt limit because we've not implemented it yet +func test4 { … } +``` + +You can run `swift run BuildTool spec-coverage` to generate a report about how many spec points have been implemented and/or tested. This script is also run in CI by the `spec-coverage` job. This script will currently only detect a spec point attribution tag if it's written exactly as shown above; that is, in a `//` comment with a single space between each component of the tag. + +#### Marking a spec point as untested + +In addition to the above, you can add the following as a comment anywhere in the test suite: + +- `@specUntested - ` — This indicates that the SDK implements the given spec point, but that there are no automated tests for it. This should be used sparingly; only use it when there is no way to test a spec point. It must be accompanied by an explanation of why this spec point is not tested. +- `@specNotApplicable - ` — This indicates that the spec item is not relevant for this version of the SDK. It must be accompanied by an explanation of why. + +Example: + +```swift +// @specUntested CHA-EX2b - I was unable to find a way to test this spec point in an environment in which concurrency is being used; there is no obvious moment at which to stop observing the emitted state changes in order to be sure that FAILED has not been emitted twice. +``` + +```swift +// @specNotApplicable CHA-EX3a - Our API does not have a concept of "partial options" unlike the JS API which this spec item considers. +``` + +#### Using ably-cocoa internals in tests + +Some of our integration tests require access to ably-cocoa internals that are not exposed via `_AblyPluginSupportPrivate`, for example to inject protocol messages. Since, unlike the plugin implementation, the test suite does not require access to a stable private API (as it will never be compiled by end users and we're in control of which version of ably-cocoa gets used for testing the plugin), we just directly import ably-cocoa's internal APIs in the test suite using `import Ably.Private`. + +## Developing ably-cocoa alongside this plugin + +The quickest way to edit ably-cocoa is to use `swift package edit ably-cocoa --path ably-cocoa`, which will give you a Git checkout of ably-cocoa at `ably-cocoa`. To edit ably-cocoa using Xcode, you will then need to open `ably-cocoa/Package.swift` _separately_ (making sure to close any other LiveObjects Xcode windows, else Xcode will not let you edit it). + +If you use edit mode, Xcode will not let you edit ably-cocoa from _within_ `./Package.swift` or `AblyLiveObjects.xcworkspace` (it does not let you edit SPM dependencies even if they're in edit mode). If you wish to edit ably-cocoa in one of these environments, consider temporarily instead pulling ably-cocoa in as a submodule. See commit [`29c6aa8`](https://github.com/ably/ably-liveobjects-swift-plugin/commit/29c6aa8) as an example. + +## Release process + +For each release, the following needs to be done: + +- Create a new branch `release/x.x.x` (where `x.x.x` is the new version number) from the `main` branch +- Go to [Github releases](https://github.com/ably/ably-liveobjects-swift-plugin/releases) and press the `Draft a new release` button. Choose your new branch as a target +- Press the `Choose a tag` dropdown and start typing a new tag, Github will suggest the `Create new tag x.x.x on publish` option. After you select it Github will unveil the `Generate release notes` button +- From the newly generated changes remove everything that don't make much sense to the library user +- Copy the final list of changes to the top of the `CHANGELOG.md` file. Modify as necessary to fit the existing format of this file +- Commit these changes and push to the origin `git add CHANGELOG.md && git commit -m "Update change log." && git push -u origin release/x.x.x` +- Make a pull request against `main` and await approval of reviewer(s) +- Once approved and/or any additional commits have been added, merge the PR +- After merging the PR, wait for all CI jobs for `main` to pass. +- Publish your drafted release (refer to previous releases for release notes format) diff --git a/LiveObjects/COPYRIGHT b/LiveObjects/COPYRIGHT new file mode 100644 index 000000000..625d86cf7 --- /dev/null +++ b/LiveObjects/COPYRIGHT @@ -0,0 +1 @@ +Copyright 2024 Ably Real-time Ltd (ably.com) diff --git a/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj b/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj new file mode 100644 index 000000000..097bba4c5 --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj @@ -0,0 +1,394 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 70; + objects = { + +/* Begin PBXBuildFile section */ + 21971DFF2C60D89C0074B8AE /* AblyLiveObjects in Frameworks */ = {isa = PBXBuildFile; productRef = 21971DFE2C60D89C0074B8AE /* AblyLiveObjects */; }; + 8436FC692CA0723C0013EDE5 /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 8436FC682CA0723C0013EDE5 /* AsyncAlgorithms */; }; +/* End PBXBuildFile section */ + +/* Begin PBXFileReference section */ + 21F09A9C2C60CAF00025AF73 /* AblyLiveObjectsExample.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AblyLiveObjectsExample.app; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 2192E6892C9B633700783BF3 /* AblyLiveObjectsExample */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = AblyLiveObjectsExample; sourceTree = ""; }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 21F09A992C60CAF00025AF73 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 21971DFF2C60D89C0074B8AE /* AblyLiveObjects in Frameworks */, + 8436FC692CA0723C0013EDE5 /* AsyncAlgorithms in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 21971DFD2C60D89C0074B8AE /* Frameworks */ = { + isa = PBXGroup; + children = ( + ); + name = Frameworks; + sourceTree = ""; + }; + 21F09A932C60CAF00025AF73 = { + isa = PBXGroup; + children = ( + 2192E6892C9B633700783BF3 /* AblyLiveObjectsExample */, + 21F09A9D2C60CAF00025AF73 /* Products */, + 21971DFD2C60D89C0074B8AE /* Frameworks */, + ); + sourceTree = ""; + }; + 21F09A9D2C60CAF00025AF73 /* Products */ = { + isa = PBXGroup; + children = ( + 21F09A9C2C60CAF00025AF73 /* AblyLiveObjectsExample.app */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 21F09A9B2C60CAF00025AF73 /* AblyLiveObjectsExample */ = { + isa = PBXNativeTarget; + buildConfigurationList = 21F09AAB2C60CAF20025AF73 /* Build configuration list for PBXNativeTarget "AblyLiveObjectsExample" */; + buildPhases = ( + 21F09A982C60CAF00025AF73 /* Sources */, + 21F09A992C60CAF00025AF73 /* Frameworks */, + 21F09A9A2C60CAF00025AF73 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 2192E6892C9B633700783BF3 /* AblyLiveObjectsExample */, + ); + name = AblyLiveObjectsExample; + packageProductDependencies = ( + 21971DFE2C60D89C0074B8AE /* AblyLiveObjects */, + 8436FC682CA0723C0013EDE5 /* AsyncAlgorithms */, + ); + productName = AblyLiveObjectsExample; + productReference = 21F09A9C2C60CAF00025AF73 /* AblyLiveObjectsExample.app */; + productType = "com.apple.product-type.application"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 21F09A942C60CAF00025AF73 /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1540; + LastUpgradeCheck = 1600; + TargetAttributes = { + 21F09A9B2C60CAF00025AF73 = { + CreatedOnToolsVersion = 15.4; + }; + }; + }; + buildConfigurationList = 21F09A972C60CAF00025AF73 /* Build configuration list for PBXProject "AblyLiveObjectsExample" */; + compatibilityVersion = "Xcode 14.0"; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 21F09A932C60CAF00025AF73; + packageReferences = ( + 8436FC672CA0723C0013EDE5 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, + ); + productRefGroup = 21F09A9D2C60CAF00025AF73 /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 21F09A9B2C60CAF00025AF73 /* AblyLiveObjectsExample */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 21F09A9A2C60CAF00025AF73 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 21F09A982C60CAF00025AF73 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin XCBuildConfiguration section */ + 21F09AA92C60CAF20025AF73 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_VERSION = 6.0; + }; + name = Debug; + }; + 21F09AAA2C60CAF20025AF73 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEAD_CODE_STRIPPING = YES; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_TREAT_WARNINGS_AS_ERRORS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_TREAT_WARNINGS_AS_ERRORS = YES; + SWIFT_VERSION = 6.0; + }; + name = Release; + }; + 21F09AAC2C60CAF20025AF73 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_ASSET_PATHS = "\"AblyLiveObjectsExample/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ably LiveObjects"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.ably.AblyLiveObjectsExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 17.0; + }; + name = Debug; + }; + 21F09AAD2C60CAF20025AF73 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEAD_CODE_STRIPPING = YES; + DEVELOPMENT_ASSET_PATHS = "\"AblyLiveObjectsExample/Preview Content\""; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = "Ably LiveObjects"; + INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.developer-tools"; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; + "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; + "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; + LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; + "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; + MACOSX_DEPLOYMENT_TARGET = 14.0; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.ably.AblyLiveObjectsExample; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = auto; + SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx"; + SUPPORTS_MACCATALYST = NO; + SWIFT_EMIT_LOC_STRINGS = YES; + TARGETED_DEVICE_FAMILY = "1,2,3"; + TVOS_DEPLOYMENT_TARGET = 17.0; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 21F09A972C60CAF00025AF73 /* Build configuration list for PBXProject "AblyLiveObjectsExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 21F09AA92C60CAF20025AF73 /* Debug */, + 21F09AAA2C60CAF20025AF73 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 21F09AAB2C60CAF20025AF73 /* Build configuration list for PBXNativeTarget "AblyLiveObjectsExample" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 21F09AAC2C60CAF20025AF73 /* Debug */, + 21F09AAD2C60CAF20025AF73 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 8436FC672CA0723C0013EDE5 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/apple/swift-async-algorithms.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 1.0.1; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 21971DFE2C60D89C0074B8AE /* AblyLiveObjects */ = { + isa = XCSwiftPackageProductDependency; + productName = AblyLiveObjects; + }; + 8436FC682CA0723C0013EDE5 /* AsyncAlgorithms */ = { + isa = XCSwiftPackageProductDependency; + package = 8436FC672CA0723C0013EDE5 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; + productName = AsyncAlgorithms; + }; +/* End XCSwiftPackageProductDependency section */ + }; + rootObject = 21F09A942C60CAF00025AF73 /* Project object */; +} diff --git a/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..05de7ab26 --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,24 @@ +{ + "originHash" : "13678df8bf756b5a26255707c7a7a2f70f4fa09e8466f4b4d0ca97436222d7d0", + "pins" : [ + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", + "version" : "1.0.4" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", + "version" : "1.2.0" + } + } + ], + "version" : 3 +} diff --git a/LiveObjects/Example/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements b/LiveObjects/Example/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements new file mode 100644 index 000000000..625af03d9 --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements @@ -0,0 +1,12 @@ + + + + + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + + + diff --git a/LiveObjects/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift b/LiveObjects/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift new file mode 100644 index 000000000..6e3cd99a3 --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift @@ -0,0 +1,26 @@ +import Ably +import AblyLiveObjects +import SwiftUI + +@main +struct AblyLiveObjectsExampleApp: App { + private func getRealtime() -> ARTRealtime { + let clientOptions = ARTClientOptions(key: Secrets.ablyAPIKey) + clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] + return ARTRealtime(options: clientOptions) + } + + var body: some Scene { + WindowGroup { + #if os(macOS) + ContentView(realtime1: getRealtime(), realtime2: getRealtime()) + .frame(width: 400, height: 700, alignment: .center) + #else + ContentView(realtime1: getRealtime(), realtime2: getRealtime()) + #endif + } + #if os(macOS) + .windowResizability(.contentSize) + #endif + } +} diff --git a/LiveObjects/Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json b/LiveObjects/Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LiveObjects/Example/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/LiveObjects/Example/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..532cd729c --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,63 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LiveObjects/Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json b/LiveObjects/Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LiveObjects/Example/AblyLiveObjectsExample/ContentView.swift b/LiveObjects/Example/AblyLiveObjectsExample/ContentView.swift new file mode 100644 index 000000000..62b93c009 --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample/ContentView.swift @@ -0,0 +1,51 @@ +import Ably +import AblyLiveObjects +import SwiftUI + +struct ContentView: View { + @StateObject private var viewModel1: LiveCounterViewModel + @StateObject private var viewModel2: LiveCounterViewModel + @StateObject private var taskViewModel1: TaskBoardViewModel + @StateObject private var taskViewModel2: TaskBoardViewModel + private let realtime1: ARTRealtime + private let realtime2: ARTRealtime + + init(realtime1: ARTRealtime, realtime2: ARTRealtime) { + _viewModel1 = StateObject(wrappedValue: LiveCounterViewModel(realtime: realtime1)) + _viewModel2 = StateObject(wrappedValue: LiveCounterViewModel(realtime: realtime2)) + _taskViewModel1 = StateObject(wrappedValue: TaskBoardViewModel(realtime: realtime1)) + _taskViewModel2 = StateObject(wrappedValue: TaskBoardViewModel(realtime: realtime2)) + self.realtime1 = realtime1 + self.realtime2 = realtime2 + } + + var body: some View { + TabView { + // Live Counter tab + Group { + VStack(spacing: 1) { + LiveCounterView(viewModel: viewModel1, clientTitle: "Client 1") + Divider() + LiveCounterView(viewModel: viewModel2, clientTitle: "Client 2") + } + } + .tabItem { + Image(systemName: "plus.forwardslash.minus") + Text("Live Counter") + } + + // Task Board tab + Group { + VStack(spacing: 1) { + TaskBoardView(viewModel: taskViewModel1, clientTitle: "Client 1") + Divider() + TaskBoardView(viewModel: taskViewModel2, clientTitle: "Client 2") + } + } + .tabItem { + Image(systemName: "checklist") + Text("Task Board") + } + } + } +} diff --git a/LiveObjects/Example/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift b/LiveObjects/Example/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift new file mode 100644 index 000000000..38308a103 --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift @@ -0,0 +1,27 @@ +import Ably + +extension ARTRealtimeChannelProtocol { + func attachAsync() async throws(ARTErrorInfo) { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + attach { error in + if let error { + continuation.resume(returning: .failure(error)) + } else { + continuation.resume(returning: .success(())) + } + } + }.get() + } + + func detachAsync() async throws(ARTErrorInfo) { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + detach { error in + if let error { + continuation.resume(returning: .failure(error)) + } else { + continuation.resume(returning: .success(())) + } + } + }.get() + } +} diff --git a/LiveObjects/Example/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json b/LiveObjects/Example/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LiveObjects/Example/AblyLiveObjectsExample/Secrets.example.swift b/LiveObjects/Example/AblyLiveObjectsExample/Secrets.example.swift new file mode 100644 index 000000000..99cfda69b --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample/Secrets.example.swift @@ -0,0 +1,9 @@ +// 1. Create a copy of this file named Secrets.swift. (Secrets.swift is included in .gitignore, to stop you from accidentally checking it in.) +// 2. In Secrets.swift, uncomment the `Secrets` enum below. +// 3. In Secrets.swift, insert your Ably API key in the double quotes. +/* + enum Secrets { + // Insert your Ably API key inside the double quotes below. + static let ablyAPIKey = "" + } + */ diff --git a/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift b/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift new file mode 100644 index 000000000..6009eb514 --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift @@ -0,0 +1,218 @@ +import Ably +import AblyLiveObjects +import SwiftUI + +enum VoteColor: String, CaseIterable { + case red + case green + case blue + + var displayName: String { + rawValue.capitalized + } + + var swiftUIColor: SwiftUI.Color { + switch self { + case .red: + .red + case .green: + .green + case .blue: + .blue + } + } +} + +@MainActor +final class LiveCounterViewModel: ObservableObject { + @Published var redCount: Double = 0 + @Published var greenCount: Double = 0 + @Published var blueCount: Double = 0 + @Published var isLoading = true + @Published var errorMessage: String? + + private var realtime: ARTRealtime + private var channel: ARTRealtimeChannel + private var objects: any RealtimeObjects + private var root: (any LiveMap)? + + private var redCounter: (any LiveCounter)? + private var greenCounter: (any LiveCounter)? + private var blueCounter: (any LiveCounter)? + + private var subscribeResponses: [String: any SubscribeResponse] = [:] + + init(realtime: ARTRealtime) { + self.realtime = realtime + + // Use URL parameters or default channel name + let channelName = "live-objects-counter" + let channelOptions = ARTRealtimeChannelOptions() + channelOptions.modes = [.objectPublish, .objectSubscribe] + channel = realtime.channels.get(channelName, options: channelOptions) + objects = channel.objects + + Task { + await initializeCounters() + } + } + + deinit { + // Clean up subscriptions + subscribeResponses.values.forEach { $0.unsubscribe() } + subscribeResponses.removeAll() + } + + private func initializeCounters() async { + do { + isLoading = true + errorMessage = nil + + // Attach channel first + try await channel.attachAsync() + + // Get root object + let root = try await objects.getRoot() + self.root = root + + // Subscribe to root changes + let rootSubscription = try root.subscribe { [weak self] update, _ in + MainActor.assumeIsolated { + // Handle root updates - this will fire when counters are reset + for (keyName, change) in update.update { + if change == .updated, let color = VoteColor(rawValue: keyName) { + self?.subscribeToCounter(color: color) + } + } + } + } + subscribeResponses["root"] = rootSubscription + + // Initialize all color counters + for color in VoteColor.allCases { + await initializeCounter(for: color) + } + + isLoading = false + } catch { + errorMessage = "Failed to initialize: \(error.localizedDescription)" + isLoading = false + } + } + + private func initializeCounter(for color: VoteColor) async { + do { + guard let root else { + return + } + + // Check if counter already exists + if let existingValue = try root.get(key: color.rawValue), let existingCounter = existingValue.liveCounterValue { + // Counter exists, store it + setCounter(existingCounter, for: color) + } else { + // Counter doesn't exist, create it + let newCounter = try await objects.createCounter(count: 0) + try await root.set(key: color.rawValue, value: .liveCounter(newCounter)) + setCounter(newCounter, for: color) + } + // Subscribe to it + subscribeToCounter(color: color) + } catch { + errorMessage = "Failed to initialize \(color.rawValue) counter: \(error.localizedDescription)" + } + } + + private func setCounter(_ counter: any LiveCounter, for color: VoteColor) { + do { + let value = try counter.value + switch color { + case .red: + redCounter = counter + redCount = value + case .green: + greenCounter = counter + greenCount = value + case .blue: + blueCounter = counter + blueCount = value + } + } catch { + errorMessage = "Error getting \(color.rawValue) counter value: \(error)" + } + } + + private func subscribeToCounter(color: VoteColor) { + do { + guard let root, + let value = try root.get(key: color.rawValue), + let counter = value.liveCounterValue else { return } + + subscribeResponses[color.rawValue]?.unsubscribe() + + subscribeResponses[color.rawValue] = try counter.subscribe { [weak self] _, _ in + MainActor.assumeIsolated { + // Update current value + self?.updateCounterValue(for: color, counter: counter) + } + } + + // Set counter with value + setCounter(counter, for: color) + } catch { + errorMessage = "Failed to subscribe to \(color.rawValue) counter: \(error)" + } + } + + private func updateCounterValue(for color: VoteColor, counter: any LiveCounter) { + do { + let value = try counter.value + switch color { + case .red: + redCount = value + case .green: + greenCount = value + case .blue: + blueCount = value + } + } catch { + errorMessage = "Error updating \(color.rawValue) counter value: \(error)" + } + } + + func vote(for color: VoteColor) { + Task { + do { + let counter: (any LiveCounter)? = switch color { + case .red: + redCounter + case .green: + greenCounter + case .blue: + blueCounter + } + + try await counter?.increment(amount: 1) + } catch { + errorMessage = "Failed to vote for \(color.rawValue): \(error.localizedDescription)" + } + } + } + + func resetCounter(color: VoteColor) { + Task { + do { + let newCounter = try await objects.createCounter(count: 0) + try await self.root?.set(key: color.rawValue, value: .liveCounter(newCounter)) + } catch { + errorMessage = "Failed to reset counters: \(error.localizedDescription)" + } + } + } + + func resetAllCounters() { + for color in VoteColor.allCases { + resetCounter(color: color) + } + } +} diff --git a/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift b/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift new file mode 100644 index 000000000..dfd1b4da1 --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift @@ -0,0 +1,180 @@ +import Ably +import AblyLiveObjects +import SwiftUI + +@MainActor +final class TaskBoardViewModel: ObservableObject { + @Published var tasks: [String: String] = [:] + @Published var isLoading = true + @Published var errorMessage: String? + + private var realtime: ARTRealtime + private var channel: ARTRealtimeChannel + private var objects: any RealtimeObjects + private var root: (any LiveMap)? + private var tasksMap: (any LiveMap)? + + private var subscribeResponses: [String: any SubscribeResponse] = [:] + + init(realtime: ARTRealtime, channelName: String = "objects-live-map") { + self.realtime = realtime + + let channelOptions = ARTRealtimeChannelOptions() + channelOptions.modes = [.objectPublish, .objectSubscribe] + channel = realtime.channels.get(channelName, options: channelOptions) + objects = channel.objects + + Task { + await initializeTasks() + } + } + + deinit { + // Clean up subscriptions + subscribeResponses.values.forEach { $0.unsubscribe() } + subscribeResponses.removeAll() + } + + private func initializeTasks() async { + do { + isLoading = true + errorMessage = nil + + // Attach channel first + try await channel.attachAsync() + + // Get root object + let root = try await objects.getRoot() + self.root = root + + // Subscribe to root changes + let rootSubscription = try root.subscribe { [weak self] update, _ in + MainActor.assumeIsolated { + // Handle root updates - this will fire when tasks map is reset + if update.update["tasks"] == .updated { + if let newTasksMap = try? root.get(key: "tasks")?.liveMapValue { + self?.tasksMap = newTasksMap + self?.subscribeToTasksUpdates(tasksMap: newTasksMap) + } + } + } + } + subscribeResponses["root"] = rootSubscription + + // Initialize or get existing tasks map + if let existingTasksMap = try root.get(key: "tasks")?.liveMapValue { + tasksMap = existingTasksMap + subscribeToTasksUpdates(tasksMap: existingTasksMap) + } else { + let newTasksMap = try await objects.createMap() + try await root.set(key: "tasks", value: .liveMap(newTasksMap)) + tasksMap = newTasksMap + subscribeToTasksUpdates(tasksMap: newTasksMap) + } + + isLoading = false + } catch { + errorMessage = "Failed to initialize: \(error.localizedDescription)" + isLoading = false + } + } + + private func subscribeToTasksUpdates(tasksMap: any LiveMap) { + do { + // Load existing tasks + let entries = try tasksMap.entries + var currentTasks: [String: String] = [:] + + for (key, value) in entries { + if let stringValue = value.stringValue { + currentTasks[key] = stringValue + } + } + + tasks = currentTasks + + // Clean up existing subscription + subscribeResponses["tasks"]?.unsubscribe() + + // Subscribe to updates + subscribeResponses["tasks"] = try tasksMap.subscribe { [weak self] update, _ in + MainActor.assumeIsolated { + for (taskId, action) in update.update { + switch action { + case .updated: + if let updatedValue = try? tasksMap.get(key: taskId)?.stringValue { + self?.tasks[taskId] = updatedValue + } + case .removed: + self?.tasks.removeValue(forKey: taskId) + } + } + } + } + } catch { + errorMessage = "Failed to subscribe to tasks: \(error.localizedDescription)" + } + } + + func addTask(_ title: String) { + let taskTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !taskTitle.isEmpty else { + return + } + + Task { + do { + if let tasksMap { + let taskId = UUID().uuidString + try await tasksMap.set(key: taskId, value: .string(taskTitle)) + } + } catch { + errorMessage = "Failed to add task: \(error.localizedDescription)" + } + } + } + + func editTask(id: String, newTitle: String) { + let taskTitle = newTitle.trimmingCharacters(in: .whitespacesAndNewlines) + guard !taskTitle.isEmpty else { + return + } + + Task { + do { + if let tasksMap { + try await tasksMap.set(key: id, value: .string(taskTitle)) + } + } catch { + errorMessage = "Failed to edit task: \(error.localizedDescription)" + } + } + } + + func removeTask(id: String) { + Task { + do { + if let tasksMap { + try await tasksMap.remove(key: id) + } + } catch { + errorMessage = "Failed to remove task: \(error.localizedDescription)" + } + } + } + + func removeAllTasks() { + Task { + do { + guard let root = self.root else { + return + } + + let newTasksMap = try await objects.createMap() + try await root.set(key: "tasks", value: .liveMap(newTasksMap)) + } catch { + errorMessage = "Failed to remove all tasks: \(error.localizedDescription)" + } + } + } +} diff --git a/LiveObjects/Example/AblyLiveObjectsExample/Views/LiveCounterView.swift b/LiveObjects/Example/AblyLiveObjectsExample/Views/LiveCounterView.swift new file mode 100644 index 000000000..93516d16a --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample/Views/LiveCounterView.swift @@ -0,0 +1,117 @@ +import Ably +import AblyLiveObjects +import SwiftUI + +struct LiveCounterView: View { + @ObservedObject var viewModel: LiveCounterViewModel + let clientTitle: String + + var body: some View { + VStack(spacing: 0) { + if viewModel.isLoading { + ProgressView("Loading...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(spacing: 16) { + // Client identifier + Text(clientTitle) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.secondary) + + // Card container + VStack(spacing: 2) { + // Header + Text("Vote for your favorite Color") + .font(.system(size: 14, weight: .bold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + + // Vote options + VStack(spacing: 0) { + ForEach(Array(VoteColor.allCases.enumerated()), id: \.offset) { index, color in + VoteRow(color: color, count: countForColor(color)) { + viewModel.vote(for: color) + } + if index < VoteColor.allCases.count - 1 { + Divider() + .background(SwiftUI.Color.gray.opacity(0.3)) + } + } + } + + // Reset button + Button(action: viewModel.resetAllCounters) { + Text("Reset") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + .padding(.horizontal, 16) + .padding(.vertical, 8) + } + .padding(.top, 6) + } + .padding(14) + .background(.regularMaterial) + .cornerRadius(12) + .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 2) + .frame(maxWidth: 320) + + // Error message + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .font(.caption) + .padding(5) + } + } + .padding(14) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.thickMaterial) + } + } + } + + private func countForColor(_ color: VoteColor) -> Int { + switch color { + case .red: + Int(viewModel.redCount) + case .green: + Int(viewModel.greenCount) + case .blue: + Int(viewModel.blueCount) + } + } +} + +struct VoteRow: View { + let color: VoteColor + let count: Int + let onVote: () -> Void + + var body: some View { + HStack(spacing: 16) { + // Color name + Text(color.displayName) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(color.swiftUIColor) + .frame(maxWidth: .infinity, alignment: .leading) + + // Count + Text("\(count)") + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.primary) + + // Vote button + Button(action: onVote) { + Text("Vote") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + .padding(.horizontal, 10) + .padding(.vertical, 4) + } + } + .padding(.horizontal, 8) + .padding(.vertical, 12) + } +} diff --git a/LiveObjects/Example/AblyLiveObjectsExample/Views/TaskBoardView.swift b/LiveObjects/Example/AblyLiveObjectsExample/Views/TaskBoardView.swift new file mode 100644 index 000000000..a3c5ce9fb --- /dev/null +++ b/LiveObjects/Example/AblyLiveObjectsExample/Views/TaskBoardView.swift @@ -0,0 +1,177 @@ +import Ably +import AblyLiveObjects +import SwiftUI + +struct TaskBoardView: View { + @ObservedObject var viewModel: TaskBoardViewModel + let clientTitle: String + + @State private var taskInput = "" + + var body: some View { + VStack(spacing: 0) { + if viewModel.isLoading { + ProgressView("Loading...") + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + VStack(spacing: 16) { + // Client identifier + Text(clientTitle) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.secondary) + + // Card container + VStack(spacing: 2) { + // Header + Text("Realtime Task Board") + .font(.system(size: 14, weight: .bold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.bottom, 8) + + // Input section + HStack(spacing: 12) { + TextField("Enter task", text: $taskInput) + #if !os(tvOS) + .textFieldStyle(.roundedBorder) + #endif + .onSubmit { + addTask() + } + + Button("Add") { + addTask() + } + .buttonStyle(.borderedProminent) + #if !os(tvOS) + .controlSize(.small) + #endif + .disabled(taskInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + + Button("Remove all") { + viewModel.removeAllTasks() + } + .buttonStyle(.bordered) + #if !os(tvOS) + .controlSize(.small) + #endif + } + .padding(.bottom, 12) + + // Tasks list + VStack(spacing: 0) { + if viewModel.tasks.isEmpty { + VStack(spacing: 8) { + Image(systemName: "checklist") + .font(.system(size: 32)) + .foregroundColor(.secondary) + Text("No tasks yet") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.secondary) + Text("Add your first task above") + .font(.system(size: 10)) + .foregroundColor(.secondary) + } + .padding(.vertical, 20) + } else { + ForEach(Array(viewModel.tasks.keys.enumerated()), id: \.offset) { index, taskId in + TaskRow( + id: taskId, + title: viewModel.tasks[taskId] ?? "", + onEdit: { newTitle in + viewModel.editTask(id: taskId, newTitle: newTitle) + }, + onRemove: { + viewModel.removeTask(id: taskId) + }, + ) + if index < viewModel.tasks.keys.count - 1 { + Divider() + .background(SwiftUI.Color.gray.opacity(0.3)) + } + } + } + } + } + .padding(24) + .background(.regularMaterial) + .cornerRadius(12) + .shadow(color: .black.opacity(0.1), radius: 8, x: 0, y: 2) + .frame(maxWidth: 320) + + // Error message + if let errorMessage = viewModel.errorMessage { + Text(errorMessage) + .foregroundColor(.red) + .font(.caption) + .padding() + } + } + .padding(24) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.thickMaterial) + } + } + } + + private func addTask() { + let title = taskInput.trimmingCharacters(in: .whitespacesAndNewlines) + guard !title.isEmpty else { + return + } + + viewModel.addTask(title) + taskInput = "" + } +} + +struct TaskRow: View { + let id: String + let title: String + let onEdit: (String) -> Void + let onRemove: () -> Void + + @State private var showingEditAlert = false + @State private var editingTitle = "" + + var body: some View { + HStack(spacing: 16) { + // Task title + Text(title) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.primary) + .frame(maxWidth: .infinity, alignment: .leading) + + // Edit button + Button("Edit") { + editingTitle = title + showingEditAlert = true + } + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.blue) + + // Remove button + Button("Remove") { + onRemove() + } + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.red) + } + .padding(.horizontal, 8) + .padding(.vertical, 12) + .alert("Edit Task", isPresented: $showingEditAlert) { + TextField("Task title", text: $editingTitle) + Button("Cancel", role: .cancel) {} + Button("Save") { + let trimmedTitle = editingTitle.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmedTitle.isEmpty { + onEdit(trimmedTitle) + } + } + .disabled(editingTitle.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + } message: { + Text("Enter a new title for this task") + } + } +} diff --git a/LiveObjects/LICENSE b/LiveObjects/LICENSE new file mode 100644 index 000000000..d9a10c0d8 --- /dev/null +++ b/LiveObjects/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/LiveObjects/MAINTAINERS.md b/LiveObjects/MAINTAINERS.md new file mode 100644 index 000000000..e5fda9517 --- /dev/null +++ b/LiveObjects/MAINTAINERS.md @@ -0,0 +1 @@ +This repository is owned by the Ably Ecosystems team. diff --git a/LiveObjects/Mintfile b/LiveObjects/Mintfile new file mode 100644 index 000000000..b2015d8dc --- /dev/null +++ b/LiveObjects/Mintfile @@ -0,0 +1,2 @@ +realm/SwiftLint@0.59.1 +nicklockwood/SwiftFormat@0.56.1 diff --git a/LiveObjects/Package.resolved b/LiveObjects/Package.resolved new file mode 100644 index 000000000..95a949028 --- /dev/null +++ b/LiveObjects/Package.resolved @@ -0,0 +1,96 @@ +{ + "originHash" : "263b1ea0fd786ff1456ebaef26e6b6baa0f018c0154cf2929eaa91abb0b54061", + "pins" : [ + { + "identity" : "ably-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/ably-cocoa.git", + "state" : { + "revision" : "83bb5366b09408723446d8ba8a146938eeba829e", + "version" : "1.2.59" + } + }, + { + "identity" : "ably-cocoa-plugin-support", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/ably-cocoa-plugin-support", + "state" : { + "revision" : "a290b8942086ffb6e21e4805d9319143669d9414", + "version" : "2.0.0" + } + }, + { + "identity" : "delta-codec-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/delta-codec-cocoa", + "state" : { + "revision" : "d53eec08f9443c6160d941327a6f9d8bbb93cea2", + "version" : "1.3.5" + } + }, + { + "identity" : "msgpack-objective-c", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rvi/msgpack-objective-C", + "state" : { + "revision" : "3e36b48e04ecd756cb927bd5f5b9bf6d45e475f9", + "version" : "0.4.0" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", + "version" : "1.0.1" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-docc-plugin", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-docc-plugin", + "state" : { + "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", + "version" : "1.4.3" + } + }, + { + "identity" : "swift-docc-symbolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", + "state" : { + "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", + "version" : "1.0.0" + } + }, + { + "identity" : "table", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JanGorman/Table.git", + "state" : { + "revision" : "7b8521c3b1078ba0b347964aec7c503cd6f1ff40", + "version" : "1.1.1" + } + } + ], + "version" : 3 +} diff --git a/LiveObjects/Package.swift b/LiveObjects/Package.swift new file mode 100644 index 000000000..982af18db --- /dev/null +++ b/LiveObjects/Package.swift @@ -0,0 +1,98 @@ +// swift-tools-version: 6.1 + +import PackageDescription + +let package = Package( + name: "AblyLiveObjects", + platforms: [ + .macOS(.v11), + .iOS(.v14), + .tvOS(.v14), + ], + products: [ + .library( + name: "AblyLiveObjects", + targets: [ + "AblyLiveObjects", + ], + ), + ], + dependencies: [ + .package( + url: "https://github.com/ably/ably-cocoa.git", + from: "1.2.59", + ), + .package( + url: "https://github.com/ably/ably-cocoa-plugin-support", + from: "2.0.0", + ), + .package( + url: "https://github.com/apple/swift-argument-parser", + from: "1.5.0", + ), + .package( + url: "https://github.com/apple/swift-async-algorithms", + from: "1.0.1", + ), + .package( + url: "https://github.com/JanGorman/Table.git", + from: "1.1.1", + ), + .package( + url: "https://github.com/apple/swift-docc-plugin", + from: "1.0.0", + ), + ], + targets: [ + .target( + name: "AblyLiveObjects", + dependencies: [ + .product( + name: "Ably", + package: "ably-cocoa", + ), + .product( + name: "_AblyPluginSupportPrivate", + package: "ably-cocoa-plugin-support", + ), + ], + ), + .testTarget( + name: "AblyLiveObjectsTests", + dependencies: [ + "AblyLiveObjects", + .product( + name: "Ably", + package: "ably-cocoa", + ), + .product( + name: "_AblyPluginSupportPrivate", + package: "ably-cocoa-plugin-support", + ), + ], + exclude: [ + "CLAUDE.md", + ], + resources: [ + .copy("ably-common"), + ], + ), + .executableTarget( + name: "BuildTool", + dependencies: [ + .product( + name: "ArgumentParser", + package: "swift-argument-parser", + ), + .product( + name: "AsyncAlgorithms", + package: "swift-async-algorithms", + ), + .product( + name: "Table", + package: "Table", + ), + ], + ), + ], +) diff --git a/LiveObjects/README.md b/LiveObjects/README.md new file mode 100644 index 000000000..6ed7a235d --- /dev/null +++ b/LiveObjects/README.md @@ -0,0 +1,73 @@ +![Ably LiveObjects Swift Header](images/SwiftSDK-LiveObjects-github.png) +[![SPM Swift Compatibility](https://img.shields.io/endpoint?url=https%3A%2F%2Fswiftpackageindex.com%2Fapi%2Fpackages%2Fably%2Fably-liveobjects-swift-plugin%2Fbadge%3Ftype%3Dswift-versions)](https://swiftpackageindex.com/ably/ably-liveobjects-swift-plugin) +[![License](https://badgen.net/github/license/ably/ably-liveobjects-swift-plugin)](https://github.com/ably/ably-liveobjects-swift-plugin/blob/main/LICENSE) + +--- + +# Ably LiveObjects Swift plugin + +The Ably LiveObjects plugin enables real-time collaborative data synchronization for the [ably-cocoa](https://github.com/ably/ably-cocoa/) SDK. LiveObjects provides a simple way to build collaborative applications with synchronized state across multiple clients in real-time. Built on [Ably's](https://ably.com/) core service, it abstracts complex details to enable efficient collaborative architectures. + +> [!WARNING] +> This plugin is currently experimental and the public API may change. + +--- + +## Getting started + +Everything you need to get started with Ably LiveObjects: + +- [Learn about Ably LiveObjects.](https://ably.com/docs/liveobjects) +- [Getting started with LiveObjects in Swift.](https://ably.com/docs/liveobjects/quickstart/swift) +- Explore the [example app](Example) to see LiveObjects in action. + +--- + +## Supported platforms + +Ably aims to support a wide range of platforms. If you experience any compatibility issues, open an issue in the repository or contact [Ably support](https://ably.com/support). + +This plugin supports the following platforms: + +| Platform | Support | +| -------- | ------- | +| iOS | >= 14.0 | +| macOS | >= 11.0 | +| tvOS | >= 14.0 | + +> [!NOTE] +> Xcode 16.3 or later is required. + +--- + +## Example app + +This repository contains an example app, written using SwiftUI, which demonstrates how to use the plugin. The code for this app is in the [`Example`](Example) directory. + +In order to allow the app to use modern SwiftUI features, it supports the following OS versions: + +- macOS 14 and above +- iOS 17 and above +- tvOS 17 and above + +To run the app: + +1. Open the `AblyLiveObjects.xcworkspace` workspace in Xcode. +2. Follow the instructions inside the `Secrets.example.swift` file to add your Ably API key to the example app. +3. Run the `AblyLiveObjectsExample` target. If you wish to run it on an iOS or tvOS device, you'll need to set up code signing. + +--- + +## Releases + +The [CHANGELOG.md](./CHANGELOG.md) contains details of the latest releases for this plugin. You can also view all Ably releases on [changelog.ably.com](https://changelog.ably.com). + +--- + +## Contribute + +Read the [CONTRIBUTING.md](./CONTRIBUTING.md) guidelines to contribute to Ably or [share feedback or request a new feature](https://forms.gle/mBw9M53NYuCBLFpMA). + +## Support, feedback and troubleshooting + +For help or technical support, visit Ably's [support page](https://ably.com/support). You can also view the [community reported GitHub issues](https://github.com/ably/ably-liveobjects-swift-plugin/issues) or raise one yourself. diff --git a/LiveObjects/Sources/AblyLiveObjects/.swiftformat b/LiveObjects/Sources/AblyLiveObjects/.swiftformat new file mode 100644 index 000000000..3dd75866c --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/.swiftformat @@ -0,0 +1,2 @@ +# To avoid clash with SwiftLint's explicit_acl rule +--disable redundantInternal diff --git a/LiveObjects/Sources/AblyLiveObjects/.swiftlint.yml b/LiveObjects/Sources/AblyLiveObjects/.swiftlint.yml new file mode 100644 index 000000000..85b1c9a91 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/.swiftlint.yml @@ -0,0 +1,3 @@ +opt_in_rules: + # Opt-in rules of type "idiomatic" that we've decided we want: + - explicit_acl diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift new file mode 100644 index 000000000..685543452 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift @@ -0,0 +1,46 @@ +internal import _AblyPluginSupportPrivate +import Ably + +internal extension ARTClientOptions { + private class Box { + internal let boxed: T + + internal init(boxed: T) { + self.boxed = boxed + } + } + + private static let garbageCollectionOptionsKey = "Objects.garbageCollectionOptions" + + /// Can be overriden for testing purposes. + var garbageCollectionOptions: InternalDefaultRealtimeObjects.GarbageCollectionOptions? { + get { + let optionsValue = Plugin.defaultPluginAPI.pluginOptionsValue( + forKey: Self.garbageCollectionOptionsKey, + clientOptions: asPluginPublicClientOptions, + ) + + guard let optionsValue else { + return nil + } + + guard let box = optionsValue as? Box else { + preconditionFailure("Expected GarbageCollectionOptionsBox, got \(optionsValue)") + } + + return box.boxed + } + + set { + guard let newValue else { + preconditionFailure("Not implemented the ability to un-set GC options") + } + + Plugin.defaultPluginAPI.setPluginOptionsValue( + Box(boxed: newValue), + forKey: Self.garbageCollectionOptionsKey, + clientOptions: asPluginPublicClientOptions, + ) + } + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift new file mode 100644 index 000000000..8c4e9c4e9 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -0,0 +1,137 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// The API that the internal components of the SDK (that is, `DefaultLiveObjects` and down) use to interact with our core SDK (i.e. ably-cocoa). +/// +/// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to `_AblyPluginSupportPrivate`'s Objective-C API (i.e. boxing). +internal protocol CoreSDK: AnyObject, Sendable { + /// Implements the internal `#publish` method of RTO15. + func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) + + /// Implements the server time fetch of RTO16, including the storing and usage of the local clock offset. + func nosync_fetchServerTime(callback: @escaping @Sendable (Result) -> Void) + + /// Replaces the implementation of ``nosync_publish(objectMessages:callback:)``. + /// + /// Used by integration tests, for example to disable `ObjectMessage` publishing so that a test can verify that a behaviour is not a side effect of an `ObjectMessage` sent by the SDK. + func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) + + /// Returns the current state of the Realtime channel that this wraps. + var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { get } +} + +internal final class DefaultCoreSDK: CoreSDK { + /// Used to synchronize access to internal mutable state. + private let mutex = NSLock() + + private let channel: _AblyPluginSupportPrivate.RealtimeChannel + private let client: _AblyPluginSupportPrivate.RealtimeClient + private let pluginAPI: PluginAPIProtocol + private let logger: Logger + + /// If set, ``publish(objectMessages:)`` delegates to this implementation. + /// + /// This enables the `testsOnly_overridePublish(with:)` test hook. + /// + /// - Note: This should be `throws(ARTErrorInfo)` but that causes a compilation error of "Runtime support for typed throws function types is only available in macOS 15.0.0 or newer". + private nonisolated(unsafe) var overriddenPublishImplementation: (([OutboundObjectMessage]) async throws -> PublishResult)? + + internal init( + channel: _AblyPluginSupportPrivate.RealtimeChannel, + client: _AblyPluginSupportPrivate.RealtimeClient, + pluginAPI: PluginAPIProtocol, + logger: Logger + ) { + self.channel = channel + self.client = client + self.pluginAPI = pluginAPI + self.logger = logger + } + + // MARK: - CoreSDK conformance + + internal func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { + logger.log("nosync_publish(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) + + // Use the overridden implementation if supplied + let overriddenImplementation = mutex.withLock { + overriddenPublishImplementation + } + if let overriddenImplementation { + let queue = pluginAPI.internalQueue(for: client) + Task { + do { + let publishResult = try await overriddenImplementation(objectMessages) + queue.async { callback(.success(publishResult)) } + } catch { + guard let artErrorInfo = error as? ARTErrorInfo else { + preconditionFailure("Expected ARTErrorInfo, got \(error)") + } + queue.async { callback(.failure(artErrorInfo)) } + } + } + return + } + + // TODO: Implement message size checking (https://github.com/ably/ably-liveobjects-swift-plugin/issues/13) + DefaultInternalPlugin.nosync_sendObject( + objectMessages: objectMessages, + channel: channel, + client: client, + pluginAPI: pluginAPI, + callback: callback, + ) + } + + internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { + mutex.withLock { + overriddenPublishImplementation = newImplementation + } + } + + internal func nosync_fetchServerTime(callback: @escaping @Sendable (Result) -> Void) { + let internalQueue = pluginAPI.internalQueue(for: client) + + pluginAPI.nosync_fetchServerTime(for: client) { serverTime, error in + dispatchPrecondition(condition: .onQueue(internalQueue)) + + if let error { + callback(.failure(ARTErrorInfo.castPluginPublicErrorInfo(error))) + } else { + guard let serverTime else { + preconditionFailure("nosync_fetchServerTime gave nil serverTime and nil error") + } + callback(.success(serverTime)) + } + } + } + + internal var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { + pluginAPI.nosync_state(for: channel) + } +} + +// MARK: - Channel State Validation + +/// Extension on CoreSDK to provide channel state validation utilities. +internal extension CoreSDK { + /// Validates that the channel is not in any of the specified invalid states. + /// + /// - Parameters: + /// - invalidStates: Array of channel states that are considered invalid for the operation + /// - operationDescription: A description of the operation being performed, used in error messages + /// - Throws: `ARTErrorInfo` with code 90001 and statusCode 400 if the channel is in any of the invalid states + func nosync_validateChannelState( + notIn invalidStates: [_AblyPluginSupportPrivate.RealtimeChannelState], + operationDescription: String, + ) throws(ARTErrorInfo) { + let currentChannelState = nosync_channelState + if invalidStates.contains(currentChannelState) { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: operationDescription, + channelState: currentChannelState, + ) + .toARTErrorInfo() + } + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift new file mode 100644 index 000000000..b15c7ab74 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -0,0 +1,197 @@ +internal import _AblyPluginSupportPrivate +import Ably + +// We explicitly import the NSObject class, else it seems to get transitively imported from `internal import _AblyPluginSupportPrivate`, leading to the error "Class cannot be declared public because its superclass is internal". +import ObjectiveC.NSObject + +/// The default implementation of `_AblyPluginSupportPrivate`'s `LiveObjectsInternalPluginProtocol`. Implements the interface that ably-cocoa uses to access the functionality provided by the LiveObjects plugin. +@objc +internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate.LiveObjectsInternalPluginProtocol { + private let pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol + + internal var compatibleWithProtocolV6: Bool { true } + + internal init(pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol) { + precondition( + pluginAPI.usesLiveObjectsProtocolV6, + "This version of the LiveObjects plugin requires a version of ably-cocoa that uses LiveObjects protocol v6.", + ) + self.pluginAPI = pluginAPI + } + + // MARK: - Channel `objects` property + + /// The `pluginDataValue(forKey:channel:)` key that we use to store the value of the `ARTRealtimeChannel.objects` property. + private static let pluginDataKey = "LiveObjects" + + /// Retrieves the `RealtimeObjects` for this channel. + /// + /// We expect this value to have been previously set by ``prepare(_:)``. + internal static func nosync_realtimeObjects(for channel: _AblyPluginSupportPrivate.RealtimeChannel, pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol) -> InternalDefaultRealtimeObjects { + guard let pluginData = pluginAPI.nosync_pluginDataValue(forKey: pluginDataKey, channel: channel) else { + // InternalPlugin.prepare was not called + fatalError("To access LiveObjects functionality, you must pass the LiveObjects plugin in the client options when creating the ARTRealtime instance: `clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self]`") + } + + // swiftlint:disable:next force_cast + return pluginData as! InternalDefaultRealtimeObjects + } + + // MARK: - LiveObjectsInternalPluginProtocol + + // Populates the channel's `objects` property. + internal func nosync_prepare(_ channel: _AblyPluginSupportPrivate.RealtimeChannel, client: _AblyPluginSupportPrivate.RealtimeClient) { + let pluginLogger = pluginAPI.logger(for: channel) + let internalQueue = pluginAPI.internalQueue(for: client) + let callbackQueue = pluginAPI.callbackQueue(for: client) + let options = ARTClientOptions.castPluginPublicClientOptions(pluginAPI.options(for: client)) + + let garbageCollectionOptions = options.garbageCollectionOptions ?? { + if let latestConnectionDetails = pluginAPI.nosync_latestConnectionDetails(for: client), let gracePeriod = latestConnectionDetails.objectsGCGracePeriod { + // If we already have connection details, then use its grace period per RTO10b2 + .init(gracePeriod: .dynamic(gracePeriod.doubleValue)) + } else { + // Use the default grace period + .init() + } + }() + + let logger = DefaultLogger(pluginLogger: pluginLogger, pluginAPI: pluginAPI) + logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) + let liveObjects = InternalDefaultRealtimeObjects( + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: callbackQueue, + clock: DefaultSimpleClock(), + garbageCollectionOptions: garbageCollectionOptions, + ) + pluginAPI.nosync_setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) + } + + /// Retrieves the internally-typed `objects` property for the channel. + private func nosync_realtimeObjects(for channel: _AblyPluginSupportPrivate.RealtimeChannel) -> InternalDefaultRealtimeObjects { + Self.nosync_realtimeObjects(for: channel, pluginAPI: pluginAPI) + } + + /// A class that wraps an object message. + /// + /// We need this intermediate type because we want object messages to be structs — because they're nicer to work with internally — but a struct can't conform to the class-bound `_AblyPluginSupportPrivate.ObjectMessageProtocol`. + internal final class ObjectMessageBox: _AblyPluginSupportPrivate.ObjectMessageProtocol where T: Sendable { + internal let objectMessage: T + + internal init(objectMessage: T) { + self.objectMessage = objectMessage + } + } + + internal func decodeObjectMessage( + _ serialized: [String: Any], + context: DecodingContextProtocol, + format: EncodingFormat, + error errorPtr: AutoreleasingUnsafeMutablePointer<_AblyPluginSupportPrivate.PublicErrorInfo?>?, + ) -> (any ObjectMessageProtocol)? { + let wireObject = WireValue.objectFromPluginSupportData(serialized) + + do { + let wireObjectMessage = try InboundWireObjectMessage( + wireObject: wireObject, + decodingContext: context, + ) + let objectMessage = try InboundObjectMessage( + wireObjectMessage: wireObjectMessage, + format: format, + ) + return ObjectMessageBox(objectMessage: objectMessage) + } catch { + errorPtr?.pointee = error.asPluginPublicErrorInfo + return nil + } + } + + internal func encodeObjectMessage( + _ publicObjectMessage: any _AblyPluginSupportPrivate.ObjectMessageProtocol, + format: EncodingFormat, + ) -> [String: Any] { + guard let outboundObjectMessageBox = publicObjectMessage as? ObjectMessageBox else { + preconditionFailure("Expected to receive the same OutboundObjectMessage type as we emit") + } + + let wireObjectMessage = outboundObjectMessageBox.objectMessage.toWire(format: format) + return wireObjectMessage.toWireObject.toPluginSupportDataDictionary + } + + internal func nosync_onChannelAttached(_ channel: _AblyPluginSupportPrivate.RealtimeChannel, hasObjects: Bool) { + nosync_realtimeObjects(for: channel).nosync_onChannelAttached(hasObjects: hasObjects) + } + + internal func nosync_handleObjectProtocolMessage(withObjectMessages publicObjectMessages: [any _AblyPluginSupportPrivate.ObjectMessageProtocol], channel: _AblyPluginSupportPrivate.RealtimeChannel) { + guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { + preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") + } + + let objectMessages = inboundObjectMessageBoxes.map(\.objectMessage) + + nosync_realtimeObjects(for: channel).nosync_handleObjectProtocolMessage( + objectMessages: objectMessages, + ) + } + + internal func nosync_handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any _AblyPluginSupportPrivate.ObjectMessageProtocol], protocolMessageChannelSerial: String?, channel: _AblyPluginSupportPrivate.RealtimeChannel) { + guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { + preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") + } + + let objectMessages = inboundObjectMessageBoxes.map(\.objectMessage) + + nosync_realtimeObjects(for: channel).nosync_handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: protocolMessageChannelSerial, + ) + } + + internal func nosync_onChannelStateChanged(_ channel: _AblyPluginSupportPrivate.RealtimeChannel, toState state: _AblyPluginSupportPrivate.RealtimeChannelState, reason: (any _AblyPluginSupportPrivate.PublicErrorInfo)?) { + let errorReason = reason.map { ARTErrorInfo.castPluginPublicErrorInfo($0) } + nosync_realtimeObjects(for: channel).nosync_onChannelStateChanged(toState: state, reason: errorReason) + } + + internal func nosync_onConnected(withConnectionDetails connectionDetails: (any ConnectionDetailsProtocol)?, channel: any RealtimeChannel) { + let realtimeObjects = nosync_realtimeObjects(for: channel) + + let gracePeriod = connectionDetails?.objectsGCGracePeriod?.doubleValue ?? InternalDefaultRealtimeObjects.GarbageCollectionOptions.defaultGracePeriod + // RTO10b + realtimeObjects.nosync_setGarbageCollectionGracePeriod(gracePeriod) + + realtimeObjects.nosync_setSiteCode(connectionDetails?.siteCode) + } + + // MARK: - Sending `OBJECT` ProtocolMessage + + internal static func nosync_sendObject( + objectMessages: [OutboundObjectMessage], + channel: _AblyPluginSupportPrivate.RealtimeChannel, + client: _AblyPluginSupportPrivate.RealtimeClient, + pluginAPI: PluginAPIProtocol, + callback: @escaping @Sendable (Result) -> Void, + ) { + let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } + let internalQueue = pluginAPI.internalQueue(for: client) + + pluginAPI.nosync_sendObject( + withObjectMessages: objectMessageBoxes, + channel: channel, + ) { pluginPublishResult, error in + dispatchPrecondition(condition: .onQueue(internalQueue)) + + if let error { + callback(.failure(ARTErrorInfo.castPluginPublicErrorInfo(error))) + } else { + guard let pluginPublishResult else { + preconditionFailure("Got nil publishResult and nil error") + } + + let publishResult = PublishResult(pluginPublishResult: pluginPublishResult) + callback(.success(publishResult)) + } + } + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift new file mode 100644 index 000000000..b45a130d8 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift @@ -0,0 +1,3 @@ +internal struct DefaultLiveCounterUpdate: LiveCounterUpdate, Equatable { + internal var amount: Double +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift new file mode 100644 index 000000000..3c1453950 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift @@ -0,0 +1,3 @@ +internal struct DefaultLiveMapUpdate: LiveMapUpdate, Equatable { + internal var update: [String: LiveMapUpdateAction] +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift new file mode 100644 index 000000000..a94b77516 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -0,0 +1,502 @@ +internal import _AblyPluginSupportPrivate +import Ably +import Foundation + +/// This provides the implementation behind ``PublicDefaultLiveCounter``, via internal versions of the ``LiveCounter`` API. +internal final class InternalDefaultLiveCounter: Sendable { + private let mutableStateMutex: DispatchQueueMutex + + internal var testsOnly_siteTimeserials: [String: String] { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.siteTimeserials + } + } + + internal var testsOnly_createOperationIsMerged: Bool { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.createOperationIsMerged + } + } + + private let logger: Logger + private let userCallbackQueue: DispatchQueue + private let clock: SimpleClock + + // MARK: - Initialization + + internal convenience init( + testsOnly_data data: Double, + objectID: String, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock + ) { + self.init( + data: data, + objectID: objectID, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + + private init( + data: Double, + objectID: String, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock + ) { + mutableStateMutex = .init( + dispatchQueue: internalQueue, + initialValue: .init(liveObjectMutableState: .init(objectID: objectID), data: data), + ) + self.logger = logger + self.userCallbackQueue = userCallbackQueue + self.clock = clock + } + + /// Creates a "zero-value LiveCounter", per RTLC4. + /// + /// - Parameters: + /// - objectID: The value for the "private objectId field" of RTO5c1b1a. + internal static func createZeroValued( + objectID: String, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) -> Self { + .init( + data: 0, + objectID: objectID, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + + // MARK: - Data access + + internal var nosync_objectID: String { + mutableStateMutex.withoutSync { mutableState in + mutableState.liveObjectMutableState.objectID + } + } + + /// Test-only accessor for objectID that handles locking internally. + internal var testsOnly_objectID: String { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.objectID + } + } + + // MARK: - Internal methods that back LiveCounter conformance + + internal func value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + try mutableState.nosync_value(coreSDK: coreSDK) + } + } + + internal func increment(amount: Double, coreSDK: CoreSDK, realtimeObjects: any InternalRealtimeObjectsProtocol) async throws(ARTErrorInfo) { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + do throws(ARTErrorInfo) { + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + // RTLC12e1 + if !amount.isFinite { + throw LiveObjectsError.counterIncrementAmountInvalid(amount: amount).toARTErrorInfo() + } + + // RTLC12c + try coreSDK.nosync_validateChannelState( + notIn: [.detached, .failed, .suspended], + operationDescription: "LiveCounter.increment", + ) + + let objectMessage = OutboundObjectMessage( + operation: .init( + // RTLC12e2 + action: .known(.counterInc), + // RTLC12e3 + objectId: mutableState.liveObjectMutableState.objectID, + counterInc: .init( + // RTLC12e5 + number: .init(value: amount), + ), + ), + ) + + // RTLC12g + realtimeObjects.nosync_publishAndApply(objectMessages: [objectMessage], coreSDK: coreSDK) { result in + continuation.resume(returning: result) + } + } + } catch { + continuation.resume(returning: .failure(error)) + } + }.get() + } + + internal func decrement(amount: Double, coreSDK: CoreSDK, realtimeObjects: any InternalRealtimeObjectsProtocol) async throws(ARTErrorInfo) { + // RTLC13b + try await increment(amount: -amount, coreSDK: coreSDK, realtimeObjects: realtimeObjects) + } + + @discardableResult + internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + // swiftlint:disable:next trailing_closure + try mutableState.liveObjectMutableState.nosync_subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + guard let self else { + return + } + + mutableStateMutex.withSync { mutableState in + action(&mutableState.liveObjectMutableState) + } + }) + } + } + + internal func unsubscribeAll() { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.unsubscribeAll() + } + } + + @discardableResult + internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + mutableStateMutex.withSync { mutableState in + // swiftlint:disable:next trailing_closure + mutableState.liveObjectMutableState.on(event: event, callback: callback, updateSelfLater: { [weak self] action in + guard let self else { + return + } + + mutableStateMutex.withSync { mutableState in + action(&mutableState.liveObjectMutableState) + } + }) + } + } + + internal func offAll() { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.offAll() + } + } + + // MARK: - Emitting update from external sources + + /// Emit an event from this `LiveCounter`. + /// + /// This is used to instruct this counter to emit updates during an `OBJECT_SYNC`. + internal func nosync_emit(_ update: LiveObjectUpdate) { + mutableStateMutex.withoutSync { mutableState in + mutableState.liveObjectMutableState.emit(update, on: userCallbackQueue) + } + } + + // MARK: - Data manipulation + + /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. + /// + /// - Parameters: + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this counter. + internal func nosync_replaceData( + using state: ObjectState, + objectMessageSerialTimestamp: Date?, + ) -> LiveObjectUpdate { + mutableStateMutex.withoutSync { mutableState in + mutableState.replaceData( + using: state, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, + userCallbackQueue: userCallbackQueue, + ) + } + } + + /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC16. + internal func nosync_mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { + mutableStateMutex.withoutSync { mutableState in + mutableState.mergeInitialValue(from: operation) + } + } + + /// Test-only method to apply a COUNTER_CREATE operation, per RTLC8. + internal func testsOnly_applyCounterCreateOperation(_ operation: ObjectOperation) -> LiveObjectUpdate { + mutableStateMutex.withSync { mutableState in + mutableState.applyCounterCreateOperation(operation, logger: logger) + } + } + + /// Test-only method to apply a COUNTER_INC operation, per RTLC9. + internal func testsOnly_applyCounterIncOperation(_ operation: WireCounterInc?) -> LiveObjectUpdate { + mutableStateMutex.withSync { mutableState in + mutableState.applyCounterIncOperation(operation) + } + } + + /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. + /// + /// - Returns: `true` if the operation was applied, `false` if it was skipped (RTLC7g). + internal func nosync_apply( + _ operation: ObjectOperation, + source: ObjectsOperationSource, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, + objectsPool: inout ObjectsPool, + ) -> Bool { + mutableStateMutex.withoutSync { mutableState in + mutableState.apply( + operation, + source: source, + objectMessageSerial: objectMessageSerial, + objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + objectsPool: &objectsPool, + logger: logger, + clock: clock, + userCallbackQueue: userCallbackQueue, + ) + } + } + + // MARK: - LiveObject + + /// Returns the object's RTLO3d `isTombstone` property. + internal var nosync_isTombstone: Bool { + mutableStateMutex.withoutSync { mutableState in + mutableState.liveObjectMutableState.isTombstone + } + } + + /// Test-only accessor for isTombstone that handles locking internally. + internal var testsOnly_isTombstone: Bool { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.isTombstone + } + } + + /// Returns the object's RTLO3e `tombstonedAt` property. + internal var nosync_tombstonedAt: Date? { + mutableStateMutex.withoutSync { mutableState in + mutableState.liveObjectMutableState.tombstonedAt + } + } + + /// Test-only accessor for tombstonedAt that handles locking internally. + internal var testsOnly_tombstonedAt: Date? { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.tombstonedAt + } + } + + // MARK: - Mutable state and the operations that affect it + + private struct MutableState: InternalLiveObject { + /// The mutable state common to all LiveObjects. + internal var liveObjectMutableState: LiveObjectMutableState + + /// The internal data that this map holds, per RTLC3. + internal var data: Double + + /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. + /// + /// - Parameters: + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this counter. + internal mutating func replaceData( + using state: ObjectState, + objectMessageSerialTimestamp: Date?, + logger: Logger, + clock: SimpleClock, + userCallbackQueue: DispatchQueue, + ) -> LiveObjectUpdate { + // RTLC6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials + liveObjectMutableState.siteTimeserials = state.siteTimeserials + + // RTLC6e, RTLC6e1: No-op if we're already tombstone + if liveObjectMutableState.isTombstone { + return .noop + } + + // RTLC6f: Tombstone if state indicates tombstoned + if state.tombstone { + let dataBeforeTombstoning = data + + tombstone( + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, + userCallbackQueue: userCallbackQueue, + ) + + // RTLC6f1 + return .update(.init(amount: -dataBeforeTombstoning)) + } + + // RTLC6g: Store the current data value as previousData for use in RTLC6h + let previousData = data + + // RTLC6b: Set the private flag createOperationIsMerged to false + liveObjectMutableState.createOperationIsMerged = false + + // RTLC6c: Set data to the value of ObjectState.counter.count, or to 0 if it does not exist + data = state.counter?.count?.doubleValue ?? 0 + + // RTLC6d: If ObjectState.createOp is present, merge the initial value into the LiveCounter as described in RTLC16 + // Discard the LiveCounterUpdate object returned by the merge operation + if let createOp = state.createOp { + _ = mergeInitialValue(from: createOp) + } + + // RTLC6h: Calculate the diff between previousData and the current data per RTLC14 + return ObjectDiffHelpers.calculateCounterDiff(previousData: previousData, newData: data) + } + + /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC16. + internal mutating func mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { + let update: LiveObjectUpdate + + // RTLC16: Resolve counterCreate from either the direct property or the one + // from which counterCreateWithObjectId was derived (RTO12f16) + let counterCreate = operation.counterCreate ?? operation.counterCreateWithObjectId?.derivedFrom + + // RTLC16a: Add counterCreate.count to data, if it exists + if let operationCount = counterCreate?.count?.doubleValue { + data += operationCount + // RTLC16c + update = .update(DefaultLiveCounterUpdate(amount: operationCount)) + } else { + // RTLC16d + update = .noop + } + + // RTLC16b: Set the private flag createOperationIsMerged to true + liveObjectMutableState.createOperationIsMerged = true + + return update + } + + /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. + /// + /// - Returns: `true` if the operation was applied, `false` if skipped (RTLC7g). + internal mutating func apply( + _ operation: ObjectOperation, + source: ObjectsOperationSource, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, + objectsPool: inout ObjectsPool, + logger: Logger, + clock: SimpleClock, + userCallbackQueue: DispatchQueue, + ) -> Bool { + 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 + } + + // RTLC7c + if source == .channel { + liveObjectMutableState.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial + } + + // RTLC7e + // TODO: are we still meant to update siteTimeserials? https://github.com/ably/specification/pull/350/files#r2218718854 + if liveObjectMutableState.isTombstone { + return false + } + + switch operation.action { + case .known(.counterCreate): + // RTLC7d1 + let update = applyCounterCreateOperation( + operation, + logger: logger, + ) + // RTLC7d1a + liveObjectMutableState.emit(update, on: userCallbackQueue) + // RTLC7d1b + return true + case .known(.counterInc): + // RTLC7d5 + let update = applyCounterIncOperation(operation.counterInc) + // RTLC7d5a + liveObjectMutableState.emit(update, on: userCallbackQueue) + // RTLC7d5b + return true + case .known(.objectDelete): + let dataBeforeApplyingOperation = data + + // RTLC7d4 + applyObjectDeleteOperation( + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, + userCallbackQueue: userCallbackQueue, + ) + + // RTLC7d4a + liveObjectMutableState.emit(.update(.init(amount: -dataBeforeApplyingOperation)), on: userCallbackQueue) + // RTLC7d4b + return true + default: + // RTLC7d3 + logger.log("Operation \(operation) has unsupported action for LiveCounter; discarding", level: .warn) + return false + } + } + + /// Applies a `COUNTER_CREATE` operation, per RTLC8. + internal mutating func applyCounterCreateOperation( + _ operation: ObjectOperation, + logger: Logger, + ) -> LiveObjectUpdate { + if liveObjectMutableState.createOperationIsMerged { + // RTLC8b + logger.log("Not applying COUNTER_CREATE because a COUNTER_CREATE has already been applied", level: .warn) + return .noop + } + + // RTLC8c, RTLC8e + return mergeInitialValue(from: operation) + } + + /// Applies a `COUNTER_INC` operation, per RTLC9. + internal mutating func applyCounterIncOperation(_ operation: WireCounterInc?) -> LiveObjectUpdate { + guard let operation else { + // RTLC9h + return .noop + } + + // RTLC9f, RTLC9g + let amount = operation.number.doubleValue + data += amount + return .update(DefaultLiveCounterUpdate(amount: amount)) + } + + /// Needed for ``InternalLiveObject`` conformance. + mutating func resetDataToZeroValued() { + // RTLC4 + data = 0 + } + + internal func nosync_value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { + // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveCounter.value") + + // RTLC5c + return data + } + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift new file mode 100644 index 000000000..e13ab40de --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -0,0 +1,1120 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// Protocol for accessing objects from the ObjectsPool. This is used by a LiveMap when it needs to return an object given an object ID. +internal protocol LiveMapObjectsPoolDelegate: AnyObject, Sendable { + /// A snapshot of the objects pool. + var nosync_objectsPool: ObjectsPool { get } +} + +/// This provides the implementation behind ``PublicDefaultLiveMap``, via internal versions of the ``LiveMap`` API. +internal final class InternalDefaultLiveMap: Sendable { + private let mutableStateMutex: DispatchQueueMutex + + internal var testsOnly_data: [String: InternalObjectsMapEntry] { + mutableStateMutex.withSync { mutableState in + mutableState.data + } + } + + internal var testsOnly_semantics: WireEnum? { + mutableStateMutex.withSync { mutableState in + mutableState.semantics + } + } + + internal var testsOnly_siteTimeserials: [String: String] { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.siteTimeserials + } + } + + internal var testsOnly_createOperationIsMerged: Bool { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.createOperationIsMerged + } + } + + internal var testsOnly_clearTimeserial: String? { + mutableStateMutex.withSync { mutableState in + mutableState.clearTimeserial + } + } + + private let logger: Logger + private let userCallbackQueue: DispatchQueue + private let clock: SimpleClock + + // MARK: - Initialization + + internal convenience init( + testsOnly_data data: [String: InternalObjectsMapEntry], + objectID: String, + testsOnly_semantics semantics: WireEnum? = nil, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) { + self.init( + data: data, + objectID: objectID, + semantics: semantics, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + + private init( + data: [String: InternalObjectsMapEntry], + objectID: String, + semantics: WireEnum?, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) { + mutableStateMutex = .init( + dispatchQueue: internalQueue, + initialValue: .init(liveObjectMutableState: .init(objectID: objectID), data: data, semantics: semantics), + ) + self.logger = logger + self.userCallbackQueue = userCallbackQueue + self.clock = clock + } + + /// Creates a "zero-value LiveMap", per RTLM4. + /// + /// - Parameters: + /// - objectID: The value to use for the RTLO3a `objectID` property. + /// - semantics: The value to use for the "private `semantics` field" of RTO5c1b1b. + internal static func createZeroValued( + objectID: String, + semantics: WireEnum? = nil, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) -> Self { + .init( + data: [:], + objectID: objectID, + semantics: semantics, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + + // MARK: - Data access + + internal var nosync_objectID: String { + mutableStateMutex.withoutSync { mutableState in + mutableState.liveObjectMutableState.objectID + } + } + + /// Test-only accessor for objectID that handles locking internally. + internal var testsOnly_objectID: String { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.objectID + } + } + + // MARK: - Internal methods that back LiveMap conformance + + /// Returns the value associated with a given key, following RTLM5d specification. + internal func get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + try mutableState.nosync_get( + key: key, + coreSDK: coreSDK, + objectsPool: delegate.nosync_objectsPool, + ) + } + } + + internal func size(coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate) throws(ARTErrorInfo) -> Int { + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + try mutableState.nosync_size( + coreSDK: coreSDK, + objectsPool: delegate.nosync_objectsPool, + ) + } + } + + internal func entries(coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + try mutableState.nosync_entries( + coreSDK: coreSDK, + objectsPool: delegate.nosync_objectsPool, + ) + } + } + + internal func keys(coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate) throws(ARTErrorInfo) -> [String] { + // RTLM12b: Identical to LiveMap#entries, except that it returns only the keys from the internal data map + try entries(coreSDK: coreSDK, delegate: delegate).map(\.key) + } + + internal func values(coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate) throws(ARTErrorInfo) -> [InternalLiveMapValue] { + // RTLM13b: Identical to LiveMap#entries, except that it returns only the values from the internal data map + try entries(coreSDK: coreSDK, delegate: delegate).map(\.value) + } + + internal func set(key: String, value: InternalLiveMapValue, coreSDK: CoreSDK, realtimeObjects: any InternalRealtimeObjectsProtocol) async throws(ARTErrorInfo) { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + do throws(ARTErrorInfo) { + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + // RTLM20c + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.set") + + let objectMessage = OutboundObjectMessage( + operation: .init( + // RTLM20e2 + action: .known(.mapSet), + // RTLM20e3 + objectId: mutableState.liveObjectMutableState.objectID, + mapSet: .init( + // RTLM20e6 + key: key, + // RTLM20e7 + value: value.nosync_toObjectData, + ), + ), + ) + + // RTLM20g + realtimeObjects.nosync_publishAndApply(objectMessages: [objectMessage], coreSDK: coreSDK) { result in + continuation.resume(returning: result) + } + } + } catch { + continuation.resume(returning: .failure(error)) + } + }.get() + } + + internal func remove(key: String, coreSDK: CoreSDK, realtimeObjects: any InternalRealtimeObjectsProtocol) async throws(ARTErrorInfo) { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + do throws(ARTErrorInfo) { + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + // RTLM21c + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.remove") + + let objectMessage = OutboundObjectMessage( + operation: .init( + // RTLM21e2 + action: .known(.mapRemove), + // RTLM21e3 + objectId: mutableState.liveObjectMutableState.objectID, + mapRemove: .init( + // RTLM21e5 + key: key, + ), + ), + ) + + // RTLM21g + realtimeObjects.nosync_publishAndApply(objectMessages: [objectMessage], coreSDK: coreSDK) { result in + continuation.resume(returning: result) + } + } + } catch { + continuation.resume(returning: .failure(error)) + } + }.get() + } + + @discardableResult + internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + // swiftlint:disable:next trailing_closure + try mutableState.liveObjectMutableState.nosync_subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + guard let self else { + return + } + + mutableStateMutex.withSync { mutableState in + action(&mutableState.liveObjectMutableState) + } + }) + } + } + + internal func unsubscribeAll() { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.unsubscribeAll() + } + } + + @discardableResult + internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + mutableStateMutex.withSync { mutableState in + // swiftlint:disable:next trailing_closure + mutableState.liveObjectMutableState.on(event: event, callback: callback, updateSelfLater: { [weak self] action in + guard let self else { + return + } + + mutableStateMutex.withSync { mutableState in + action(&mutableState.liveObjectMutableState) + } + }) + } + } + + internal func offAll() { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.offAll() + } + } + + // MARK: - Emitting update from external sources + + /// Emit an event from this `LiveMap`. + /// + /// This is used to instruct this map to emit updates during an `OBJECT_SYNC`. + internal func nosync_emit(_ update: LiveObjectUpdate) { + mutableStateMutex.withoutSync { mutableState in + mutableState.liveObjectMutableState.emit(update, on: userCallbackQueue) + } + } + + // MARK: - Data manipulation + + /// Replaces the internal data of this map with the provided ObjectState, per RTLM6. + /// + /// - Parameters: + /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this map. + internal func nosync_replaceData( + using state: ObjectState, + objectMessageSerialTimestamp: Date?, + objectsPool: inout ObjectsPool, + ) -> LiveObjectUpdate { + mutableStateMutex.withoutSync { mutableState in + mutableState.replaceData( + using: state, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + objectsPool: &objectsPool, + logger: logger, + clock: clock, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + ) + } + } + + /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM23. + internal func nosync_mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { + mutableStateMutex.withoutSync { mutableState in + mutableState.mergeInitialValue( + from: operation, + objectsPool: &objectsPool, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } + + /// Test-only method to apply a MAP_CREATE operation, per RTLM16. + internal func testsOnly_applyMapCreateOperation(_ operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { + mutableStateMutex.withSync { mutableState in + mutableState.applyMapCreateOperation( + operation, + objectsPool: &objectsPool, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } + + /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLM15. + /// + /// - Returns: `true` if the operation was applied, `false` if it was skipped (RTLM15g). + internal func nosync_apply( + _ operation: ObjectOperation, + source: ObjectsOperationSource, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, + objectsPool: inout ObjectsPool, + ) -> Bool { + mutableStateMutex.withoutSync { mutableState in + mutableState.apply( + operation, + source: source, + objectMessageSerial: objectMessageSerial, + objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + objectsPool: &objectsPool, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } + + /// 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. + internal func testsOnly_applyMapSetOperation( + key: String, + operationTimeserial: String?, + operationData: ObjectData, + objectsPool: inout ObjectsPool, + ) -> LiveObjectUpdate { + mutableStateMutex.withSync { mutableState in + mutableState.applyMapSetOperation( + key: key, + operationTimeserial: operationTimeserial, + operationData: operationData, + objectsPool: &objectsPool, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } + + /// 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 { + mutableStateMutex.withSync { mutableState in + mutableState.applyMapRemoveOperation( + key: key, + operationTimeserial: operationTimeserial, + operationSerialTimestamp: operationSerialTimestamp, + logger: logger, + clock: clock, + ) + } + } + + /// Test-only method to apply a MAP_CLEAR operation, per RTLM24. + internal func testsOnly_applyMapClearOperation(serial: String?) -> LiveObjectUpdate { + mutableStateMutex.withSync { mutableState in + mutableState.applyMapClearOperation( + serial: serial, + ) + } + } + + /// 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() { + mutableStateMutex.withoutSync { mutableState in + mutableState.resetData(userCallbackQueue: userCallbackQueue) + } + } + + /// Releases entries that were tombstoned more than `gracePeriod` ago, per RTLM19. + internal func nosync_releaseTombstonedEntries(gracePeriod: TimeInterval, clock: SimpleClock) { + mutableStateMutex.withoutSync { mutableState in + mutableState.releaseTombstonedEntries(gracePeriod: gracePeriod, logger: logger, clock: clock) + } + } + + // MARK: - LiveObject + + /// Returns the object's RTLO3d `isTombstone` property. + internal var nosync_isTombstone: Bool { + mutableStateMutex.withoutSync { mutableState in + mutableState.liveObjectMutableState.isTombstone + } + } + + /// Test-only accessor for isTombstone that handles locking internally. + internal var testsOnly_isTombstone: Bool { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.isTombstone + } + } + + /// Returns the object's RTLO3e `tombstonedAt` property. + internal var nosync_tombstonedAt: Date? { + mutableStateMutex.withoutSync { mutableState in + mutableState.liveObjectMutableState.tombstonedAt + } + } + + /// Test-only accessor for tombstonedAt that handles locking internally. + internal var testsOnly_tombstonedAt: Date? { + mutableStateMutex.withSync { mutableState in + mutableState.liveObjectMutableState.tombstonedAt + } + } + + // MARK: - Mutable state and the operations that affect it + + private struct MutableState: InternalLiveObject { + /// The mutable state common to all LiveObjects. + internal var liveObjectMutableState: LiveObjectMutableState + + /// The internal data that this map holds, per RTLM3. + internal var data: [String: InternalObjectsMapEntry] + + /// The "private `semantics` field" of RTO5c1b1b. + internal var semantics: WireEnum? + + /// RTLM25 + internal var clearTimeserial: String? + + /// Replaces the internal data of this map with the provided ObjectState, per RTLM6. + /// + /// - Parameters: + /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this map. + internal mutating func replaceData( + using state: ObjectState, + objectMessageSerialTimestamp: Date?, + objectsPool: inout ObjectsPool, + logger: Logger, + clock: SimpleClock, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + ) -> LiveObjectUpdate { + // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials + liveObjectMutableState.siteTimeserials = state.siteTimeserials + + // RTLM6e, RTLM6e1: No-op if we're already tombstone + if liveObjectMutableState.isTombstone { + return .noop + } + + // RTLM6f: Tombstone if state indicates tombstoned + if state.tombstone { + let dataBeforeTombstoning = data + + tombstone( + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, + userCallbackQueue: userCallbackQueue, + ) + + // RTLM6f1 + return .update(.init(update: dataBeforeTombstoning.mapValues { _ in .removed })) + } + + // RTLM6g: Store the current data value as previousData for use in RTLM6h + let previousData = data + + // RTLM6i + clearTimeserial = state.map?.clearTimeserial + + // RTLM6b: Set the private flag createOperationIsMerged to false + liveObjectMutableState.createOperationIsMerged = false + + // RTLM6c: Set data to ObjectState.map.entries, or to an empty map if it does not exist + data = state.map?.entries?.mapValues { entry in + // Set tombstonedAt for tombstoned entries + let tombstonedAt: Date? + if entry.tombstone == true { + // RTLM6c1a + if let serialTimestamp = entry.serialTimestamp { + tombstonedAt = serialTimestamp + } else { + // RTLM6c1b + logger.log("serialTimestamp not found in ObjectsMapEntry, using local clock for tombstone timestamp", level: .debug) + // RTLM6cb1 + tombstonedAt = clock.now + } + } else { + tombstonedAt = nil + } + + return .init(objectsMapEntry: entry, tombstonedAt: tombstonedAt) + } ?? [:] + + // RTLM6d: If ObjectState.createOp is present, merge the initial value into the LiveMap as described in RTLM23 + // Discard the LiveMapUpdate object returned by the merge operation + if let createOp = state.createOp { + _ = mergeInitialValue( + from: createOp, + objectsPool: &objectsPool, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + + // RTLM6h: Calculate the diff between previousData and the current data per RTLM22 + return ObjectDiffHelpers.calculateMapDiff(previousData: previousData, newData: data) + } + + /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM23. + internal mutating func mergeInitialValue( + from operation: ObjectOperation, + objectsPool: inout ObjectsPool, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) -> LiveObjectUpdate { + // RTLM23: Resolve mapCreate from either the direct property or the one + // from which mapCreateWithObjectId was derived (RTO11f18) + let mapCreate = operation.mapCreate ?? operation.mapCreateWithObjectId?.derivedFrom + + // RTLM23a: For each key–ObjectsMapEntry pair in mapCreate.entries + let perKeyUpdates: [LiveObjectUpdate] = if let entries = mapCreate?.entries { + entries.map { key, entry in + if entry.tombstone == true { + // RTLM23a2: If ObjectsMapEntry.tombstone is true, apply the MAP_REMOVE operation + // as described in RTLM8, passing in the current key as MapRemove, ObjectsMapEntry.timeserial as the operation's serial, and ObjectsMapEntry.serialTimestamp as the operation's serial timestamp + applyMapRemoveOperation( + key: key, + operationTimeserial: entry.timeserial, + operationSerialTimestamp: entry.serialTimestamp, + logger: logger, + clock: clock, + ) + } else { + // RTLM23a1: If ObjectsMapEntry.tombstone is false, apply the MAP_SET operation + // as described in RTLM7, passing in ObjectsMapEntry.data and the current key as MapSet, and ObjectsMapEntry.timeserial as the operation's serial + applyMapSetOperation( + key: key, + operationTimeserial: entry.timeserial, + operationData: entry.data, + objectsPool: &objectsPool, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } + } else { + [] + } + + // RTLM23b: Set the private flag createOperationIsMerged to true + liveObjectMutableState.createOperationIsMerged = true + + // RTLM23c: Merge the updates, skipping no-ops + // I don't love having to use uniqueKeysWithValues, when I shouldn't have to. I should be able to reason _statically_ that there are no overlapping keys. The problem that we're trying to use LiveMapUpdate throughout instead of something more communicative. But I don't know what's to come in the spec so I don't want to mess with this internal interface. + let filteredPerKeyUpdates = perKeyUpdates.compactMap { update -> LiveMapUpdate? in + switch update { + case .noop: + nil + case let .update(update): + update + } + } + let filteredPerKeyUpdateKeyValuePairs = filteredPerKeyUpdates.reduce(into: []) { result, element in + result.append(contentsOf: Array(element.update)) + } + let update = Dictionary(uniqueKeysWithValues: filteredPerKeyUpdateKeyValuePairs) + return .update(DefaultLiveMapUpdate(update: update)) + } + + /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLM15. + /// + /// - Returns: `true` if the operation was applied, `false` if skipped (RTLM15g). + internal mutating func apply( + _ operation: ObjectOperation, + source: ObjectsOperationSource, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, + objectsPool: inout ObjectsPool, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) -> Bool { + 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 + } + + // RTLM15c + if source == .channel { + liveObjectMutableState.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial + } + + // RTLM15e + // TODO: are we still meant to update siteTimeserials? https://github.com/ably/specification/pull/350/files#r2218718854 + if liveObjectMutableState.isTombstone { + return false + } + + switch operation.action { + case .known(.mapCreate): + // RTLM15d1 + let update = applyMapCreateOperation( + operation, + objectsPool: &objectsPool, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + // RTLM15d1a + liveObjectMutableState.emit(update, on: userCallbackQueue) + // RTLM15d1b + return true + 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 + } + guard let value = mapSet.value else { + logger.log("Could not apply MAP_SET since operation.mapSet.value is missing", level: .warn) + return false + } + + // RTLM15d6 + let update = applyMapSetOperation( + key: mapSet.key, + operationTimeserial: applicableOperation.objectMessageSerial, + operationData: value, + objectsPool: &objectsPool, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + // RTLM15d6a + liveObjectMutableState.emit(update, on: userCallbackQueue) + // RTLM15d6b + return true + case .known(.mapRemove): + guard let mapRemove = operation.mapRemove else { + return false + } + + // RTLM15d7 + let update = applyMapRemoveOperation( + key: mapRemove.key, + operationTimeserial: applicableOperation.objectMessageSerial, + operationSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, + ) + // RTLM15d7a + liveObjectMutableState.emit(update, on: userCallbackQueue) + // RTLM15d7b + return true + case .known(.objectDelete): + let dataBeforeApplyingOperation = data + + // RTLM15d5 + applyObjectDeleteOperation( + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, + userCallbackQueue: userCallbackQueue, + ) + + // RTLM15d5a + liveObjectMutableState.emit(.update(.init(update: dataBeforeApplyingOperation.mapValues { _ in .removed })), on: userCallbackQueue) + // RTLM15d5b + return true + case .known(.mapClear): + // RTLM15d8 + let update = applyMapClearOperation( + serial: applicableOperation.objectMessageSerial, + ) + // RTLM15d8a + liveObjectMutableState.emit(update, on: userCallbackQueue) + // RTLM15d8b + return true + default: + // RTLM15d4 + logger.log("Operation \(operation) has unsupported action for LiveMap; discarding", level: .warn) + return false + } + } + + /// Applies a `MAP_SET` operation to a key, per RTLM7. + internal mutating func applyMapSetOperation( + key: String, + operationTimeserial: String?, + operationData: ObjectData?, + objectsPool: inout ObjectsPool, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) -> LiveObjectUpdate { + // RTLM7h + if let clearTimeserial, operationTimeserial.map({ $0 <= clearTimeserial }) ?? true { + return .noop + } + + // RTLM7a: If an entry exists in the private data for the specified key + if let existingEntry = data[key] { + // RTLM7a1: If the operation cannot be applied as per RTLM9, discard the operation + if !Self.canApplyMapOperation(entryTimeserial: existingEntry.timeserial, operationTimeserial: operationTimeserial) { + return .noop + } + // RTLM7a2: Otherwise, apply the operation + // RTLM7a2e: Set ObjectsMapEntry.data to the MapSet.value + // RTLM7a2b: Set ObjectsMapEntry.timeserial to the operation's serial + // RTLM7a2c: Set ObjectsMapEntry.tombstone to false (same as RTLM7a2d: Set ObjectsMapEntry.tombstonedAt to nil) + var updatedEntry = existingEntry + updatedEntry.data = operationData + updatedEntry.timeserial = operationTimeserial + updatedEntry.tombstonedAt = nil + data[key] = updatedEntry + } else { + // RTLM7b: If an entry does not exist in the private data for the specified key + // RTLM7b4: Create a new entry in data for the specified key with the provided ObjectData and the operation's serial + // RTLM7b2: Set ObjectsMapEntry.tombstone for the new entry to false (same as RTLM7b3: Set tombstonedAt to nil) + data[key] = InternalObjectsMapEntry(tombstonedAt: nil, timeserial: operationTimeserial, data: operationData) + } + + // RTLM7g: If MapSet.value.objectId is non-empty + if let objectId = operationData?.objectId, !objectId.isEmpty { + // RTLM7g1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 + _ = objectsPool.createZeroValueObject( + forObjectID: objectId, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + + // RTLM7f + return .update(DefaultLiveMapUpdate(update: [key: .updated])) + } + + /// 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 { + // (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 + if let clearTimeserial, operationTimeserial.map({ $0 <= clearTimeserial }) ?? true { + return .noop + } + + // Calculate the tombstonedAt for the new or updated entry per RTLM8f + let tombstonedAt: Date? + if let operationSerialTimestamp { + // RTLM8f1 + tombstonedAt = operationSerialTimestamp + } else { + // RTLM8f2 + logger.log("serialTimestamp not provided for MAP_REMOVE, using local clock for tombstone timestamp", level: .debug) + // RTLM8f2a + tombstonedAt = clock.now + } + + // RTLM8a: If an entry exists in the private data for the specified key + if let existingEntry = data[key] { + // RTLM8a1: If the operation cannot be applied as per RTLM9, discard the operation + if !Self.canApplyMapOperation(entryTimeserial: existingEntry.timeserial, operationTimeserial: operationTimeserial) { + return .noop + } + // RTLM8a2: Otherwise, apply the operation + // RTLM8a2a: Set ObjectsMapEntry.data to undefined/null + // RTLM8a2b: Set ObjectsMapEntry.timeserial to the operation's serial + // RTLM8a2c: Set ObjectsMapEntry.tombstone to true (equivalent to next point) + // RTLM8a2d: Set ObjectsMapEntry.tombstonedAt per RTLM8a2d + var updatedEntry = existingEntry + updatedEntry.data = nil + updatedEntry.timeserial = operationTimeserial + updatedEntry.tombstonedAt = tombstonedAt + data[key] = updatedEntry + } else { + // RTLM8b: If an entry does not exist in the private data for the specified key + // RTLM8b1: Create a new entry in data for the specified key, with ObjectsMapEntry.data set to undefined/null and the operation's serial + // RTLM8b2: Set ObjectsMapEntry.tombstone for the new entry to true + // RTLM8b3: Set ObjectsMapEntry.tombstonedAt per RTLM8f + data[key] = InternalObjectsMapEntry(tombstonedAt: tombstonedAt, timeserial: operationTimeserial, data: nil) + } + + return .update(DefaultLiveMapUpdate(update: [key: .removed])) + } + + /// Determines whether a map operation can be applied to a map entry, per RTLM9. + private static func canApplyMapOperation(entryTimeserial: String?, operationTimeserial: String?) -> Bool { + // I am going to treat "exists" and "is non-empty" as equivalent here, because the spec mentions "null or empty" in some places and is vague in others. + func normalize(timeserial: String?) -> String? { + // swiftlint:disable:next empty_string + timeserial == "" ? nil : timeserial + } + + let ( + normalizedEntryTimeserial, + normalizedOperationTimeserial + ) = ( + normalize(timeserial: entryTimeserial), + normalize(timeserial: operationTimeserial), + ) + + return switch (normalizedEntryTimeserial, normalizedOperationTimeserial) { + case let (.some(normalizedEntryTimeserial), .some(normalizedOperationTimeserial)): + // RTLM9a: For a LiveMap using LWW (Last-Write-Wins) CRDT semantics, the operation must + // only be applied if its serial is strictly greater ("after") than the entry's serial + // when compared lexicographically + // RTLM9e: If both serials exist, compare them lexicographically and allow operation + // to be applied only if the operation's serial is greater than the entry's serial + normalizedOperationTimeserial > normalizedEntryTimeserial + case (nil, .some): + // RTLM9d: If only the operation serial exists, it is considered greater than the missing + // entry serial, so the operation can be applied + true + case (.some, nil): + // RTLM9c: If only the entry serial exists, the missing operation serial is considered lower + // than the existing entry serial, so the operation must not be applied + false + case (nil, nil): + // RTLM9b: If both the entry serial and the operation serial are null or empty strings, + // they are treated as the "earliest possible" serials and considered "equal", + // so the operation must not be applied + false + } + } + + /// Applies a `MAP_CREATE` operation, per RTLM16. + internal mutating func applyMapCreateOperation( + _ operation: ObjectOperation, + objectsPool: inout ObjectsPool, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) -> LiveObjectUpdate { + if liveObjectMutableState.createOperationIsMerged { + // RTLM16b + logger.log("Not applying MAP_CREATE because a MAP_CREATE has already been applied", level: .warn) + return .noop + } + + // TODO: RTLM16c `semantics` comparison; outstanding question in https://github.com/ably/specification/pull/343/files#r2192784482 + + // RTLM16d, RTLM16f + return mergeInitialValue( + from: operation, + objectsPool: &objectsPool, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + + /// Applies a `MAP_CLEAR` operation, per RTLM24. + internal mutating func applyMapClearOperation( + serial: String?, + ) -> LiveObjectUpdate { + guard let serial else { + return .noop + } + + // RTLM24c + if let clearTimeserial, serial <= clearTimeserial { + return .noop + } + + // RTLM24d + clearTimeserial = serial + + // RTLM24e, RTLM24e1: entry timeserial is nil, or serial > entry timeserial + let keysToRemove = data.filter { _, entry in + guard let entryTimeserial = entry.timeserial else { + return true + } + return serial > entryTimeserial + }.keys + + for key in keysToRemove { + data.removeValue(forKey: key) + } + + // RTLM24e1b, RTLM24f + let removedKeys = Dictionary(uniqueKeysWithValues: keysToRemove.map { ($0, LiveMapUpdateAction.removed) }) + return .update(DefaultLiveMapUpdate(update: removedKeys)) + } + + /// 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) { + // RTO4b2 + let previousData = data + resetDataToZeroValued() + + // RTO4b2a + let mapUpdate = DefaultLiveMapUpdate(update: previousData.mapValues { _ in .removed }) + liveObjectMutableState.emit(.update(mapUpdate), on: userCallbackQueue) + } + + /// Needed for ``InternalLiveObject`` conformance. + mutating func resetDataToZeroValued() { + // RTLM4 + data = [:] + clearTimeserial = nil + } + + /// Releases entries that were tombstoned more than `gracePeriod` ago, per RTLM19. + internal mutating func releaseTombstonedEntries( + gracePeriod: TimeInterval, + logger: Logger, + clock: SimpleClock, + ) { + let now = clock.now + + // RTLM19a, RTLM19a1 + data = data.filter { key, entry in + let shouldRelease = { + guard let tombstonedAt = entry.tombstonedAt else { + return false + } + + return now.timeIntervalSince(tombstonedAt) >= gracePeriod + }() + + if shouldRelease { + logger.log("Releasing tombstoned entry \(entry) for key \(key)", level: .debug) + } + return !shouldRelease + } + } + + /// Returns the value associated with a given key, following RTLM5d specification. + internal func nosync_get(key: String, coreSDK: CoreSDK, objectsPool: ObjectsPool) throws(ARTErrorInfo) -> InternalLiveMapValue? { + // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.get") + + // RTLM5e - Return nil if self is tombstone + if liveObjectMutableState.isTombstone { + return nil + } + + // RTLM5d1: If no ObjectsMapEntry exists at the key, return undefined/null + guard let entry = data[key] else { + return nil + } + + // RTLM5d2: If a ObjectsMapEntry exists at the key, convert it using the shared logic + return nosync_convertEntryToLiveMapValue(entry, objectsPool: objectsPool) + } + + internal func nosync_size(coreSDK: CoreSDK, objectsPool: ObjectsPool) throws(ARTErrorInfo) -> Int { + // RTLM10c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.size") + + // 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) + } + } + + internal func nosync_entries(coreSDK: CoreSDK, objectsPool: ObjectsPool) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { + // RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.entries") + + // RTLM11d: Returns key-value pairs from the internal data map + // RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned + var result: [(key: String, value: InternalLiveMapValue)] = [] + + for (key, entry) in data where !Self.nosync_isEntryTombstoned(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)) + } + } + + return result + } + + // MARK: - Helper Methods + + /// 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 { + // RTLM14a + if entry.tombstone { + return true + } + + // RTLM14c + if let objectId = entry.data?.objectId { + if let poolEntry = objectsPool.entries[objectId], poolEntry.nosync_isTombstone { + return true + } + } + + // RTLM14b + return false + } + + /// 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? { + // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null + if entry.tombstone == true { + return nil + } + + // Handle primitive values in the order specified by RTLM5d2b through RTLM5d2e + + // RTLM5d2b: If ObjectsMapEntry.data.boolean exists, return it + if let boolean = entry.data?.boolean { + return .bool(boolean) + } + + // RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it + if let bytes = entry.data?.bytes { + return .data(bytes) + } + + // RTLM5d2d: If ObjectsMapEntry.data.number exists, return it + if let number = entry.data?.number { + return .number(number.doubleValue) + } + + // RTLM5d2e: If ObjectsMapEntry.data.string exists, return it + if let string = entry.data?.string { + return .string(string) + } + + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + if let json = entry.data?.json { + switch json { + case let .array(array): + return .jsonArray(array) + case let .object(object): + return .jsonObject(object) + } + } + + // RTLM5d2f: If ObjectsMapEntry.data.objectId exists, get the object stored at that objectId from the internal ObjectsPool + if let objectId = entry.data?.objectId { + // RTLM5d2f1: If an object with id objectId does not exist, return undefined/null + guard let poolEntry = objectsPool.entries[objectId] else { + return nil + } + + // RTLM5d2f3: If referenced object is tombstoned, return nil + if poolEntry.nosync_isTombstone { + return nil + } + + // RTLM5d2f2: Return referenced object + switch poolEntry { + case let .map(map): + return .liveMap(map) + case let .counter(counter): + return .liveCounter(counter) + } + } + + // RTLM5d2g: Otherwise, return undefined/null + return nil + } + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift new file mode 100644 index 000000000..d50ca339e --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -0,0 +1,1083 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// Protocol that abstracts `InternalDefaultRealtimeObjects`, for testability. +internal protocol InternalRealtimeObjectsProtocol: LiveMapObjectsPoolDelegate { + /// Per RTO20. + /// + /// The callback may be invoked while exclusive access to internal state is held, so it must not + /// call other methods on this object that read or mutate its state. + /// https://github.com/ably/ably-liveobjects-swift-plugin/issues/120 tracks removing this restriction. + func nosync_publishAndApply( + objectMessages: [OutboundObjectMessage], + coreSDK: CoreSDK, + callback: @escaping @Sendable (Result) -> Void, + ) +} + +/// This provides the implementation behind ``PublicDefaultRealtimeObjects``, via internal versions of the ``RealtimeObjects`` API. +internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeObjectsProtocol { + private let mutableStateMutex: DispatchQueueMutex + + private let logger: Logger + private let userCallbackQueue: DispatchQueue + private let clock: SimpleClock + + // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. + private let receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> + private let receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation + private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> + private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation + + /// The RTO10a interval at which we will perform garbage collection. + private let garbageCollectionInterval: TimeInterval + // The task that runs the periodic garbage collection described in RTO10. + private nonisolated(unsafe) var garbageCollectionTask: Task! + + /// Parameters used to control the garbage collection of tombstoned objects and map entries, as described in RTO10. + internal struct GarbageCollectionOptions: Encodable, Hashable { + /// The RTO10a interval at which we will perform garbage collection. + /// + /// The default value comes from the suggestion in RTO10a. + internal var interval: TimeInterval = 5 * 60 + + /// The initial RTO10b grace period for which we will retain tombstoned objects and map entries. This value may later get overridden by the `objectsGCGracePeriod` of a `CONNECTED` `ProtocolMessage` from Realtime. + /// + /// This default value comes from RTO10b3; can be overridden for testing. + internal var gracePeriod: GracePeriod = .dynamic(Self.defaultGracePeriod) + + /// The default value from RTO10b3. + internal static let defaultGracePeriod: TimeInterval = 24 * 60 * 60 + + internal enum GracePeriod: Encodable, Hashable { + /// The client will always use this grace period, and will not update the grace period from the `objectsGCGracePeriod` of a `CONNECTED` `ProtocolMessage`. + /// + /// - Important: This should only be used in tests. + case fixed(TimeInterval) + + /// The client will use this grace period, which may be subsequently updated by the `objectsGCGracePeriod` of a `CONNECTED` `ProtocolMessage`. + case dynamic(TimeInterval) + + internal var toTimeInterval: TimeInterval { + switch self { + case let .fixed(timeInterval), let .dynamic(timeInterval): + timeInterval + } + } + } + } + + internal var testsOnly_objectsPool: ObjectsPool { + mutableStateMutex.withSync { mutableState in + mutableState.objectsPool + } + } + + /// 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 + if case let .syncing(syncingData) = mutableState.state, syncingData.syncSequence != nil { + true + } else { + false + } + } + } + + /// Returns the number of buffered object operations if in the SYNCING state, or nil otherwise. + internal var testsOnly_bufferedObjectOperationsCount: Int? { + mutableStateMutex.withSync { mutableState in + if case let .syncing(syncingData) = mutableState.state { + syncingData.bufferedObjectOperations.count + } else { + nil + } + } + } + + // These drive the testsOnly_waitingForSyncEvents property that informs the test suite when `getRoot()` is waiting for the object sync sequence to complete per RTO1c. + private let waitingForSyncEvents: AsyncStream + private let waitingForSyncEventsContinuation: AsyncStream.Continuation + /// Emits an element whenever `getRoot()` starts waiting for the object sync sequence to complete per RTO1c. + internal var testsOnly_waitingForSyncEvents: AsyncStream { + waitingForSyncEvents + } + + /// Contains the data gathered during an `OBJECT_SYNC` sequence. + private struct SyncSequence { + /// The sync sequence ID, per RTO5a1. + internal var id: String + + /// The `ObjectMessage`s gathered during this sync sequence. + internal var syncObjectsPool: SyncObjectsPool + } + + internal init( + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + garbageCollectionOptions: GarbageCollectionOptions = .init() + ) { + self.logger = logger + self.userCallbackQueue = userCallbackQueue + self.clock = clock + (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() + (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() + (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() + (completedGarbageCollectionEventsWithoutBuffering, completedGarbageCollectionEventsWithoutBufferingContinuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(0)) + mutableStateMutex = .init( + dispatchQueue: internalQueue, + initialValue: .init( + objectsPool: .init( + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ), + garbageCollectionGracePeriod: garbageCollectionOptions.gracePeriod, + ), + ) + garbageCollectionInterval = garbageCollectionOptions.interval + + garbageCollectionTask = Task { [weak self, garbageCollectionInterval] in + do { + while true { + logger.log("Will perform garbage collection in \(garbageCollectionInterval)s", level: .debug) + try await Task.sleep(nanoseconds: UInt64(garbageCollectionInterval * Double(NSEC_PER_SEC))) + + guard let self else { + return + } + + performGarbageCollection() + } + } catch { + precondition(error is CancellationError) + logger.log("Garbage collection task terminated due to cancellation", level: .debug) + } + } + } + + deinit { + garbageCollectionTask.cancel() + } + + // MARK: - LiveMapObjectsPoolDelegate + + internal var nosync_objectsPool: ObjectsPool { + mutableStateMutex.withoutSync { mutableState in + mutableState.objectsPool + } + } + + // MARK: - Internal methods that power RealtimeObjects conformance + + internal func getRoot(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { + let state = try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + // RTO1b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "getRoot") + + return mutableState.state + } + + if state.toObjectsSyncState != .synced { + // RTO1c + waitingForSyncEventsContinuation.yield() + logger.log("getRoot started waiting for sync sequence to complete", level: .debug) + await withCheckedContinuation { continuation in + onInternal(event: .synced) { subscription in + subscription.off() + continuation.resume() + } + } + logger.log("getRoot completed waiting for sync sequence to complete", level: .debug) + } + + return mutableStateMutex.withSync { mutableState in + // RTO1d + mutableState.objectsPool.root + } + } + + internal func createMap(entries: [String: InternalLiveMapValue], coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + do throws(ARTErrorInfo) { + try mutableStateMutex.withSync { _ throws(ARTErrorInfo) in + // RTO11d + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createMap") + + // RTO11f7 + coreSDK.nosync_fetchServerTime { [self] result in + let timestamp: Date + switch result { + case let .failure(error): + continuation.resume(returning: .failure(error)) + return + case let .success(t): + timestamp = t + } + + // RTO11f + let creationOperation = ObjectCreationHelpers.nosync_creationOperationForLiveMap( + entries: entries, + timestamp: timestamp, + ) + + // RTO11i + nosync_publishAndApply(objectMessages: [creationOperation.objectMessage], coreSDK: coreSDK) { mutableState, result in + switch result { + case let .failure(error): + continuation.resume(returning: .failure(error)) + case .success: + // RTO11h + + // RTO11h2 + guard case let .map(existingMap) = mutableState.objectsPool.entries[creationOperation.objectID] else { + // RTO11h3d: Object should have been created by publishAndApply + let error = LiveObjectsError.newlyCreatedObjectNotInPool(objectID: creationOperation.objectID).toARTErrorInfo() + continuation.resume(returning: .failure(error)) + return + } + + continuation.resume(returning: .success(existingMap)) + } + } + } + } + } catch { + continuation.resume(returning: .failure(error)) + } + }.get() + } + + internal func createMap(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { + // RTO11f14b + try await createMap(entries: [:], coreSDK: coreSDK) + } + + internal func createCounter(count: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + do throws(ARTErrorInfo) { + try mutableStateMutex.withSync { _ throws(ARTErrorInfo) in + // RTO12d + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createCounter") + + // RTO12f1 + if !count.isFinite { + throw LiveObjectsError.counterInitialValueInvalid(value: count).toARTErrorInfo() + } + + // RTO12f + + // RTO12f5 + coreSDK.nosync_fetchServerTime { [self] result in + let timestamp: Date + switch result { + case let .failure(error): + continuation.resume(returning: .failure(error)) + return + case let .success(t): + timestamp = t + } + + let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter( + count: count, + timestamp: timestamp, + ) + + // RTO12i + nosync_publishAndApply(objectMessages: [creationOperation.objectMessage], coreSDK: coreSDK) { mutableState, result in + switch result { + case let .failure(error): + continuation.resume(returning: .failure(error)) + case .success: + // RTO12h + + // RTO12h2 + guard case let .counter(existingCounter) = mutableState.objectsPool.entries[creationOperation.objectID] else { + // RTO12h3d: Object should have been created by publishAndApply + let error = LiveObjectsError.newlyCreatedObjectNotInPool(objectID: creationOperation.objectID).toARTErrorInfo() + continuation.resume(returning: .failure(error)) + return + } + + continuation.resume(returning: .success(existingCounter)) + } + } + } + } + } catch { + continuation.resume(returning: .failure(error)) + } + }.get() + } + + internal func createCounter(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { + // RTO12f12a + try await createCounter(count: 0, coreSDK: coreSDK) + } + + // RTO18 + @discardableResult + internal func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { + mutableStateMutex.withSync { mutableState in + // swiftlint:disable:next trailing_closure + mutableState.on(event: event, callback: callback, updateSelfLater: { [weak self] action in + guard let self else { + return + } + + mutableStateMutex.withSync { mutableState in + action(&mutableState) + } + }) + } + } + + /// Adds a subscriber to the ``internalObjectsEventSubscriptionStorage`` (i.e. unaffected by `offAll()`). + @discardableResult + internal func onInternal(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { + // TODO: Looking at this again later the whole process for adding a subscriber is really verbose and boilerplate-y, and I think the unfortunate result of me trying to be clever at some point; revisit in https://github.com/ably/ably-liveobjects-swift-plugin/issues/102 + mutableStateMutex.withSync { mutableState in + // swiftlint:disable:next trailing_closure + mutableState.onInternal(event: event, callback: callback, updateSelfLater: { [weak self] action in + guard let self else { + return + } + + mutableStateMutex.withSync { mutableState in + action(&mutableState) + } + }) + } + } + + internal func offAll() { + mutableStateMutex.withSync { mutableState in + mutableState.offAll() + } + } + + // MARK: Handling channel events + + internal var testsOnly_onChannelAttachedHasObjects: Bool? { + mutableStateMutex.withSync { mutableState in + mutableState.onChannelAttachedHasObjects + } + } + + internal func nosync_onChannelAttached(hasObjects: Bool) { + mutableStateMutex.withoutSync { mutableState in + mutableState.nosync_onChannelAttached( + hasObjects: hasObjects, + logger: logger, + userCallbackQueue: userCallbackQueue, + ) + } + } + + internal func nosync_onChannelStateChanged(toState state: _AblyPluginSupportPrivate.RealtimeChannelState, reason: ARTErrorInfo?) { + mutableStateMutex.withoutSync { mutableState in + mutableState.nosync_onChannelStateChanged( + toState: state, + reason: reason, + logger: logger, + ) + } + } + + internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { + receivedObjectProtocolMessages + } + + /// Implements the `OBJECT` handling of RTO8. + internal func nosync_handleObjectProtocolMessage(objectMessages: [InboundObjectMessage]) { + mutableStateMutex.withoutSync { mutableState in + mutableState.nosync_handleObjectProtocolMessage( + objectMessages: objectMessages, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + receivedObjectProtocolMessagesContinuation: receivedObjectProtocolMessagesContinuation, + ) + } + } + + internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { + receivedObjectSyncProtocolMessages + } + + /// Implements the `OBJECT_SYNC` handling of RTO5. + internal func nosync_handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial: String?) { + mutableStateMutex.withoutSync { mutableState in + mutableState.nosync_handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: protocolMessageChannelSerial, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + receivedObjectSyncProtocolMessagesContinuation: receivedObjectSyncProtocolMessagesContinuation, + ) + } + } + + /// Creates a zero-value LiveObject in the object pool for this object ID. + /// + /// Intended as a way for tests to populate the object pool. + internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String) -> ObjectsPool.Entry? { + mutableStateMutex.withSync { mutableState in + mutableState.objectsPool.createZeroValueObject( + forObjectID: objectID, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } + + // MARK: - Sending `OBJECT` ProtocolMessage + + // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. + internal func testsOnly_publish(objectMessages: [OutboundObjectMessage], coreSDK: CoreSDK) async throws(ARTErrorInfo) { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + mutableStateMutex.withSync { _ in + coreSDK.nosync_publish(objectMessages: objectMessages) { result in + continuation.resume(returning: result.map { _ in }) + } + } + }.get() + } + + /// RTO20: Publishes ObjectMessages and applies them locally upon receiving the ACK from the server. + /// + /// Must be called from within `mutableStateMutex.withSync` (i.e. on the internal queue). + internal func nosync_publishAndApply( + objectMessages: [OutboundObjectMessage], + coreSDK: CoreSDK, + callback: @escaping @Sendable (Result) -> Void, + ) { + nosync_publishAndApply(objectMessages: objectMessages, coreSDK: coreSDK) { _, result in + callback(result) + } + } + + /// Internal variant of ``nosync_publishAndApply`` whose callback receives `inout MutableState`, + /// allowing the caller to access mutable state (e.g. to look up a newly-created object) without + /// re-acquiring the mutex. + /// + /// This is a workaround for the fact that behavioural methods currently live on `MutableState`, + /// meaning callbacks are invoked while the mutex is held. See + /// https://github.com/ably/ably-liveobjects-swift-plugin/issues/120 for the longer-term fix. + /// + /// Must be called from within `mutableStateMutex.withSync` (i.e. on the internal queue). + private func nosync_publishAndApply( + objectMessages: [OutboundObjectMessage], + coreSDK: CoreSDK, + mutableStateCallback: @escaping @Sendable (inout MutableState, Result) -> Void, + ) { + // 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 + switch result { + case let .failure(error): + mutableStateMutex.withoutSync { mutableState in + mutableStateCallback(&mutableState, .failure(error)) + } + return + case let .success(pr): + publishResult = pr + } + + logger.log("nosync_publishAndApply: received ACK for \(objectMessages.count) message(s), applying locally", level: .debug) + + mutableStateMutex.withoutSync { mutableState in + // RTO20c1: Check siteCode + guard let siteCode = mutableState.siteCode else { + logger.log("nosync_publishAndApply: operations will not be applied locally: siteCode not available from connectionDetails", level: .error) + mutableStateCallback(&mutableState, .success(())) + return + } + + // RTO20c2: Check serials length + guard publishResult.serials.count == objectMessages.count else { + logger.log("nosync_publishAndApply: operations will not be applied locally: PublishResult.serials has unexpected length (expected \(objectMessages.count), got \(publishResult.serials.count))", level: .error) + mutableStateCallback(&mutableState, .success(())) + return + } + + // RTO20d: Create synthetic inbound ObjectMessages + let syntheticMessages = objectMessages.enumerated().compactMap { index, outboundMessage -> InboundObjectMessage? in + // RTO20d1: Skip null serials (conflated) + guard let serial = publishResult.serials[index] else { + logger.log("nosync_publishAndApply: operation at index \(index) will not be applied locally: serial is null in PublishResult", level: .debug) + return nil + } + + // RTO20d2, RTO20d3: Create synthetic inbound message + return .createSynthetic(from: outboundMessage, serial: serial, siteCode: siteCode) + } + + // 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, + // avoiding re-entrant mutex acquisition. + let waiter: (inout MutableState, MutableState.PublishAndApplySyncWaiterOutcome) -> Void = { [self] mutableState, outcome in + switch outcome { + case .synced: + // RTO20f: Apply synthetic messages with source: .local + for syntheticMessage in syntheticMessages { + mutableState.nosync_applyObjectProtocolMessageObjectMessage( + syntheticMessage, + source: .local, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + mutableStateCallback(&mutableState, .success(())) + case let .channelStateFailed(state, reason): + // RTO20e1 + let error = LiveObjectsError.publishAndApplyFailedChannelStateChanged( + channelState: state, + reason: reason, + ) + mutableStateCallback(&mutableState, .failure(error.toARTErrorInfo())) + } + } + + // Check sync state: if synced, invoke the waiter immediately; otherwise store it. + if mutableState.state.toObjectsSyncState != .synced { + // RTO20e, RTO20e1: Store as a waiter; will be invoked when sync completes + // or when the channel enters detached/suspended/failed. + logger.log("nosync_publishAndApply: waiting for sync to complete before applying \(syntheticMessages.count) message(s)", level: .debug) + mutableState.publishAndApplySyncWaiters.append(waiter) + } else { + waiter(&mutableState, .synced) + } + } + } + } + + // MARK: - Garbage collection of deleted objects and map entries + + /// Performs garbage collection of tombstoned objects and map entries, per RTO10c. + internal func performGarbageCollection() { + mutableStateMutex.withSync { mutableState in + mutableState.objectsPool.nosync_performGarbageCollection( + gracePeriod: mutableState.garbageCollectionGracePeriod.toTimeInterval, + clock: clock, + logger: logger, + eventsContinuation: completedGarbageCollectionEventsWithoutBufferingContinuation, + ) + } + } + + // These drive the testsOnly_completedGarbageCollectionEventsWithoutBuffering property that informs the test suite when a garbage collection cycle has completed. + private let completedGarbageCollectionEventsWithoutBuffering: AsyncStream + private let completedGarbageCollectionEventsWithoutBufferingContinuation: AsyncStream.Continuation + /// Emits an element whenever a garbage collection cycle has completed. + internal var testsOnly_completedGarbageCollectionEventsWithoutBuffering: AsyncStream { + completedGarbageCollectionEventsWithoutBuffering + } + + /// Sets the `siteCode` from the latest `connectionDetails`. + /// + /// Call this upon receiving a `CONNECTED` `ProtocolMessage`. + internal func nosync_setSiteCode(_ siteCode: String?) { + mutableStateMutex.withoutSync { mutableState in + mutableState.siteCode = siteCode + } + } + + /// Sets the garbage collection grace period. + /// + /// Call this upon receiving a `CONNECTED` `ProtocolMessage`, per RTO10b2. + /// + /// - Note: If the `.fixed` grace period option was chosen on instantiation, this is a no-op. + internal func nosync_setGarbageCollectionGracePeriod(_ gracePeriod: TimeInterval) { + mutableStateMutex.withoutSync { mutableState in + switch mutableState.garbageCollectionGracePeriod { + case .fixed: + // no-op + break + case .dynamic: + mutableState.garbageCollectionGracePeriod = .dynamic(gracePeriod) + } + } + } + + internal var testsOnly_gcGracePeriod: TimeInterval { + mutableStateMutex.withSync { mutableState in + mutableState.garbageCollectionGracePeriod.toTimeInterval + } + } + + internal var testsOnly_appliedOnAckSerials: Set { + mutableStateMutex.withSync { mutableState in + mutableState.appliedOnAckSerials + } + } + + // MARK: - Testing + + /// Finishes the following streams, to allow a test to perform assertions about which elements the streams have emitted to this moment: + /// + /// - testsOnly_receivedObjectProtocolMessages + /// - testsOnly_receivedObjectStateProtocolMessages + /// - testsOnly_waitingForSyncEvents + /// - testsOnly_completedGarbageCollectionEventsWithoutBuffering + internal func testsOnly_finishAllTestHelperStreams() { + receivedObjectProtocolMessagesContinuation.finish() + receivedObjectSyncProtocolMessagesContinuation.finish() + waitingForSyncEventsContinuation.finish() + completedGarbageCollectionEventsWithoutBufferingContinuation.finish() + } + + // MARK: - Mutable state and the operations that affect it + + private struct MutableState { + internal var objectsPool: ObjectsPool + internal var onChannelAttachedHasObjects: Bool? + internal var objectsEventSubscriptionStorage = SubscriptionStorage() + + /// Used when the object wishes to subscribe to its own events (i.e. unaffected by `offAll()`); used e.g. to wait for a sync before returning from `getRoot()`, per RTO1c. + internal var internalObjectsEventSubscriptionStorage = SubscriptionStorage() + + /// The RTO10b grace period for which we will retain tombstoned objects and map entries. + internal var garbageCollectionGracePeriod: GarbageCollectionOptions.GracePeriod + + /// RTO7b: Serials of operations that have been applied locally upon ACK but whose echoed OBJECT message has not yet been received. + internal var appliedOnAckSerials: Set = [] // RTO7b1 + + /// The `siteCode` from the latest `ConnectionDetails`, pushed via ``nosync_setSiteCode``. + internal var siteCode: String? + + /// The outcome passed to a `nosync_publishAndApply` sync waiter closure. + enum PublishAndApplySyncWaiterOutcome: Sendable { + case synced + case channelStateFailed( + state: _AblyPluginSupportPrivate.RealtimeChannelState, + reason: ARTErrorInfo?, + ) + } + + /// RTO20e/RTO20e1: Pending `nosync_publishAndApply` calls waiting for sync to complete. + /// Each closure captures the synthetic messages to apply and the user callback. + /// On sync completion, closures are called with `.synced`. + /// On channel state change to detached/suspended/failed, closures are called with `.channelStateFailed`. + /// + /// Waiters receive `inout MutableState` so they can apply synthetic messages and pass + /// mutable state access through to their callback, avoiding re-entrant mutex acquisition. + internal var publishAndApplySyncWaiters: [(inout MutableState, PublishAndApplySyncWaiterOutcome) -> Void] = [] + + /// The RTO17 sync state. Also stores the sync sequence data. + internal var state = State.initialized + + /// Has the same cases as `ObjectsSyncState` but with associated data to store the sync sequence data and represent the constraint that you only have a sync sequence if you're SYNCING. + internal enum State { + case initialized + case syncing(AssociatedData.Syncing) + case synced + + /// Note: We follow the same pattern as used in the WIP ably-swift: a state's associated data is a class instance and the convention is that to update the associated data for the current state you mutate the existing instance instead of creating a new one. + enum AssociatedData { + class Syncing { + /// `OBJECT` ProtocolMessages that were received whilst SYNCING, to be applied once the sync sequence is complete, per RTO7a. + var bufferedObjectOperations: [InboundObjectMessage] + + /// Note that we only ever populate this during a multi-`ProtocolMessage` sync sequence. It is not used in the RTO4b or RTO5a5 cases where the sync data is entirely contained within a single ProtocolMessage, because an individual ProtocolMessage is processed atomically and so no other operations that might wish to query this property can occur concurrently with the handling of these cases. + /// + /// It is optional because there are times that we transition to SYNCING even when the sync data is contained in a single ProtocolMessage. + var syncSequence: SyncSequence? + + init(bufferedObjectOperations: [InboundObjectMessage], syncSequence: SyncSequence?) { + self.bufferedObjectOperations = bufferedObjectOperations + self.syncSequence = syncSequence + } + } + } + + var toObjectsSyncState: ObjectsSyncState { + switch self { + case .initialized: + .initialized + case .syncing: + .syncing + case .synced: + .synced + } + } + } + + mutating func transition( + to newState: State, + userCallbackQueue: DispatchQueue, + ) { + guard newState.toObjectsSyncState != state.toObjectsSyncState else { + preconditionFailure("Cannot transition to the current state") + } + state = newState + guard let event = newState.toObjectsSyncState.toEvent else { + return + } + // RTO17b + emitObjectsEvent(event, on: userCallbackQueue) + } + + internal mutating func nosync_onChannelAttached( + hasObjects: Bool, + logger: Logger, + userCallbackQueue: DispatchQueue, + ) { + logger.log("onChannelAttached(hasObjects: \(hasObjects)", level: .debug) + + onChannelAttachedHasObjects = hasObjects + + // We will subsequently transition to .synced either by the completion of the RTO4a OBJECT_SYNC, or by the RTO4b no-HAS_OBJECTS case below + switch state { + case let .syncing(syncingData): + // RTO4d + syncingData.bufferedObjectOperations = [] + case .initialized, .synced: + // RTO4c, RTO4d + transition(to: .syncing(.init(bufferedObjectOperations: [], syncSequence: nil)), userCallbackQueue: userCallbackQueue) + } + + // We only care about the case where HAS_OBJECTS is not set (RTO4b); if it is set then we're going to shortly receive an OBJECT_SYNC instead (RTO4a) + guard !hasObjects else { + return + } + + // RTO4b1, RTO4b2: Reset the ObjectsPool to have a single empty root object + objectsPool.nosync_reset() + + // 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. + + // RTO4b3, RTO4b4, RTO5c3, RTO5c4, RTO5c5, RTO5c9, RTO5c8 + appliedOnAckSerials.removeAll() + transition(to: .synced, userCallbackQueue: userCallbackQueue) + + // Resume any publishAndApply waiters now that sync is complete + nosync_drainPublishAndApplySyncWaiters( + outcome: .synced, + ) + } + + /// Implements the `OBJECT_SYNC` handling of RTO5. + internal mutating func nosync_handleObjectSyncProtocolMessage( + objectMessages: [InboundObjectMessage], + protocolMessageChannelSerial: String?, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, + ) { + logger.log("handleObjectSyncProtocolMessage(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) + + receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) + + 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 + } + } else { + syncCursor = nil + } + + if case let .syncing(syncingData) = state { + // Figure out whether to continue any existing sync sequence or start a new one + let isNewSyncSequence = syncCursor == nil || syncingData.syncSequence?.id != syncCursor?.sequenceID + if isNewSyncSequence { + // RTO5a2a: new sequence started, discard previous. Else we continue the existing sequence per RTO5a3 + syncingData.syncSequence = nil + } + } + + // If populated, this contains a full set of sync data for the channel, and should be applied to the ObjectsPool. + let completedSyncObjectsPool: SyncObjectsPool? + // The SyncSequence, if any, to store in the SYNCING state that results from this OBJECT_SYNC. + let syncSequenceForSyncingState: SyncSequence? + + if let syncCursor { + let syncSequenceToContinue: SyncSequence? = if case let .syncing(syncingData) = state { + syncingData.syncSequence + } else { + nil + } + var updatedSyncSequence = syncSequenceToContinue ?? .init(id: syncCursor.sequenceID, syncObjectsPool: .init()) + // RTO5f + updatedSyncSequence.syncObjectsPool.accumulate(objectMessages, logger: logger) + syncSequenceForSyncingState = updatedSyncSequence + + completedSyncObjectsPool = syncCursor.isEndOfSequence ? updatedSyncSequence.syncObjectsPool : nil + } else { + // RTO5a5: The sync data is contained entirely within this single OBJECT_SYNC + var pool = SyncObjectsPool() + pool.accumulate(objectMessages, logger: logger) + completedSyncObjectsPool = pool + syncSequenceForSyncingState = nil + } + + if case let .syncing(syncingData) = state { + syncingData.syncSequence = syncSequenceForSyncingState + } else { + // RTO5e + transition(to: .syncing(.init(bufferedObjectOperations: [], syncSequence: syncSequenceForSyncingState)), userCallbackQueue: userCallbackQueue) + } + + if let completedSyncObjectsPool { + // RTO5c + objectsPool.nosync_applySyncObjectsPool( + completedSyncObjectsPool, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + + // RTO5c6 + guard case let .syncing(syncingData) = state else { + // We put ourselves into SYNCING above + preconditionFailure() + } + let bufferedObjectOperations = syncingData.bufferedObjectOperations + if !bufferedObjectOperations.isEmpty { + logger.log("Applying \(bufferedObjectOperations.count) buffered OBJECT ObjectMessages", level: .debug) + for objectMessage in bufferedObjectOperations { + // RTO5c6 + nosync_applyObjectProtocolMessageObjectMessage( + objectMessage, + source: .channel, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } + + // RTO5c9: Clear appliedOnAckSerials after sync + appliedOnAckSerials.removeAll() + + // RTO5c3, RTO5c4, RTO5c5, RTO5c8 + transition(to: .synced, userCallbackQueue: userCallbackQueue) + + // Resume any publishAndApply waiters now that sync is complete + nosync_drainPublishAndApplySyncWaiters( + outcome: .synced, + ) + } + } + + /// Implements the `OBJECT` handling of RTO8. + internal mutating func nosync_handleObjectProtocolMessage( + objectMessages: [InboundObjectMessage], + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, + ) { + receivedObjectProtocolMessagesContinuation.yield(objectMessages) + + logger.log("handleObjectProtocolMessage(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) + + if case let .syncing(syncingData) = state { + // RTO8a: Buffer the OBJECT message, to be handled once the sync completes + // Note that RTO8a says to buffer if "not SYNCED" (i.e. it includes the INITIALIZED state). But, "if SYNCING" is an equivalent check since we will only receive operations once attached, and we become SYNCING upon receipt of ATTACHED + logger.log("Buffering OBJECT message due to in-progress sync", level: .debug) + syncingData.bufferedObjectOperations.append(contentsOf: objectMessages) + } else { + // RTO8b: Handle the OBJECT message immediately + for objectMessage in objectMessages { + nosync_applyObjectProtocolMessageObjectMessage( + objectMessage, + source: .channel, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } + } + + /// Implements the `OBJECT` application of RTO9. + internal mutating func nosync_applyObjectProtocolMessageObjectMessage( + _ objectMessage: InboundObjectMessage, + source: ObjectsOperationSource, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) { + guard let operation = objectMessage.operation else { + // RTO9a1 + logger.log("Unsupported OBJECT message received (no operation); \(objectMessage)", level: .warn) + return + } + + // RTO9a3: Skip if already applied on ACK + if let serial = objectMessage.serial, appliedOnAckSerials.contains(serial) { + logger.log("Skipping OBJECT message: already applied on ACK; serial=\(serial)", level: .debug) + appliedOnAckSerials.remove(serial) + return + } + + // RTO9a2a1, RTO9a2a2 + let entry: ObjectsPool.Entry + if let existingEntry = objectsPool.entries[operation.objectId] { + entry = existingEntry + } else { + guard let newEntry = objectsPool.createZeroValueObject( + forObjectID: operation.objectId, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) else { + logger.log("Unable to create zero-value object for \(operation.objectId) when processing OBJECT message; dropping", level: .warn) + return + } + + 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, + ) + + // RTO9a2a4 + if source == .local, applied, let serial = objectMessage.serial { + appliedOnAckSerials.insert(serial) + } + } + case let .unknown(rawValue): + // RTO9a2b + logger.log("Unsupported OBJECT operation action \(rawValue) received", level: .warn) + return + } + } + + /// 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 + /// access through to its callback without re-acquiring the mutex. + internal mutating func nosync_drainPublishAndApplySyncWaiters( + outcome: PublishAndApplySyncWaiterOutcome, + ) { + let waiters = publishAndApplySyncWaiters + publishAndApplySyncWaiters.removeAll() + for waiter in waiters { + waiter(&self, outcome) + } + } + + /// RTO20e1: Called when the channel enters detached, suspended, or failed state. + /// Drains all waiting `nosync_publishAndApply` closures with a `.channelStateFailed` outcome. + internal mutating func nosync_onChannelStateChanged( + toState state: _AblyPluginSupportPrivate.RealtimeChannelState, + reason: ARTErrorInfo?, + logger: Logger, + ) { + switch state { + case .detached, .suspended, .failed: + guard !publishAndApplySyncWaiters.isEmpty else { + return + } + + logger.log("Channel entered \(state) state; rejecting \(publishAndApplySyncWaiters.count) publishAndApply waiter(s)", level: .debug) + + // RTO20e1 + nosync_drainPublishAndApplySyncWaiters( + outcome: .channelStateFailed(state: state, reason: reason), + ) + default: + break + } + } + + internal typealias UpdateMutableState = @Sendable (_ action: (inout Self) -> Void) -> Void + + @discardableResult + internal mutating func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback, updateSelfLater: @escaping UpdateMutableState) -> any OnObjectsEventResponse { + let updateSubscriptionStorage: SubscriptionStorage.UpdateSubscriptionStorage = { action in + updateSelfLater { mutableState in + action(&mutableState.objectsEventSubscriptionStorage) + } + } + + let subscription = objectsEventSubscriptionStorage.subscribe( + listener: { _, subscriptionInCallback in + let response = ObjectsEventResponse(subscription: subscriptionInCallback) + callback(response) + }, + eventName: event, + updateSelfLater: updateSubscriptionStorage, + ) + + return ObjectsEventResponse(subscription: subscription) + } + + /// Adds a subscriber to the ``internalObjectsEventSubscriptionStorage`` (i.e. unaffected by `offAll()`). + @discardableResult + internal mutating func onInternal(event: ObjectsEvent, callback: @escaping ObjectsEventCallback, updateSelfLater: @escaping UpdateMutableState) -> any OnObjectsEventResponse { + // TODO: Looking at this again later the whole process for adding a subscriber is really verbose and boilerplate-y, and I think the unfortunate result of me trying to be clever at some point; revisit in https://github.com/ably/ably-liveobjects-swift-plugin/issues/102. Also as things stand we end up not being able to use this method because we run into Swift exclusivity violations when we try to unsubscribe from within a listener that's invoked when the mutable state mutex is already held (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/120), so e.g. the RTO20 wait-for-synced can't use this mechanism, which it should be able to. + let updateSubscriptionStorage: SubscriptionStorage.UpdateSubscriptionStorage = { action in + updateSelfLater { mutableState in + action(&mutableState.internalObjectsEventSubscriptionStorage) + } + } + + let subscription = internalObjectsEventSubscriptionStorage.subscribe( + listener: { _, subscriptionInCallback in + let response = ObjectsEventResponse(subscription: subscriptionInCallback) + callback(response) + }, + eventName: event, + updateSelfLater: updateSubscriptionStorage, + ) + + return ObjectsEventResponse(subscription: subscription) + } + + // RTO18f + private struct ObjectsEventResponse: OnObjectsEventResponse { + let subscription: any SubscribeResponse + + func off() { + subscription.unsubscribe() + } + } + + internal mutating func offAll() { + objectsEventSubscriptionStorage.unsubscribeAll() + } + + internal func emitObjectsEvent(_ event: ObjectsEvent, on queue: DispatchQueue) { + objectsEventSubscriptionStorage.emit(eventName: event, on: queue) + internalObjectsEventSubscriptionStorage.emit(eventName: event, on: queue) + } + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift new file mode 100644 index 000000000..d8264ed89 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -0,0 +1,165 @@ +import Foundation + +/// Same as the public ``LiveMapValue`` type but with associated values of internal type. +internal enum InternalLiveMapValue: Sendable, Equatable { + case string(String) + case number(Double) + case bool(Bool) + case data(Data) + case jsonArray([JSONValue]) + case jsonObject([String: JSONValue]) + case liveMap(InternalDefaultLiveMap) + case liveCounter(InternalDefaultLiveCounter) + + // MARK: - Creating from a public LiveMapValue + + /// Converts a public ``LiveMapValue`` into an ``InternalLiveMapValue``. + /// + /// Needed in order to access the internals of user-provided LiveObject-valued LiveMap entries to extract their object ID. + internal init(liveMapValue: LiveMapValue) { + switch liveMapValue { + case let .string(value): + self = .string(value) + case let .number(value): + self = .number(value) + case let .bool(value): + self = .bool(value) + case let .data(value): + self = .data(value) + case let .jsonArray(value): + self = .jsonArray(value) + case let .jsonObject(value): + self = .jsonObject(value) + case let .liveMap(publicLiveMap): + guard let publicDefaultLiveMap = publicLiveMap as? PublicDefaultLiveMap else { + // TODO: Try and remove this runtime check and know this type statically, see https://github.com/ably/ably-liveobjects-swift-plugin/issues/37 + preconditionFailure("Expected PublicDefaultLiveMap, got \(publicLiveMap)") + } + self = .liveMap(publicDefaultLiveMap.proxied) + case let .liveCounter(publicLiveCounter): + guard let publicDefaultLiveCounter = publicLiveCounter as? PublicDefaultLiveCounter else { + // TODO: Try and remove this runtime check and know this type statically, see https://github.com/ably/ably-liveobjects-swift-plugin/issues/37 + preconditionFailure("Expected PublicDefaultLiveCounter, got \(publicLiveCounter)") + } + self = .liveCounter(publicDefaultLiveCounter.proxied) + } + } + + // MARK: - Representation in the Realtime protocol + + /// Converts an `InternalLiveMapValue` to the value that should be used when creating or updating a map entry in the Realtime protocol, per the rules of RTO11f14 and RTLM20e7. + internal var nosync_toObjectData: ObjectData { + // RTO11f14c1: Create an ObjectsMapEntry for the current value + switch self { + case let .bool(value): + .init(boolean: value) + case let .data(value): + .init(bytes: value) + case let .number(value): + .init(number: NSNumber(value: value)) + case let .string(value): + .init(string: value) + case let .jsonArray(value): + .init(json: .array(value)) + case let .jsonObject(value): + .init(json: .object(value)) + case let .liveMap(liveMap): + // RTO11f14c1a: If the value is of type LiveMap, set ObjectsMapEntry.data.objectId to the objectId of that object + .init(objectId: liveMap.nosync_objectID) + case let .liveCounter(liveCounter): + // RTO11f14c1a: If the value is of type LiveCounter, set ObjectsMapEntry.data.objectId to the objectId of that object + .init(objectId: liveCounter.nosync_objectID) + } + } + + // MARK: - Convenience getters for associated values + + /// If this `InternalLiveMapValue` has case `liveMap`, this returns the associated value. Else, it returns `nil`. + internal var liveMapValue: InternalDefaultLiveMap? { + if case let .liveMap(value) = self { + return value + } + return nil + } + + /// If this `InternalLiveMapValue` has case `liveCounter`, this returns the associated value. Else, it returns `nil`. + internal var liveCounterValue: InternalDefaultLiveCounter? { + if case let .liveCounter(value) = self { + return value + } + return nil + } + + /// If this `InternalLiveMapValue` has case `string`, this returns that value. Else, it returns `nil`. + internal var stringValue: String? { + if case let .string(value) = self { + return value + } + return nil + } + + /// If this `InternalLiveMapValue` has case `number`, this returns that value. Else, it returns `nil`. + internal var numberValue: Double? { + if case let .number(value) = self { + return value + } + return nil + } + + /// If this `InternalLiveMapValue` has case `bool`, this returns that value. Else, it returns `nil`. + internal var boolValue: Bool? { + if case let .bool(value) = self { + return value + } + return nil + } + + /// If this `InternalLiveMapValue` has case `data`, this returns that value. Else, it returns `nil`. + internal var dataValue: Data? { + if case let .data(value) = self { + return value + } + return nil + } + + /// If this `InternalLiveMapValue` has case `jsonArray`, this returns that value. Else, it returns `nil`. + internal var jsonArrayValue: [JSONValue]? { + if case let .jsonArray(value) = self { + return value + } + return nil + } + + /// If this `InternalLiveMapValue` has case `jsonObject`, this returns that value. Else, it returns `nil`. + internal var jsonObjectValue: [String: JSONValue]? { + if case let .jsonObject(value) = self { + return value + } + return nil + } + + // MARK: - Equatable Implementation + + internal static func == (lhs: InternalLiveMapValue, rhs: InternalLiveMapValue) -> Bool { + switch (lhs, rhs) { + case let (.string(lhsValue), .string(rhsValue)): + lhsValue == rhsValue + case let (.number(lhsValue), .number(rhsValue)): + lhsValue == rhsValue + case let (.bool(lhsValue), .bool(rhsValue)): + lhsValue == rhsValue + case let (.data(lhsValue), .data(rhsValue)): + lhsValue == rhsValue + case let (.jsonArray(lhsValue), .jsonArray(rhsValue)): + lhsValue == rhsValue + case let (.jsonObject(lhsValue), .jsonObject(rhsValue)): + lhsValue == rhsValue + case let (.liveMap(lhsMap), .liveMap(rhsMap)): + lhsMap === rhsMap + case let (.liveCounter(lhsCounter), .liveCounter(rhsCounter)): + lhsCounter === rhsCounter + default: + false + } + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift new file mode 100644 index 000000000..11679d152 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift @@ -0,0 +1,58 @@ +internal import _AblyPluginSupportPrivate + +/// Provides RTLO spec point functionality common to all LiveObjects. +/// +/// This exists in addition to ``LiveObjectMutableState`` to enable polymorphism. +internal protocol InternalLiveObject { + associatedtype Update: Sendable + + var liveObjectMutableState: LiveObjectMutableState { get set } + + /// Resets the LiveObject's internal data to that of a zero-value, per RTLO4e4. + mutating func resetDataToZeroValued() +} + +internal extension InternalLiveObject { + /// Convenience method for tombstoning a `LiveObject`, as specified in RTLO4e. + mutating func tombstone( + objectMessageSerialTimestamp: Date?, + logger: Logger, + clock: SimpleClock, + userCallbackQueue: DispatchQueue, + ) { + // RTLO4e2, RTLO4e3 + if let objectMessageSerialTimestamp { + // RTLO4e3a + liveObjectMutableState.tombstonedAt = objectMessageSerialTimestamp + } else { + // RTLO4e3b1 + logger.log("serialTimestamp not found in ObjectMessage, using local clock for tombstone timestamp", level: .debug) + // RTLO4e3b + liveObjectMutableState.tombstonedAt = clock.now + } + + // RTLO4e4 + resetDataToZeroValued() + + // Emit the deleted lifecycle event + // Taken from https://github.com/ably/ably-js/blob/e280bff11a4a7627362c5185e764b7ebd0490570/src/plugins/objects/liveobject.ts#L168 + // TODO: Bring in line with spec once it exists (https://github.com/ably/ably-liveobjects-swift-plugin/issues/77) + liveObjectMutableState.emitLifecycleEvent(.deleted, on: userCallbackQueue) + } + + /// Applies an `OBJECT_DELETE` operation, per RTLO5. + mutating func applyObjectDeleteOperation( + objectMessageSerialTimestamp: Date?, + logger: Logger, + clock: SimpleClock, + userCallbackQueue: DispatchQueue, + ) { + // RTLO5b + tombstone( + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, + userCallbackQueue: userCallbackQueue, + ) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift new file mode 100644 index 000000000..4922ddfc3 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift @@ -0,0 +1,21 @@ +import Foundation + +/// The entries stored in a `LiveMap`'s data. Same as an `ObjectsMapEntry` but with an additional `tombstonedAt` property, per RTLM3a. +internal struct InternalObjectsMapEntry: Equatable { + internal var tombstonedAt: Date? // RTLM3a + internal var tombstone: Bool { + // TODO: Confirm that we don't need to store this (https://github.com/ably/specification/pull/350/files#r2213895661) + tombstonedAt != nil + } + + internal var timeserial: String? // OME2b + internal var data: ObjectData? // OME2c +} + +internal extension InternalObjectsMapEntry { + init(objectsMapEntry: ObjectsMapEntry, tombstonedAt: Date?) { + self.tombstonedAt = tombstonedAt + timeserial = objectsMapEntry.timeserial + data = objectsMapEntry.data + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift new file mode 100644 index 000000000..8978b9ea2 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -0,0 +1,152 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// This is the equivalent of the `LiveObject` abstract class described in RTLO. +/// +/// ``InternalDefaultLiveCounter`` and ``InternalDefaultLiveMap`` include it by composition. +internal struct LiveObjectMutableState { + // RTLO3a + internal var objectID: String + // RTLO3b + internal var siteTimeserials: [String: String] = [:] + // RTLO3c + internal var createOperationIsMerged = false + // RTLO3d + internal var isTombstone: Bool { + // TODO: Confirm that we don't need to store this (https://github.com/ably/specification/pull/350/files#r2213895661) + tombstonedAt != nil + } + + // RTLO3e + internal var tombstonedAt: Date? + + private enum EventName { + case update + } + + /// Internal subscription storage. + private var subscriptionStorage = SubscriptionStorage() + + /// Internal lifecycle event subscription storage. + private var lifecycleEventSubscriptionStorage = SubscriptionStorage() + + internal init( + objectID: String, + testsOnly_siteTimeserials siteTimeserials: [String: String]? = nil, + testsOnly_tombstonedAt tombstonedAt: Date? = nil, + ) { + self.objectID = objectID + self.siteTimeserials = siteTimeserials ?? [:] + self.tombstonedAt = tombstonedAt + } + + /// Represents parameters of an operation that `canApplyOperation` has decided can be applied to a `LiveObject`. + /// + /// The key thing is that it offers a non-nil `serial` and `siteCode`, which will be needed when subsequently performing the operation. + internal struct ApplicableOperation: Equatable { + internal let objectMessageSerial: String + internal let objectMessageSiteCode: String + } + + /// Indicates whether an operation described by an `ObjectMessage` should be applied or discarded, per RTLO4a. + /// + /// Instead of returning a `Bool`, in the case where the operation can be applied it returns a non-nil `ApplicableOperation` (whose non-nil `serial` and `siteCode` will be needed as part of subsequently performing this operation). + internal func canApplyOperation(objectMessageSerial: String?, objectMessageSiteCode: String?, logger: Logger) -> ApplicableOperation? { + // RTLO4a3: Both ObjectMessage.serial and ObjectMessage.siteCode must be non-empty strings + guard let serial = objectMessageSerial, !serial.isEmpty, + let siteCode = objectMessageSiteCode, !siteCode.isEmpty + else { + // RTLO4a3: Otherwise, log a warning that the object operation message has invalid serial values + logger.log("Object operation message has invalid serial values: serial=\(objectMessageSerial ?? "nil"), siteCode=\(objectMessageSiteCode ?? "nil")", level: .warn) + return nil + } + + // RTLO4a4: Get the siteSerial value stored for this LiveObject in the siteTimeserials map using the key ObjectMessage.siteCode + let siteSerial = siteTimeserials[siteCode] + + // RTLO4a5: If the siteSerial for this LiveObject is null or an empty string, return true + guard let siteSerial, !siteSerial.isEmpty else { + return ApplicableOperation(objectMessageSerial: serial, objectMessageSiteCode: siteCode) + } + + // RTLO4a6: If the siteSerial for this LiveObject is not an empty string, return true if ObjectMessage.serial is greater than siteSerial when compared lexicographically + if serial > siteSerial { + return ApplicableOperation(objectMessageSerial: serial, objectMessageSiteCode: siteCode) + } + + return nil + } + + // MARK: - Subscriptions + + internal typealias UpdateLiveObject = @Sendable (_ action: (inout Self) -> Void) -> Void + + @discardableResult + internal mutating func nosync_subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK, updateSelfLater: @escaping UpdateLiveObject) throws(ARTErrorInfo) -> any AblyLiveObjects.SubscribeResponse { + // RTLO4b2 + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "subscribe") + + let updateSubscriptionStorage: SubscriptionStorage.UpdateSubscriptionStorage = { action in + updateSelfLater { liveObject in + action(&liveObject.subscriptionStorage) + } + } + + return subscriptionStorage.subscribe( + listener: listener, + eventName: .update, + updateSelfLater: updateSubscriptionStorage, + ) + } + + @discardableResult + internal mutating func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback, updateSelfLater: @escaping UpdateLiveObject) -> any OnLiveObjectLifecycleEventResponse { + let updateSubscriptionStorage: SubscriptionStorage.UpdateSubscriptionStorage = { action in + updateSelfLater { liveObject in + action(&liveObject.lifecycleEventSubscriptionStorage) + } + } + + let subscription = lifecycleEventSubscriptionStorage.subscribe( + listener: { _, subscriptionInCallback in + let response = LifecycleEventResponse(subscription: subscriptionInCallback) + callback(response) + }, + eventName: event, + updateSelfLater: updateSubscriptionStorage, + ) + + return LifecycleEventResponse(subscription: subscription) + } + + private struct LifecycleEventResponse: OnLiveObjectLifecycleEventResponse { + let subscription: any SubscribeResponse + + func off() { + subscription.unsubscribe() + } + } + + internal mutating func unsubscribeAll() { + subscriptionStorage.unsubscribeAll() + } + + internal mutating func offAll() { + lifecycleEventSubscriptionStorage.unsubscribeAll() + } + + internal func emit(_ update: LiveObjectUpdate, on queue: DispatchQueue) { + switch update { + case .noop: + // RTLO4b4c1 + return + case let .update(update): + // RTLO4b4c2 + subscriptionStorage.emit(update, eventName: .update, on: queue) + } + } + + internal func emitLifecycleEvent(_ event: LiveObjectLifecycleEvent, on queue: DispatchQueue) { + lifecycleEventSubscriptionStorage.emit(eventName: event, on: queue) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift new file mode 100644 index 000000000..e31a06393 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift @@ -0,0 +1,26 @@ +internal enum LiveObjectUpdate: Sendable { + case noop // RTLO4b4 + case update(Update) // RTLO4b4a + + // MARK: - Convenience getters + + /// Returns `true` if and only if this `LiveObjectUpdate` has case `noop`. + internal var isNoop: Bool { + if case .noop = self { + true + } else { + false + } + } + + /// If this `LiveObjectUpdate` has case `update`, returns the associated value. Else, returns `nil`. + internal var update: Update? { + if case let .update(update) = self { + update + } else { + nil + } + } +} + +extension LiveObjectUpdate: Equatable where Update: Equatable {} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift new file mode 100644 index 000000000..66b17485d --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift @@ -0,0 +1,205 @@ +internal import _AblyPluginSupportPrivate +import CryptoKit +import Foundation + +/// Helpers for creating a new LiveObject. +/// +/// These generate an object ID and the `ObjectMessage` needed to create the LiveObject. +internal enum ObjectCreationHelpers { + /// The metadata that `createCounter` needs in order to request that Realtime create a LiveCounter and to populate the local objects pool. + internal struct CounterCreationOperation { + /// The generated object ID. Needed for populating the local objects pool. + /// + /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. + internal var objectID: String + + /// The ObjectMessage that must be sent in order for Realtime to create the object. + internal var objectMessage: OutboundObjectMessage + } + + /// The metadata that `createMap` needs in order to request that Realtime create a LiveMap and to populate the local objects pool. + internal struct MapCreationOperation { + /// The generated object ID. Needed for populating the local objects pool. + /// + /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. + internal var objectID: String + + /// The ObjectMessage that must be sent in order for Realtime to create the object. + internal var objectMessage: OutboundObjectMessage + + /// The semantics that should be used for the created LiveMap. + /// + /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. + internal var semantics: ObjectsMapSemantics + } + + /// Creates a `COUNTER_CREATE` `ObjectMessage` for the `RealtimeObjects.createCounter` method per RTO12f. + /// + /// - Parameters: + /// - count: The initial count for the new LiveCounter object + /// - timestamp: The timestamp to use for the generated object ID. + internal static func creationOperationForLiveCounter( + count: Double, + timestamp: Date, + ) -> CounterCreationOperation { + // RTO12f12: Create initial value for the new LiveCounter + let counterCreate = WireCounterCreate(count: NSNumber(value: count)) + + // RTO12f13: Create an initial value JSON string as described in RTO12f13 + let initialValueJSONString = createInitialValueJSONString(from: counterCreate) + + // RTO12f4: Create a unique nonce as a random string + let nonce = generateNonce() + + // RTO12f5: Get the current server time (using the provided timestamp) + let serverTime = timestamp + + // RTO12f6: Create an objectId for the new LiveCounter object as described in RTO14 + let objectId = createObjectID( + type: "counter", + initialValue: initialValueJSONString, + nonce: nonce, + timestamp: serverTime, + ) + + // RTO12f7-12: Set ObjectMessage.operation fields + let operation = ObjectOperation( + action: .known(.counterCreate), + objectId: objectId, + counterCreateWithObjectId: .init( + initialValue: initialValueJSONString, + nonce: nonce, + derivedFrom: counterCreate, // RTO12f16 + ), + ) + + // Create the OutboundObjectMessage + let objectMessage = OutboundObjectMessage( + operation: operation, + ) + + return CounterCreationOperation( + objectID: objectId, + objectMessage: objectMessage, + ) + } + + /// Creates a `MAP_CREATE` `ObjectMessage` for the `RealtimeObjects.createMap` method per RTO11f. + /// + /// - Parameters: + /// - entries: The initial entries for the new LiveMap object + /// - timestamp: The timestamp to use for the generated object ID. + internal static func nosync_creationOperationForLiveMap( + entries: [String: InternalLiveMapValue], + timestamp: Date, + ) -> MapCreationOperation { + // RTO11f14: Create initial value for the new LiveMap + let mapEntries = entries.mapValues { liveMapValue -> ObjectsMapEntry in + ObjectsMapEntry(data: liveMapValue.nosync_toObjectData) + } + + let semantics = ObjectsMapSemantics.lww + let mapCreate = MapCreate( + semantics: .known(semantics), + entries: mapEntries, + ) + + // RTO11f15: Create an initial value JSON string as described in RTO11f15 + let initialValueJSONString = createInitialValueJSONString(from: mapCreate.toWire(format: .json)) + + // RTO11f6: Create a unique nonce as a random string + let nonce = generateNonce() + + // RTO11f7: Get the current server time (using the provided timestamp) + let serverTime = timestamp + + // RTO11f8: Create an objectId for the new LiveMap object as described in RTO14 + let objectId = createObjectID( + type: "map", + initialValue: initialValueJSONString, + nonce: nonce, + timestamp: serverTime, + ) + + // RTO11f9-13: Set ObjectMessage.operation fields + let operation = ObjectOperation( + action: .known(.mapCreate), + objectId: objectId, + mapCreateWithObjectId: .init( + initialValue: initialValueJSONString, + nonce: nonce, + derivedFrom: mapCreate, // RTO11f18 + ), + ) + + // Create the OutboundObjectMessage + let objectMessage = OutboundObjectMessage( + operation: operation, + ) + + return MapCreationOperation( + objectID: objectId, + objectMessage: objectMessage, + semantics: semantics, + ) + } + + // MARK: - Private Helper Methods + + /// Encodes a wire-encodable object to a JSON string for use as an initial value, per RTO11f15 and RTO12f13. + private static func createInitialValueJSONString(from encodable: some WireObjectEncodable) -> String { + let jsonObject: [String: JSONValue] = encodable.toWireObject.mapValues { wireValue in + do { + return try wireValue.toJSONValue + } catch { + // By using `format: .json` we've requested a type that should be JSON-encodable, so if it isn't then it's a programmer error. (We can't reason about it statically though because of our choice to use a general-purpose WireValue type; maybe could improve upon this in the future.) + preconditionFailure("Failed to convert WireValue \(wireValue) to JSONValue when encoding initialValue") + } + } + + return JSONObjectOrArray.object(jsonObject).toJSONString + } + + /// Creates an Object ID for a new LiveObject instance, per RTO14. + internal static func testsOnly_createObjectID( + type: String, + initialValue: String, + nonce: String, + timestamp: Date, + ) -> String { + createObjectID( + type: type, + initialValue: initialValue, + nonce: nonce, + timestamp: timestamp, + ) + } + + /// Creates an Object ID for a new LiveObject instance, per RTO14. + private static func createObjectID( + type: String, + initialValue: String, + nonce: String, + timestamp: Date, + ) -> String { + // RTO14b1: Generate a SHA-256 digest + let hash = SHA256.hash(data: Data("\(initialValue):\(nonce)".utf8)) + + // RTO14b2: Base64URL-encode the generated digest + let base64URLHash = Data(hash).base64EncodedString() + .replacingOccurrences(of: "+", with: "-") + .replacingOccurrences(of: "/", with: "_") + .replacingOccurrences(of: "=", with: "") + + // RTO14c: Return an Object ID in the format [type]:[hash]@[timestamp] + let timestampMillis = Int(timestamp.timeIntervalSince1970 * 1000) + return "\(type):\(base64URLHash)@\(timestampMillis)" + } + + /// Generates a unique nonce as a random string, per RTO11f6 and RTO12f4. + private static func generateNonce() -> String { + // TODO: confirm if there's any specific rules here: https://github.com/ably/specification/pull/353/files#r2228252389 + let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return String((0 ..< 16).map { _ in letters.randomElement()! }) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift new file mode 100644 index 000000000..dbd930918 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Helper methods for calculating diffs between LiveObject data values. +internal enum ObjectDiffHelpers { + /// Calculates the diff between two LiveCounter data values, per RTLC14. + /// + /// - Parameters: + /// - previousData: The previous `data` value (RTLC14a1). + /// - newData: The new `data` value (RTLC14a2). + /// - Returns: Per RTLC14b. + internal static func calculateCounterDiff( + previousData: Double, + newData: Double, + ) -> LiveObjectUpdate { + // RTLC14b + .update(DefaultLiveCounterUpdate(amount: newData - previousData)) + } + + /// Calculates the diff between two LiveMap data values, per RTLM22. + /// + /// - Parameters: + /// - previousData: The previous `data` value (RTLM22a1). + /// - newData: The new `data` value (RTLM22a2). + /// - Returns: Per RTLM22b. + internal static func calculateMapDiff( + previousData: [String: InternalObjectsMapEntry], + newData: [String: InternalObjectsMapEntry], + ) -> LiveObjectUpdate { + // RTLM22b + let previousNonTombstonedKeys = Set(previousData.filter { !$0.value.tombstone }.keys) + let newNonTombstonedKeys = Set(newData.filter { !$0.value.tombstone }.keys) + + var update: [String: LiveMapUpdateAction] = [:] + + // RTLM22b1 + for key in previousNonTombstonedKeys.subtracting(newNonTombstonedKeys) { + update[key] = .removed + } + + // RTLM22b2 + for key in newNonTombstonedKeys.subtracting(previousNonTombstonedKeys) { + update[key] = .updated + } + + // RTLM22b3 + for key in previousNonTombstonedKeys.intersection(newNonTombstonedKeys) { + let previousEntry = previousData[key]! + let newEntry = newData[key]! + + if previousEntry.data != newEntry.data { + update[key] = .updated + } + } + + return .update(DefaultLiveMapUpdate(update: update)) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift new file mode 100644 index 000000000..eb8672a77 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift @@ -0,0 +1,7 @@ +/// RTO22: Describes the source of an operation being applied. +internal enum ObjectsOperationSource { + /// RTO22a: An operation that originated locally, being applied upon receipt of the ACK from Realtime. + case local + /// RTO22b: An operation received over a Realtime channel. + case channel +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift new file mode 100644 index 000000000..5d36f674d --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -0,0 +1,453 @@ +internal import _AblyPluginSupportPrivate + +/// Maintains the list of objects present on a channel, as described by RTO3. +/// +/// Note that this is a value type. +internal struct ObjectsPool { + /// The possible `ObjectsPool` entries, as described by RTO3a. + internal enum Entry { + case map(InternalDefaultLiveMap) + case counter(InternalDefaultLiveCounter) + + /// Convenience getter for accessing the map value if this entry is a map + internal var mapValue: InternalDefaultLiveMap? { + switch self { + case let .map(map): + map + case .counter: + nil + } + } + + /// Convenience getter for accessing the counter value if this entry is a counter + internal var counterValue: InternalDefaultLiveCounter? { + switch self { + case .map: + nil + case let .counter(counter): + counter + } + } + + /// 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: ObjectOperation, + source: ObjectsOperationSource, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, + objectsPool: inout ObjectsPool, + ) -> Bool { + switch self { + case let .map(map): + map.nosync_apply( + operation, + source: source, + objectMessageSerial: objectMessageSerial, + objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + objectsPool: &objectsPool, + ) + case let .counter(counter): + counter.nosync_apply( + operation, + source: source, + objectMessageSerial: objectMessageSerial, + objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + objectsPool: &objectsPool, + ) + } + } + + /// A LiveObject plus an update that can be emitted on this LiveObject. Can be used to store pending events while applying the `SyncObjectsPool`. + fileprivate enum DeferredUpdate { + case map(InternalDefaultLiveMap, LiveObjectUpdate) + case counter(InternalDefaultLiveCounter, LiveObjectUpdate) + + /// Causes the referenced `LiveObject` to emit the stored event to its subscribers. + internal func nosync_emit() { + switch self { + case let .map(map, update): + map.nosync_emit(update) + case let .counter(counter, update): + counter.nosync_emit(update) + } + } + } + + /// Overrides the internal data for the object as per RTLC6, RTLM6. + /// + /// Returns a ``DeferredUpdate`` which contains the object plus an update that should be emitted on this object once the `SyncObjectsPool` has been applied. + /// + /// - Parameters: + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone the object. + fileprivate func nosync_replaceData( + using state: ObjectState, + objectMessageSerialTimestamp: Date?, + objectsPool: inout ObjectsPool, + userCallbackQueue: DispatchQueue, + ) -> DeferredUpdate { + switch self { + case let .map(map): + .map( + map, + map.nosync_replaceData( + using: state, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + objectsPool: &objectsPool, + ), + ) + case let .counter(counter): + .counter( + counter, + counter.nosync_replaceData( + using: state, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + ), + ) + } + } + + /// Returns the object's RTLO3d `isTombstone` property. + internal var nosync_isTombstone: Bool { + switch self { + case let .counter(counter): + counter.nosync_isTombstone + case let .map(map): + map.nosync_isTombstone + } + } + + internal var nosync_tombstonedAt: Date? { + switch self { + case let .counter(counter): + counter.nosync_tombstonedAt + case let .map(map): + map.nosync_tombstonedAt + } + } + + /// Test-only accessor for isTombstone that handles locking internally. + internal var testsOnly_isTombstone: Bool { + switch self { + case let .counter(counter): + counter.testsOnly_isTombstone + case let .map(map): + map.testsOnly_isTombstone + } + } + + /// Test-only accessor for tombstonedAt that handles locking internally. + internal var testsOnly_tombstonedAt: Date? { + switch self { + case let .counter(counter): + counter.testsOnly_tombstonedAt + case let .map(map): + map.testsOnly_tombstonedAt + } + } + } + + /// Keyed by `objectId`. + /// + /// Per RTO3b, always contains an entry for `ObjectsPool.rootKey`, and this entry is always of type `map`. + internal private(set) var entries: [String: Entry] + + /// The key under which the root object is stored. + internal static let rootKey = "root" + + // MARK: - Initialization + + /// Creates an `ObjectsPool` whose root is a zero-value `LiveMap`. + internal init( + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + testsOnly_otherEntries otherEntries: [String: Entry]? = nil, + ) { + self.init( + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + otherEntries: otherEntries, + ) + } + + private init( + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + otherEntries: [String: Entry]? + ) { + entries = otherEntries ?? [:] + // TODO: What initial root entry to use? https://github.com/ably/specification/pull/333/files#r2152312933 + entries[Self.rootKey] = .map( + .createZeroValued( + objectID: Self.rootKey, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ), + ) + } + + // MARK: - Typed root + + /// Fetches the root object. + internal var root: InternalDefaultLiveMap { + guard let rootEntry = entries[Self.rootKey] else { + preconditionFailure("ObjectsPool should always contain a root object") + } + + switch rootEntry { + case let .map(map): + return map + case .counter: + preconditionFailure("The ObjectsPool root object must always be a map") + } + } + + // 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. + /// + /// - Parameters: + /// - objectID: The ID of the object to create + /// - logger: The logger to use for any created LiveObject + /// - userCallbackQueue: The callback queue to use for any created LiveObject + /// - clock: The clock to use for any created LiveObject + /// - Returns: The existing or newly created object + internal mutating func createZeroValueObject( + forObjectID objectID: String, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) -> Entry? { + // RTO6a: If an object with objectId exists in ObjectsPool, do not create a new object + if let existingEntry = entries[objectID] { + return existingEntry + } + + // RTO6b: The expected type of the object can be inferred from the provided objectId + // RTO6b1: Split the objectId (formatted as type:hash@timestamp) on the separator : and parse the first part as the type string + let components = objectID.split(separator: ":") + guard let typeString = components.first else { + return nil + } + + // RTO6b2: If the parsed type is map, create a zero-value LiveMap per RTLM4 in the ObjectsPool + // RTO6b3: If the parsed type is counter, create a zero-value LiveCounter per RTLC4 in the ObjectsPool + let entry: Entry + switch typeString { + case "map": + entry = .map( + .createZeroValued( + objectID: objectID, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ), + ) + case "counter": + entry = .counter( + .createZeroValued( + objectID: objectID, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ), + ) + default: + return nil + } + + // Note that already know that the key is not "root" per the above check so there's no risk of breaking the RTO3b invariant that the root object is always a map + entries[objectID] = entry + return entry + } + + /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1 and RTO5c2. + internal mutating func nosync_applySyncObjectsPool( + _ syncObjectsPool: SyncObjectsPool, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) { + logger.log("applySyncObjectsPool called with \(syncObjectsPool.count) objects", level: .debug) + + // Keep track of object IDs that were received during sync for RTO5c2 + var receivedObjectIds = Set() + + // Keep track of updates to existing objects during sync for RTO5c1a2 + var updatesToExistingObjects: [ObjectsPool.Entry.DeferredUpdate] = [] + + // RTO5c1: For each ObjectState member in the SyncObjectsPool list + for objectMessage in syncObjectsPool { + // Every message yielded by SyncObjectsPool is guaranteed to have a non-nil `.object` with `.map` or `.counter`. + guard let state = objectMessage.object else { + preconditionFailure("SyncObjectsPool yielded a message with nil object") + } + receivedObjectIds.insert(state.objectId) + + // RTO5c1a: If an object with ObjectState.objectId exists in the internal ObjectsPool + if let existingEntry = entries[state.objectId] { + logger.log("Updating existing object with ID: \(state.objectId)", level: .debug) + + // RTO5c1a1: Override the internal data for the object as per RTLC6, RTLM6 + let deferredUpdate = existingEntry.nosync_replaceData( + using: state, + objectMessageSerialTimestamp: objectMessage.serialTimestamp, + objectsPool: &self, + userCallbackQueue: userCallbackQueue, + ) + // RTO5c1a2: Store this update to emit at end + updatesToExistingObjects.append(deferredUpdate) + } else { + // RTO5c1b: If an object with ObjectState.objectId does not exist in the internal ObjectsPool + // (The nosync_createObjectFromSync precondition that this is not the root object is satisfied because the pool always contains a root object. The precondition that state has counter or map is satisfied because SyncObjectsPool guarantees this for every yielded message.) + nosync_createObjectFromSync( + state: state, + objectMessage: objectMessage, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } + + // RTO5c2: Remove any objects from the internal ObjectsPool for which objectIds were not received during the sync sequence + // RTO5c2a: The object with ID "root" must not be removed from ObjectsPool, as per RTO3b + let objectIdsToRemove = Set(entries.keys).subtracting(receivedObjectIds + [Self.rootKey]) + if !objectIdsToRemove.isEmpty { + logger.log("Removing objects with IDs: \(objectIdsToRemove) as they were not in sync", level: .debug) + for objectId in objectIdsToRemove { + entries.removeValue(forKey: objectId) + } + } + + // RTO5c7: Emit the updates to existing objects + for deferredUpdate in updatesToExistingObjects { + deferredUpdate.nosync_emit() + } + + logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) + } + + /// 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. + /// - Precondition: `state` must have either `.counter` or `.map` populated. + private mutating func nosync_createObjectFromSync( + state: ObjectState, + objectMessage: InboundObjectMessage, + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) { + precondition(state.objectId != ObjectsPool.rootKey) + + logger.log("Creating new object with ID: \(state.objectId)", level: .debug) + + // RTO5c1b1: Create a new LiveObject using the data from ObjectState and add it to the internal ObjectsPool: + let newEntry: Entry + + if state.counter != nil { + // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, + // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 + let counter = InternalDefaultLiveCounter.createZeroValued( + objectID: state.objectId, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + _ = counter.nosync_replaceData( + using: state, + objectMessageSerialTimestamp: objectMessage.serialTimestamp, + ) + newEntry = .counter(counter) + } else if let objectsMap = state.map { + // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, + // set its private objectId equal to ObjectState.objectId, set its private semantics + // equal to ObjectState.map.semantics and override its internal data per RTLM6 + let map = InternalDefaultLiveMap.createZeroValued( + objectID: state.objectId, + semantics: objectsMap.semantics, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + _ = map.nosync_replaceData( + using: state, + objectMessageSerialTimestamp: objectMessage.serialTimestamp, + objectsPool: &self, + ) + newEntry = .map(map) + } else { + preconditionFailure("state for objectId \(state.objectId) has neither counter nor map") + } + + entries[state.objectId] = newEntry + } + + /// 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() { + let root = root + + // RTO4b1 + entries = [Self.rootKey: .map(root)] + + // 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() + } + + /// Performs garbage collection of tombstoned objects and map entries, per RTO10c. + internal mutating func nosync_performGarbageCollection( + gracePeriod: TimeInterval, + clock: SimpleClock, + logger: Logger, + eventsContinuation: AsyncStream.Continuation, + ) { + logger.log("Performing garbage collection, grace period \(gracePeriod)s", level: .debug) + + let now = clock.now + + entries = entries.filter { key, entry in + if case let .map(map) = entry { + // RTO10c1a + map.nosync_releaseTombstonedEntries(gracePeriod: gracePeriod, clock: clock) + } + + // RTO10c1b + let shouldRelease = { + guard let tombstonedAt = entry.nosync_tombstonedAt else { + return false + } + + return now.timeIntervalSince(tombstonedAt) >= gracePeriod + }() + + if shouldRelease { + logger.log("Releasing tombstoned entry \(entry) for key \(key)", level: .debug) + } + return !shouldRelease + } + + eventsContinuation.yield() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift new file mode 100644 index 000000000..0a95d9520 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift @@ -0,0 +1,18 @@ +/// The type that the spec uses to represent the client's state of syncing its local Objects data with the server, per RTO17a. +internal enum ObjectsSyncState { + case initialized + case syncing + case synced + + /// The event to emit when transitioning to this state, per RTO17b. + internal var toEvent: ObjectsEvent? { + switch self { + case .initialized: + nil + case .syncing: + .syncing + case .synced: + .synced + } + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/PublishResult.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/PublishResult.swift new file mode 100644 index 000000000..3c4a9202b --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/PublishResult.swift @@ -0,0 +1,14 @@ +internal import _AblyPluginSupportPrivate + +/// As described by `PBR*` spec points. +/// +/// We use this internally instead of `_AblyCocoaPluginSupportPrivate.PublishResultProtocol` because Swift allows us to directly store `nil` values in an array. +internal struct PublishResult { + internal var serials: [String?] +} + +internal extension PublishResult { + init(pluginPublishResult: _AblyPluginSupportPrivate.PublishResultProtocol) { + serials = pluginPublishResult.serials.map(\.value) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/SimpleClock.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/SimpleClock.swift new file mode 100644 index 000000000..bc6c0faa0 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/SimpleClock.swift @@ -0,0 +1,19 @@ +import Foundation + +/// A simple clock interface for getting the current time. +/// +/// This protocol allows for dependency injection of time-related functionality, +/// making it easier to test time-dependent code. +internal protocol SimpleClock: Sendable { + /// Returns the current time as a Date. + var now: Date { get } +} + +/// The default implementation of SimpleClock that uses the system clock. +internal final class DefaultSimpleClock: SimpleClock { + internal init() {} + + internal var now: Date { + Date() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift new file mode 100644 index 000000000..e03ac2a21 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift @@ -0,0 +1,91 @@ +import Foundation + +/// Handles subscription bookkeeping, providing methods for subscribing and emitting events. +internal struct SubscriptionStorage { + /// Internal bookkeeping for subscriptions, organized by event name. + /// Each event name maps to a dictionary of subscriptions keyed by their ID for O(1) operations. + private var subscriptionsByEventName: [EventName: [Subscription.ID: Subscription]] = [:] + + // MARK: - Subscriptions + + private struct Subscription: Identifiable { + var id = UUID() + var listener: LiveObjectUpdateCallback + var updateSubscriptionStorage: UpdateSubscriptionStorage + } + + /// A function that allows a `SubscriptionStorage` to later perform mutations to an externally-held copy of itself. This is used to allow a `SubscribeResponse` to unsubscribe. + /// + /// Accepts an action, which, if called, should be called with an `inout` reference to the externally-held copy. The function is not required to call this action (for example, if the function holds a weak reference which is now `nil`). + /// + /// Note that the `SubscriptionStorage` will store a copy of this function and thus this function should be careful not to introduce a strong reference cycle. + internal typealias UpdateSubscriptionStorage = @Sendable (_ action: (inout Self) -> Void) -> Void + + private struct SubscribeResponse: AblyLiveObjects.SubscribeResponse { + var subscriptionID: Subscription.ID + var eventName: EventName + var updateSubscriptionStorage: UpdateSubscriptionStorage + + func unsubscribe() { + updateSubscriptionStorage { subscriptionStorage in + subscriptionStorage.unsubscribe(subscriptionID: subscriptionID, eventName: eventName) + } + } + } + + @discardableResult + internal mutating func subscribe( + listener: @escaping LiveObjectUpdateCallback, + eventName: EventName, + updateSelfLater: @escaping UpdateSubscriptionStorage, + ) -> any AblyLiveObjects.SubscribeResponse { + let subscription = Subscription(listener: listener, updateSubscriptionStorage: updateSelfLater) + + // Initialize the dictionary for this event name if it doesn't exist + if subscriptionsByEventName[eventName] == nil { + subscriptionsByEventName[eventName] = [:] + } + + // Add the subscription to the appropriate event name dictionary + subscriptionsByEventName[eventName]?[subscription.id] = subscription + + return SubscribeResponse(subscriptionID: subscription.id, eventName: eventName, updateSubscriptionStorage: updateSelfLater) + } + + internal mutating func unsubscribeAll() { + subscriptionsByEventName.removeAll() + } + + private mutating func unsubscribe(subscriptionID: Subscription.ID, eventName: EventName) { + // O(1) removal using dictionary key + subscriptionsByEventName[eventName]?.removeValue(forKey: subscriptionID) + + // Clean up empty event name dictionaries + if subscriptionsByEventName[eventName]?.isEmpty == true { + subscriptionsByEventName.removeValue(forKey: eventName) + } + } + + internal func emit(_ update: Update, eventName: EventName, on queue: DispatchQueue) { + // Only emit to subscribers who subscribed to this specific event name + guard let subscriptions = subscriptionsByEventName[eventName] else { + return + } + + for subscription in subscriptions.values { + queue.async { + let response = SubscribeResponse(subscriptionID: subscription.id, eventName: eventName, updateSubscriptionStorage: subscription.updateSubscriptionStorage) + subscription.listener(update, response) + } + } + } +} + +// MARK: - Convenience extension for Void updates + +internal extension SubscriptionStorage where Update == Void { + /// Convenience method for emitting events when there's no update data to pass. + func emit(eventName: EventName, on queue: DispatchQueue) { + emit((), eventName: eventName, on: queue) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift new file mode 100644 index 000000000..53f43e763 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift @@ -0,0 +1,86 @@ +import Foundation + +/// The RTO5f collection of objects gathered during an `OBJECT_SYNC` sequence, ready to be applied to the `ObjectsPool`. +/// +/// Every stored message is guaranteed to have a non-nil `.object` with either `.map` or `.counter` populated. +internal struct SyncObjectsPool: Collection { + /// Keyed by `objectId`. Every value has a non-nil `.object` with either `.map` or `.counter` populated; the + /// `accumulate` method enforces this invariant. + private var objectMessages: [String: InboundObjectMessage] + + /// Creates an empty pool. + internal init() { + objectMessages = [:] + } + + /// Accumulates object messages into the pool per RTO5f. + internal mutating func accumulate( + _ objectMessages: [InboundObjectMessage], + logger: Logger, + ) { + for objectMessage in objectMessages { + accumulate(objectMessage, logger: logger) + } + } + + /// Accumulates a single `ObjectMessage` into the pool per RTO5f. + private mutating func accumulate( + _ objectMessage: InboundObjectMessage, + logger: Logger, + ) { + // RTO5f3: Reject unsupported object types before pool lookup. Only messages whose `.object` has `.map` or `.counter` + // are stored, which callers of the iteration can rely on. + guard let object = objectMessage.object, object.map != nil || object.counter != nil else { + logger.log("Skipping unsupported object type during sync for objectId \(objectMessage.object?.objectId ?? "unknown")", level: .warn) + return + } + + let objectId = object.objectId + + if let existing = objectMessages[objectId] { + // RTO5f2: An entry already exists for this objectId (partial object state). + if object.map != nil { + // RTO5f2a: Incoming message has a map. + if object.tombstone { + // RTO5f2a1: Incoming tombstone is true — replace the entire entry. + objectMessages[objectId] = objectMessage + } else { + // RTO5f2a2: Merge map entries into the existing message. + var merged = existing + if let incomingEntries = object.map?.entries { + var mergedObject = merged.object! + guard var mergedMap = mergedObject.map else { + // Not a specified scenario — the server won't send a map and a non-map for the same + // objectId in practice. Guard defensively rather than force-unwrapping. + logger.log("Existing entry for objectId \(objectId) is not a map; replacing with incoming message", level: .error) + objectMessages[objectId] = objectMessage + return + } + var mergedEntries = mergedMap.entries ?? [:] + mergedEntries.merge(incomingEntries) { _, new in new } + mergedMap.entries = mergedEntries + mergedObject.map = mergedMap + merged.object = mergedObject + } + objectMessages[objectId] = merged + } + } else { + // RTO5f2b: Incoming message has a counter — log error, skip. + logger.log("Received partial counter sync for objectId \(objectId); skipping", level: .error) + } + } else { + // RTO5f1: No entry exists for this objectId — store the message. + objectMessages[objectId] = objectMessage + } + } + + // MARK: - Collection conformance + + internal typealias Index = Dictionary.Values.Index + internal typealias Element = InboundObjectMessage + + internal var startIndex: Index { objectMessages.values.startIndex } + internal var endIndex: Index { objectMessages.values.endIndex } + internal func index(after i: Index) -> Index { objectMessages.values.index(after: i) } + internal subscript(position: Index) -> Element { objectMessages.values[position] } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift b/LiveObjects/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift new file mode 100644 index 000000000..48cf75a59 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift @@ -0,0 +1,19 @@ +internal extension InboundObjectMessage { + /// Creates a synthetic inbound message from an outbound message, per RTO20d2 and RTO20d3. + /// + /// Used to apply a locally-published operation upon receipt of the ACK from Realtime. + static func createSynthetic(from outboundMessage: OutboundObjectMessage, serial: String, siteCode: String) -> InboundObjectMessage { + InboundObjectMessage( + id: outboundMessage.id, + clientId: outboundMessage.clientId, + connectionId: outboundMessage.connectionId, + extras: outboundMessage.extras, + timestamp: outboundMessage.timestamp, + operation: outboundMessage.operation, + object: nil, + serial: serial, // RTO20d2a + siteCode: siteCode, // RTO20d2b + serialTimestamp: nil, + ) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/LiveObjects/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift new file mode 100644 index 000000000..9ca106f58 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -0,0 +1,648 @@ +internal import _AblyPluginSupportPrivate +import Ably +import Foundation + +// This file contains the ObjectMessage types that we use within the codebase. We convert them to and from the corresponding wire types (e.g. `InboundWireObjectMessage`) for sending and receiving over the wire. + +/// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. +internal struct InboundObjectMessage: Equatable { + internal var id: String? // OM2a + internal var clientId: String? // OM2b + internal var connectionId: String? // OM2c + internal var extras: [String: JSONValue]? // OM2d + internal var timestamp: Date? // OM2e + internal var operation: ObjectOperation? // OM2f + internal var object: ObjectState? // OM2g + internal var serial: String? // OM2h + internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j +} + +/// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. +/// +/// - Important: When adding new fields, also update ``InboundObjectMessage/createSynthetic(from:serial:siteCode:)``. +internal struct OutboundObjectMessage: Equatable { + internal var id: String? // OM2a + internal var clientId: String? // OM2b + internal var connectionId: String? + internal var extras: [String: JSONValue]? // OM2d + internal var timestamp: Date? // OM2e + internal var operation: ObjectOperation? // OM2f + internal var object: ObjectState? // OM2g + internal var serial: String? // OM2h + internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j +} + +internal struct ObjectOperation: Equatable { + internal var action: WireEnum // OOP3a + internal var objectId: String // OOP3b + internal var mapCreate: MapCreate? // OOP3j + internal var mapSet: MapSet? // OOP3k + internal var mapRemove: WireMapRemove? // OOP3l + internal var counterCreate: WireCounterCreate? // OOP3m + internal var counterInc: WireCounterInc? // OOP3n + internal var objectDelete: WireObjectDelete? // OOP3o + internal var mapCreateWithObjectId: MapCreateWithObjectId? // OOP3p + internal var counterCreateWithObjectId: CounterCreateWithObjectId? // OOP3q + internal var mapClear: WireMapClear? // OOP3r +} + +internal struct ObjectData: Equatable { + internal var objectId: String? // OD2a + internal var boolean: Bool? // OD2c + internal var bytes: Data? // OD2d + internal var number: NSNumber? // OD2e + internal var string: String? // OD2f + internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) +} + +internal struct MapSet: Equatable { + internal var key: String // MST2a + internal var value: ObjectData? // MST2b +} + +internal struct MapCreate: Equatable { + internal var semantics: WireEnum // MCR2a + internal var entries: [String: ObjectsMapEntry]? // MCR2b +} + +internal struct MapCreateWithObjectId: Equatable { + internal var initialValue: String // MCRO2a + internal var nonce: String // MCRO2b + + /// The source `MapCreate` from which this `MapCreateWithObjectId` was derived. + /// For local use only (apply-on-ACK per RTLM23); must not be sent over the wire. + /// - SeeAlso: RTO11f18 + internal var derivedFrom: MapCreate? +} + +internal struct CounterCreateWithObjectId: Equatable { + internal var initialValue: String // CCRO2a + internal var nonce: String // CCRO2b + + /// The source `WireCounterCreate` from which this `CounterCreateWithObjectId` was derived. + /// For local use only (apply-on-ACK per RTLC16); must not be sent over the wire. + /// - SeeAlso: RTO12f16 + internal var derivedFrom: WireCounterCreate? +} + +internal struct ObjectsMapEntry: Equatable { + internal var tombstone: Bool? // OME2a + internal var timeserial: String? // OME2b + internal var data: ObjectData? // OME2c + internal var serialTimestamp: Date? // OME2d +} + +internal struct ObjectsMap: Equatable { + internal var semantics: WireEnum // OMP3a + internal var entries: [String: ObjectsMapEntry]? // OMP3b + internal var clearTimeserial: String? // OMP3c +} + +internal struct ObjectState: Equatable { + internal var objectId: String // OST2a + internal var siteTimeserials: [String: String] // OST2b + internal var tombstone: Bool // OST2c + internal var createOp: ObjectOperation? // OST2d + internal var map: ObjectsMap? // OST2e + internal var counter: WireObjectsCounter? // OST2f +} + +internal extension InboundObjectMessage { + /// Initializes an `InboundObjectMessage` from an `InboundWireObjectMessage`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. + init( + wireObjectMessage: InboundWireObjectMessage, + format: _AblyPluginSupportPrivate.EncodingFormat + ) throws(ARTErrorInfo) { + id = wireObjectMessage.id + clientId = wireObjectMessage.clientId + connectionId = wireObjectMessage.connectionId + extras = wireObjectMessage.extras + timestamp = wireObjectMessage.timestamp + operation = try wireObjectMessage.operation.map { wireObjectOperation throws(ARTErrorInfo) in + try .init(wireObjectOperation: wireObjectOperation, format: format) + } + object = try wireObjectMessage.object.map { wireObjectState throws(ARTErrorInfo) in + try .init(wireObjectState: wireObjectState, format: format) + } + serial = wireObjectMessage.serial + siteCode = wireObjectMessage.siteCode + serialTimestamp = wireObjectMessage.serialTimestamp + } +} + +internal extension OutboundObjectMessage { + /// Converts this `OutboundObjectMessage` to an `OutboundWireObjectMessage`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> OutboundWireObjectMessage { + .init( + id: id, + clientId: clientId, + connectionId: connectionId, + extras: extras, + timestamp: timestamp, + operation: operation?.toWire(format: format), + object: object?.toWire(format: format), + serial: serial, + siteCode: siteCode, + serialTimestamp: serialTimestamp, + ) + } +} + +internal extension ObjectOperation { + /// Initializes an `ObjectOperation` from a `WireObjectOperation`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. + init( + wireObjectOperation: WireObjectOperation, + format: _AblyPluginSupportPrivate.EncodingFormat + ) throws(ARTErrorInfo) { + action = wireObjectOperation.action + objectId = wireObjectOperation.objectId + + mapCreate = try wireObjectOperation.mapCreate.map { wireMapCreate throws(ARTErrorInfo) in + try .init(wireMapCreate: wireMapCreate, format: format) + } + mapSet = try wireObjectOperation.mapSet.map { wireMapSet throws(ARTErrorInfo) in + try .init(wireMapSet: wireMapSet, format: format) + } + mapRemove = wireObjectOperation.mapRemove + counterCreate = wireObjectOperation.counterCreate + counterInc = wireObjectOperation.counterInc + objectDelete = wireObjectOperation.objectDelete + mapClear = wireObjectOperation.mapClear + // Outbound-only — do not access on inbound data + mapCreateWithObjectId = nil + counterCreateWithObjectId = nil + } + + /// Converts this `ObjectOperation` to a `WireObjectOperation`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectOperation { + .init( + action: action, + objectId: objectId, + mapCreate: mapCreate?.toWire(format: format), + mapSet: mapSet?.toWire(format: format), + mapRemove: mapRemove, + counterCreate: counterCreate, + counterInc: counterInc, + objectDelete: objectDelete, + mapCreateWithObjectId: mapCreateWithObjectId?.toWire(), + counterCreateWithObjectId: counterCreateWithObjectId?.toWire(), + mapClear: mapClear, + ) + } +} + +internal extension ObjectData { + /// Initializes an `ObjectData` from a `WireObjectData`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. + init( + wireObjectData: WireObjectData, + format: _AblyPluginSupportPrivate.EncodingFormat + ) throws(ARTErrorInfo) { + objectId = wireObjectData.objectId + boolean = wireObjectData.boolean + number = wireObjectData.number + string = wireObjectData.string + + // OD5: Decode data based on format + switch format { + case .messagePack: + // OD5a: When the MessagePack protocol is used + // OD5a1: The payloads in (…) ObjectData.bytes (…) are decoded as their corresponding MessagePack types + if let wireBytes = wireObjectData.bytes { + switch wireBytes { + case let .data(data): + bytes = data + case .string: + // Not very clear what we're meant to do if `bytes` contains a string; let's ignore it. I think it's a bit moot - shouldn't happen. The only reason I'm considering it here is because of our slightly weird WireObjectData.bytes type which is typed as a string or data; might be good to at some point figure out how to rule out the string case earlier when using MessagePack, but it's not a big issue + bytes = nil + } + } else { + bytes = nil + } + case .json: + // OD5b: When the JSON protocol is used + // OD5b2: The ObjectData.bytes payload is Base64-decoded into a binary value + if let wireBytes = wireObjectData.bytes { + switch wireBytes { + case let .string(base64String): + bytes = try Data.fromBase64Throwing(base64String) + case .data: + // This is an error in our logic, not a malformed wire value + preconditionFailure("Should not receive Data for JSON encoding format") + } + } else { + bytes = nil + } + } + + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + if let wireJson = wireObjectData.json { + let jsonValue = try JSONObjectOrArray(jsonString: wireJson) + json = jsonValue + } else { + json = nil + } + } + + /// Converts this `ObjectData` to a `WireObjectData`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectData { + // OD4: Encode data based on format + let wireBytes: StringOrData? = if let bytes { + switch format { + case .messagePack: + // OD4c: When the MessagePack protocol is used + // OD4c2: A binary payload is encoded as a MessagePack binary type, and the result is set on the ObjectData.bytes attribute + .data(bytes) + case .json: + // OD4d: When the JSON protocol is used + // OD4d2: A binary payload is Base64-encoded and represented as a JSON string; the result is set on the ObjectData.bytes attribute + .string(bytes.base64EncodedString()) + } + } else { + nil + } + + let wireNumber: NSNumber? = if let number { + switch format { + case .json: + number + case .messagePack: + // OD4c: When the MessagePack protocol is used + // OD4c3 A number payload is encoded as a MessagePack float64 type, and the result is set on the ObjectData.number attribute + .init(value: number.doubleValue) + } + } else { + nil + } + + return .init( + objectId: objectId, + boolean: boolean, + bytes: wireBytes, + number: wireNumber, + // OD4c4: A string payload is encoded as a MessagePack string type, and the result is set on the ObjectData.string attribute + // OD4d4: A string payload is represented as a JSON string and set on the ObjectData.string attribute + string: string, + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + json: json?.toJSONString, + ) + } +} + +internal extension MapSet { + init( + wireMapSet: WireMapSet, + format: _AblyPluginSupportPrivate.EncodingFormat + ) throws(ARTErrorInfo) { + key = wireMapSet.key + value = try wireMapSet.value.map { wireObjectData throws(ARTErrorInfo) in + try .init(wireObjectData: wireObjectData, format: format) + } + } + + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireMapSet { + .init( + key: key, + value: value?.toWire(format: format), + ) + } +} + +internal extension MapCreate { + init( + wireMapCreate: WireMapCreate, + format: _AblyPluginSupportPrivate.EncodingFormat + ) throws(ARTErrorInfo) { + semantics = wireMapCreate.semantics + entries = try wireMapCreate.entries?.ablyLiveObjects_mapValuesWithTypedThrow { wireMapEntry throws(ARTErrorInfo) in + try .init(wireObjectsMapEntry: wireMapEntry, format: format) + } + } + + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireMapCreate { + .init( + semantics: semantics, + entries: entries?.mapValues { $0.toWire(format: format) }, + ) + } +} + +internal extension MapCreateWithObjectId { + init(wireMapCreateWithObjectId: WireMapCreateWithObjectId) { + nonce = wireMapCreateWithObjectId.nonce + initialValue = wireMapCreateWithObjectId.initialValue + } + + func toWire() -> WireMapCreateWithObjectId { + .init(initialValue: initialValue, nonce: nonce) + } +} + +internal extension CounterCreateWithObjectId { + init(wireCounterCreateWithObjectId: WireCounterCreateWithObjectId) { + nonce = wireCounterCreateWithObjectId.nonce + initialValue = wireCounterCreateWithObjectId.initialValue + } + + func toWire() -> WireCounterCreateWithObjectId { + .init(initialValue: initialValue, nonce: nonce) + } +} + +internal extension ObjectsMapEntry { + /// Initializes an `ObjectsMapEntry` from a `WireObjectsMapEntry`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. + init( + wireObjectsMapEntry: WireObjectsMapEntry, + format: _AblyPluginSupportPrivate.EncodingFormat + ) throws(ARTErrorInfo) { + tombstone = wireObjectsMapEntry.tombstone + timeserial = wireObjectsMapEntry.timeserial + data = if let wireObjectData = wireObjectsMapEntry.data { + try .init(wireObjectData: wireObjectData, format: format) + } else { + nil + } + serialTimestamp = wireObjectsMapEntry.serialTimestamp + } + + /// Converts this `ObjectsMapEntry` to a `WireObjectsMapEntry`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectsMapEntry { + .init( + tombstone: tombstone, + timeserial: timeserial, + data: data?.toWire(format: format), + ) + } +} + +internal extension ObjectsMap { + /// Initializes an `ObjectsMap` from a `WireObjectsMap`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. + init( + wireObjectsMap: WireObjectsMap, + format: _AblyPluginSupportPrivate.EncodingFormat + ) throws(ARTErrorInfo) { + semantics = wireObjectsMap.semantics + entries = try wireObjectsMap.entries?.ablyLiveObjects_mapValuesWithTypedThrow { wireMapEntry throws(ARTErrorInfo) in + try .init(wireObjectsMapEntry: wireMapEntry, format: format) + } + clearTimeserial = wireObjectsMap.clearTimeserial + } + + /// Converts this `ObjectsMap` to a `WireObjectsMap`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectsMap { + .init( + semantics: semantics, + entries: entries?.mapValues { $0.toWire(format: format) }, + clearTimeserial: clearTimeserial, + ) + } +} + +internal extension ObjectState { + /// Initializes an `ObjectState` from a `WireObjectState`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. + init( + wireObjectState: WireObjectState, + format: _AblyPluginSupportPrivate.EncodingFormat + ) throws(ARTErrorInfo) { + objectId = wireObjectState.objectId + siteTimeserials = wireObjectState.siteTimeserials + tombstone = wireObjectState.tombstone + createOp = try wireObjectState.createOp.map { wireObjectOperation throws(ARTErrorInfo) in + try .init(wireObjectOperation: wireObjectOperation, format: format) + } + map = try wireObjectState.map.map { wireObjectsMap throws(ARTErrorInfo) in + try .init(wireObjectsMap: wireObjectsMap, format: format) + } + counter = wireObjectState.counter + } + + /// Converts this `ObjectState` to a `WireObjectState`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectState { + .init( + objectId: objectId, + siteTimeserials: siteTimeserials, + tombstone: tombstone, + createOp: createOp?.toWire(format: format), + map: map?.toWire(format: format), + counter: counter, + ) + } +} + +// MARK: - CustomDebugStringConvertible + +extension InboundObjectMessage: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + if let id { parts.append("id: \(id)") } + if let clientId { parts.append("clientId: \(clientId)") } + if let connectionId { parts.append("connectionId: \(connectionId)") } + if let extras { parts.append("extras: \(extras)") } + if let timestamp { parts.append("timestamp: \(timestamp)") } + if let operation { parts.append("operation: \(operation)") } + if let object { parts.append("object: \(object)") } + if let serial { parts.append("serial: \(serial)") } + if let siteCode { parts.append("siteCode: \(siteCode)") } + if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension OutboundObjectMessage: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + if let id { parts.append("id: \(id)") } + if let clientId { parts.append("clientId: \(clientId)") } + if let connectionId { parts.append("connectionId: \(connectionId)") } + if let extras { parts.append("extras: \(extras)") } + if let timestamp { parts.append("timestamp: \(timestamp)") } + if let operation { parts.append("operation: \(operation)") } + if let object { parts.append("object: \(object)") } + if let serial { parts.append("serial: \(serial)") } + if let siteCode { parts.append("siteCode: \(siteCode)") } + if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension ObjectOperation: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("action: \(action)") + parts.append("objectId: \(objectId)") + if let mapCreate { parts.append("mapCreate: \(mapCreate)") } + if let mapSet { parts.append("mapSet: \(mapSet)") } + if let mapRemove { parts.append("mapRemove: \(mapRemove)") } + if let counterCreate { parts.append("counterCreate: \(counterCreate)") } + if let counterInc { parts.append("counterInc: \(counterInc)") } + if let objectDelete { parts.append("objectDelete: \(objectDelete)") } + if let mapCreateWithObjectId { parts.append("mapCreateWithObjectId: \(mapCreateWithObjectId)") } + if let counterCreateWithObjectId { parts.append("counterCreateWithObjectId: \(counterCreateWithObjectId)") } + if let mapClear { parts.append("mapClear: \(mapClear)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension ObjectState: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("objectId: \(objectId)") + parts.append("siteTimeserials: \(siteTimeserials)") + parts.append("tombstone: \(tombstone)") + if let createOp { parts.append("createOp: \(createOp)") } + if let map { parts.append("map: \(map)") } + if let counter { parts.append("counter: \(counter)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension ObjectsMap: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("semantics: \(semantics)") + if let entries { + let formattedEntries = entries + .map { key, entry in + "\(key): \(entry)" + } + .joined(separator: ", ") + parts.append("entries: { \(formattedEntries) }") + } + if let clearTimeserial { parts.append("clearTimeserial: \(clearTimeserial)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension ObjectsMapEntry: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + if let tombstone { parts.append("tombstone: \(tombstone)") } + if let timeserial { parts.append("timeserial: \(timeserial)") } + if let data { parts.append("data: \(data)") } + if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension ObjectData: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + if let objectId { parts.append("objectId: \(objectId)") } + if let boolean { parts.append("boolean: \(boolean)") } + if let bytes { parts.append("bytes: \(bytes.count) bytes") } + if let number { parts.append("number: \(number)") } + if let string { parts.append("string: \(string)") } + if let json { parts.append("json: \(json)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension MapSet: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("key: \(key)") + if let value { parts.append("value: \(value)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension MapCreate: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("semantics: \(semantics)") + if let entries { + let formattedEntries = entries + .map { key, entry in + "\(key): \(entry)" + } + .joined(separator: ", ") + parts.append("entries: { \(formattedEntries) }") + } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension MapCreateWithObjectId: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("initialValue: \(initialValue)") + parts.append("nonce: \(nonce)") + if let derivedFrom { parts.append("derivedFrom: \(derivedFrom)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension CounterCreateWithObjectId: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("initialValue: \(initialValue)") + parts.append("nonce: \(nonce)") + if let derivedFrom { parts.append("derivedFrom: \(derivedFrom)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Protocol/SyncCursor.swift b/LiveObjects/Sources/AblyLiveObjects/Protocol/SyncCursor.swift new file mode 100644 index 000000000..311e8f98e --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Protocol/SyncCursor.swift @@ -0,0 +1,39 @@ +import Ably +import Foundation + +/// The `OBJECT_SYNC` sync cursor, as extracted from a `channelSerial` per RTO5a1 and RTO5a4. +internal struct SyncCursor { + internal var sequenceID: String + /// `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) { + let scanner = Scanner(string: channelSerial) + scanner.charactersToBeSkipped = nil + + // Get everything up to the colon as the sequence ID + let sequenceID = scanner.scanUpToString(":") ?? "" + + // Check if we have a colon + guard scanner.scanString(":") != nil else { + throw Error.channelSerialDoesNotMatchExpectedFormat(channelSerial).toARTErrorInfo() + } + + // Everything after the colon (if anything) is the cursor value + let remainingString = channelSerial[scanner.currentIndex...] + let cursorValue = remainingString.isEmpty ? nil : String(remainingString) + + self.sequenceID = sequenceID + self.cursorValue = cursorValue + } + + /// Whether this cursor represents the end of the sync sequence, per RTO5a4. + internal var isEndOfSequence: Bool { + cursorValue == nil + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Protocol/WireEnum.swift b/LiveObjects/Sources/AblyLiveObjects/Protocol/WireEnum.swift new file mode 100644 index 000000000..9df4f47e9 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Protocol/WireEnum.swift @@ -0,0 +1,25 @@ +/// An enum extracted from a wire representation that either belongs to one of a set of known values or is a new, unknown value. +internal enum WireEnum where Known: RawRepresentable { + case known(Known) + case unknown(Known.RawValue) + + internal init(rawValue: Known.RawValue) { + if let known = Known(rawValue: rawValue) { + self = .known(known) + } else { + self = .unknown(rawValue) + } + } + + internal var rawValue: Known.RawValue { + switch self { + case let .known(known): + known.rawValue + case let .unknown(rawValue): + rawValue + } + } +} + +extension WireEnum: Sendable where Known: Sendable, Known.RawValue: Sendable {} +extension WireEnum: Equatable where Known: Equatable, Known.RawValue: Equatable {} diff --git a/LiveObjects/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/LiveObjects/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift new file mode 100644 index 000000000..8647e733f --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -0,0 +1,798 @@ +internal import _AblyPluginSupportPrivate +import Ably +import Foundation + +// This file contains the ObjectMessage types that we send and receive over the wire. We convert them to and from the corresponding non-wire types (e.g. `InboundObjectMessage`) for use within the codebase. + +/// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. +internal struct InboundWireObjectMessage { + // TODO: Spec has `id`, `connectionId`, `timestamp`, `clientId`, `serial`, `sideCode` as non-nullable but I don't think this is right; raised https://github.com/ably/specification/issues/334 + internal var id: String? // OM2a + internal var clientId: String? // OM2b + internal var connectionId: String? // OM2c + internal var extras: [String: JSONValue]? // OM2d + internal var timestamp: Date? // OM2e + internal var operation: WireObjectOperation? // OM2f + internal var object: WireObjectState? // OM2g + internal var serial: String? // OM2h + internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j +} + +/// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. +internal struct OutboundWireObjectMessage { + internal var id: String? // OM2a + internal var clientId: String? // OM2b + internal var connectionId: String? + internal var extras: [String: JSONValue]? // OM2d + internal var timestamp: Date? // OM2e + internal var operation: WireObjectOperation? // OM2f + internal var object: WireObjectState? // OM2g + internal var serial: String? // OM2h + internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j +} + +/// The keys for decoding an `InboundWireObjectMessage` or encoding an `OutboundWireObjectMessage`. +internal enum WireObjectMessageWireKey: String { + case id + case clientId + case connectionId + case extras + case timestamp + case operation + case object + case serial + case siteCode + case serialTimestamp +} + +internal extension InboundWireObjectMessage { + /// An error that can occur when decoding an ``InboundWireObjectMessage``. + enum DecodingError: Error { + // TODO: after https://github.com/ably/specification/issues/334 resolved, throw or remove these as needed + /// The containing `ProtocolMessage` does not have an `id`. + case parentMissingID + /// The containing `ProtocolMessage` does not have a `connectionId`. + case parentMissingConnectionID + /// The containing `ProtocolMessage` does not have a `timestamp`. + case parentMissingTimestamp + } + + /// Decodes the `ObjectMessage` and then uses the containing `ProtocolMessage` to populate some absent fields per the rules of the specification. + init( + wireObject: [String: WireValue], + decodingContext: _AblyPluginSupportPrivate.DecodingContextProtocol + ) throws(ARTErrorInfo) { + // OM2a + if let id = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.id.rawValue) { + self.id = id + } else if let parentID = decodingContext.parentID { + id = "\(parentID):\(decodingContext.indexInParent)" + } + + clientId = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.clientId.rawValue) + + // OM2c + if let connectionId = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.connectionId.rawValue) { + self.connectionId = connectionId + } else if let parentConnectionID = decodingContext.parentConnectionID { + connectionId = parentConnectionID + } + + // Convert WireValue extras to JSONValue extras + if let wireExtras = try wireObject.optionalObjectValueForKey(WireObjectMessageWireKey.extras.rawValue) { + extras = try wireExtras.ablyLiveObjects_mapValuesWithTypedThrow { wireValue throws(ARTErrorInfo) in + try wireValue.toJSONValue + } + } else { + extras = nil + } + + // OM2e + if let timestamp = try wireObject.optionalAblyProtocolDateValueForKey(WireObjectMessageWireKey.timestamp.rawValue) { + self.timestamp = timestamp + } else if let parentTimestamp = decodingContext.parentTimestamp { + timestamp = parentTimestamp + } + + operation = try wireObject.optionalDecodableValueForKey(WireObjectMessageWireKey.operation.rawValue) + object = try wireObject.optionalDecodableValueForKey(WireObjectMessageWireKey.object.rawValue) + serial = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.serial.rawValue) + siteCode = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.siteCode.rawValue) + serialTimestamp = try wireObject.optionalAblyProtocolDateValueForKey(WireObjectMessageWireKey.serialTimestamp.rawValue) + } +} + +extension OutboundWireObjectMessage: WireObjectEncodable { + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [:] + + if let id { + result[WireObjectMessageWireKey.id.rawValue] = .string(id) + } + if let connectionId { + result[WireObjectMessageWireKey.connectionId.rawValue] = .string(connectionId) + } + if let timestamp { + result[WireObjectMessageWireKey.timestamp.rawValue] = .number(NSNumber(value: (timestamp.timeIntervalSince1970) * 1000)) + } + if let siteCode { + result[WireObjectMessageWireKey.siteCode.rawValue] = .string(siteCode) + } + if let serial { + result[WireObjectMessageWireKey.serial.rawValue] = .string(serial) + } + if let clientId { + result[WireObjectMessageWireKey.clientId.rawValue] = .string(clientId) + } + if let extras { + // Convert JSONValue extras to WireValue extras + result[WireObjectMessageWireKey.extras.rawValue] = .object(extras.mapValues { .init(jsonValue: $0) }) + } + if let operation { + result[WireObjectMessageWireKey.operation.rawValue] = .object(operation.toWireObject) + } + if let object { + result[WireObjectMessageWireKey.object.rawValue] = .object(object.toWireObject) + } + if let serialTimestamp { + result[WireObjectMessageWireKey.serialTimestamp.rawValue] = .number(NSNumber(value: serialTimestamp.timeIntervalSince1970 * 1000)) + } + return result + } +} + +// OOP2 +internal enum ObjectOperationAction: Int { + case mapCreate = 0 + case mapSet = 1 + case mapRemove = 2 + case counterCreate = 3 + case counterInc = 4 + case objectDelete = 5 + case mapClear = 6 +} + +// OMP2 +internal enum ObjectsMapSemantics: Int { + case lww = 0 +} + +internal struct WireObjectOperation { + internal var action: WireEnum // OOP3a + internal var objectId: String // OOP3b + internal var mapCreate: WireMapCreate? // OOP3j + internal var mapSet: WireMapSet? // OOP3k + internal var mapRemove: WireMapRemove? // OOP3l + internal var counterCreate: WireCounterCreate? // OOP3m + internal var counterInc: WireCounterInc? // OOP3n + internal var objectDelete: WireObjectDelete? // OOP3o + internal var mapCreateWithObjectId: WireMapCreateWithObjectId? // OOP3p + internal var counterCreateWithObjectId: WireCounterCreateWithObjectId? // OOP3q + internal var mapClear: WireMapClear? // OOP3r +} + +extension WireObjectOperation: WireObjectCodable { + internal enum WireKey: String { + case action + case objectId + case mapCreate + case mapSet + case mapRemove + case counterCreate + case counterInc + case objectDelete + case mapCreateWithObjectId + case counterCreateWithObjectId + case mapClear + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) + objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) + + mapCreate = try wireObject.optionalDecodableValueForKey(WireKey.mapCreate.rawValue) + mapSet = try wireObject.optionalDecodableValueForKey(WireKey.mapSet.rawValue) + mapRemove = try wireObject.optionalDecodableValueForKey(WireKey.mapRemove.rawValue) + counterCreate = try wireObject.optionalDecodableValueForKey(WireKey.counterCreate.rawValue) + counterInc = try wireObject.optionalDecodableValueForKey(WireKey.counterInc.rawValue) + objectDelete = try wireObject.optionalDecodableValueForKey(WireKey.objectDelete.rawValue) + mapClear = try wireObject.optionalDecodableValueForKey(WireKey.mapClear.rawValue) + // Outbound-only — do not access on inbound data + mapCreateWithObjectId = nil + counterCreateWithObjectId = nil + } + + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.action.rawValue: .number(action.rawValue as NSNumber), + WireKey.objectId.rawValue: .string(objectId), + ] + + if let mapCreate { + result[WireKey.mapCreate.rawValue] = .object(mapCreate.toWireObject) + } + if let mapSet { + result[WireKey.mapSet.rawValue] = .object(mapSet.toWireObject) + } + if let mapRemove { + result[WireKey.mapRemove.rawValue] = .object(mapRemove.toWireObject) + } + if let counterCreate { + result[WireKey.counterCreate.rawValue] = .object(counterCreate.toWireObject) + } + if let counterInc { + result[WireKey.counterInc.rawValue] = .object(counterInc.toWireObject) + } + if let objectDelete { + result[WireKey.objectDelete.rawValue] = .object(objectDelete.toWireObject) + } + if let mapCreateWithObjectId { + result[WireKey.mapCreateWithObjectId.rawValue] = .object(mapCreateWithObjectId.toWireObject) + } + if let counterCreateWithObjectId { + result[WireKey.counterCreateWithObjectId.rawValue] = .object(counterCreateWithObjectId.toWireObject) + } + if let mapClear { + result[WireKey.mapClear.rawValue] = .object(mapClear.toWireObject) + } + + return result + } +} + +internal struct WireObjectState { + internal var objectId: String // OST2a + internal var siteTimeserials: [String: String] // OST2b + internal var tombstone: Bool // OST2c + internal var createOp: WireObjectOperation? // OST2d + internal var map: WireObjectsMap? // OST2e + internal var counter: WireObjectsCounter? // OST2f +} + +extension WireObjectState: WireObjectCodable { + internal enum WireKey: String { + case objectId + case siteTimeserials + case tombstone + case createOp + case map + case counter + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) + siteTimeserials = try wireObject.objectValueForKey(WireKey.siteTimeserials.rawValue).ablyLiveObjects_mapValuesWithTypedThrow { value throws(ARTErrorInfo) in + guard case let .string(string) = value else { + throw WireValueDecodingError.wrongTypeForKey(WireKey.siteTimeserials.rawValue, actualValue: value).toARTErrorInfo() + } + return string + } + tombstone = try wireObject.boolValueForKey(WireKey.tombstone.rawValue) + createOp = try wireObject.optionalDecodableValueForKey(WireKey.createOp.rawValue) + map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) + counter = try wireObject.optionalDecodableValueForKey(WireKey.counter.rawValue) + } + + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.objectId.rawValue: .string(objectId), + WireKey.siteTimeserials.rawValue: .object(siteTimeserials.mapValues { .string($0) }), + WireKey.tombstone.rawValue: .bool(tombstone), + ] + + if let createOp { + result[WireKey.createOp.rawValue] = .object(createOp.toWireObject) + } + if let map { + result[WireKey.map.rawValue] = .object(map.toWireObject) + } + if let counter { + result[WireKey.counter.rawValue] = .object(counter.toWireObject) + } + + return result + } +} + +internal struct WireObjectsMap { + internal var semantics: WireEnum // OMP3a + internal var entries: [String: WireObjectsMapEntry]? // OMP3b + internal var clearTimeserial: String? // OMP3c +} + +extension WireObjectsMap: WireObjectCodable { + internal enum WireKey: String { + case semantics + case entries + case clearTimeserial + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + semantics = try wireObject.wireEnumValueForKey(WireKey.semantics.rawValue) + entries = try wireObject.optionalObjectValueForKey(WireKey.entries.rawValue)?.ablyLiveObjects_mapValuesWithTypedThrow { value throws(ARTErrorInfo) in + guard case let .object(object) = value else { + throw WireValueDecodingError.wrongTypeForKey(WireKey.entries.rawValue, actualValue: value).toARTErrorInfo() + } + return try WireObjectsMapEntry(wireObject: object) + } + clearTimeserial = try wireObject.optionalStringValueForKey(WireKey.clearTimeserial.rawValue) + } + + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.semantics.rawValue: .number(semantics.rawValue as NSNumber), + ] + + if let entries { + result[WireKey.entries.rawValue] = .object(entries.mapValues { .object($0.toWireObject) }) + } + if let clearTimeserial { + result[WireKey.clearTimeserial.rawValue] = .string(clearTimeserial) + } + + return result + } +} + +internal struct WireObjectsCounter: Equatable { + internal var count: NSNumber? // OCN2a +} + +extension WireObjectsCounter: WireObjectCodable { + internal enum WireKey: String { + case count + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + count = try wireObject.optionalNumberValueForKey(WireKey.count.rawValue) + } + + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [:] + if let count { + result[WireKey.count.rawValue] = .number(count) + } + return result + } +} + +internal struct WireMapSet { + internal var key: String // MST2a + internal var value: WireObjectData? // MST2b +} + +extension WireMapSet: WireObjectCodable { + internal enum WireKey: String { + case key + case value + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + key = try wireObject.stringValueForKey(WireKey.key.rawValue) + value = try wireObject.optionalDecodableValueForKey(WireKey.value.rawValue) + } + + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.key.rawValue: .string(key), + ] + + if let value { + result[WireKey.value.rawValue] = .object(value.toWireObject) + } + + return result + } +} + +internal struct WireMapRemove: Equatable { + internal var key: String // MRM2a +} + +extension WireMapRemove: WireObjectCodable { + internal enum WireKey: String { + case key + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + key = try wireObject.stringValueForKey(WireKey.key.rawValue) + } + + internal var toWireObject: [String: WireValue] { + [ + WireKey.key.rawValue: .string(key), + ] + } +} + +internal struct WireMapCreate { + internal var semantics: WireEnum // MCR2a + internal var entries: [String: WireObjectsMapEntry]? // MCR2b +} + +extension WireMapCreate: WireObjectCodable { + internal enum WireKey: String { + case semantics + case entries + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + semantics = try wireObject.wireEnumValueForKey(WireKey.semantics.rawValue) + entries = try wireObject.optionalObjectValueForKey(WireKey.entries.rawValue)?.ablyLiveObjects_mapValuesWithTypedThrow { value throws(ARTErrorInfo) in + guard case let .object(object) = value else { + throw WireValueDecodingError.wrongTypeForKey(WireKey.entries.rawValue, actualValue: value).toARTErrorInfo() + } + return try WireObjectsMapEntry(wireObject: object) + } + } + + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.semantics.rawValue: .number(semantics.rawValue as NSNumber), + ] + + if let entries { + result[WireKey.entries.rawValue] = .object(entries.mapValues { .object($0.toWireObject) }) + } + + return result + } +} + +internal struct WireCounterCreate: Equatable { + internal var count: NSNumber? // CCR2a +} + +extension WireCounterCreate: WireObjectCodable { + internal enum WireKey: String { + case count + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + count = try wireObject.optionalNumberValueForKey(WireKey.count.rawValue) + } + + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [:] + if let count { + result[WireKey.count.rawValue] = .number(count) + } + return result + } +} + +internal struct WireCounterInc: Equatable { + internal var number: NSNumber // CIN2a +} + +extension WireCounterInc: WireObjectCodable { + internal enum WireKey: String { + case number + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + number = try wireObject.numberValueForKey(WireKey.number.rawValue) + } + + internal var toWireObject: [String: WireValue] { + [ + WireKey.number.rawValue: .number(number), + ] + } +} + +internal struct WireObjectDelete: Equatable { + // Empty struct +} + +extension WireObjectDelete: WireObjectCodable { + internal init(wireObject _: [String: WireValue]) throws(ARTErrorInfo) { + // No fields to decode + } + + internal var toWireObject: [String: WireValue] { + [:] + } +} + +internal struct WireMapClear: Equatable { + // Empty struct +} + +extension WireMapClear: WireObjectCodable { + internal init(wireObject _: [String: WireValue]) throws(ARTErrorInfo) { + // No fields to decode + } + + internal var toWireObject: [String: WireValue] { + [:] + } +} + +internal struct WireMapCreateWithObjectId: Equatable { + internal var initialValue: String // MCRO2a + internal var nonce: String // MCRO2b +} + +extension WireMapCreateWithObjectId: WireObjectCodable { + internal enum WireKey: String { + case nonce + case initialValue + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + nonce = try wireObject.stringValueForKey(WireKey.nonce.rawValue) + initialValue = try wireObject.stringValueForKey(WireKey.initialValue.rawValue) + } + + internal var toWireObject: [String: WireValue] { + [ + WireKey.nonce.rawValue: .string(nonce), + WireKey.initialValue.rawValue: .string(initialValue), + ] + } +} + +internal struct WireCounterCreateWithObjectId: Equatable { + internal var initialValue: String // CCRO2a + internal var nonce: String // CCRO2b +} + +extension WireCounterCreateWithObjectId: WireObjectCodable { + internal enum WireKey: String { + case nonce + case initialValue + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + nonce = try wireObject.stringValueForKey(WireKey.nonce.rawValue) + initialValue = try wireObject.stringValueForKey(WireKey.initialValue.rawValue) + } + + internal var toWireObject: [String: WireValue] { + [ + WireKey.nonce.rawValue: .string(nonce), + WireKey.initialValue.rawValue: .string(initialValue), + ] + } +} + +internal struct WireObjectsMapEntry { + internal var tombstone: Bool? // OME2a + internal var timeserial: String? // OME2b + internal var data: WireObjectData? // OME2c + internal var serialTimestamp: Date? // OME2d +} + +extension WireObjectsMapEntry: WireObjectCodable { + internal enum WireKey: String { + case tombstone + case timeserial + case data + case serialTimestamp + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + tombstone = try wireObject.optionalBoolValueForKey(WireKey.tombstone.rawValue) + timeserial = try wireObject.optionalStringValueForKey(WireKey.timeserial.rawValue) + data = try wireObject.optionalDecodableValueForKey(WireKey.data.rawValue) + serialTimestamp = try wireObject.optionalAblyProtocolDateValueForKey(WireKey.serialTimestamp.rawValue) + } + + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [:] + + if let data { + result[WireKey.data.rawValue] = .object(data.toWireObject) + } + if let tombstone { + result[WireKey.tombstone.rawValue] = .bool(tombstone) + } + if let timeserial { + result[WireKey.timeserial.rawValue] = .string(timeserial) + } + if let serialTimestamp { + result[WireKey.serialTimestamp.rawValue] = .number(NSNumber(value: serialTimestamp.timeIntervalSince1970 * 1000)) + } + + return result + } +} + +internal struct WireObjectData { + internal var objectId: String? // OD2a + internal var boolean: Bool? // OD2c + internal var bytes: StringOrData? // OD2d + internal var number: NSNumber? // OD2e + internal var string: String? // OD2f + internal var json: String? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) +} + +extension WireObjectData: WireObjectCodable { + internal enum WireKey: String { + case objectId + case boolean + case bytes + case number + case string + case json + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + objectId = try wireObject.optionalStringValueForKey(WireKey.objectId.rawValue) + boolean = try wireObject.optionalBoolValueForKey(WireKey.boolean.rawValue) + bytes = try wireObject.optionalDecodableValueForKey(WireKey.bytes.rawValue) + number = try wireObject.optionalNumberValueForKey(WireKey.number.rawValue) + string = try wireObject.optionalStringValueForKey(WireKey.string.rawValue) + json = try wireObject.optionalStringValueForKey(WireKey.json.rawValue) + } + + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [:] + + if let objectId { + result[WireKey.objectId.rawValue] = .string(objectId) + } + if let boolean { + result[WireKey.boolean.rawValue] = .bool(boolean) + } + if let bytes { + result[WireKey.bytes.rawValue] = bytes.toWireValue + } + if let number { + result[WireKey.number.rawValue] = .number(number) + } + if let string { + result[WireKey.string.rawValue] = .string(string) + } + if let json { + result[WireKey.json.rawValue] = .string(json) + } + + return result + } +} + +/// A type that can be either a string or binary data. +/// +/// Used to represent the values that `WireObjectData.bytes` might hold, after being encoded per OD4 or before being decoded per OD5. +internal enum StringOrData: Equatable, WireCodable { + case string(String) + case data(Data) + + /// An error that can occur when decoding a ``StringOrData``. + internal enum DecodingError: Error { + case unsupportedValue(WireValue) + } + + internal init(wireValue: WireValue) throws(ARTErrorInfo) { + self = switch wireValue { + case let .string(string): + .string(string) + case let .data(data): + .data(data) + default: + throw DecodingError.unsupportedValue(wireValue).toARTErrorInfo() + } + } + + internal var toWireValue: WireValue { + switch self { + case let .string(string): + .string(string) + case let .data(data): + .data(data) + } + } +} + +// MARK: - CustomDebugStringConvertible + +extension WireObjectsCounter: CustomDebugStringConvertible { + internal var debugDescription: String { + if let count { + "{ count: \(count) }" + } else { + "{ count: nil }" + } + } +} + +extension WireObjectsMapEntry: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + if let tombstone { parts.append("tombstone: \(tombstone)") } + if let timeserial { parts.append("timeserial: \(timeserial)") } + if let data { parts.append("data: \(data)") } + if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension WireObjectData: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + if let objectId { parts.append("objectId: \(objectId)") } + if let boolean { parts.append("boolean: \(boolean)") } + if let bytes { parts.append("bytes: \(bytes)") } + if let number { parts.append("number: \(number)") } + if let string { parts.append("string: \(string)") } + if let json { parts.append("json: \(json)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension WireMapSet: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("key: \(key)") + if let value { parts.append("value: \(value)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension WireMapRemove: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ key: \(key) }" + } +} + +extension WireMapCreate: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("semantics: \(semantics)") + if let entries { + let formattedEntries = entries + .map { key, entry in + "\(key): \(entry)" + } + .joined(separator: ", ") + parts.append("entries: { \(formattedEntries) }") + } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension WireCounterCreate: CustomDebugStringConvertible { + internal var debugDescription: String { + if let count { + "{ count: \(count) }" + } else { + "{ count: nil }" + } + } +} + +extension WireCounterInc: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ number: \(number) }" + } +} + +extension WireObjectDelete: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ }" + } +} + +extension WireMapCreateWithObjectId: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ initialValue: \(initialValue), nonce: \(nonce) }" + } +} + +extension WireCounterCreateWithObjectId: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ initialValue: \(initialValue), nonce: \(nonce) }" + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/LiveObjects/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift new file mode 100644 index 000000000..0beb04b02 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -0,0 +1,41 @@ +internal import _AblyPluginSupportPrivate +import Ably + +public extension ARTRealtimeChannel { + /// A ``RealtimeObjects`` object. + var objects: RealtimeObjects { + nonTypeErasedObjects + } + + private var nonTypeErasedObjects: PublicDefaultRealtimeObjects { + let pluginAPI = Plugin.defaultPluginAPI + let underlyingObjects = pluginAPI.underlyingObjects(for: asPluginPublicRealtimeChannel) + let internalQueue = pluginAPI.internalQueue(for: underlyingObjects.client) + let internalObjects = internalQueue.ably_syncNoDeadlock { + DefaultInternalPlugin.nosync_realtimeObjects(for: underlyingObjects.channel, pluginAPI: pluginAPI) + } + + let pluginLogger = pluginAPI.logger(for: underlyingObjects.channel) + let logger = DefaultLogger(pluginLogger: pluginLogger, pluginAPI: pluginAPI) + + let coreSDK = DefaultCoreSDK( + channel: underlyingObjects.channel, + client: underlyingObjects.client, + pluginAPI: Plugin.defaultPluginAPI, + logger: logger, + ) + + return PublicObjectsStore.shared.getOrCreateRealtimeObjects( + proxying: internalObjects, + creationArgs: .init( + coreSDK: coreSDK, + logger: logger, + ), + ) + } + + /// For tests to access the non-public API of `PublicDefaultRealtimeObjects`. + internal var testsOnly_nonTypeErasedObjects: PublicDefaultRealtimeObjects { + nonTypeErasedObjects + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Plugin.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Plugin.swift new file mode 100644 index 000000000..a39b7a74f --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Plugin.swift @@ -0,0 +1,46 @@ +internal import _AblyPluginSupportPrivate + +// We explicitly import the NSObject class, else it seems to get transitively imported from `internal import _AblyPluginSupportPrivate`, leading to the error "Class cannot be declared public because its superclass is internal". +import ObjectiveC.NSObject + +/// This plugin enables LiveObjects functionality in ably-cocoa. Set the `.liveObjects` key in the ably-cocoa `plugins` client option to this class in order to enable LiveObjects. +/// +/// For example: +/// ```swift +/// import Ably +/// import AblyLiveObjects +/// +/// let clientOptions = ARTClientOptions(key: /* */) +/// clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] +/// +/// let realtime = ARTRealtime(options: clientOptions) +/// +/// // Fetch a channel, specifying the `.objectPublish` and `.objectSubscribe` modes +/// let channelOptions = ARTRealtimeChannelOptions() +/// channelOptions.modes = [.objectPublish, .objectSubscribe] +/// let channel = realtime.channels.get("myChannel", options: channelOptions) +/// +/// // Attach the channel +/// try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in +/// channel.attach { error in +/// if let error { +/// continuation.resume(throwing: error) +/// } else { +/// continuation.resume() +/// } +/// } +/// } +/// +/// // You can now access LiveObjects functionality via the channel's `objects` property: +/// let rootObject = try await channel.objects.getRoot() +/// // …and so on +/// ``` +@objc +public class Plugin: NSObject { + /// The `_AblyPluginSupportPrivate.PluginAPIProtocol` that the LiveObjects plugin should use by default (i.e. when one hasn't been injected for test purposes). + internal static let defaultPluginAPI = _AblyPluginSupportPrivate.DependencyStore.sharedInstance().fetchPluginAPI() + + // MARK: - Informal conformance to _AblyPluginSupportPrivate.LiveObjectsPluginProtocol + + @objc private static let internalPlugin = DefaultInternalPlugin(pluginAPI: defaultPluginAPI) +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift new file mode 100644 index 000000000..6e0419a75 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift @@ -0,0 +1,51 @@ +internal import _AblyPluginSupportPrivate + +internal extension InternalLiveMapValue { + // MARK: - Mapping to public types + + struct PublicValueCreationArgs { + internal var coreSDK: CoreSDK + internal var realtimeObjects: any InternalRealtimeObjectsProtocol + internal var logger: Logger + + internal var toCounterCreationArgs: PublicObjectsStore.CounterCreationArgs { + .init(coreSDK: coreSDK, realtimeObjects: realtimeObjects, logger: logger) + } + + internal var toMapCreationArgs: PublicObjectsStore.MapCreationArgs { + .init(coreSDK: coreSDK, realtimeObjects: realtimeObjects, logger: logger) + } + } + + /// Fetches the cached public object that wraps this `InternalLiveMapValue`'s associated value, creating a new public object if there isn't already one. + func toPublic(creationArgs: PublicValueCreationArgs) -> LiveMapValue { + switch self { + case let .string(value): + .string(value) + case let .number(value): + .number(value) + case let .bool(value): + .bool(value) + case let .data(value): + .data(value) + case let .jsonArray(value): + .jsonArray(value) + case let .jsonObject(value): + .jsonObject(value) + case let .liveMap(internalLiveMap): + .liveMap( + PublicObjectsStore.shared.getOrCreateMap( + proxying: internalLiveMap, + creationArgs: creationArgs.toMapCreationArgs, + ), + ) + case let .liveCounter(internalLiveCounter): + .liveCounter( + PublicObjectsStore.shared.getOrCreateCounter( + proxying: internalLiveCounter, + creationArgs: creationArgs.toCounterCreationArgs, + ), + ) + } + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift new file mode 100644 index 000000000..53995a79a --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -0,0 +1,54 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// Our default implementation of ``LiveCounter``. +/// +/// This is largely a wrapper around ``InternalDefaultLiveCounter``. +internal final class PublicDefaultLiveCounter: LiveCounter { + internal let proxied: InternalDefaultLiveCounter + + // MARK: - Dependencies that hold a strong reference to `proxied` + + private let coreSDK: CoreSDK + private let realtimeObjects: any InternalRealtimeObjectsProtocol + private let logger: Logger + + internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK, realtimeObjects: any InternalRealtimeObjectsProtocol, logger: Logger) { + self.proxied = proxied + self.coreSDK = coreSDK + self.realtimeObjects = realtimeObjects + self.logger = logger + } + + // MARK: - `LiveCounter` protocol + + internal var value: Double { + get throws(ARTErrorInfo) { + try proxied.value(coreSDK: coreSDK) + } + } + + internal func increment(amount: Double) async throws(ARTErrorInfo) { + try await proxied.increment(amount: amount, coreSDK: coreSDK, realtimeObjects: realtimeObjects) + } + + internal func decrement(amount: Double) async throws(ARTErrorInfo) { + try await proxied.decrement(amount: amount, coreSDK: coreSDK, realtimeObjects: realtimeObjects) + } + + internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { + try proxied.subscribe(listener: listener, coreSDK: coreSDK) + } + + internal func unsubscribeAll() { + proxied.unsubscribeAll() + } + + internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + proxied.on(event: event, callback: callback) + } + + internal func offAll() { + proxied.offAll() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift new file mode 100644 index 000000000..d3b003cf6 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -0,0 +1,103 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// Our default implementation of ``LiveMap``. +/// +/// This is largely a wrapper around ``InternalDefaultLiveMap``. +internal final class PublicDefaultLiveMap: LiveMap { + internal let proxied: InternalDefaultLiveMap + + // MARK: - Dependencies that hold a strong reference to `proxied` + + private let coreSDK: CoreSDK + private let realtimeObjects: any InternalRealtimeObjectsProtocol + private let logger: Logger + + internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, realtimeObjects: any InternalRealtimeObjectsProtocol, logger: Logger) { + self.proxied = proxied + self.coreSDK = coreSDK + self.realtimeObjects = realtimeObjects + self.logger = logger + } + + // MARK: - `LiveMap` protocol + + internal func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? { + try proxied.get(key: key, coreSDK: coreSDK, delegate: realtimeObjects)?.toPublic( + creationArgs: .init( + coreSDK: coreSDK, + realtimeObjects: realtimeObjects, + logger: logger, + ), + ) + } + + internal var size: Int { + get throws(ARTErrorInfo) { + try proxied.size(coreSDK: coreSDK, delegate: realtimeObjects) + } + } + + internal var entries: [(key: String, value: LiveMapValue)] { + get throws(ARTErrorInfo) { + try proxied.entries(coreSDK: coreSDK, delegate: realtimeObjects).map { entry in + ( + entry.key, + entry.value.toPublic( + creationArgs: .init( + coreSDK: coreSDK, + realtimeObjects: realtimeObjects, + logger: logger, + ), + ) + ) + } + } + } + + internal var keys: [String] { + get throws(ARTErrorInfo) { + try proxied.keys(coreSDK: coreSDK, delegate: realtimeObjects) + } + } + + internal var values: [LiveMapValue] { + get throws(ARTErrorInfo) { + try proxied.values(coreSDK: coreSDK, delegate: realtimeObjects).map { value in + value.toPublic( + creationArgs: .init( + coreSDK: coreSDK, + realtimeObjects: realtimeObjects, + logger: logger, + ), + ) + } + } + } + + internal func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) { + let internalValue = InternalLiveMapValue(liveMapValue: value) + + try await proxied.set(key: key, value: internalValue, coreSDK: coreSDK, realtimeObjects: realtimeObjects) + } + + internal func remove(key: String) async throws(ARTErrorInfo) { + try await proxied.remove(key: key, coreSDK: coreSDK, realtimeObjects: realtimeObjects) + } + + internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { + try proxied.subscribe(listener: listener, coreSDK: coreSDK) + } + + internal func unsubscribeAll() { + proxied.unsubscribeAll() + } + + internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + proxied.on(event: event, callback: callback) + } + + internal func offAll() { + proxied.offAll() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift new file mode 100644 index 000000000..1dd1fb3a1 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -0,0 +1,131 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. +/// +/// This is largely a wrapper around ``InternalDefaultRealtimeObjects``. +internal final class PublicDefaultRealtimeObjects: RealtimeObjects { + private let proxied: InternalDefaultRealtimeObjects + internal var testsOnly_proxied: InternalDefaultRealtimeObjects { + proxied + } + + // MARK: - Dependencies that hold a strong reference to `proxied` + + private let coreSDK: CoreSDK + private let logger: Logger + + internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK, logger: Logger) { + self.proxied = proxied + self.coreSDK = coreSDK + self.logger = logger + } + + // MARK: - `RealtimeObjects` protocol + + internal func getRoot() async throws(ARTErrorInfo) -> any LiveMap { + let internalMap = try await proxied.getRoot(coreSDK: coreSDK) + return PublicObjectsStore.shared.getOrCreateMap( + proxying: internalMap, + creationArgs: .init( + coreSDK: coreSDK, + realtimeObjects: proxied, + logger: logger, + ), + ) + } + + internal func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { + let internalEntries: [String: InternalLiveMapValue] = entries.mapValues { .init(liveMapValue: $0) } + let internalMap = try await proxied.createMap(entries: internalEntries, coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateMap( + proxying: internalMap, + creationArgs: .init( + coreSDK: coreSDK, + realtimeObjects: proxied, + logger: logger, + ), + ) + } + + internal func createMap() async throws(ARTErrorInfo) -> any LiveMap { + let internalMap = try await proxied.createMap(coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateMap( + proxying: internalMap, + creationArgs: .init( + coreSDK: coreSDK, + realtimeObjects: proxied, + logger: logger, + ), + ) + } + + internal func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter { + let internalCounter = try await proxied.createCounter(count: count, coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateCounter( + proxying: internalCounter, + creationArgs: .init( + coreSDK: coreSDK, + realtimeObjects: proxied, + logger: logger, + ), + ) + } + + internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter { + let internalCounter = try await proxied.createCounter(coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateCounter( + proxying: internalCounter, + creationArgs: .init( + coreSDK: coreSDK, + realtimeObjects: proxied, + logger: logger, + ), + ) + } + + internal func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { + proxied.on(event: event, callback: callback) + } + + internal func offAll() { + proxied.offAll() + } + + // MARK: - Test-only APIs + + // These are only used by our plumbingSmokeTest (the rest of our unit tests test the internal classes, not the public ones). + + internal var testsOnly_onChannelAttachedHasObjects: Bool? { + proxied.testsOnly_onChannelAttachedHasObjects + } + + internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { + proxied.testsOnly_receivedObjectProtocolMessages + } + + internal func testsOnly_publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) { + try await proxied.testsOnly_publish(objectMessages: objectMessages, coreSDK: coreSDK) + } + + internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { + proxied.testsOnly_receivedObjectSyncProtocolMessages + } + + // These are used by the integration tests. + + /// Replaces the method that this `RealtimeObjects` uses to send any outbound `ObjectMessage`s. + /// + /// Used by integration tests, for example to disable `ObjectMessage` publishing so that a test can verify that a behaviour is not a side effect of an `ObjectMessage` sent by the SDK. + internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { + coreSDK.testsOnly_overridePublish(with: newImplementation) + } + + internal var testsOnly_gcGracePeriod: TimeInterval { + proxied.testsOnly_gcGracePeriod + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift new file mode 100644 index 000000000..865210856 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift @@ -0,0 +1,160 @@ +internal import _AblyPluginSupportPrivate +import Foundation + +/// Stores the public objects that wrap the SDK's internal components. +/// +/// This allows us to provide stable object identity for our public `RealtimeObjects`, `LiveMap`, and `LiveCounter` objects. Concretely, this means that it allows us to, for example, consistently return: +/// +/// - the same `PublicDefaultRealtimeObjects` instance across multiple calls to `ARTRealtimeChannel.objects` +/// - the same `PublicDefaultLiveMap` instance across multiple calls to `PublicDefaultRealtimeObjects.getRoot()` +/// - the same `PublicDefaultLiveMap` and `PublicDefaultLiveCounter` instance across multiple calls to `PublicDefaultLiveMap.get(…)` with the same key (similarly for other `LiveMap` getters) +/// +/// This differs from the approach that we take in ably-cocoa, in which we create a new public object each time we need to return one. Given that the LiveObjects SDK revolves around the concept of various live-updating objects, it seemed like it might be quite a confusing user experience if the pointer identity of, say, a `LiveMap` changed each time it was fetched. +/// +/// - Note: We can only make a best-effort attempt to maintain the pointer identity of the public objects. Since the SDK cannot maintain a strong reference to the public objects (given that the whole reason that these objects exist is for us to know whether the user holds a strong reference to them), if the user releases all of their strong references to a public object then the next time they fetch the public object they will receive a new object. +internal final class PublicObjectsStore: Sendable { + // Used to synchronize access to mutable state + private let mutex = NSLock() + private nonisolated(unsafe) var mutableState = MutableState() + + internal static let shared = PublicObjectsStore() + + internal struct RealtimeObjectsCreationArgs { + internal var coreSDK: CoreSDK + internal var logger: Logger + } + + /// Fetches the cached `PublicDefaultRealtimeObjects` that wraps a given `InternalDefaultRealtimeObjects`, creating a new public object if there isn't already one. + internal func getOrCreateRealtimeObjects(proxying proxied: InternalDefaultRealtimeObjects, creationArgs: RealtimeObjectsCreationArgs) -> PublicDefaultRealtimeObjects { + mutex.withLock { + mutableState.getOrCreateRealtimeObjects(proxying: proxied, creationArgs: creationArgs) + } + } + + internal struct CounterCreationArgs { + internal var coreSDK: CoreSDK + internal var realtimeObjects: any InternalRealtimeObjectsProtocol + internal var logger: Logger + } + + /// Fetches the cached `PublicDefaultLiveCounter` that wraps a given `InternalDefaultLiveCounter`, creating a new public object if there isn't already one. + internal func getOrCreateCounter(proxying proxied: InternalDefaultLiveCounter, creationArgs: CounterCreationArgs) -> PublicDefaultLiveCounter { + mutex.withLock { + mutableState.getOrCreateCounter(proxying: proxied, creationArgs: creationArgs) + } + } + + internal struct MapCreationArgs { + internal var coreSDK: CoreSDK + internal var realtimeObjects: any InternalRealtimeObjectsProtocol + internal var logger: Logger + } + + /// Fetches the cached `PublicDefaultLiveMap` that wraps a given `InternalDefaultLiveMap`, creating a new public object if there isn't already one. + internal func getOrCreateMap(proxying proxied: InternalDefaultLiveMap, creationArgs: MapCreationArgs) -> PublicDefaultLiveMap { + mutex.withLock { + mutableState.getOrCreateMap(proxying: proxied, creationArgs: creationArgs) + } + } + + private struct MutableState { + private var realtimeObjectsProxies = Proxies() + private var counterProxies = Proxies() + private var mapProxies = Proxies() + + /// Stores weak references to proxy objects. + private struct Proxies { + private var proxiesByProxiedObjectIdentifier: [ObjectIdentifier: WeakRef] = [:] + + /// Fetches the proxy that wraps `proxied`, creating a new proxy if there isn't already one. Stores a weak reference to the proxy. + mutating func getOrCreate( + proxying proxied: some AnyObject, + logger: Logger, + logObjectType: String, + createProxy: () -> Proxy, + ) -> Proxy { + // Remove any entries that are no longer useful + removeDeallocatedEntries(logger: logger, logObjectType: logObjectType) + + // Do the get-or-create + let proxiedObjectIdentifier = ObjectIdentifier(proxied) + + if let existing = proxiesByProxiedObjectIdentifier[proxiedObjectIdentifier]?.referenced { + logger.log("Reusing existing \(logObjectType) proxy (proxy: \(ObjectIdentifier(existing)), proxied: \(proxiedObjectIdentifier))", level: .debug) + return existing + } + + let created = createProxy() + proxiesByProxiedObjectIdentifier[proxiedObjectIdentifier] = .init(referenced: created) + logger.log("Creating new \(logObjectType) proxy (proxy: \(ObjectIdentifier(created)), proxied: \(proxiedObjectIdentifier))", level: .debug) + + return created + } + + private mutating func removeDeallocatedEntries(logger: Logger, logObjectType: String) { + var keysToRemove: Set = [] + for (proxiedObjectIdentifier, weakProxyRef) in proxiesByProxiedObjectIdentifier where weakProxyRef.referenced == nil { + logger.log("Clearing unused \(logObjectType) proxy from cache (proxied: \(proxiedObjectIdentifier))", level: .debug) + keysToRemove.insert(proxiedObjectIdentifier) + } + + for key in keysToRemove { + proxiesByProxiedObjectIdentifier.removeValue(forKey: key) + } + } + } + + internal mutating func getOrCreateRealtimeObjects( + proxying proxied: InternalDefaultRealtimeObjects, + creationArgs: RealtimeObjectsCreationArgs, + ) -> PublicDefaultRealtimeObjects { + realtimeObjectsProxies.getOrCreate( + proxying: proxied, + logger: creationArgs.logger, + logObjectType: "RealtimeObjects", + ) { + .init( + proxied: proxied, + coreSDK: creationArgs.coreSDK, + logger: creationArgs.logger, + ) + } + } + + internal mutating func getOrCreateCounter( + proxying proxied: InternalDefaultLiveCounter, + creationArgs: CounterCreationArgs, + ) -> PublicDefaultLiveCounter { + counterProxies.getOrCreate( + proxying: proxied, + logger: creationArgs.logger, + logObjectType: "LiveCounter", + ) { + .init( + proxied: proxied, + coreSDK: creationArgs.coreSDK, + realtimeObjects: creationArgs.realtimeObjects, + logger: creationArgs.logger, + ) + } + } + + internal mutating func getOrCreateMap( + proxying proxied: InternalDefaultLiveMap, + creationArgs: MapCreationArgs, + ) -> PublicDefaultLiveMap { + mapProxies.getOrCreate( + proxying: proxied, + logger: creationArgs.logger, + logObjectType: "LiveMap", + ) { + .init( + proxied: proxied, + coreSDK: creationArgs.coreSDK, + realtimeObjects: creationArgs.realtimeObjects, + logger: creationArgs.logger, + ) + } + } + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/PublicTypes.swift b/LiveObjects/Sources/AblyLiveObjects/Public/PublicTypes.swift new file mode 100644 index 000000000..086ee9863 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -0,0 +1,389 @@ +import Ably + +/// A callback used in ``LiveObject`` to listen for updates to the object. +/// +/// - Parameters: +/// - update: The update object describing the changes made to the object. +/// - subscription: A ``SubscribeResponse`` object that allows the provided listener to deregister itself from future updates. +public typealias LiveObjectUpdateCallback = @Sendable (_ update: sending T, _ subscription: SubscribeResponse) -> Void + +/// The callback used for the events emitted by ``RealtimeObjects``. +/// +/// - Parameter subscription: An ``OnObjectsEventResponse`` object that allows the provided listener to deregister itself from future updates. +public typealias ObjectsEventCallback = @Sendable (_ subscription: OnObjectsEventResponse) -> Void + +/// The callback used for the lifecycle events emitted by ``LiveObject``. +/// - Parameter subscription: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to deregister itself from future updates. +public typealias LiveObjectLifecycleEventCallback = @Sendable (_ subscription: OnLiveObjectLifecycleEventResponse) -> Void + +/// Describes the events emitted by an ``RealtimeObjects`` object. +public enum ObjectsEvent: Sendable { + /// The local copy of Objects on a channel is currently being synchronized with the Ably service. + case syncing + /// The local copy of Objects on a channel has been synchronized with the Ably service. + case synced +} + +/// Describes the events emitted by a ``LiveObject`` object. +public enum LiveObjectLifecycleEvent: Sendable { + /// Indicates that the object has been deleted from the Objects pool and should no longer be interacted with. + case deleted +} + +/// Enables the Objects to be read, modified and subscribed to for a channel. +public protocol RealtimeObjects: Sendable { + /// Retrieves the root ``LiveMap`` object for Objects on a channel. + func getRoot() async throws(ARTErrorInfo) -> any LiveMap + + /// Creates a new ``LiveMap`` object instance with the provided entries. + /// + /// - Parameter entries: The initial entries for the new ``LiveMap`` object. + func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap + + /// Creates a new empty ``LiveMap`` object instance. + func createMap() async throws(ARTErrorInfo) -> any LiveMap + + /// Creates a new ``LiveCounter`` object instance with the provided `count` value. + /// + /// - Parameter count: The initial value for the new ``LiveCounter`` object. + func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter + + /// Creates a new ``LiveCounter`` object instance with a value of zero. + func createCounter() async throws(ARTErrorInfo) -> any LiveCounter + + /// Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. + /// + /// - Parameters: + /// - event: The named event to listen for. + /// - callback: The event listener. + /// - Returns: An ``OnObjectsEventResponse`` object that allows the provided listener to be deregistered from future updates. + @discardableResult + func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> OnObjectsEventResponse + + /// Deregisters all registrations, for all events and listeners. + func offAll() +} + +/// Represents the type of data stored for a given key in a ``LiveMap``. +/// It may be a primitive value (string, number, boolean, binary data, JSON array, or JSON object), or another ``LiveObject``. +/// +/// `LiveMapValue` implements Swift's `ExpressibleBy*Literal` protocols. This, in combination with `JSONValue`'s conformance to these protocols, allows you to write type-safe map values using familiar syntax. For example: +/// +/// ```swift +/// let map = try await channel.objects.createMap(entries: [ +/// "someStringKey": "someString", +/// "someIntegerKey": 123, +/// "someFloatKey": 123.456, +/// "someTrueKey": true, +/// "someFalseKey": false, +/// "someJSONObjectKey": [ +/// "someNestedJSONObjectKey": [ +/// "someOtherKey": "someOtherValue", +/// ], +/// ], +/// "someJSONArrayKey": [ +/// "foo", +/// 42, +/// ], +/// ]) +/// ``` +public enum LiveMapValue: Sendable, Equatable { + case string(String) + case number(Double) + case bool(Bool) + case data(Data) + case jsonArray([JSONValue]) + case jsonObject([String: JSONValue]) + case liveMap(any LiveMap) + case liveCounter(any LiveCounter) + + // MARK: - Convenience getters for associated values + + /// If this `LiveMapValue` has case `liveMap`, this returns the associated value. Else, it returns `nil`. + public var liveMapValue: (any LiveMap)? { + if case let .liveMap(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `liveCounter`, this returns the associated value. Else, it returns `nil`. + public var liveCounterValue: (any LiveCounter)? { + if case let .liveCounter(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `string`, this returns the associated value. Else, it returns `nil`. + public var stringValue: String? { + if case let .string(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `number`, this returns the associated value. Else, it returns `nil`. + public var numberValue: Double? { + if case let .number(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `bool`, this returns the associated value. Else, it returns `nil`. + public var boolValue: Bool? { + if case let .bool(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `data`, this returns the associated value. Else, it returns `nil`. + public var dataValue: Data? { + if case let .data(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `jsonArray`, this returns the associated value. Else, it returns `nil`. + public var jsonArrayValue: [JSONValue]? { + if case let .jsonArray(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `jsonObject`, this returns the associated value. Else, it returns `nil`. + public var jsonObjectValue: [String: JSONValue]? { + if case let .jsonObject(value) = self { + return value + } + return nil + } + + // MARK: - Equatable Implementation + + public static func == (lhs: LiveMapValue, rhs: LiveMapValue) -> Bool { + switch (lhs, rhs) { + case let (.string(lhsValue), .string(rhsValue)): + lhsValue == rhsValue + case let (.number(lhsValue), .number(rhsValue)): + lhsValue == rhsValue + case let (.bool(lhsValue), .bool(rhsValue)): + lhsValue == rhsValue + case let (.data(lhsValue), .data(rhsValue)): + lhsValue == rhsValue + case let (.jsonArray(lhsValue), .jsonArray(rhsValue)): + lhsValue == rhsValue + case let (.jsonObject(lhsValue), .jsonObject(rhsValue)): + lhsValue == rhsValue + case let (.liveMap(lhsMap), .liveMap(rhsMap)): + lhsMap === rhsMap + case let (.liveCounter(lhsCounter), .liveCounter(rhsCounter)): + lhsCounter === rhsCounter + default: + false + } + } +} + +// MARK: - ExpressibleBy*Literal conformances + +extension LiveMapValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .jsonObject(.init(uniqueKeysWithValues: elements)) + } +} + +extension LiveMapValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .jsonArray(elements) + } +} + +extension LiveMapValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension LiveMapValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +extension LiveMapValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} + +extension LiveMapValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. +public protocol OnObjectsEventResponse: Sendable { + /// Deregisters the listener passed to the `on` call. + func off() +} + +/// The `LiveMap` class represents a key-value map data structure, similar to a Swift `Dictionary`, where all changes are synchronized across clients in realtime. +/// Conflicts in a LiveMap are automatically resolved with last-write-wins (LWW) semantics, +/// meaning that if two clients update the same key in the map, the update with the most recent timestamp wins. +/// +/// Keys must be strings. Values can be another ``LiveObject``, or a primitive type, such as a string, number, boolean, JSON-serializable object or array, or binary data. +public protocol LiveMap: LiveObject where Update == LiveMapUpdate { + /// Returns the value associated with a given key. Returns `nil` if the key doesn't exist in a map or if the associated ``LiveObject`` has been deleted. + /// + /// Always returns `nil` if this map object is deleted. + /// + /// - Parameter key: The key to retrieve the value for. + /// - Returns: A ``LiveObject``, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `nil` if the key doesn't exist in a map or the associated ``LiveObject`` has been deleted. Always `nil` if this map object is deleted. + func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? + + /// Returns the number of key-value pairs in the map. + var size: Int { get throws(ARTErrorInfo) } + + /// Returns an array of key-value pairs for every entry in the map. + var entries: [(key: String, value: LiveMapValue)] { get throws(ARTErrorInfo) } + + /// Returns an array of keys in the map. + var keys: [String] { get throws(ARTErrorInfo) } + + /// Returns an iterable of values in the map. + var values: [LiveMapValue] { get throws(ARTErrorInfo) } + + /// Sends an operation to the Ably system to set a key on this `LiveMap` object to a specified value. + /// + /// This does not modify the underlying data of this object. Instead, the change is applied when + /// the published operation is echoed back to the client and applied to the object. + /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. + /// + /// - Parameters: + /// - key: The key to set the value for. + /// - value: The value to assign to the key. + func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) + + /// Sends an operation to the Ably system to remove a key from this `LiveMap` object. + /// + /// This does not modify the underlying data of this object. Instead, the change is applied when + /// the published operation is echoed back to the client and applied to the object. + /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. + /// + /// - Parameter key: The key to remove. + func remove(key: String) async throws(ARTErrorInfo) +} + +/// Describes whether an entry in ``LiveMapUpdate/update`` represents an update or a removal. +public enum LiveMapUpdateAction: Sendable { + /// The value of a key in the map was updated. + case updated + /// The value of a key in the map was removed. + case removed +} + +/// Represents an update to a ``LiveMap`` object, describing the keys that were updated or removed. +public protocol LiveMapUpdate: Sendable { + /// An object containing keys from a `LiveMap` that have changed, along with their change status: + /// - ``LiveMapUpdateAction/updated`` - the value of a key in the map was updated. + /// - ``LiveMapUpdateAction/removed`` - the key was removed from the map. + var update: [String: LiveMapUpdateAction] { get } +} + +/// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. +public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { + /// Returns the current value of the counter. + var value: Double { get throws(ARTErrorInfo) } + + /// Sends an operation to the Ably system to increment the value of this `LiveCounter` object. + /// + /// This does not modify the underlying data of this object. Instead, the change is applied when + /// the published operation is echoed back to the client and applied to the object. + /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. + /// + /// - Parameter amount: The amount by which to increase the counter value. + func increment(amount: Double) async throws(ARTErrorInfo) + + /// An alias for calling [`increment(-amount)`](doc:LiveCounter/increment(amount:)). + /// + /// - Parameter amount: The amount by which to decrease the counter value. + func decrement(amount: Double) async throws(ARTErrorInfo) +} + +/// Represents an update to a ``LiveCounter`` object. +public protocol LiveCounterUpdate: Sendable { + /// Holds the numerical change to the counter value. + var amount: Double { get } +} + +/// Describes the common interface for all conflict-free data structures supported by the Objects. +public protocol LiveObject: AnyObject, Sendable { + /// The type of update event that this object emits. + associatedtype Update + + /// Registers a listener that is called each time this LiveObject is updated. + /// + /// - Parameter listener: An event listener function that is called with an update object whenever this LiveObject is updated. + /// - Returns: A ``SubscribeResponse`` object that allows the provided listener to be deregistered from future updates. + @discardableResult + func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> SubscribeResponse + + /// Deregisters all listeners from updates for this LiveObject. + func unsubscribeAll() + + /// Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. + /// + /// - Parameters: + /// - event: The named event to listen for. + /// - callback: The event listener. + /// - Returns: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to be deregistered from future updates. + @discardableResult + func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse + + /// Deregisters all registrations, for all events and listeners. + func offAll() +} + +/// Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. +public protocol SubscribeResponse: Sendable { + /// Deregisters the listener passed to the `subscribe` call. + func unsubscribe() +} + +/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. +public protocol OnLiveObjectLifecycleEventResponse: Sendable { + /// Deregisters the listener passed to the `on` call. + func off() +} + +// MARK: - AsyncSequence Extensions + +/// Extension to provide AsyncSequence-based subscription for `LiveObject` updates. +public extension LiveObject { + /// Returns an `AsyncSequence` that emits updates to this `LiveObject`. + /// + /// This provides an alternative to the callback-based ``subscribe(listener:)`` method, + /// allowing you to use Swift's structured concurrency features like `for await` loops. + /// + /// - Returns: An AsyncSequence that emits ``Update`` values when the object is updated. + /// - Throws: An ``ARTErrorInfo`` if the subscription fails. + func updates() throws(ARTErrorInfo) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: Update.self) + + let subscription = try subscribe { update, _ in + continuation.yield(update) + } + + continuation.onTermination = { _ in + subscription.unsubscribe() + } + + return stream + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/Assertions.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/Assertions.swift new file mode 100644 index 000000000..bc1045e27 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/Assertions.swift @@ -0,0 +1,7 @@ +/// Stops execution because we tried to use a feature that is not yet implemented. +internal func notYetImplemented(_ message: @autoclosure () -> String = String(), file _: StaticString = #file, line _: UInt = #line) -> Never { + fatalError({ + let returnedMessage = message() + return "Not yet implemented\(returnedMessage.isEmpty ? "" : ": \(returnedMessage)")" + }()) +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/Data+Extensions.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/Data+Extensions.swift new file mode 100644 index 000000000..286e0bb67 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/Data+Extensions.swift @@ -0,0 +1,19 @@ +import Ably +import Foundation + +/// Errors that can occur during decoding operations. +internal enum DecodingError: Error, Equatable { + case invalidBase64String(String) +} + +internal extension Data { + /// Initialize Data from a Base64-encoded string, throwing an error if decoding fails. + /// - Parameter base64String: The Base64-encoded string to decode + /// - Throws: `ARTErrorInfo` if the string cannot be decoded as Base64 + static func fromBase64Throwing(_ base64String: String) throws(ARTErrorInfo) -> Data { + guard let data = Data(base64Encoded: base64String) else { + throw DecodingError.invalidBase64String(base64String).toARTErrorInfo() + } + return data + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift new file mode 100644 index 000000000..9635b9ebd --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift @@ -0,0 +1,8 @@ +internal extension Dictionary { + /// Behaves like `Dictionary.mapValues`, but the thrown error has the same type as that thrown by the transform. (`mapValues` uses `rethrows`, which is always an untyped throw.) + func ablyLiveObjects_mapValuesWithTypedThrow(_ transform: (Value) throws(E) -> T) throws(E) -> [Key: T] where E: Error { + try .init(uniqueKeysWithValues: map { key, value throws(E) in + try (key, transform(value)) + }) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift new file mode 100644 index 000000000..fc188589d --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift @@ -0,0 +1,15 @@ +import Foundation + +internal extension DispatchQueue { + /// Same as `sync(execute:)` but with a runtime precondition that we are not already on this queue. + func ably_syncNoDeadlock(execute block: () -> Void) { + dispatchPrecondition(condition: .notOnQueue(self)) + sync(execute: block) + } + + /// Same as `sync(execute:)` but with a runtime precondition that we are not already on this queue. + func ably_syncNoDeadlock(execute work: () throws -> T) rethrows -> T { + dispatchPrecondition(condition: .notOnQueue(self)) + return try sync(execute: work) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift new file mode 100644 index 000000000..5be97eb46 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift @@ -0,0 +1,48 @@ +import Foundation + +/// A class that provides mutually exclusive access to a value using a serial dispatch queue. +/// +/// In order to access or mutate the mutex's value, it is expected that you know whether or not you are already executing on the mutex's queue. If you are, then use ``withoutSync(_:)``, which simply performs a runtime check that the current queue is correct. If not, then use ``withSync(_:)``, which synchronously dispatches to the queue. ``withSync(_:)`` must not be called from the queue, as doing so would cause a deadlock (there is a runtime check which terminates execution in this case). +/// +/// This class is styled on Swift's built-in `Mutex` type. +internal final class DispatchQueueMutex: Sendable { + /// The queue that this mutex uses to synchronise access to the wrapped value. + internal let dispatchQueue: DispatchQueue + + private nonisolated(unsafe) var value: T + + internal init(dispatchQueue: DispatchQueue, initialValue: T) { + self.dispatchQueue = dispatchQueue + value = initialValue + } + + /// Provides access to the wrapped value by dispatching synchronously to the dispatch queue. + /// + /// - Parameters: + /// - body: The action to perform. It can read and/or mutate the wrapped value. + /// + /// - Warning: This must only be called when not already on the dispatch queue. Violating this precondition will result in a runtime error. + internal func withSync(_ body: (inout T) throws(E) -> R) throws(E) -> R { + let result: Result = dispatchQueue.ably_syncNoDeadlock { + do throws(E) { + return try .success(body(&value)) + } catch { + return .failure(error) + } + } + + return try result.get() + } + + /// Provides access to the wrapped value without dispatching to the dispatch queue. + /// + /// - Parameters: + /// - body: The action to perform. It can read and/or mutate the wrapped value. + /// + /// - Warning: This must only be called when already on the dispatch queue. Violating this precondition will result in a runtime error. + internal func withoutSync(_ body: (inout T) throws(E) -> R) throws(E) -> R { + dispatchPrecondition(condition: .onQueue(dispatchQueue)) + + return try body(&value) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift new file mode 100644 index 000000000..e434d6094 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift @@ -0,0 +1,182 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/** + Describes the errors that can be thrown by the LiveObjects SDK. Use ``toARTErrorInfo()`` to convert to an `ARTErrorInfo` that you can throw. + */ +internal enum LiveObjectsError { + // operationDescription should be a description of a method like "LiveCounter.value"; it will be interpolated into an error message + case objectsOperationFailedInvalidChannelState(operationDescription: String, channelState: _AblyPluginSupportPrivate.RealtimeChannelState) + case counterInitialValueInvalid(value: Double) + case counterIncrementAmountInvalid(amount: Double) + /// RTO20e1: The channel entered a non-`ATTACHED` state whilst a `publishAndApply` call was waiting for objects sync to complete. + 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) + case other(Error) + + /// The ``ARTErrorInfo/code`` that should be returned for this error. + internal var code: ARTErrorCode { + switch self { + case .objectsOperationFailedInvalidChannelState: + .channelOperationFailedInvalidState + case .counterInitialValueInvalid, .counterIncrementAmountInvalid: + // RTO12f1, RTLC12e1 + .invalidParameterValue + case .publishAndApplyFailedChannelStateChanged: + // RTO20e1 + .unableToApplyObjectsOperationSyncDidNotComplete + case .newlyCreatedObjectNotInPool: + .internalError + case .other: + .badRequest + } + } + + /// The ``ARTErrorInfo/statusCode`` that should be returned for this error. + internal var statusCode: Int { + switch self { + case .objectsOperationFailedInvalidChannelState, + .counterInitialValueInvalid, + .counterIncrementAmountInvalid, + .publishAndApplyFailedChannelStateChanged, + .other: + 400 + case .newlyCreatedObjectNotInPool: + 500 + } + } + + /// The ``ARTErrorInfo/localizedDescription`` that should be returned for this error. + internal var localizedDescription: String { + switch self { + case let .objectsOperationFailedInvalidChannelState(operationDescription: operationDescription, channelState: channelState): + "\(operationDescription) operation failed (invalid channel state: \(channelState))" + case let .counterInitialValueInvalid(value: value): + "Invalid counter initial value (must be a finite number): \(value)" + case let .counterIncrementAmountInvalid(amount: amount): + "Invalid counter increment amount (must be a finite number): \(amount)" + case let .publishAndApplyFailedChannelStateChanged(channelState: channelState, reason: _): + // RTO20e1 + "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 .other(error): + "\(error)" + } + } + + /// The ``ARTErrorInfo/cause`` that should be returned for this error. + internal var cause: ARTErrorInfo? { + switch self { + case let .publishAndApplyFailedChannelStateChanged(channelState: _, reason: reason): + // RTO20e1 + reason + case .objectsOperationFailedInvalidChannelState, + .counterInitialValueInvalid, + .counterIncrementAmountInvalid, + .newlyCreatedObjectNotInPool, + .other: + nil + } + } + + internal func toARTErrorInfo() -> ARTErrorInfo { + var userInfo: [String: Any] = [liveObjectsErrorUserInfoKey: self] + if let cause { + // Note that here we're making use of an implementation detail of ably-cocoa (the fact that this user info key populates `ARTErrorInfo.cause`). + userInfo[NSUnderlyingErrorKey] = cause + } + + return ARTErrorInfo.create( + withCode: Int(code.rawValue), + status: statusCode, + message: localizedDescription, + additionalUserInfo: userInfo, + ) + } +} + +// MARK: - ConvertibleToLiveObjectsError Protocol + +/// Protocol for types that can be converted to a `LiveObjectsError`. +/// +/// We deliberately do not conform `ARTErrorInfo` (or its parent types `NSError` or `Error`) to this protocol, so that we do not accidentally end up flattening an `ARTErrorInfo` into the `.other` `LiveObjectsError` case; if we have an `ARTErrorInfo` then it should just be thrown directly. +/// +/// If you need to convert a non-specific `NSError` or `Error` to a `LiveObjects` error, then do so explicitly using `LiveObjectsError.other`. +internal protocol ConvertibleToLiveObjectsError { + func toLiveObjectsError() -> LiveObjectsError +} + +internal extension ConvertibleToLiveObjectsError { + /// Convenience method to convert directly to an `ARTErrorInfo`. + func toARTErrorInfo() -> ARTErrorInfo { + toLiveObjectsError().toARTErrorInfo() + } +} + +// MARK: - Conversion Extensions + +extension DecodingError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +extension WireValueDecodingError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +extension WireValue.ConversionError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +extension SyncCursor.Error: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +extension InboundWireObjectMessage.DecodingError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +extension StringOrData.DecodingError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +extension JSONObjectOrArray.ConversionError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +// MARK: - ARTErrorInfo Extension + +/// The `ARTErrorInfo.userInfo` key under which we store the underlying `LiveObjectsError`. Used by `testsOnly_underlyingLiveObjectsError`. +private let liveObjectsErrorUserInfoKey = "LiveObjectsError" + +internal extension ARTErrorInfo { + /// Retrieves the underlying `LiveObjectsError` from this `ARTErrorInfo` if it was generated from a `LiveObjectsError`. + /// + /// - Returns: The underlying `LiveObjectsError` if this error was generated from one, `nil` otherwise. + var testsOnly_underlyingLiveObjectsError: LiveObjectsError? { + guard let userInfoEntry = userInfo[liveObjectsErrorUserInfoKey] else { + return nil + } + + guard let liveObjectsError = userInfoEntry as? LiveObjectsError else { + preconditionFailure("Expected a LiveObjectsError, got \(userInfoEntry)") + } + + return liveObjectsError + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift new file mode 100644 index 000000000..1f1d133d9 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Like ``JSONValue``, but provides a flexible `number` case and an additional case named `extra`, which allows you to support additional types of data. It's used as a common base for the implementations of ``JSONValue`` and ``WireValue``, and for converting between them. +internal indirect enum ExtendedJSONValue { + case object([String: Self]) + case array([Self]) + case string(String) + case number(Number) + case bool(Bool) + case null + case extra(Extra) +} + +// MARK: - Bridging with Foundation + +internal extension ExtendedJSONValue { + /// Creates an `ExtendedJSONValue` from an object. + /// + /// The rules for what `deserialized` will accept are the same as those of `JSONValue.init(jsonSerializationOutput)`, with one addition: any nonsupported values are passed to the `createExtraValue` function, and the result of this function will be used to create an `ExtendedJSONValue` of case `.extra`. + init(deserialized: Any, createNumberValue: (NSNumber) -> Number, createExtraValue: (Any) -> Extra) { + switch deserialized { + case let dictionary as [String: Any]: + self = .object(dictionary.mapValues { .init(deserialized: $0, createNumberValue: createNumberValue, createExtraValue: createExtraValue) }) + case let array as [Any]: + self = .array(array.map { .init(deserialized: $0, createNumberValue: createNumberValue, createExtraValue: createExtraValue) }) + case let string as String: + self = .string(string) + case let number as NSNumber: + // We need to be careful to distinguish booleans from numbers of value 0 or 1; technique taken from https://forums.swift.org/t/jsonserialization-turns-bool-value-to-nsnumber/31909/3 + if number === kCFBooleanTrue { + self = .bool(true) + } else if number === kCFBooleanFalse { + self = .bool(false) + } else { + self = .number(createNumberValue(number)) + } + case is NSNull: + self = .null + default: + self = .extra(createExtraValue(deserialized)) + } + } + + /// Converts an `ExtendedJSONValue` to an object. + /// + /// The contract for what this will return are the same as those of `JSONValue.toJSONSerializationInputElement`, with one addition: any values in the input of case `.extra` will be passed to the `serializeExtraValue` function, and the result of this function call will be inserted into the output object. + func serialized(serializeNumberValue: (Number) -> Any, serializeExtraValue: (Extra) -> Any) -> Any { + switch self { + case let .object(underlying): + underlying.mapValues { $0.serialized(serializeNumberValue: serializeNumberValue, serializeExtraValue: serializeExtraValue) } + case let .array(underlying): + underlying.map { $0.serialized(serializeNumberValue: serializeNumberValue, serializeExtraValue: serializeExtraValue) } + case let .string(underlying): + underlying + case let .number(underlying): + serializeNumberValue(underlying) + case let .bool(underlying): + underlying + case .null: + NSNull() + case let .extra(extra): + serializeExtraValue(extra) + } + } +} + +// MARK: - Transforming the extra data + +internal extension ExtendedJSONValue { + /// Converts this `ExtendedJSONValue` to an `ExtendedJSONValue` using given transformations. + func map(number transformNumber: @escaping (Number) throws(Failure) -> NewNumber, extra transformExtra: @escaping (Extra) throws(Failure) -> NewExtra) throws(Failure) -> ExtendedJSONValue { + switch self { + case let .object(underlying): + try .object(underlying.ablyLiveObjects_mapValuesWithTypedThrow { value throws(Failure) in + try value.map(number: transformNumber, extra: transformExtra) + }) + case let .array(underlying): + try .array(underlying.map { element throws(Failure) in + try element.map(number: transformNumber, extra: transformExtra) + }) + case let .string(underlying): + .string(underlying) + case let .number(underlying): + try .number(transformNumber(underlying)) + case let .bool(underlying): + .bool(underlying) + case .null: + .null + case let .extra(extra): + try .extra(transformExtra(extra)) + } + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/JSONValue.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/JSONValue.swift new file mode 100644 index 000000000..02a71c907 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -0,0 +1,309 @@ +import Ably +import Foundation + +/// A JSON value (where "value" has the meaning defined by the [JSON specification](https://www.json.org)). +/// +/// `JSONValue` provides a type-safe API for working with JSON values. It implements Swift's `ExpressibleBy*Literal` protocols. This allows you to write type-safe JSON values using familiar syntax. For example: +/// +/// ```swift +/// let jsonValue: JSONValue = [ +/// "someArray": [ +/// [ +/// "someStringKey": "someString", +/// "someIntegerKey": 123, +/// "someFloatKey": 123.456, +/// "someTrueKey": true, +/// "someFalseKey": false, +/// "someNullKey": .null, +/// ], +/// "someOtherArrayElement", +/// ], +/// "someNestedObject": [ +/// "someOtherKey": "someOtherValue", +/// ], +/// ] +/// ``` +/// +/// > Note: To write a `JSONValue` that corresponds to the `null` JSON value, you must explicitly write `.null`. `JSONValue` deliberately does not implement the `ExpressibleByNilLiteral` protocol in order to avoid confusion between a value of type `JSONValue?` and a `JSONValue` with case `.null`. +public indirect enum JSONValue: Sendable, Equatable { + case object([String: JSONValue]) + case array([JSONValue]) + case string(String) + case number(Double) + case bool(Bool) + case null + + // MARK: - Convenience getters for associated values + + /// If this `JSONValue` has case `object`, this returns the associated value. Else, it returns `nil`. + public var objectValue: [String: JSONValue]? { + if case let .object(objectValue) = self { + objectValue + } else { + nil + } + } + + /// If this `JSONValue` has case `array`, this returns the associated value. Else, it returns `nil`. + public var arrayValue: [JSONValue]? { + if case let .array(arrayValue) = self { + arrayValue + } else { + nil + } + } + + /// If this `JSONValue` has case `string`, this returns the associated value. Else, it returns `nil`. + public var stringValue: String? { + if case let .string(stringValue) = self { + stringValue + } else { + nil + } + } + + /// If this `JSONValue` has case `number`, this returns the associated value. Else, it returns `nil`. + public var numberValue: Double? { + if case let .number(numberValue) = self { + numberValue + } else { + nil + } + } + + /// If this `JSONValue` has case `bool`, this returns the associated value. Else, it returns `nil`. + public var boolValue: Bool? { + if case let .bool(boolValue) = self { + boolValue + } else { + nil + } + } + + /// Returns true if and only if this `JSONValue` has case `null`. + public var isNull: Bool { + if case .null = self { + true + } else { + false + } + } +} + +extension JSONValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(.init(uniqueKeysWithValues: elements)) + } +} + +extension JSONValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +extension JSONValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension JSONValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +extension JSONValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} + +extension JSONValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +// MARK: - Bridging with JSONSerialization + +internal extension JSONValue { + /// Creates a `JSONValue` from the output of Foundation's `JSONSerialization`. + /// + /// This means that it accepts either: + /// + /// - The result of serializing an array or dictionary using `JSONSerialization` + /// - Some nested element of the result of serializing such an array or dictionary + init(jsonSerializationOutput: Any) { + let extended = ExtendedJSONValue(deserialized: jsonSerializationOutput, createNumberValue: { $0.doubleValue }, createExtraValue: { deserializedExtraValue in + // JSONSerialization is not conforming to our assumptions; our assumptions are probably wrong. Either way, bring this loudly to our attention instead of trying to carry on + preconditionFailure("JSONValue(jsonSerializationOutput:) was given unsupported value \(deserializedExtraValue)") + }) + + self.init(extendedJSONValue: extended) + } + + /// Converts a `JSONValue` to an input for Foundation's `JSONSerialization`. + /// + /// This means that it returns: + /// + /// - All cases: An object which we can put inside an array or dictionary that we ask `JSONSerialization` to serialize + /// - Additionally, if case `object` or `array`: An object which we can ask `JSONSerialization` to serialize + var toJSONSerializationInputElement: Any { + toExtendedJSONValue.serialized(serializeNumberValue: { $0 as NSNumber }, serializeExtraValue: { _ in }) + } +} + +// MARK: - JSON objects and arrays + +/// A subset of ``JSONValue`` that has only `object` or `array` cases. +internal enum JSONObjectOrArray: Equatable { + case object([String: JSONValue]) + case array([JSONValue]) + + internal enum ConversionError: Swift.Error { + case incompatibleJSONValue(JSONValue) + } + + internal init(jsonValue: JSONValue) throws(ARTErrorInfo) { + self = switch jsonValue { + case let .array(array): + .array(array) + case let .object(object): + .object(object) + case .bool, .number, .string, .null: + throw ConversionError.incompatibleJSONValue(jsonValue).toARTErrorInfo() + } + } + + // MARK: - Convenience getters for associated values + + /// If this `JSONObjectOrArray` has case `object`, this returns the associated value. Else, it returns `nil`. + internal var objectValue: [String: JSONValue]? { + if case let .object(value) = self { + return value + } + return nil + } + + /// If this `JSONObjectOrArray` has case `array`, this returns the associated value. Else, it returns `nil`. + internal var arrayValue: [JSONValue]? { + if case let .array(value) = self { + return value + } + return nil + } +} + +extension JSONObjectOrArray: ExpressibleByDictionaryLiteral { + internal init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(.init(uniqueKeysWithValues: elements)) + } +} + +extension JSONObjectOrArray: ExpressibleByArrayLiteral { + internal init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +internal extension [String: JSONValue] { + /// Converts a dictionary that has string keys and `JSONValue` values into an input for Foundation's `JSONSerialization`. + var toJSONSerializationInput: [String: Any] { + mapValues(\.toJSONSerializationInputElement) + } +} + +internal extension [JSONValue] { + /// Converts an array that has `JSONValue` values into an input for Foundation's `JSONSerialization`. + var toJSONSerializationInput: [Any] { + map(\.toJSONSerializationInputElement) + } +} + +// MARK: - Conversion to/from ExtendedJSONValue + +internal extension JSONValue { + init(extendedJSONValue: ExtendedJSONValue) { + switch extendedJSONValue { + case let .object(underlying): + self = .object(underlying.mapValues { .init(extendedJSONValue: $0) }) + case let .array(underlying): + self = .array(underlying.map { .init(extendedJSONValue: $0) }) + case let .string(underlying): + self = .string(underlying) + case let .number(underlying): + self = .number(underlying) + case let .bool(underlying): + self = .bool(underlying) + case .null: + self = .null + } + } + + var toExtendedJSONValue: ExtendedJSONValue { + switch self { + case let .object(underlying): + .object(underlying.mapValues(\.toExtendedJSONValue)) + case let .array(underlying): + .array(underlying.map(\.toExtendedJSONValue)) + case let .string(underlying): + .string(underlying) + case let .number(underlying): + .number(underlying) + case let .bool(underlying): + .bool(underlying) + case .null: + .null + } + } +} + +// MARK: Serializing to and deserializing from a JSON string + +internal extension JSONObjectOrArray { + enum DecodingError: Swift.Error { + case incompatibleJSONValue(JSONValue) + } + + /// Deserializes a JSON string into a `JSONObjectOrArray`. Throws an error if not given a valid JSON string. + init(jsonString: String) throws(ARTErrorInfo) { + let data = Data(jsonString.utf8) + let jsonSerializationOutput: Any + do { + jsonSerializationOutput = try JSONSerialization.jsonObject(with: data) + } catch { + throw LiveObjectsError.other(error).toARTErrorInfo() + } + + let jsonValue = JSONValue(jsonSerializationOutput: jsonSerializationOutput) + try self.init(jsonValue: jsonValue) + } + + /// Converts a `JSONObjectOrArray` into an input for Foundation's `JSONSerialization`. + private var toJSONSerializationInput: Any { + switch self { + case let .array(array): + array.toJSONSerializationInput + case let .object(object): + object.toJSONSerializationInput + } + } + + /// Serializes a `JSONObjectOrArray` to a JSON string. + var toJSONString: String { + let data: Data + do { + data = try JSONSerialization.data(withJSONObject: toJSONSerializationInput) + } catch { + preconditionFailure("Unexpected error encoding to JSON: \(error)") + } + + guard let string = String(data: data, encoding: .utf8) else { + preconditionFailure("Unexpected failure to decode output of JSONSerialization as UTF-8") + } + + return string + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/Logger.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/Logger.swift new file mode 100644 index 000000000..90cb7d49a --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/Logger.swift @@ -0,0 +1,41 @@ +internal import _AblyPluginSupportPrivate + +/// A reference to a line within a source code file. +internal struct CodeLocation: Equatable { + /// A file identifier in the format used by Swift's `#fileID` macro. For example, `"AblyChat/Room.swift"`. + internal var fileID: String + /// The line number in the source code file referred to by ``fileID``. + internal var line: Int +} + +internal protocol Logger: Sendable { + func log(_ message: String, level: _AblyPluginSupportPrivate.LogLevel, codeLocation: CodeLocation) +} + +internal extension AblyLiveObjects.Logger { + /// A convenience method that provides default values for `file` and `line`. + func log(_ message: String, level: _AblyPluginSupportPrivate.LogLevel, fileID: String = #fileID, line: Int = #line) { + let codeLocation = CodeLocation(fileID: fileID, line: line) + log(message, level: level, codeLocation: codeLocation) + } +} + +internal final class DefaultLogger: Logger { + private let pluginLogger: _AblyPluginSupportPrivate.Logger + private let pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol + + internal init(pluginLogger: _AblyPluginSupportPrivate.Logger, pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol) { + self.pluginLogger = pluginLogger + self.pluginAPI = pluginAPI + } + + internal func log(_ message: String, level: LogLevel, codeLocation: CodeLocation) { + pluginAPI.log( + message, + with: level, + file: codeLocation.fileID, + line: codeLocation.line, + logger: pluginLogger, + ) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift new file mode 100644 index 000000000..c1939abdc --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift @@ -0,0 +1,14 @@ +import Foundation + +internal enum LoggingUtilities { + /// Formats an array of object messages for logging with one message per line. + /// - Parameter objectMessages: The array of object messages to format + /// - Returns: A formatted string with one message per line + internal static func formatObjectMessagesForLogging(_ objectMessages: [some CustomDebugStringConvertible]) -> String { + guard !objectMessages.isEmpty else { + return "[]" + } + + return "[\n" + objectMessages.map { " \($0)" }.joined(separator: ",\n") + "\n]" + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift new file mode 100644 index 000000000..28bf619c3 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift @@ -0,0 +1,54 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// Upcasts an instance of an `_AblyPluginSupportPrivate` marker protocol to the concrete type that this marker protocol represents. +internal func castPluginPublicMarkerProtocolValue(_ pluginMarkerProtocolValue: Any, to _: T.Type) -> T { + guard let actualPublicValue = pluginMarkerProtocolValue as? T else { + preconditionFailure("Expected \(T.self), got \(type(of: pluginMarkerProtocolValue))") + } + + return actualPublicValue +} + +internal extension ARTRealtimeChannel { + /// Downcasts this `ARTRealtimeChannel` to its `_AblyPluginSupportPrivate` equivalent type `PublicRealtimeChannel`. + /// + /// - Note: Swift compiler restrictions prevent us from declaring `ARTRealtimeChannel` as conforming to `PublicRealtimeChannel` (this is due to our use of `internal import`). + var asPluginPublicRealtimeChannel: _AblyPluginSupportPrivate.PublicRealtimeChannel { + // In order for this cast to succeed, we rely on the fact that ably-cocoa internally declares ARTRealtimeChannel as conforming to PublicRealtimeChannel. + // swiftlint:disable:next force_cast + self as! _AblyPluginSupportPrivate.PublicRealtimeChannel + } +} + +internal extension ARTClientOptions { + /// Downcasts this `ARTClientOptions` to its `_AblyPluginSupportPrivate` marker protocol type `PublicClientOptions`. + /// + /// - Note: Swift compiler restrictions prevent us from declaring `ARTClientOptions` as conforming to `PublicClientOptions` (this is due to our use of `internal import`). + var asPluginPublicClientOptions: _AblyPluginSupportPrivate.PublicClientOptions { + // In order for this cast to succeed, we rely on the fact that ably-cocoa internally declares ARTClientOptions as conforming to PublicClientOptions. + // swiftlint:disable:next force_cast + self as! _AblyPluginSupportPrivate.PublicClientOptions + } + + /// Upcasts an instance of `_AblyPluginSupportPrivate`'s `PublicClientOptions`, which is the marker protocol that it uses to represent an `ARTClientOptions`, to an `ARTClientOptions`. + static func castPluginPublicClientOptions(_ pluginPublicClientOptions: PublicClientOptions) -> Self { + castPluginPublicMarkerProtocolValue(pluginPublicClientOptions, to: Self.self) + } +} + +internal extension ARTErrorInfo { + /// Downcasts this `ARTErrorInfo` to its `_AblyPluginSupportPrivate` marker protocol type `PublicErrorInfo`. + /// + /// - Note: Swift compiler restrictions prevent us from declaring `ARTErrorInfo` as conforming to `PublicErrorInfo` (this is due to our use of `internal import`). + var asPluginPublicErrorInfo: _AblyPluginSupportPrivate.PublicErrorInfo { + // In order for this cast to succeed, we rely on the fact that ably-cocoa internally declares ARTErrorInfo as conforming to PublicErrorInfo. + // swiftlint:disable:next force_cast + self as! _AblyPluginSupportPrivate.PublicErrorInfo + } + + /// Upcasts an instance of `_AblyPluginSupportPrivate`'s `PublicErrorInfo`, which is the marker protocol that it uses to represent an `ARTErrorInfo`, to an `ARTErrorInfo`. + static func castPluginPublicErrorInfo(_ pluginPublicErrorInfo: PublicErrorInfo) -> Self { + castPluginPublicMarkerProtocolValue(pluginPublicErrorInfo, to: Self.self) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift new file mode 100644 index 000000000..0a9540aa1 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift @@ -0,0 +1,10 @@ +import Foundation + +internal extension NSLock { + /// Behaves like `NSLock.withLock`, but the thrown error has the same type as that thrown by the body. (`withLock` uses `rethrows`, which is always an untyped throw.) + func ablyLiveObjects_withLockWithTypedThrow(_ body: () throws(E) -> R) throws(E) -> R { + lock() + defer { unlock() } + return try body() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/WeakRef.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/WeakRef.swift new file mode 100644 index 000000000..c7175a7ee --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/WeakRef.swift @@ -0,0 +1,8 @@ +/// A struct that holds a weak reference to an object. +/// +/// This allows us to store a weak reference inside a Sendable object. The pattern comes from the [`weak let` proposal](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0481-weak-let.md). (We can get rid of this type and use `weak let` once Swift 6.2 is out.) +internal struct WeakRef { + internal weak var referenced: Referenced? +} + +extension WeakRef: Sendable where Referenced: Sendable {} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/WireCodable.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/WireCodable.swift new file mode 100644 index 000000000..b68485d18 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/WireCodable.swift @@ -0,0 +1,390 @@ +import Ably +import Foundation + +internal protocol WireEncodable { + var toWireValue: WireValue { get } +} + +internal protocol WireDecodable { + init(wireValue: WireValue) throws(ARTErrorInfo) +} + +internal typealias WireCodable = WireDecodable & WireEncodable + +internal protocol WireObjectEncodable: WireEncodable { + var toWireObject: [String: WireValue] { get } +} + +// Default implementation of `WireEncodable` conformance for `WireObjectEncodable` +internal extension WireObjectEncodable { + var toWireValue: WireValue { + .object(toWireObject) + } +} + +internal protocol WireObjectDecodable: WireDecodable { + init(wireObject: [String: WireValue]) throws(ARTErrorInfo) +} + +internal enum WireValueDecodingError: Error { + case valueIsNotObject + case noValueForKey(String) + case wrongTypeForKey(String, actualValue: WireValue) + case failedToDecodeFromRawValue(String) +} + +// Default implementation of `WireDecodable` conformance for `WireObjectDecodable` +internal extension WireObjectDecodable { + init(wireValue: WireValue) throws(ARTErrorInfo) { + guard case let .object(wireObject) = wireValue else { + throw WireValueDecodingError.valueIsNotObject.toARTErrorInfo() + } + + self = try .init(wireObject: wireObject) + } +} + +internal typealias WireObjectCodable = WireObjectDecodable & WireObjectEncodable + +// MARK: - Extracting primitive values from a dictionary + +/// This extension adds some helper methods for extracting values from a dictionary of `WireValue` values; you may find them helpful when implementing `WireCodable`. +internal extension [String: WireValue] { + /// If this dictionary contains a value for `key`, and this value has case `object`, this returns the associated value. + /// + /// - Throws: + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `object` + func objectValueForKey(_ key: String) throws(ARTErrorInfo) -> [String: WireValue] { + guard let value = self[key] else { + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() + } + + guard case let .object(objectValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() + } + + return objectValue + } + + /// If this dictionary contains a value for `key`, and this value has case `object`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. + /// + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `object` or `null` + func optionalObjectValueForKey(_ key: String) throws(ARTErrorInfo) -> [String: WireValue]? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .object(objectValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() + } + + return objectValue + } + + /// If this dictionary contains a value for `key`, and this value has case `array`, this returns the associated value. + /// + /// - Throws: + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `array` + func arrayValueForKey(_ key: String) throws(ARTErrorInfo) -> [WireValue] { + guard let value = self[key] else { + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() + } + + guard case let .array(arrayValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() + } + + return arrayValue + } + + /// If this dictionary contains a value for `key`, and this value has case `array`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. + /// + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `array` or `null` + func optionalArrayValueForKey(_ key: String) throws(ARTErrorInfo) -> [WireValue]? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .array(arrayValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() + } + + return arrayValue + } + + /// If this dictionary contains a value for `key`, and this value has case `string`, this returns the associated value. + /// + /// - Throws: + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` + func stringValueForKey(_ key: String) throws(ARTErrorInfo) -> String { + guard let value = self[key] else { + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() + } + + guard case let .string(stringValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() + } + + return stringValue + } + + /// If this dictionary contains a value for `key`, and this value has case `string`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. + /// + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` + func optionalStringValueForKey(_ key: String) throws(ARTErrorInfo) -> String? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .string(stringValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() + } + + return stringValue + } + + /// If this dictionary contains a value for `key`, and this value has case `number`, this returns the associated value. + /// + /// - Throws: + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` + func numberValueForKey(_ key: String) throws(ARTErrorInfo) -> NSNumber { + guard let value = self[key] else { + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() + } + + guard case let .number(numberValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() + } + + return numberValue + } + + /// If this dictionary contains a value for `key`, and this value has case `number`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. + /// + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` + func optionalNumberValueForKey(_ key: String) throws(ARTErrorInfo) -> NSNumber? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .number(numberValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() + } + + return numberValue + } + + /// If this dictionary contains a value for `key`, and this value has case `bool`, this returns the associated value. + /// + /// - Throws: + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `bool` + func boolValueForKey(_ key: String) throws(ARTErrorInfo) -> Bool { + guard let value = self[key] else { + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() + } + + guard case let .bool(boolValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() + } + + return boolValue + } + + /// If this dictionary contains a value for `key`, and this value has case `bool`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. + /// + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `bool` or `null` + func optionalBoolValueForKey(_ key: String) throws(ARTErrorInfo) -> Bool? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .bool(boolValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() + } + + return boolValue + } + + /// If this dictionary contains a value for `key`, and this value has case `data`, this returns the associated value. + /// + /// - Throws: + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `data` + func dataValueForKey(_ key: String) throws(ARTErrorInfo) -> Data { + guard let value = self[key] else { + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() + } + + guard case let .data(dataValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() + } + + return dataValue + } + + /// If this dictionary contains a value for `key`, and this value has case `data`, this returns the associated value. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. + /// + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `data` or `null` + func optionalDataValueForKey(_ key: String) throws(ARTErrorInfo) -> Data? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .data(dataValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() + } + + return dataValue + } +} + +// MARK: - Extracting dates from a dictionary + +internal extension [String: WireValue] { + /// If this dictionary contains a value for `key`, and this value has case `number`, this returns a date created by interpreting this value as the number of milliseconds since the Unix epoch (which is the format used by Ably). + /// + /// - Throws: + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` + func ablyProtocolDateValueForKey(_ key: String) throws(ARTErrorInfo) -> Date { + let millisecondsSinceEpoch = try numberValueForKey(key).uint64Value + + return dateFromMillisecondsSinceEpoch(millisecondsSinceEpoch) + } + + /// If this dictionary contains a value for `key`, and this value has case `number`, this returns a date created by interpreting this value as the number of milliseconds since the Unix epoch (which is the format used by Ably). If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. + /// + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` + func optionalAblyProtocolDateValueForKey(_ key: String) throws(ARTErrorInfo) -> Date? { + guard let millisecondsSinceEpoch = try optionalNumberValueForKey(key)?.uint64Value else { + return nil + } + return dateFromMillisecondsSinceEpoch(millisecondsSinceEpoch) + } + + private func dateFromMillisecondsSinceEpoch(_ millisecondsSinceEpoch: UInt64) -> Date { + .init(timeIntervalSince1970: Double(millisecondsSinceEpoch) / 1000) + } +} + +// MARK: - Extracting RawRepresentable values from a dictionary + +internal extension [String: WireValue] { + /// If this dictionary contains a value for `key`, and this value has case `string`, this creates an instance of `T` using its `init(rawValue:)` initializer. + /// + /// - Throws: + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` + /// - `WireValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` + func rawRepresentableValueForKey(_ key: String, type _: T.Type = T.self) throws(ARTErrorInfo) -> T where T.RawValue == String { + let rawValue = try stringValueForKey(key) + + return try rawRepresentableValueFromRawValue(rawValue, type: T.self) + } + + /// If this dictionary contains a value for `key`, and this value has case `string`, this creates an instance of `T` using its `init(rawValue:)` initializer. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. + /// + /// - Throws: + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` + /// - `WireValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` + func optionalRawRepresentableValueForKey(_ key: String, type _: T.Type = T.self) throws(ARTErrorInfo) -> T? where T.RawValue == String { + guard let rawValue = try optionalStringValueForKey(key) else { + return nil + } + + return try rawRepresentableValueFromRawValue(rawValue, type: T.self) + } + + private func rawRepresentableValueFromRawValue(_ rawValue: String, type _: T.Type = T.self) throws(ARTErrorInfo) -> T where T.RawValue == String { + guard let value = T(rawValue: rawValue) else { + throw WireValueDecodingError.failedToDecodeFromRawValue(rawValue).toARTErrorInfo() + } + + return value + } +} + +// MARK: - Extracting WireEnum values from a dictionary + +internal extension [String: WireValue] { + /// If this dictionary contains a value for `key`, and this value has case `number`, this creates a `WireEnum` instance using its `init(rawValue:)` initializer. + /// + /// - Throws: + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` + func wireEnumValueForKey(_ key: String, type _: Known.Type = Known.self) throws(ARTErrorInfo) -> WireEnum where Known.RawValue == Int { + let rawValue = try numberValueForKey(key).intValue + return WireEnum(rawValue: rawValue) + } + + /// If this dictionary contains a value for `key`, and this value has case `number`, this creates a `WireEnum` instance using its `init(rawValue:)` initializer. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. + /// + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` + func optionalWireEnumValueForKey(_ key: String, type _: Known.Type = Known.self) throws(ARTErrorInfo) -> WireEnum? where Known.RawValue == Int { + guard let rawValue = try optionalNumberValueForKey(key)?.intValue else { + return nil + } + return WireEnum(rawValue: rawValue) + } +} + +// MARK: - Extracting WireDecodable values from a dictionary + +internal extension [String: WireValue] { + /// If this dictionary contains a value for `key`, this attempts to decode it into an instance of `T` using its `init(wireValue:)` initializer. + /// + /// - Throws: + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - Any error thrown by `T.init(wireValue:)` + func decodableValueForKey(_ key: String, type _: T.Type = T.self) throws(ARTErrorInfo) -> T { + guard let value = self[key] else { + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() + } + + return try T(wireValue: value) + } + + /// If this dictionary contains a value for `key`, this attempts to decode it into an instance of `T` using its `init(wireValue:)` initializer. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. + /// + /// - Throws: Any error thrown by `T.init(wireValue:)` + func optionalDecodableValueForKey(_ key: String, type _: T.Type = T.self) throws(ARTErrorInfo) -> T? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + return try T(wireValue: value) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/WireValue.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/WireValue.swift new file mode 100644 index 000000000..c8b75f28b --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/WireValue.swift @@ -0,0 +1,250 @@ +import Ably +import Foundation + +/// A wire value that can be represents the kinds of data that we expect to find inside a deserialized wire object received from `_AblyPluginSupportPrivate`, or which we may put inside a serialized wire object that we send to `_AblyPluginSupportPrivate`. +/// +/// Its cases are a superset of those of ``JSONValue``, adding a further `data` case for binary data (we expect to be able to send and receive binary data in the case where ably-cocoa is using the MessagePack format). Also, its `number` case is `NSNumber` instead of `Double`, to allow us to communicate to ably-cocoa's MessagePack encoder that it should encode certain values (e.g. enums) as integers, not doubles. +internal indirect enum WireValue: Sendable, Equatable { + case object([String: WireValue]) + case array([WireValue]) + case string(String) + case number(NSNumber) + case bool(Bool) + case null + case data(Data) + + // MARK: - Convenience getters for associated values + + /// If this `WireValue` has case `object`, this returns the associated value. Else, it returns `nil`. + internal var objectValue: [String: WireValue]? { + if case let .object(objectValue) = self { + objectValue + } else { + nil + } + } + + /// If this `WireValue` has case `array`, this returns the associated value. Else, it returns `nil`. + internal var arrayValue: [WireValue]? { + if case let .array(arrayValue) = self { + arrayValue + } else { + nil + } + } + + /// If this `WireValue` has case `string`, this returns the associated value. Else, it returns `nil`. + internal var stringValue: String? { + if case let .string(stringValue) = self { + stringValue + } else { + nil + } + } + + /// If this `WireValue` has case `number`, this returns the associated value. Else, it returns `nil`. + internal var numberValue: NSNumber? { + if case let .number(numberValue) = self { + numberValue + } else { + nil + } + } + + /// If this `WireValue` has case `bool`, this returns the associated value. Else, it returns `nil`. + internal var boolValue: Bool? { + if case let .bool(boolValue) = self { + boolValue + } else { + nil + } + } + + /// If this `WireValue` has case `data`, this returns the associated value. Else, it returns `nil`. + internal var dataValue: Data? { + if case let .data(dataValue) = self { + dataValue + } else { + nil + } + } + + /// Returns true if and only if this `WireValue` has case `null`. + internal var isNull: Bool { + if case .null = self { + true + } else { + false + } + } +} + +extension WireValue: ExpressibleByDictionaryLiteral { + internal init(dictionaryLiteral elements: (String, WireValue)...) { + self = .object(.init(uniqueKeysWithValues: elements)) + } +} + +extension WireValue: ExpressibleByArrayLiteral { + internal init(arrayLiteral elements: WireValue...) { + self = .array(elements) + } +} + +extension WireValue: ExpressibleByStringLiteral { + internal init(stringLiteral value: String) { + self = .string(value) + } +} + +extension WireValue: ExpressibleByIntegerLiteral { + internal init(integerLiteral value: Int) { + self = .number(value as NSNumber) + } +} + +extension WireValue: ExpressibleByFloatLiteral { + internal init(floatLiteral value: Double) { + self = .number(value as NSNumber) + } +} + +extension WireValue: ExpressibleByBooleanLiteral { + internal init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +// MARK: - Bridging with ably-cocoa + +internal extension WireValue { + /// Creates a `WireValue` from an `_AblyPluginSupportPrivate` deserialized wire object. + /// + /// Specifically, `pluginSupportData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. + init(pluginSupportData: Any) { + let extendedJSONValue = ExtendedJSONValue(deserialized: pluginSupportData, createNumberValue: { $0 }, createExtraValue: { deserializedExtraValue in + // We support binary data (used for MessagePack format) in addition to JSON values + if let data = deserializedExtraValue as? Data { + return .data(data) + } + + // ably-cocoa is not conforming to our assumptions; our assumptions are probably wrong. Either way, bring this loudly to our attention instead of trying to carry on + preconditionFailure("WireValue(pluginSupportData:) was given unsupported value \(deserializedExtraValue)") + }) + + self.init(extendedJSONValue: extendedJSONValue) + } + + /// Creates a `WireValue` from an `_AblyPluginSupportPrivate` deserialized wire object. Specifically, `pluginSupportData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. + static func objectFromPluginSupportData(_ pluginSupportData: [String: Any]) -> [String: WireValue] { + let wireValue = WireValue(pluginSupportData: pluginSupportData) + guard case let .object(wireObject) = wireValue else { + preconditionFailure() + } + + return wireObject + } + + /// Creates an `_AblyPluginSupportPrivate` deserialized wire object from a `WireValue`. + /// + /// Used by `[String: WireValue].toPluginSupportDataDictionary`. + var toPluginSupportData: Any { + toExtendedJSONValue.serialized(serializeNumberValue: { $0 }, serializeExtraValue: { extendedValue in + switch extendedValue { + case let .data(data): + data + } + }) + } +} + +internal extension [String: WireValue] { + /// Creates an `_AblyPluginSupportPrivate` deserialized wire object from a dictionary that has string keys and `WireValue` values. + /// + /// Specifically, the value of this property can be returned from `APLiveObjectsPlugin.encodeObjectMessage:`. + var toPluginSupportDataDictionary: [String: Any] { + mapValues(\.toPluginSupportData) + } +} + +// MARK: - Conversion to/from ExtendedJSONValue + +internal extension WireValue { + enum ExtraValue { + case data(Data) + } + + init(extendedJSONValue: ExtendedJSONValue) { + switch extendedJSONValue { + case let .object(underlying): + self = .object(underlying.mapValues { .init(extendedJSONValue: $0) }) + case let .array(underlying): + self = .array(underlying.map { .init(extendedJSONValue: $0) }) + case let .string(underlying): + self = .string(underlying) + case let .number(underlying): + self = .number(underlying) + case let .bool(underlying): + self = .bool(underlying) + case .null: + self = .null + case let .extra(extra): + switch extra { + case let .data(data): + self = .data(data) + } + } + } + + var toExtendedJSONValue: ExtendedJSONValue { + switch self { + case let .object(underlying): + .object(underlying.mapValues(\.toExtendedJSONValue)) + case let .array(underlying): + .array(underlying.map(\.toExtendedJSONValue)) + case let .string(underlying): + .string(underlying) + case let .number(underlying): + .number(underlying) + case let .bool(underlying): + .bool(underlying) + case .null: + .null + case let .data(data): + .extra(.data(data)) + } + } +} + +// MARK: - Conversion to/from JSONValue + +internal extension WireValue { + /// Converts a `JSONValue` to its corresponding `WireValue`. + init(jsonValue: JSONValue) { + self.init(extendedJSONValue: jsonValue.toExtendedJSONValue.map(number: { (number: Double) -> NSNumber in + number as NSNumber + }, extra: { (extra: Never) in extra })) + } + + enum ConversionError: Error { + case dataCannotBeConvertedToJSONValue + } + + /// Tries to convert this `WireValue` to its corresponding `JSONValue`. + /// + /// - Throws: `ConversionError.dataCannotBeConvertedToJSONValue` if `WireValue` represents binary data. + var toJSONValue: JSONValue { + get throws(ARTErrorInfo) { + let neverExtended = try toExtendedJSONValue.map(number: { (number: NSNumber) throws(ARTErrorInfo) -> Double in + number.doubleValue + }, extra: { (extra: ExtraValue) throws(ARTErrorInfo) -> Never in + switch extra { + case .data: + throw ConversionError.dataCannotBeConvertedToJSONValue.toARTErrorInfo() + } + }) + + return .init(extendedJSONValue: neverExtended) + } + } +} diff --git a/LiveObjects/Sources/BuildTool/BuildTool.swift b/LiveObjects/Sources/BuildTool/BuildTool.swift new file mode 100644 index 000000000..8e9fbb858 --- /dev/null +++ b/LiveObjects/Sources/BuildTool/BuildTool.swift @@ -0,0 +1,845 @@ +import ArgumentParser +import AsyncAlgorithms +import Foundation +import Table + +@main +@available(macOS 14, *) +struct BuildTool: AsyncParsableCommand { + static let configuration = CommandConfiguration( + subcommands: [ + BuildLibrary.self, + BuildLibraryForTesting.self, + TestLibrary.self, + BuildExampleApp.self, + GenerateMatrices.self, + Lint.self, + SpecCoverage.self, + BuildDocumentation.self, + GenerateCodeCoverage.self, + ], + ) +} + +@available(macOS 14, *) +struct BuildLibrary: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Build the AblyLiveObjects library", + ) + + @Option var configuration: Configuration? + @Option var platform: Platform + + mutating func run() async throws { + let destinationSpecifier = try await platform.resolve() + let scheme = "AblyLiveObjects" + + try await XcodeRunner.runXcodebuild(action: "build", configuration: configuration, scheme: scheme, destination: destinationSpecifier) + } +} + +@available(macOS 14, *) +struct BuildLibraryForTesting: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Build the AblyLiveObjects library for testing", + discussion: "This is for use with test-library --without-building.", + ) + + @Option var platform: Platform + + mutating func run() async throws { + let destinationSpecifier = try await platform.resolve() + let scheme = "AblyLiveObjects" + + try await XcodeRunner.runXcodebuild(action: "build-for-testing", scheme: scheme, destination: destinationSpecifier) + } +} + +@available(macOS 14, *) +struct TestLibrary: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Test the AblyLiveObjects library", + discussion: "By default, this builds and tests in a single step. Pass --without-building to skip the build (requires a prior build-library-for-testing step).", + ) + + @Option var platform: Platform + @Flag(help: "Only run unit tests (excludes integration tests).") + var onlyUnitTests = false + @Flag(help: "Skip building; requires a prior build-library-for-testing step.") + var withoutBuilding = false + + mutating func run() async throws { + let destinationSpecifier = try await platform.resolve() + let scheme = "AblyLiveObjects" + + let action = withoutBuilding ? "test-without-building" : "test" + try await XcodeRunner.runXcodebuild(action: action, scheme: scheme, destination: destinationSpecifier, testPlan: onlyUnitTests ? "UnitTests" : nil) + } +} + +@available(macOS 14, *) +struct GenerateCodeCoverage: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Generate code coverage for the AblyLiveObjects library", + discussion: "Runs the unit tests and outputs a .xcresult bundle containing code coverage information", + ) + + @Option(help: "Pathname of where to output the .xcresult bundle.") + var resultBundlePath: String + + mutating func run() async throws { + let platform = Platform.macOS + let destinationSpecifier = try await platform.resolve() + let scheme = "AblyLiveObjects" + + try await XcodeRunner.runXcodebuild( + action: "test", + scheme: scheme, + destination: destinationSpecifier, + testPlan: "UnitTests", + resultBundlePath: resultBundlePath, + ) + } +} + +@available(macOS 14, *) +struct BuildExampleApp: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Build the AblyLiveObjectsExample example app") + + @Option var platform: Platform + + mutating func run() async throws { + let destinationSpecifier = try await platform.resolve() + + let secretsFilePath = "Example/AblyLiveObjectsExample/Secrets.swift" + if !FileManager.default.fileExists(atPath: secretsFilePath) { + // If it doesn't already exist (e.g. if running in CI), create the Secrets.swift file needed to build the example app + let secretsFileContents = """ + enum Secrets { + // Insert your Ably API key inside the double quotes below. + static let ablyAPIKey = "" + } + """ + + let data = secretsFileContents.data(using: .utf8)! + try data.write(to: .init(filePath: secretsFilePath)) + } + + try await XcodeRunner.runXcodebuild(action: nil, scheme: "AblyLiveObjectsExample", destination: destinationSpecifier) + } +} + +struct GenerateMatrices: ParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Generate a build matrix that can be used for specifying which GitHub jobs to run", + discussion: """ + Outputs a key=value string which, when appended to $GITHUB_OUTPUT, sets the job's `matrix` output to a JSON object which can be used for generating builds. This allows us to make sure that our various matrix jobs use consistent parameters. + + This object has the following structure: + + { + withoutPlatform: { tooling: Tooling }[] + withPlatform: { tooling: Tooling, platform: PlatformArgument }[] + } + + where Tooling is + + { + xcodeVersion: string + } + + and PlatformArgument is a value that can be passed as the --platform argument of the build-and-test-library or build-example-app commands. + """, + ) + + mutating func run() throws { + let tooling = ["16.4"].map { xcodeVersion in + [ + "xcodeVersion": xcodeVersion, + ] + } + + let matrix: [String: Any] = [ + "withoutPlatform": [ + "tooling": tooling, + ], + "withPlatform": [ + "tooling": tooling, + "platform": Platform.allCases.map(\.rawValue), + ], + ] + + // I'm assuming the JSONSerialization output has no newlines + let keyValue = try "matrix=\(String(data: JSONSerialization.data(withJSONObject: matrix), encoding: .utf8))" + fputs("\(keyValue)\n", stderr) + print(keyValue) + } +} + +@available(macOS 14, *) +struct Lint: AsyncParsableCommand { + static let configuration = CommandConfiguration(abstract: "Checks code formatting and quality.") + + enum Error: Swift.Error { + case malformedSwiftVersionFile + case malformedPackageManifestFile + case malformedPackageLockfile + case mismatchedVersions(swiftVersionFileVersion: String, packageManifestFileVersion: String) + case packageLockfilesHaveDifferentContents(paths: [String]) + } + + @Flag(name: .customLong("fix"), help: .init("Fixes linting errors where possible before linting")) + var shouldFix = false + + mutating func run() async throws { + if shouldFix { + try await fix() + try await lint() + } else { + try await lint() + } + } + + func lint() async throws { + try await ProcessRunner.run(executableName: "mint", arguments: ["run", "swiftformat", "--lint", "."]) + try await ProcessRunner.run(executableName: "mint", arguments: ["run", "swiftlint"]) + try await ProcessRunner.run(executableName: "npm", arguments: ["run", "prettier:check"]) + try await checkSwiftVersionFile() + try await comparePackageLockfiles() + } + + func fix() async throws { + try await ProcessRunner.run(executableName: "mint", arguments: ["run", "swiftformat", "."]) + try await ProcessRunner.run(executableName: "mint", arguments: ["run", "swiftlint", "--fix"]) + try await ProcessRunner.run(executableName: "npm", arguments: ["run", "prettier:fix"]) + } + + /// Checks that the Swift version specified by the `Package.swift`'s `"swift-tools-version"` matches that in the `.swift-version` file (which is used to tell SwiftFormat the minimum version of Swift supported by our code). Per [SwiftFormat#1496](https://github.com/nicklockwood/SwiftFormat/issues/1496) it's currently our responsibility to make sure they're kept in sync./// + func checkSwiftVersionFile() async throws { + async let swiftVersionFileContents = loadUTF8StringFromFile(at: ".swift-version") + async let packageManifestFileContents = loadUTF8StringFromFile(at: "Package.swift") + + guard let swiftVersionFileMatch = try await /^(\d+\.\d+)\n$/.firstMatch(in: swiftVersionFileContents) else { + throw Error.malformedSwiftVersionFile + } + + let swiftVersionFileVersion = String(swiftVersionFileMatch.1) + + guard let packageManifestFileMatch = try await /^\/\/ swift-tools-version: (\d+\.\d+)\n/.firstMatch(in: packageManifestFileContents) else { + throw Error.malformedPackageManifestFile + } + + let packageManifestFileVersion = String(packageManifestFileMatch.1) + + if swiftVersionFileVersion != packageManifestFileVersion { + throw Error.mismatchedVersions( + swiftVersionFileVersion: swiftVersionFileVersion, + packageManifestFileVersion: packageManifestFileVersion, + ) + } + } + + /// Checks that the SPM-managed Package.resolved matches the Xcode-managed one. (I still don't fully understand _why_ there are two files). + /// + /// Ignores the `originHash` property of the Package.resolved file, because this property seems to frequently be different between the SPM version and the Xcode version, and I don't know enough about SPM to know what this property means or whether there's a reproducible way to get them to match. + func comparePackageLockfiles() async throws { + let lockfilePaths = ["Package.resolved", "AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved"] + let lockfileContents = try await withThrowingTaskGroup(of: Data.self) { group in + for lockfilePath in lockfilePaths { + group.addTask { + try await loadDataFromFile(at: lockfilePath) + } + } + + return try await group.reduce(into: []) { accum, fileContents in + accum.append(fileContents) + } + } + + // Remove the `originHash` property from the Package.resolved contents before comparing (for reasons described above). + let lockfileContentsWeCareAbout = try lockfileContents.map { data in + guard var dictionary = try JSONSerialization.jsonObject(with: data) as? [String: Any] else { + throw Error.malformedPackageLockfile + } + + dictionary.removeValue(forKey: "originHash") + + // We use .sortedKeys to get a canonical JSON encoding for comparison. + return try JSONSerialization.data(withJSONObject: dictionary, options: .sortedKeys) + } + + if Set(lockfileContentsWeCareAbout).count > 1 { + throw Error.packageLockfilesHaveDifferentContents(paths: lockfilePaths) + } + } + + private func loadDataFromFile(at path: String) async throws -> Data { + let (data, _) = try await URLSession.shared.data(from: .init(filePath: path)) + return data + } + + private func loadUTF8StringFromFile(at path: String) async throws -> String { + let data = try await loadDataFromFile(at: path) + return try String(data: data, encoding: .utf8) + } +} + +@available(macOS 14, *) +struct SpecCoverage: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Print information about which spec points are implemented", + discussion: "You can set the GITHUB_TOKEN environment variable to provide a GitHub authentication token to use when fetching the latest commit.", + ) + + @Option(help: "The SHA of the spec commit to use") + var specCommitSHA: String? + + enum Error: Swift.Error { + case unexpectedStatusCodeLoadingCommit(Int) + case unexpectedStatusCodeLoadingSpec(Int) + case conformanceToNonexistentSpecPoints(specPointIDs: [String]) + case couldNotFindTestTarget + case malformedSpecOneOfTag + case specUntestedTagMissingComment + case specNotApplicableTagMissingComment + case specOneOfIncorrectTotals(specPointID: String, coverageTagTotals: [Int], actualTotal: Int) + case specOneOfIncorrectIndices(specPointID: String, coverageTagIndices: [Int], expectedIndices: [Int]) + case multipleConformanceTagTypes(specPointID: String, types: [String]) + } + + /** + * A representation of the chat features spec Textile file. + */ + private struct SpecFile { + struct SpecPoint: Identifiable { + var id: String + var isTestable: Bool + + init?(specLine: String) { + // example line that corresponds to a testable spec point: + // ** @(CHA-RS4b)@ @[Testable]@ Room status update events must contain the previous room status. + // (This `Testable` is a convention that's being used only in the Chat spec) + + let specPointLineRegex = /^\*+ @\((.*?)\)@( @\[Testable\]@ )?/ + + // swiftlint:disable:next force_try + guard let match = try! specPointLineRegex.firstMatch(in: specLine) else { + return nil + } + + id = String(match.output.1) + isTestable = match.output.2 != nil + } + } + + var specPoints: [SpecPoint] + + init(fileContents: String) { + specPoints = fileContents.split(whereSeparator: \.isNewline).compactMap { line in + SpecPoint(specLine: String(line)) + } + } + } + + /** + * A tag, extracted from a comment in the SDK's test code, which indicates conformance to a spec point, as described in the "Attributing tests to a spec point" section of `CONTRIBUTING.md`. + */ + private struct ConformanceTag { + enum `Type` { + case spec(comment: String?) + case specOneOf(index: Int, total: Int, comment: String?) + case specPartial(comment: String?) + case specUntested(comment: String) + case specNotApplicable(comment: String) + + enum Case { + case spec + case specOneOf + case specPartial + case specUntested + case specNotApplicable + } + + var `case`: Case { + switch self { + case .spec: + .spec + case .specOneOf: + .specOneOf + case .specPartial: + .specPartial + case .specUntested: + .specUntested + case .specNotApplicable: + .specNotApplicable + } + } + } + + var type: `Type` + var specPointID: String + + init?(sourceLine: String) throws { + let conformanceTagSourceLineRegex = /^\s+\/\/ @spec(OneOf|Partial|Untested|NotApplicable)?(?:\((\d)?\/(\d)?\))? (.*?)(?: - (.*))?$/ + + guard let match = try conformanceTagSourceLineRegex.firstMatch(in: sourceLine) else { + return nil + } + + specPointID = String(match.output.4) + + let comment: String? = if let capture = match.output.5 { + String(capture) + } else { + nil + } + + switch match.output.1 { + case nil: + type = .spec(comment: comment) + case "OneOf": + guard let indexString = match.output.2, let index = Int(indexString), let totalString = match.output.3, let total = Int(totalString) else { + throw Error.malformedSpecOneOfTag + } + type = .specOneOf(index: index, total: total, comment: comment) + case "Partial": + type = .specPartial(comment: comment) + case "Untested": + guard let comment else { + throw Error.specUntestedTagMissingComment + } + type = .specUntested(comment: comment) + case "NotApplicable": + guard let comment else { + throw Error.specNotApplicableTagMissingComment + } + type = .specNotApplicable(comment: comment) + default: + preconditionFailure("Incorrect assumption when reading regex captures") + } + } + } + + private struct CoverageReport { + struct Summary { + var specPointCount: Int + var testableSpecPointCount: Int + private var specPointCountsByCoverageLevel: [CoverageLevel: Int] + + init(specPointCount: Int, testableSpecPointCount: Int, specPointCoverages: [SpecPointCoverage]) { + self.specPointCount = specPointCount + self.testableSpecPointCount = testableSpecPointCount + + specPointCountsByCoverageLevel = Dictionary(grouping: specPointCoverages, by: \.coverageLevel) + .mapValues(\.count) + for coverageLevel in CoverageLevel.allCases where specPointCountsByCoverageLevel[coverageLevel] == nil { + specPointCountsByCoverageLevel[coverageLevel] = 0 + } + } + + func specPointCountForCoverageLevel(_ coverageLevel: CoverageLevel) -> Int { + guard let count = specPointCountsByCoverageLevel[coverageLevel] else { + preconditionFailure("Missing key \(coverageLevel)") + } + return count + } + } + + var summary: Summary + + /** + * One per testable spec point. + */ + var testableSpecPointCoverages: [SpecPointCoverage] + + /** + * The IDs of spec points that are not marked as Testable but which have a conformance tag. We'll emit a warning for these, because it might mean that the spec point they refer to has been replaced or deleted; might need to re-think this approach if it turns out there are other good reasons for testing non-testable points). + */ + var nonTestableSpecPointIDsWithConformanceTags: Set + + enum CoverageLevel: CaseIterable { + case tested + case partiallyTested + case implementedButDeliberatelyNotTested + case notTested + case notApplicable + } + + struct SpecPointCoverage { + var specPointID: String + var coverageLevel: CoverageLevel + var comments: [String] + } + + static func generate(specFile: SpecFile, conformanceTags: [ConformanceTag]) throws -> CoverageReport { + let conformanceTagsBySpecPointID = Dictionary(grouping: conformanceTags, by: \.specPointID) + + // 1. Check that all of the conformance tags correspond to actual spec points. + let invalidSpecPointIDs = Set(conformanceTagsBySpecPointID.keys).subtracting(specFile.specPoints.map(\.id)) + if !invalidSpecPointIDs.isEmpty { + throw Error.conformanceToNonexistentSpecPoints(specPointIDs: invalidSpecPointIDs.sorted()) + } + + // 2. Find any conformance tags for non-testable spec points (see documentation of the `nonTestableSpecPointIDsWithConformanceTags` property for motivation). + let specPointsByID = Dictionary(grouping: specFile.specPoints, by: \.id) + + var nonTestableSpecPointIDsWithConformanceTags: Set = [] + for conformanceTag in conformanceTags { + let specPointID = conformanceTag.specPointID + let specPoint = specPointsByID[specPointID]!.first! + if !specPoint.isTestable { + nonTestableSpecPointIDsWithConformanceTags.insert(specPointID) + } + } + + // 3. Validate the spec coverage tags, and determine the coverage of each testable spec point. + let testableSpecPoints = specFile.specPoints.filter(\.isTestable) + let specPointCoverages = try testableSpecPoints.map { specPoint in + let conformanceTagsForSpecPoint = conformanceTagsBySpecPointID[specPoint.id, default: []] + return try generateCoverage(for: specPoint, conformanceTagsForSpecPoint: conformanceTagsForSpecPoint) + } + + return .init( + summary: .init( + specPointCount: specFile.specPoints.count, + testableSpecPointCount: testableSpecPoints.count, + specPointCoverages: specPointCoverages, + ), + testableSpecPointCoverages: specPointCoverages, + nonTestableSpecPointIDsWithConformanceTags: nonTestableSpecPointIDsWithConformanceTags, + ) + } + + /// Validates the spec coverage tags for this spec point, and determines its coverage. + private static func generateCoverage(for specPoint: SpecFile.SpecPoint, conformanceTagsForSpecPoint: [ConformanceTag]) throws -> SpecPointCoverage { + // Calculated data to be used in output + var coverageLevel: CoverageLevel? + var comments: [String] = [] + + // Bookkeeping data for validation of conformance tags + var specOneOfDatas: [(index: Int, total: Int)] = [] + var conformanceTagTypeCases: Set = [] + + for conformanceTag in conformanceTagsForSpecPoint { + // We only make use of the comments that explain why something is untested or partially tested. + switch conformanceTag.type { + case .spec: + coverageLevel = .tested + case let .specOneOf(index: index, total: total, _): + coverageLevel = .tested + specOneOfDatas.append((index: index, total: total)) + case let .specPartial(comment: comment): + coverageLevel = .partiallyTested + if let comment { + comments.append(comment) + } + case let .specUntested(comment: comment): + coverageLevel = .implementedButDeliberatelyNotTested + comments.append(comment) + case let .specNotApplicable(comment: comment): + coverageLevel = .notApplicable + comments.append(comment) + } + + conformanceTagTypeCases.insert(conformanceTag.type.case) + } + + // Before returning, we validate the conformance tags for this spec point: + + // 1. Check we don't have more than one type of conformance tag for this spec point. + if conformanceTagTypeCases.count > 1 { + throw Error.multipleConformanceTagTypes( + specPointID: specPoint.id, + types: conformanceTagTypeCases.map { "\($0)" }, + ) + } + + // 2. Validate the data attached to the @specOneOf(m/n) conformance tags. + if !specOneOfDatas.isEmpty { + // Do the totals stated in the tags match the number of tags? + let coverageTagTotals = specOneOfDatas.map(\.total) + if !(coverageTagTotals.allSatisfy { $0 == specOneOfDatas.count }) { + throw Error.specOneOfIncorrectTotals( + specPointID: specPoint.id, + coverageTagTotals: specOneOfDatas.map(\.total), + actualTotal: specOneOfDatas.count, + ) + } + + // Are the indices as expected? + let coverageTagIndices = specOneOfDatas.map(\.index).sorted() + let expectedIndices = Array(1 ... specOneOfDatas.count) + if coverageTagIndices != expectedIndices { + throw Error.specOneOfIncorrectIndices( + specPointID: specPoint.id, + coverageTagIndices: coverageTagIndices, + expectedIndices: expectedIndices, + ) + } + } + + return SpecPointCoverage( + specPointID: specPoint.id, + coverageLevel: coverageLevel ?? .notTested, + comments: comments, + ) + } + } + + private struct CoverageReportViewModel { + struct SummaryViewModel { + var specContentsMessage: String + var table: String + + init(summary: CoverageReport.Summary) { + specContentsMessage = "There are \(summary.specPointCount) spec points, \(summary.testableSpecPointCount) of which are marked as testable." + + let headers = ["Coverage level", "Number of spec points", "Percentage of testable spec points"] + + let percentageFormatter = NumberFormatter() + percentageFormatter.numberStyle = .percent + percentageFormatter.minimumFractionDigits = 1 + percentageFormatter.maximumFractionDigits = 1 + + let rows = CoverageReport.CoverageLevel.allCases.map { coverageLevel in + let specPointCount = summary.specPointCountForCoverageLevel(coverageLevel) + + return [ + CoverageReportViewModel.descriptionForCoverageLevel(coverageLevel), + String(specPointCount), + percentageFormatter.string(from: NSNumber(value: Double(specPointCount) / Double(summary.testableSpecPointCount)))!, + ] + } + + // swiftlint:disable:next force_try + table = try! Table(data: [headers] + rows).table() + } + } + + var summary: SummaryViewModel + var warningMessages: [String] + var specPointsTable: String + + init(report: CoverageReport) { + warningMessages = [] + if !report.nonTestableSpecPointIDsWithConformanceTags.isEmpty { + warningMessages.append("Warning: The tests have conformance tags for the following non-Testable spec points: \(Array(report.nonTestableSpecPointIDsWithConformanceTags).sorted().joined(separator: ", ")). Have these spec points been deleted or replaced?") + } + + let headers = ["Spec point ID", "Coverage level", "Comments"] + + let rows = report.testableSpecPointCoverages.map { coverage in + // TODO: https://github.com/ably-labs/ably-chat-swift/issues/94 - Improve the output of comments. The Table library doesn't: + // + // 1. offer the ability to wrap long lines + // 2. handle multi-line strings + // + // so I'm currently just combining all the comments into a single line and then truncating this line. + let comments = coverage.comments.joined(separator: ",") + + let truncateCommentsToLength = 80 + let truncatedComments = comments.count > truncateCommentsToLength ? comments.prefix(truncateCommentsToLength - 1) + "…" : comments + + return [ + coverage.specPointID, + Self.descriptionForCoverageLevel(coverage.coverageLevel), + truncatedComments, + ] + } + + // swiftlint:disable:next force_try + specPointsTable = try! Table(data: [headers] + rows).table() + + summary = .init(summary: report.summary) + } + + static func descriptionForCoverageLevel(_ coverageLevel: CoverageReport.CoverageLevel) -> String { + switch coverageLevel { + case .tested: + "Tested" + case .partiallyTested: + "Partially tested" + case .implementedButDeliberatelyNotTested: + "Implemented, not tested" + case .notTested: + "Not tested" + case .notApplicable: + "Not applicable" + } + } + } + + mutating func run() async throws { + let branchName = "main" + + let specCommitSHA: String + if let specCommitSHAOption = self.specCommitSHA { + print("Using forced spec commit (\(specCommitSHAOption.prefix(7))).\n") + specCommitSHA = specCommitSHAOption + } else { + specCommitSHA = try await fetchLatestSpecCommitSHAForBranchName(branchName) + print("Using latest spec commit (\(specCommitSHA.prefix(7))) from branch \(branchName).\n") + } + + let specFile = try await loadSpecFile(forCommitSHA: specCommitSHA) + let conformanceTags = try await fetchConformanceTags() + + let report = try CoverageReport.generate(specFile: specFile, conformanceTags: conformanceTags) + + let reportViewModel = CoverageReportViewModel(report: report) + print(reportViewModel.summary.specContentsMessage + "\n") + print((reportViewModel.warningMessages + [""]).joined(separator: "\n")) + print(reportViewModel.summary.table) + print(reportViewModel.specPointsTable) + } + + /** + * The response from GitHub's ["get a commit" endpoint](https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit). + */ + private struct GitHubCommitResponseDTO: Codable { + var sha: String + } + + private func fetchLatestSpecCommitSHAForBranchName(_ branchName: String) async throws -> String { + // https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit + var request = URLRequest(url: URL(string: "https://api.github.com/repos/ably/specification/commits/\(branchName)")!) + request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept") + request.setValue("2022-11-28", forHTTPHeaderField: "X-GitHub-Api-Version") + if let gitHubToken = ProcessInfo.processInfo.environment["GITHUB_TOKEN"] { + print("Using GitHub token from GITHUB_TOKEN environment variable.") + request.setValue("Bearer \(gitHubToken)", forHTTPHeaderField: "Authorization") + } + + let (commitData, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + preconditionFailure("Expected an HTTPURLResponse") + } + + guard (200 ..< 300).contains(httpResponse.statusCode) else { + throw Error.unexpectedStatusCodeLoadingCommit(httpResponse.statusCode) + } + + let responseDTO = try JSONDecoder().decode(GitHubCommitResponseDTO.self, from: commitData) + return responseDTO.sha + } + + private func loadSpecFile(forCommitSHA commitSHA: String) async throws -> SpecFile { + let specFileURL = URL(string: "https://raw.githubusercontent.com/ably/specification/\(commitSHA)/textile/chat-features.textile")! + let (specData, response) = try await URLSession.shared.data(from: specFileURL) + + guard let httpResponse = response as? HTTPURLResponse else { + preconditionFailure("Expected an HTTPURLResponse") + } + + guard (200 ..< 300).contains(httpResponse.statusCode) else { + throw Error.unexpectedStatusCodeLoadingSpec(httpResponse.statusCode) + } + + let specContents: String = try String(data: specData, encoding: .utf8) + + return SpecFile(fileContents: specContents) + } + + private func fetchConformanceTags() async throws -> [ConformanceTag] { + let testSourceFilePaths = try await fetchTestSourceFilePaths() + let testSources = try await withThrowingTaskGroup(of: String.self) { group in + for testSourceFilePath in testSourceFilePaths { + group.addTask { + let (data, _) = try await URLSession.shared.data(from: testSourceFilePath) + return try String(data: data, encoding: .utf8) + } + } + + return try await Array(group) + } + + return try testSources.flatMap { testSource in + try testSource.split(whereSeparator: \.isNewline).compactMap { sourceLine in + try ConformanceTag(sourceLine: String(sourceLine)) + } + } + } + + /** + * The result of invoking `swift package describe`. + */ + private struct PackageDescribeOutput: Codable { + /** + * The absolute path of the directory containing the `Package.swift` file. + */ + var path: String + + struct Target: Codable { + var name: String + + /** + * The path of this target's sources, relative to ``PackageDescribeOutput/path``. + */ + var path: String + + /** + * The paths of each of this target's sources, relative to ``path``. + */ + var sources: [String] + } + + var targets: [Target] + } + + /** + * Fetches the absolute file URLs of all of the source files for the SDK's tests. + */ + private func fetchTestSourceFilePaths() async throws -> [URL] { + let packageDescribeOutputData = try await ProcessRunner.runAndReturnStdout( + executableName: "swift", + arguments: ["package", "describe", "--type", "json"], + ) + + let packageDescribeOutput = try JSONDecoder().decode(PackageDescribeOutput.self, from: packageDescribeOutputData) + + guard let testTarget = (packageDescribeOutput.targets.first { $0.name == "AblyLiveObjectsTests" }) else { + throw Error.couldNotFindTestTarget + } + + let targetSourcesAbsoluteURL = URL(filePath: packageDescribeOutput.path).appending(path: testTarget.path) + return testTarget.sources.map { sourceRelativePath in + targetSourcesAbsoluteURL.appending(component: sourceRelativePath) + } + } +} + +@available(macOS 14, *) +struct BuildDocumentation: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Build documentation for the library", + ) + + mutating func run() async throws { + // For now, this is intended to just perform some validation of the documentation comments. We'll generate HTML output in https://github.com/ably/ably-chat-swift/issues/2. + + try await ProcessRunner.run( + executableName: "swift", + arguments: [ + "package", + "generate-documentation", + + "--product", "AblyLiveObjects", + + // Useful because it alerts us about links to nonexistent symbols. + "--warnings-as-errors", + + // Outputs the following information about which symbols have been documented and to what level of detail: + // + // - a table at the end of the CLI output + // - as a JSON file in ./.build/plugins/Swift-DocC/outputs/AblyLiveObjects.doccarchive/documentation-coverage.json + // + // I do not yet know how to make use of these (there's all sorts of unexpected symbols that we didn't directly declare there, e.g. `compactMap(_:)`), but maybe it'll be a bit helpful still. + "--experimental-documentation-coverage", + + // Increases the detail level of the aforementioned coverage table in CLI output. + "--coverage-summary-level", "detailed", + ], + ) + } +} diff --git a/LiveObjects/Sources/BuildTool/Configuration.swift b/LiveObjects/Sources/BuildTool/Configuration.swift new file mode 100644 index 000000000..48ddbc6b0 --- /dev/null +++ b/LiveObjects/Sources/BuildTool/Configuration.swift @@ -0,0 +1,12 @@ +import ArgumentParser + +enum Configuration: String, CaseIterable { + case debug + case release +} + +extension Configuration: ExpressibleByArgument { + init?(argument: String) { + self.init(rawValue: argument) + } +} diff --git a/LiveObjects/Sources/BuildTool/DestinationFetcher.swift b/LiveObjects/Sources/BuildTool/DestinationFetcher.swift new file mode 100644 index 000000000..ceb6427fd --- /dev/null +++ b/LiveObjects/Sources/BuildTool/DestinationFetcher.swift @@ -0,0 +1,41 @@ +import Foundation + +@available(macOS 14, *) +enum DestinationFetcher { + static func fetchDeviceUDID(destinationPredicate: DestinationPredicate) async throws -> String { + let simctlAvailableDevicesOutput = try await fetchSimctlAvailableDevicesOutput() + + let runtimeIdentifier = "com.apple.CoreSimulator.SimRuntime.\(destinationPredicate.runtime)" + let deviceTypeIdentifier = "com.apple.CoreSimulator.SimDeviceType.\(destinationPredicate.deviceType)" + + let matchingDevices = (simctlAvailableDevicesOutput.devices[runtimeIdentifier] ?? []).filter { $0.deviceTypeIdentifier == deviceTypeIdentifier } + + guard !matchingDevices.isEmpty else { + throw Error.simulatorLookupFailed(message: "Couldn't find a simulator with runtime \(runtimeIdentifier) and device type \(deviceTypeIdentifier); available devices are \(simctlAvailableDevicesOutput.devices)") + } + + guard matchingDevices.count == 1 else { + throw Error.simulatorLookupFailed(message: "Found multiple simulators with runtime \(runtimeIdentifier) and device type \(deviceTypeIdentifier); matching devices are \(matchingDevices)") + } + + return matchingDevices[0].udid + } + + struct SimctlOutput: Codable { + var devices: [String: [Device]] + + struct Device: Codable { + var udid: String + var deviceTypeIdentifier: String + } + } + + private static func fetchSimctlAvailableDevicesOutput() async throws -> SimctlOutput { + let data = try await ProcessRunner.runAndReturnStdout( + executableName: "xcrun", + arguments: ["simctl", "list", "--json", "devices", "available"], + ) + + return try JSONDecoder().decode(SimctlOutput.self, from: data) + } +} diff --git a/LiveObjects/Sources/BuildTool/DestinationPredicate.swift b/LiveObjects/Sources/BuildTool/DestinationPredicate.swift new file mode 100644 index 000000000..82b245861 --- /dev/null +++ b/LiveObjects/Sources/BuildTool/DestinationPredicate.swift @@ -0,0 +1,7 @@ +import Foundation + +/// Specifies the attributes to use when searching for a simulator device. +struct DestinationPredicate { + var runtime: String + var deviceType: String +} diff --git a/LiveObjects/Sources/BuildTool/DestinationSpecifier.swift b/LiveObjects/Sources/BuildTool/DestinationSpecifier.swift new file mode 100644 index 000000000..25f1f8c69 --- /dev/null +++ b/LiveObjects/Sources/BuildTool/DestinationSpecifier.swift @@ -0,0 +1,15 @@ +import Foundation + +enum DestinationSpecifier { + case platform(String) + case deviceID(String) + + var xcodebuildArgument: String { + switch self { + case let .platform(platform): + "platform=\(platform)" + case let .deviceID(deviceID): + "id=\(deviceID)" + } + } +} diff --git a/LiveObjects/Sources/BuildTool/DestinationStrategy.swift b/LiveObjects/Sources/BuildTool/DestinationStrategy.swift new file mode 100644 index 000000000..5acdd4d2e --- /dev/null +++ b/LiveObjects/Sources/BuildTool/DestinationStrategy.swift @@ -0,0 +1,6 @@ +import Foundation + +enum DestinationStrategy { + case fixed(platform: String) + case lookup(destinationPredicate: DestinationPredicate) +} diff --git a/LiveObjects/Sources/BuildTool/Error.swift b/LiveObjects/Sources/BuildTool/Error.swift new file mode 100644 index 000000000..1978ed975 --- /dev/null +++ b/LiveObjects/Sources/BuildTool/Error.swift @@ -0,0 +1,6 @@ +import Foundation + +enum Error: Swift.Error { + case terminatedWithExitCode(Int32) + case simulatorLookupFailed(message: String) +} diff --git a/LiveObjects/Sources/BuildTool/Platform.swift b/LiveObjects/Sources/BuildTool/Platform.swift new file mode 100644 index 000000000..f8147ea27 --- /dev/null +++ b/LiveObjects/Sources/BuildTool/Platform.swift @@ -0,0 +1,38 @@ +import ArgumentParser +import Foundation + +enum Platform: String, CaseIterable { + case macOS + case iOS + case tvOS + + var destinationStrategy: DestinationStrategy { + switch self { + case .macOS: + .fixed(platform: "macOS") + case .iOS: + .lookup(destinationPredicate: .init(runtime: "iOS-26-2", deviceType: "iPhone-16")) + case .tvOS: + .lookup(destinationPredicate: .init(runtime: "tvOS-26-2", deviceType: "Apple-TV-4K-3rd-generation-4K")) + } + } + + /// Determines which `destination` argument should be passed to `xcodebuild` for this platform. + /// + /// It does this by finding a simulator device matching the device type and OS version that we wish to use for this platform. + @available(macOS 14, *) + func resolve() async throws -> DestinationSpecifier { + switch destinationStrategy { + case let .fixed(platform): + .platform(platform) + case let .lookup(destinationPredicate): + try await .deviceID(DestinationFetcher.fetchDeviceUDID(destinationPredicate: destinationPredicate)) + } + } +} + +extension Platform: ExpressibleByArgument { + init?(argument: String) { + self.init(rawValue: argument) + } +} diff --git a/LiveObjects/Sources/BuildTool/ProcessRunner.swift b/LiveObjects/Sources/BuildTool/ProcessRunner.swift new file mode 100644 index 000000000..61ccb3d01 --- /dev/null +++ b/LiveObjects/Sources/BuildTool/ProcessRunner.swift @@ -0,0 +1,79 @@ +import Foundation + +@available(macOS 14, *) +enum ProcessRunner { + private static let queue = DispatchQueue(label: "ProcessRunner") + + // There's probably a better way to implement these, which doesn't involve having to use a separate dispatch queue. There's a proposal for a Subprocess API coming up in Foundation which will marry Process with Swift concurrency. + + static func run(executableName: String, arguments: [String]) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + queue.async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executableName] + arguments + + do { + try process.run() + } catch { + continuation.resume(throwing: error) + return + } + + process.waitUntilExit() + + if process.terminationStatus != 0 { + continuation.resume(throwing: Error.terminatedWithExitCode(process.terminationStatus)) + return + } + + continuation.resume() + } + } + } + + static func runAndReturnStdout(executableName: String, arguments: [String]) async throws -> Data { + try await withCheckedThrowingContinuation { continuation in + queue.async { + let process = Process() + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = [executableName] + arguments + + let standardOutput = Pipe() + process.standardOutput = standardOutput + + do { + try process.run() + } catch { + continuation.resume(throwing: error) + return + } + + var stdoutData = Data() + while true { + do { + if let data = try standardOutput.fileHandleForReading.readToEnd() { + stdoutData.append(data) + } else { + break + } + } catch { + process.terminate() + process.waitUntilExit() + continuation.resume(throwing: error) + return + } + } + + process.waitUntilExit() + + if process.terminationStatus != 0 { + continuation.resume(throwing: Error.terminatedWithExitCode(process.terminationStatus)) + return + } + + continuation.resume(returning: stdoutData) + } + } + } +} diff --git a/LiveObjects/Sources/BuildTool/String+Decoding.swift b/LiveObjects/Sources/BuildTool/String+Decoding.swift new file mode 100644 index 000000000..13565bdbc --- /dev/null +++ b/LiveObjects/Sources/BuildTool/String+Decoding.swift @@ -0,0 +1,16 @@ +import Foundation + +extension String { + enum DecodingError: Swift.Error { + case decodingFailed + } + + /// Like `init(data:encoding:)`, but indicates decoding failure by throwing an error instead of returning an optional. + init(data: Data, encoding: String.Encoding) throws { + guard let decoded = String(data: data, encoding: encoding) else { + throw DecodingError.decodingFailed + } + + self = decoded + } +} diff --git a/LiveObjects/Sources/BuildTool/XcodeRunner.swift b/LiveObjects/Sources/BuildTool/XcodeRunner.swift new file mode 100644 index 000000000..379fcb237 --- /dev/null +++ b/LiveObjects/Sources/BuildTool/XcodeRunner.swift @@ -0,0 +1,69 @@ +import Foundation + +@available(macOS 14, *) +enum XcodeRunner { + static func runXcodebuild(action: String?, configuration: Configuration? = nil, scheme: String, destination: DestinationSpecifier, testPlan: String? = nil, resultBundlePath: String? = nil) async throws { + var arguments: [String] = [] + + if let action { + arguments.append(action) + } + + if let configuration { + arguments.append(contentsOf: ["-configuration", configuration.rawValue]) + } + + arguments.append(contentsOf: ["-scheme", scheme]) + arguments.append(contentsOf: ["-destination", destination.xcodebuildArgument]) + + if let testPlan { + arguments.append(contentsOf: ["-testPlan", testPlan]) + } + + if let resultBundlePath { + arguments.append(contentsOf: ["-resultBundlePath", resultBundlePath]) + } + + /* + Note: I was previously passing SWIFT_TREAT_WARNINGS_AS_ERRORS=YES here, but am no longer able to do so, for the following reasons: + + 1. After adding a new package dependency, Xcode started trying to pass + the Swift compiler the -suppress-warnings flag when compiling one of + the newly-added transitive dependencies. This clashes with the + -warnings-as-errors flag that Xcode adds when you set + SWIFT_TREAT_WARNINGS_AS_ERRORS=YES, leading to a compiler error like + + > error: Conflicting options '-warnings-as-errors' and '-suppress-warnings' (in target 'InternalCollectionsUtilities' from project 'swift-collections') + + It's not clear _why_ Xcode is adding this flag (see + https://forums.swift.org/t/warnings-as-errors-in-sub-packages/70810), + but perhaps it's because of what I mention in point 2 below. + + It seems that there is no way to tell Xcode, when building your own + Swift package, "treat warnings as errors, but only for my package, and + not for its dependencies". + + 2. So, I thought that I'd try making Xcode remove the + -suppress-warnings flag by additionally passing + SWIFT_SUPPRESS_WARNINGS=NO, but this also doesn't work because it turns + out that one of our dependencies (swift-async-algorithms) actually does + have some warnings, causing the build to fail. + + tl;dr: There doesn't seem to be a way to treat warnings as errors when + compiling the package from Package.swift using Xcode. + + It's probably OK, though, because we also compile the package with SPM, + and hopefully that will flag any warnings in CI (unless there's some + class of warnings I'm not aware of that only appear when compiling + against the tvOS or iOS SDK). + + (I imagine that using .unsafeFlags(["-warnings-as-errors"]) in the + manifest might work, but then that'd stop other people from being able + to use us as a dependency. I suppose we could, in CI at least, do + something like modifying the manifest as part of the build process, but + that seems like a nuisance.) + */ + + try await ProcessRunner.run(executableName: "xcodebuild", arguments: arguments) + } +} diff --git a/LiveObjects/TestPlans/AllTests.xctestplan b/LiveObjects/TestPlans/AllTests.xctestplan new file mode 100644 index 000000000..ac6758141 --- /dev/null +++ b/LiveObjects/TestPlans/AllTests.xctestplan @@ -0,0 +1,24 @@ +{ + "configurations" : [ + { + "id" : "A023F5E6-323A-41DE-8909-2E6D5ECA28B1", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + + }, + "testTargets" : [ + { + "target" : { + "containerPath" : "container:", + "identifier" : "AblyLiveObjectsTests", + "name" : "AblyLiveObjectsTests" + } + } + ], + "version" : 1 +} diff --git a/LiveObjects/TestPlans/UnitTests.xctestplan b/LiveObjects/TestPlans/UnitTests.xctestplan new file mode 100644 index 000000000..fc6f413af --- /dev/null +++ b/LiveObjects/TestPlans/UnitTests.xctestplan @@ -0,0 +1,29 @@ +{ + "configurations" : [ + { + "id" : "5E4FC24B-44CD-4F7C-93C7-E07A7B219D5A", + "name" : "Configuration 1", + "options" : { + + } + } + ], + "defaultOptions" : { + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "skippedTags" : { + "tags" : [ + ".integration" + ] + }, + "target" : { + "containerPath" : "container:", + "identifier" : "AblyLiveObjectsTests", + "name" : "AblyLiveObjectsTests" + } + } + ], + "version" : 1 +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/.swiftformat b/LiveObjects/Tests/AblyLiveObjectsTests/.swiftformat new file mode 100644 index 000000000..b7ca949e2 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/.swiftformat @@ -0,0 +1,2 @@ +# To avoid turning Swift Testing suites (which are usually written as structs — whether there's a compelling reason for them to be, I'm not sure, but it's what the documentation shows) into enums +--disable enumNamespaces diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/.swiftlint.yml b/LiveObjects/Tests/AblyLiveObjectsTests/.swiftlint.yml new file mode 100644 index 000000000..d7c5fc369 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/.swiftlint.yml @@ -0,0 +1,3 @@ +disabled_rules: + # See disabling of enumNamespaces in .swiftformat in this directory for motivation (Swift Testing suite types) + - convenience_type diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift new file mode 100644 index 000000000..fbb9ef899 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -0,0 +1,191 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Testing + +@Suite(.tags(.integration)) +struct AblyLiveObjectsTests { + @Test + func objectsProperty() async throws { + // Given + + let clientOptions = ARTClientOptions(key: "foo:bar") + clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] + // Don't need to connect + clientOptions.autoConnect = false + + let realtime = ARTRealtime(options: clientOptions) + + let channel = realtime.channels.get("someChannel") + + // Then + + // Check that the `channel.objects` property works and gives the internal type we expect + #expect(channel.objects is PublicDefaultRealtimeObjects) + } + + /// A basic test of the core interactions between this plugin and ably-cocoa. + @Test(arguments: [true, false]) + func plumbingSmokeTest(useBinaryProtocol: Bool) async throws { + let key = try await Sandbox.fetchSharedAPIKey() + let clientOptions = ARTClientOptions(key: key) + clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] + clientOptions.environment = "sandbox" + clientOptions.useBinaryProtocol = useBinaryProtocol + + let realtime = ARTRealtime(options: clientOptions) + defer { realtime.close() } + + // 1. Create a Map on a channel using the REST API. + // https://ably.com/docs/api/liveobjects-rest + + let channelName = UUID().uuidString + + // swiftlint:disable:next force_cast + let restClientOptions = clientOptions.copy() as! ARTClientOptions + // TODO: Understand why the LiveObjects REST API is failing when I try to use MessagePack (asked in https://ably-real-time.slack.com/archives/CURL4U2FP/p1749739112276359); for now am just using a separate client that always uses JSON. + restClientOptions.useBinaryProtocol = false + let rest = ARTRest(options: restClientOptions) + + let currentAblyTimestamp = UInt64(Date().timeIntervalSince1970) * MSEC_PER_SEC + + let mapCreateResponse = try await rest.requestAsync( + "POST", + path: "/channels/\(channelName)/objects", + params: nil, + body: [ + "operation": "MAP_CREATE", + "data": [ + "title": [ + "string": "LiveObjects is awesome", + ], + "createdAt": [ + "number": currentAblyTimestamp, + ], + "isPublished": [ + "boolean": true, + ], + ], + ], + headers: nil, + ) + + try #require(mapCreateResponse.statusCode == 201) + let restCreatedMapObjectID = try #require((mapCreateResponse.items.first?["objectIds"] as? [String])?.first) + + // 2. Attach to the channel on which we just created the Map. + let channelOptions = ARTRealtimeChannelOptions() + channelOptions.modes = [.objectPublish, .objectSubscribe] + let channel = realtime.channels.get(channelName, options: channelOptions) + try await channel.attachAsync() + + // 3. Check that ably-cocoa called our onChannelAttached and passed the HAS_OBJECTS flag. + #expect(channel.testsOnly_nonTypeErasedObjects.testsOnly_onChannelAttachedHasObjects == true) + + // 4. Check that ably-cocoa used us to decode the ObjectMessages in the OBJECT_SYNC, and then called our handleObjectSyncProtocolMessage with these ObjectMessages; we expect the OBJECT_SYNC to contain the root object and the map that we created in the REST call above. + let objectSyncObjectMessages = try #require(await channel.testsOnly_nonTypeErasedObjects.testsOnly_receivedObjectSyncProtocolMessages.first { _ in true }) + #expect(Set(objectSyncObjectMessages.map(\.object?.objectId)) == ["root", restCreatedMapObjectID]) + + // 5. Now, send an OBJECT ProtocolMessage that creates a new Map. This confirms that Ably is using us to encode this ProtocolMessage's contained ObjectMessages. + + let initialValueJSON = #"{"mapCreate":{"semantics":0}}"# + let realtimeCreatedMapObjectID = ObjectCreationHelpers.testsOnly_createObjectID( + type: "map", + initialValue: initialValueJSON, + nonce: "1", + timestamp: Date(timeIntervalSince1970: Double(currentAblyTimestamp) / 1000), + ) + + try await channel.testsOnly_nonTypeErasedObjects.testsOnly_publish(objectMessages: [ + OutboundObjectMessage( + operation: .init( + action: .known(.mapCreate), + objectId: realtimeCreatedMapObjectID, + mapCreate: .init(semantics: .known(.lww), entries: nil), + mapCreateWithObjectId: .init(initialValue: initialValueJSON, nonce: "1"), + ), + ), + ]) + + // 6. Check that ably-cocoa used us to decode the ObjectMessages in the OBJECT triggered by this map creation, and then called our handleObjectProtocolMessage with these ObjectMessages; we expect the OBJECT to contain the map create operation that we just performed. + let objectObjectMessages = try #require(await channel.testsOnly_nonTypeErasedObjects.testsOnly_receivedObjectProtocolMessages.first { _ in true }) + try #require(objectObjectMessages.count == 1) + let receivedMapCreateObjectMessage = objectObjectMessages[0] + #expect(receivedMapCreateObjectMessage.operation?.objectId == realtimeCreatedMapObjectID) + #expect(receivedMapCreateObjectMessage.operation?.action == .known(.mapCreate)) + + // 7. Now, send an invalid OBJECT ProtocolMessage to check that ably-cocoa correctly reports on its NACK. + let invalidObjectThrownError = try await #require(throws: ARTErrorInfo.self) { + try await channel.testsOnly_nonTypeErasedObjects.testsOnly_publish(objectMessages: [ + .init(), + ]) + } + + // (These are just based on what I observed in the NACK) + #expect(invalidObjectThrownError.code == 92000) + #expect(invalidObjectThrownError.message == "invalid object message: object operation required") + } + + /// A basic test of the public API of the LiveObjects plugin. + @Test(arguments: [true, false]) + func smokeTest(useBinaryProtocol: Bool) async throws { + let client = try await ClientHelper.realtimeWithObjects(options: .init(useBinaryProtocol: useBinaryProtocol)) + let channel = client.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) + try await channel.attachAsync() + + let root = try await channel.objects.getRoot() + let rootSubscription = try root.updates() + + // Create a counter + let counter = try await channel.objects.createCounter(count: 52) + let counterSubscription = try counter.updates() + + // Create a map and check its initial entries + let map = try await channel.objects.createMap(entries: [ + "boolKey": true, + "numberKey": 10, + ]) + #expect( + try Dictionary(uniqueKeysWithValues: map.entries) == [ + "boolKey": true, + "numberKey": 10, + ], + ) + let mapSubscription = try map.updates() + + // Perform a `set` on the root and check it comes through on subscription + try await root.set(key: "mapKey", value: .liveMap(map)) + let rootUpdate = try #require(await rootSubscription.first { _ in true }) + #expect(rootUpdate.update == ["mapKey": .updated]) + #expect(try Dictionary(uniqueKeysWithValues: root.entries) == ["mapKey": .liveMap(map)]) + + // Perform a `set` on the map and check it comes through on subscription and that the map is updated + try await map.set(key: "counterKey", value: .liveCounter(counter)) + let mapUpdate = try #require(await mapSubscription.first { _ in true }) + #expect(mapUpdate.update == ["counterKey": .updated]) + #expect( + try Dictionary(uniqueKeysWithValues: map.entries) == [ + "boolKey": true, + "numberKey": 10, + "counterKey": .liveCounter(counter), + ], + ) + + // Perform an `increment` on the counter and check it comes through on subscription and that the counter is updated + try await counter.increment(amount: 30) + let counterUpdate = try #require(await counterSubscription.first { _ in true }) + #expect(counterUpdate.amount == 30) + #expect(try counter.value == 82) + + // Perform a `remove` on the map and check it comes through on subscription and that the map is updated + try await map.remove(key: "boolKey") + let mapRemoveUpdate = try #require(await mapSubscription.first { _ in true }) + #expect(mapRemoveUpdate.update == ["boolKey": .removed]) + #expect( + try Dictionary(uniqueKeysWithValues: map.entries) == [ + "numberKey": 10, + "counterKey": .liveCounter(counter), + ], + ) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/CLAUDE.md b/LiveObjects/Tests/AblyLiveObjectsTests/CLAUDE.md new file mode 100644 index 000000000..ab3e56b56 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/CLAUDE.md @@ -0,0 +1,15 @@ +# Testing guidelines + +- Use the Swift Testing framework (`import Testing`), not XCTest. +- Do not use `fatalError` in response to a test expectation failure; use Swift Testing's `#require` macro instead. +- Only add labels to test cases or suites when the label differs from the name of the suite `struct` or test method. +- Follow the guidelines under "Attributing tests to a spec point" in `CONTRIBUTING.md` to tag unit tests with the relevant specification points. Follow the exact comment format described there. Pay particular attention to the difference between `@spec` and `@specPartial`, and do not write `@spec` multiple times for the same specification point. +- Add comments explaining when some piece of test data is not important for the scenario being tested. +- Run the tests to check they pass. +- When importing these modules in test code: + - Ably: `import Ably` + - AblyLiveObjects: `@testable import AblyLiveObjects` + - `_AblyPluginSupportPrivate`: `import _AblyPluginSupportPrivate` (not `internal import`) +- When you need to pass a logger to internal components in tests, pass `TestLogger()`. +- When you need to unwrap an optional value in tests, use `#require` instead of `guard let`. +- When creating `testsOnly_` property declarations, do not write generic comments describing them; their meaning is already well understood. diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift new file mode 100644 index 000000000..a41296974 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift @@ -0,0 +1,61 @@ +import Ably + +// Helpers for using ably-cocoa with Swift concurrency and typed throws. + +extension ARTRealtimeChannelProtocol { + func attachAsync() async throws(ARTErrorInfo) { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + attach { error in + if let error { + continuation.resume(returning: .failure(error)) + } else { + continuation.resume(returning: .success(())) + } + } + }.get() + } + + func detachAsync() async throws(ARTErrorInfo) { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + detach { error in + if let error { + continuation.resume(returning: .failure(error)) + } else { + continuation.resume(returning: .success(())) + } + } + }.get() + } +} + +extension ARTRestProtocol { + func requestAsync(_ method: String, path: String, params: [String: String]?, body: Any?, headers: [String: String]?) async throws(ARTErrorInfo) -> ARTHTTPPaginatedResponse { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + do { + try request(method, path: path, params: params, body: body, headers: headers) { response, error in + if let error { + continuation.resume(returning: .failure(error)) + } else if let response { + continuation.resume(returning: .success(response)) + } else { + preconditionFailure("There is no error, so expected a response") + } + } + } catch { + // This is a weird bit of API design in ably-cocoa (see https://github.com/ably/ably-cocoa/issues/2043 for fixing it); it throws an error to indicate a programmer error (it should be using exceptions). Since the type of the thrown error is NSError and not ARTErrorInfo, which would mess up our typed throw, let's not try and propagate it. + fatalError("ably-cocoa request threw an error - this indicates a programmer error") + } + }.get() + } +} + +extension ARTConnectionProtocol { + @discardableResult + func onceAsync(_ event: ARTRealtimeConnectionEvent) async -> ARTConnectionStateChange { + await withCheckedContinuation { continuation in + once(event) { stateChange in + continuation.resume(returning: stateChange) + } + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift new file mode 100644 index 000000000..876416449 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift @@ -0,0 +1,7 @@ +/// Stops execution because we tried to use a protocol requirement that is not implemented. +func protocolRequirementNotImplemented(_ message: @autoclosure () -> String = String(), file _: StaticString = #file, line _: UInt = #line) -> Never { + fatalError({ + let returnedMessage = message() + return "Protocol requirement not implemented\(returnedMessage.isEmpty ? "" : ": \(returnedMessage)")" + }()) +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift new file mode 100644 index 000000000..04892098a --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift @@ -0,0 +1,80 @@ +import Ably +@testable import AblyLiveObjects + +/// Helper for creating ably-cocoa objects, for use in integration tests. +enum ClientHelper { + /// Creates a sandbox Realtime client with LiveObjects support. + static func realtimeWithObjects(options: PartialClientOptions = .init()) async throws -> ARTRealtime { + let key = try await Sandbox.fetchSharedAPIKey() + let clientOptions = ARTClientOptions(key: key) + clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] + clientOptions.environment = "sandbox" + + clientOptions.testOptions.transportFactory = TestProxyTransportFactory() + + if TestLogger.loggingEnabled { + clientOptions.logLevel = .verbose + } + + if let useBinaryProtocol = options.useBinaryProtocol { + clientOptions.useBinaryProtocol = useBinaryProtocol + } + if let autoConnect = options.autoConnect { + clientOptions.autoConnect = autoConnect + } + if let logIdentifier = options.logIdentifier { + let logger = PrefixedLogger(prefix: "(\(logIdentifier)) ") + clientOptions.logHandler = logger + } + if let garbageCollectionOptions = options.garbageCollectionOptions { + clientOptions.garbageCollectionOptions = garbageCollectionOptions + } + + return ARTRealtime(options: clientOptions) + } + + /// An ably-cocoa logger that adds a given prefix to all emitted log messages. + private class PrefixedLogger: ARTLog { + // This dance of using an implicitly unwrapped optional instead of a `let` is because we can't write a custom designated initializer (see comment below). + var _prefix: String! + var prefix: String { + get { + _prefix + } + + set { + if _prefix != nil { + fatalError("PrefixedLogger prefix cannot be changed after initialization") + } + _prefix = newValue + } + } + + // We use a convenience initializer because it's not clear to a consumer of the public API how to implement a custom designated initializer (super.init delegates to the non-public init(capturingOutput:). + convenience init(prefix: String) { + self.init() + self.prefix = prefix + } + + override public func log(_ message: String, with level: ARTLogLevel) { + let newMessage = "\(prefix)\(message)" + super.log(newMessage, with: level) + } + } + + /// Creates channel options that include the channel modes needed for LiveObjects. + static func channelOptionsWithObjects() -> ARTRealtimeChannelOptions { + let options = ARTRealtimeChannelOptions() + options.modes = [.objectSubscribe, .objectPublish] + return options + } + + struct PartialClientOptions: Encodable, Hashable { + var useBinaryProtocol: Bool? + var autoConnect: Bool? + var garbageCollectionOptions: InternalDefaultRealtimeObjects.GarbageCollectionOptions? + + /// A prefix for all log messages emitted by the client. Allows clients to be distinguished in log messages for tests which use multiple clients. + var logIdentifier: String? + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift new file mode 100644 index 000000000..d88edf51e --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift @@ -0,0 +1,92 @@ +import Foundation + +/// Provides the ``createAPIKey()`` function to create an API key for the Ably sandbox environment. +enum Sandbox { + private struct TestApp: Codable { + var keys: [Key] + + struct Key: Codable { + var keyStr: String + } + } + + enum Error: Swift.Error { + case badResponseStatus(Int) + } + + private static func loadAppCreationRequestBody() async throws -> Data { + let testAppSetupFileURL = Bundle.module.url( + forResource: "test-app-setup", + withExtension: "json", + subdirectory: "ably-common/test-resources", + )! + + let (data, _) = try await URLSession.shared.data(for: .init(url: testAppSetupFileURL)) + // swiftlint:disable:next force_cast + let dictionary = try JSONSerialization.jsonObject(with: data) as! [String: Any] + return try JSONSerialization.data(withJSONObject: dictionary["post_apps"]!) + } + + static func createAPIKey() async throws -> String { + var request = URLRequest(url: .init(string: "https://sandbox-rest.ably.io/apps")!) + request.httpMethod = "POST" + request.addValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = try await loadAppCreationRequestBody() + + let (data, response) = try await URLSession.shared.data(for: request) + + // swiftlint:disable:next force_cast + let statusCode = (response as! HTTPURLResponse).statusCode + + guard (200 ..< 300).contains(statusCode) else { + throw Error.badResponseStatus(statusCode) + } + + let testApp = try JSONDecoder().decode(TestApp.self, from: data) + + // From JS chat repo at 7985ab7 — "The key we need to use is the one at index 5, which gives enough permissions to interact with Chat and Channels" + return testApp.keys[5].keyStr + } + + /// An actor that manages a cached API key for the Ably sandbox environment. + private actor APIKeyManager { + /// The cached API key, if one has been successfully generated + private var cachedKey: String? + + /// The current key generation task, if one is in progress + private var keyGenerationTask: Task? + + /// Retrieves an API key, either from cache or by generating a new one. + /// + /// - Returns: An API key for the Ably sandbox environment + /// - Throws: Any error that occurred during key generation + func getKey() async throws -> String { + if let cachedKey { + return cachedKey + } + + if let existingTask = keyGenerationTask { + return try await existingTask.value + } + + let task = Task { + defer { keyGenerationTask = nil } + let key = try await createAPIKey() + cachedKey = key + return key + } + keyGenerationTask = task + return try await task.value + } + } + + private static let keyManager = APIKeyManager() + + /// Fetches an API key for the Ably sandbox environment. If a key has already been generated, returns the cached key. + /// + /// - Returns: A valid API key for the Ably sandbox environment + /// - Throws: Any error that occurred during key generation + static func fetchSharedAPIKey() async throws -> String { + try await keyManager.getKey() + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift new file mode 100644 index 000000000..5b733bff0 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift @@ -0,0 +1,89 @@ +import Foundation + +/// A class for testing LiveObjects subscriptions. +/// +/// Create a listener function using ``createListener``, and pass it to the `subscribe(listener:)` method of a LiveObject. Fetch details of the invocations of this listener function using ``getInvocations``. +@available(iOS 17.0.0, tvOS 17.0.0, *) // "Parameter packs in generic types are only available in tvOS 17.0.0 or newer". I wrote this class using this language feature and only after a while realised that this issue exists. So I've gone and marked all of the tests that use this as having the same availability. Might revisit this class at some point if this turns out to be a big nuisance (it's annoying that you can't mark whole suites as @available). +final class Subscriber: Sendable { + private let callbackQueue: DispatchQueue + // Used to synchronize access to the nonisolated(unsafe) mutable state. + private let mutex = NSLock() + private nonisolated(unsafe) var invocations: [(repeat each CallbackArg)] = [] + private nonisolated(unsafe) var listeners: [CallbackWrapper] = [] + + /// Creates a `Subscriber`. + /// + /// - Parameters: + /// - callbackQueue: The queue on which this subscriber expects its listeners to be called. + init(callbackQueue: DispatchQueue) { + self.callbackQueue = callbackQueue + } + + /// Waits for the `callbackQueue` to perform all of its pending work, and then returns all of the invocations of a ``createListener`` listener that this subscriber has so far received. + func getInvocations() async -> [(repeat each CallbackArg)] { + await withCheckedContinuation { continuation in + callbackQueue.async { + continuation.resume() + } + } + + return mutex.withLock { + invocations + } + } + + /// Creates a listener function which, when invoked, records an invocation. The details of this invocation can subsequently be fetched using ``getInvocations``. + func createListener(_ action: (@Sendable (repeat each CallbackArg) -> Void)? = nil) -> (@Sendable (repeat each CallbackArg) -> Void) { + { [callbackQueue, weak self](arg: repeat each CallbackArg) in + dispatchPrecondition(condition: .onQueue(callbackQueue)) + + guard let self else { + return + } + let callListeners = mutex.withLock { + let invocation = (repeat each arg) + invocations.append(invocation) + + return { [listeners] in + for listener in listeners { + listener.callAsFunction(repeat each invocation) + } + } + } + if let action { + action(repeat each arg) + } + callListeners() + } + } + + /// A wrapper that allows us to store a callback that takes variadic args. + /// + /// This allows us to avoid the error "Cannot fully abstract a value of variadic function type '@Sendable (repeat each CallbackArg) -> ()' because different contexts will not be able to reliably agree on a calling convention; try wrapping it in a struct" that we get if we try to directly store the callback in an array. Claude suggested this solution. + private struct CallbackWrapper { + let callback: @Sendable (repeat each CallbackArg) -> Void + + func callAsFunction(_ args: repeat each CallbackArg) { + callback(repeat each args) + } + } + + /// Adds a listener which replays all previously buffered and future invocations of any function previously created by ``createListener(_:)``. + /// + /// This is useful for the scenario where you want to set up a subscription synchronously (so as not to miss any events) but then in an `async` context perform actions as a result of the invocation of the listener. (You could equally use the SDK's `AsyncSequence` interface but the approach here is a closer mapping of the ported JS integration tests that call `subscribe`.) + func addListener(_ listener: @escaping (@Sendable (repeat each CallbackArg) -> Void)) { + let performInvocations = mutex.withLock { + listeners.append(.init(callback: listener)) + + return { [invocations, callbackQueue] in + for invocation in invocations { + callbackQueue.async { + listener(repeat each invocation) + } + } + } + } + + performInvocations() + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift new file mode 100644 index 000000000..11a3a4cd4 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift @@ -0,0 +1,6 @@ +import Testing + +extension Tag { + /// Tests that integrate with ably-cocoa. Usually long-running. + @Tag static var integration: Self +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift new file mode 100644 index 000000000..55b63a008 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -0,0 +1,719 @@ +import _AblyPluginSupportPrivate +@testable import AblyLiveObjects +import Foundation + +// Note that this file was created entirely by Cursor upon my giving it some guidelines — I have not checked its contents in any detail and it may well turn out that there are mistakes here which we need to fix in the future. + +/// Factory for creating test objects with sensible defaults and override capabilities. +/// This follows a pattern similar to Ruby's factory_bot to reduce boilerplate in tests. +/// +/// ## Key Principles +/// +/// 1. **Sensible Defaults**: All factory methods provide reasonable default values +/// 2. **Override Capability**: You can override any default value when needed +/// 3. **No Assertions Against Defaults**: Tests should specify input values explicitly when making assertions about outputs +/// 4. **Common Scenarios**: Factory methods exist for common test scenarios +/// +/// ## Usage Examples +/// +/// ### Creating ObjectState instances +/// ```swift +/// // Basic map object state with defaults +/// let mapState = TestFactories.mapObjectState() +/// +/// // Map object state with custom objectId and entries +/// let (key, entry) = TestFactories.stringMapEntry(key: "customKey", value: "customValue") +/// let customMapState = TestFactories.mapObjectState( +/// objectId: "map:custom@123", +/// entries: [key: entry] +/// ) +/// +/// // Counter object state with custom count +/// let counterState = TestFactories.counterObjectState(count: 100) +/// ``` +/// +/// ### Creating InboundObjectMessage instances +/// ```swift +/// // Simple map message +/// let mapMessage = TestFactories.simpleMapMessage( +/// objectId: "map:test@123", +/// key: "testKey", +/// value: "testValue" +/// ) +/// +/// // Counter message +/// let counterMessage = TestFactories.simpleCounterMessage( +/// objectId: "counter:test@123", +/// count: 42 +/// ) +/// +/// // Root message with multiple entries +/// let rootMessage = TestFactories.rootMessageWithEntries([ +/// "key1": "value1", +/// "key2": "value2" +/// ]) +/// ``` +/// +/// ### Creating Map Entries +/// ```swift +/// // String entry +/// let (stringKey, stringEntry) = TestFactories.stringMapEntry( +/// key: "stringKey", +/// value: "stringValue" +/// ) +/// +/// // Number entry +/// let (numberKey, numberEntry) = TestFactories.numberMapEntry( +/// key: "numberKey", +/// value: NSNumber(value: 123.45) +/// ) +/// +/// // Boolean entry +/// let (boolKey, boolEntry) = TestFactories.booleanMapEntry( +/// key: "boolKey", +/// value: true +/// ) +/// +/// // Object reference entry +/// let (refKey, refEntry) = TestFactories.objectReferenceMapEntry( +/// key: "refKey", +/// objectId: "map:referenced@123" +/// ) +/// ``` +/// +/// ## Migration Guide +/// +/// When migrating existing tests to use factories: +/// +/// 1. **Replace direct object creation** with factory calls +/// 2. **Remove arbitrary values** that don't affect the test +/// 3. **Keep only the values** that are relevant to the test assertions +/// 4. **Use descriptive factory method names** to make test intent clear +/// +/// ### Before (with boilerplate) +/// ```swift +/// let state = ObjectState( +/// objectId: "arbitrary-id", +/// siteTimeserials: ["site1": "ts1"], +/// tombstone: false, +/// createOp: nil, +/// map: nil, +/// counter: WireObjectsCounter(count: 42), // Only this value matters +/// ) +/// ``` +/// +/// ### After (using factory) +/// ```swift +/// let state = TestFactories.counterObjectState(count: 42) // Only specify what matters +/// ``` +/// +/// ## Best Practices +/// +/// 1. **Use the most specific factory method** available for your use case +/// 2. **Override only the values** that are relevant to your test +/// 3. **Use descriptive parameter names** when overriding defaults +/// 4. **Document complex factory usage** with comments when needed +/// 5. **Group related factory calls** together for readability +/// +/// ## Available Factory Methods +/// +/// ### ObjectState Factories +/// - `objectState()` - Basic ObjectState with defaults +/// - `mapObjectState()` - ObjectState for map objects +/// - `counterObjectState()` - ObjectState for counter objects +/// - `rootObjectState()` - ObjectState for root object +/// +/// ### InboundObjectMessage Factories +/// - `inboundObjectMessage()` - Basic InboundObjectMessage with defaults +/// - `mapObjectMessage()` - InboundObjectMessage with map ObjectState +/// - `counterObjectMessage()` - InboundObjectMessage with counter ObjectState +/// - `rootObjectMessage()` - InboundObjectMessage with root ObjectState +/// - `objectMessageWithoutState()` - InboundObjectMessage without ObjectState +/// - `simpleMapMessage()` - Simple map message with one string entry +/// - `simpleCounterMessage()` - Simple counter message +/// - `rootMessageWithEntries()` - Root message with multiple string entries +/// +/// ### ObjectOperation Factories +/// - `objectOperation()` - Basic ObjectOperation with defaults +/// - `mapCreateOperation()` - Map create operation +/// - `counterCreateOperation()` - Counter create operation +/// +/// ### Map Entry Factories +/// - `mapEntry()` - Basic ObjectsMapEntry with defaults +/// - `stringMapEntry()` - Map entry with string data +/// - `numberMapEntry()` - Map entry with number data +/// - `booleanMapEntry()` - Map entry with boolean data +/// - `bytesMapEntry()` - Map entry with bytes data +/// - `objectReferenceMapEntry()` - Map entry with object reference data +/// +/// ### Other Factories +/// - `objectsMap()` - Basic ObjectsMap with defaults +/// - `objectsMapWithStringEntries()` - ObjectsMap with string entries +/// - `wireObjectsCounter()` - WireObjectsCounter with defaults +/// +/// ## Extending the Factory System +/// +/// When adding new factory methods, follow these patterns: +/// +/// 1. **Use descriptive method names** that indicate the type and purpose +/// 2. **Provide sensible defaults** for all parameters +/// 3. **Group related methods** together with MARK comments +/// 4. **Include comprehensive documentation** explaining the purpose and usage +/// 5. **Follow the existing naming conventions** (e.g., `objectState()`, `mapObjectState()`) +/// 6. **Consider common test scenarios** and create convenience methods for them +/// 7. **Ensure all factory methods are static** for easy access +/// 8. **Use type-safe parameters** and avoid magic strings/numbers +/// 9. **Include examples in documentation** showing typical usage patterns +/// 10. **Test the factory methods** to ensure they work correctly +/// +/// ### Example of Adding a New Factory Method +/// ```swift +/// /// Creates a LiveMap with specific data for testing +/// /// - Parameters: +/// /// - objectId: The object ID for the map (default: "map:test@123") +/// /// - entries: Dictionary of key-value pairs to populate the map +/// /// - Returns: A configured InternalDefaultLiveMap instance +/// static func liveMap( +/// objectId: String = "map:test@123", +/// entries: [String: String] = [:], +/// ) -> InternalDefaultLiveMap { +/// let map = InternalDefaultLiveMap.createZeroValued() +/// // Configure map with entries... +/// return map +/// } +/// ``` +struct TestFactories { + // MARK: - ObjectState Factory + + /// Creates an ObjectState with sensible defaults + static func objectState( + objectId: String = "test:object@123", + siteTimeserials: [String: String] = ["site1": "ts1"], + tombstone: Bool = false, + createOp: ObjectOperation? = nil, + map: ObjectsMap? = nil, + counter: WireObjectsCounter? = nil, + ) -> ObjectState { + ObjectState( + objectId: objectId, + siteTimeserials: siteTimeserials, + tombstone: tombstone, + createOp: createOp, + map: map, + counter: counter, + ) + } + + /// Creates an ObjectState for a map object + static func mapObjectState( + objectId: String = "map:test@123", + siteTimeserials: [String: String] = ["site1": "ts1"], + tombstone: Bool = false, + createOp: ObjectOperation? = nil, + entries: [String: ObjectsMapEntry]? = nil, + ) -> ObjectState { + objectState( + objectId: objectId, + siteTimeserials: siteTimeserials, + tombstone: tombstone, + createOp: createOp, + map: ObjectsMap( + semantics: .known(.lww), + entries: entries, + ), + counter: nil, + ) + } + + /// Creates an ObjectState for a counter object + static func counterObjectState( + objectId: String = "counter:test@123", + siteTimeserials: [String: String] = ["site1": "ts1"], + tombstone: Bool = false, + createOp: ObjectOperation? = nil, + count: Int? = 42, + ) -> ObjectState { + objectState( + objectId: objectId, + siteTimeserials: siteTimeserials, + tombstone: tombstone, + createOp: createOp, + map: nil, + counter: WireObjectsCounter(count: count.map { NSNumber(value: $0) }), + ) + } + + /// Creates an ObjectState for the root object + static func rootObjectState( + siteTimeserials: [String: String] = ["site1": "ts1"], + entries: [String: ObjectsMapEntry]? = nil, + ) -> ObjectState { + mapObjectState( + objectId: "root", + siteTimeserials: siteTimeserials, + entries: entries, + ) + } + + // MARK: - InboundObjectMessage Factory + + /// Creates an InboundObjectMessage with sensible defaults + static func inboundObjectMessage( + id: String? = nil, + clientId: String? = nil, + connectionId: String? = nil, + extras: [String: JSONValue]? = nil, + timestamp: Date? = nil, + operation: ObjectOperation? = nil, + object: ObjectState? = nil, + serial: String? = nil, + siteCode: String? = nil, + serialTimestamp: Date? = nil, + ) -> InboundObjectMessage { + InboundObjectMessage( + id: id, + clientId: clientId, + connectionId: connectionId, + extras: extras, + timestamp: timestamp, + operation: operation, + object: object, + serial: serial, + siteCode: siteCode, + serialTimestamp: serialTimestamp, + ) + } + + /// Creates an InboundObjectMessage with a map ObjectState + static func mapObjectMessage( + objectId: String = "map:test@123", + siteTimeserials: [String: String] = ["site1": "ts1"], + entries: [String: ObjectsMapEntry]? = nil, + ) -> InboundObjectMessage { + inboundObjectMessage( + object: mapObjectState( + objectId: objectId, + siteTimeserials: siteTimeserials, + entries: entries, + ), + ) + } + + /// Creates an InboundObjectMessage with a counter ObjectState + static func counterObjectMessage( + objectId: String = "counter:test@123", + siteTimeserials: [String: String] = ["site1": "ts1"], + count: Int? = 42, + ) -> InboundObjectMessage { + inboundObjectMessage( + object: counterObjectState( + objectId: objectId, + siteTimeserials: siteTimeserials, + count: count, + ), + ) + } + + /// Creates an InboundObjectMessage with a root ObjectState + static func rootObjectMessage( + siteTimeserials: [String: String] = ["site1": "ts1"], + entries: [String: ObjectsMapEntry]? = nil, + ) -> InboundObjectMessage { + inboundObjectMessage( + object: rootObjectState( + siteTimeserials: siteTimeserials, + entries: entries, + ), + ) + } + + /// Creates an InboundObjectMessage without an ObjectState + static func objectMessageWithoutState() -> InboundObjectMessage { + inboundObjectMessage(object: nil) + } + + // MARK: - ObjectOperation Factory + + /// Creates an ObjectOperation with sensible defaults + static func objectOperation( + action: WireEnum = .known(.mapCreate), + objectId: String = "test:object@123", + mapCreate: MapCreate? = nil, + mapSet: MapSet? = nil, + mapRemove: WireMapRemove? = nil, + counterCreate: WireCounterCreate? = nil, + counterInc: WireCounterInc? = nil, + objectDelete: WireObjectDelete? = nil, + mapCreateWithObjectId: MapCreateWithObjectId? = nil, + counterCreateWithObjectId: CounterCreateWithObjectId? = nil, + mapClear: WireMapClear? = nil, + ) -> ObjectOperation { + ObjectOperation( + action: action, + objectId: objectId, + mapCreate: mapCreate, + mapSet: mapSet, + mapRemove: mapRemove, + counterCreate: counterCreate, + counterInc: counterInc, + objectDelete: objectDelete, + mapCreateWithObjectId: mapCreateWithObjectId, + counterCreateWithObjectId: counterCreateWithObjectId, + mapClear: mapClear, + ) + } + + /// Creates a map create operation + static func mapCreateOperation( + objectId: String = "map:test@123", + entries: [String: ObjectsMapEntry]? = nil, + ) -> ObjectOperation { + objectOperation( + action: .known(.mapCreate), + objectId: objectId, + mapCreate: MapCreate( + semantics: .known(.lww), + entries: entries, + ), + ) + } + + /// Creates a counter create operation + static func counterCreateOperation( + objectId: String = "counter:test@123", + count: Int? = 42, + ) -> ObjectOperation { + objectOperation( + action: .known(.counterCreate), + objectId: objectId, + counterCreate: WireCounterCreate(count: count.map { NSNumber(value: $0) }), + ) + } + + /// Creates a WireCounterInc + static func counterInc(number: Int = 10) -> WireCounterInc { + WireCounterInc(number: NSNumber(value: number)) + } + + // MARK: - ObjectsMapEntry Factory + + /// Creates an ObjectsMapEntry with sensible defaults + static func mapEntry( + tombstone: Bool? = false, + timeserial: String? = "ts1", + data: ObjectData?, + ) -> ObjectsMapEntry { + ObjectsMapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: data, + ) + } + + /// Creates an InternalObjectsMapEntry with sensible defaults + /// + /// This should be kept in sync with ``mapEntry``. + static func internalMapEntry( + tombstonedAt: Date? = nil, + timeserial: String? = "ts1", + data: ObjectData, + ) -> InternalObjectsMapEntry { + InternalObjectsMapEntry( + tombstonedAt: tombstonedAt, + timeserial: timeserial, + data: data, + ) + } + + /// Creates a map entry with string data + static func stringMapEntry( + key: String = "testKey", + value: String = "testValue", + tombstone: Bool? = false, + timeserial: String? = "ts1", + ) -> (key: String, entry: ObjectsMapEntry) { + ( + key: key, + entry: mapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: ObjectData(string: value), + ), + ) + } + + /// Creates an internal map entry with string data + /// + /// This should be kept in sync with ``stringMapEntry``. + static func internalStringMapEntry( + key: String = "testKey", + value: String = "testValue", + tombstonedAt: Date? = nil, + timeserial: String? = "ts1", + ) -> (key: String, entry: InternalObjectsMapEntry) { + ( + key: key, + entry: internalMapEntry( + tombstonedAt: tombstonedAt, + timeserial: timeserial, + data: ObjectData(string: value), + ), + ) + } + + /// Creates a map entry with number data + static func numberMapEntry( + key: String = "testKey", + value: NSNumber = NSNumber(value: 42), + tombstone: Bool? = false, + timeserial: String? = "ts1", + ) -> (key: String, entry: ObjectsMapEntry) { + ( + key: key, + entry: mapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: ObjectData(number: value), + ), + ) + } + + /// Creates a map entry with boolean data + static func booleanMapEntry( + key: String = "testKey", + value: Bool = true, + tombstone: Bool? = false, + timeserial: String? = "ts1", + ) -> (key: String, entry: ObjectsMapEntry) { + ( + key: key, + entry: mapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: ObjectData(boolean: value), + ), + ) + } + + /// Creates a map entry with bytes data + static func bytesMapEntry( + key: String = "testKey", + value: Data = Data([0x01, 0x02, 0x03]), + tombstone: Bool? = false, + timeserial: String? = "ts1", + ) -> (key: String, entry: ObjectsMapEntry) { + ( + key: key, + entry: mapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: ObjectData(bytes: value), + ), + ) + } + + /// Creates a map entry with object reference data + static func objectReferenceMapEntry( + key: String = "testKey", + objectId: String = "map:referenced@123", + tombstone: Bool? = false, + timeserial: String? = "ts1", + ) -> (key: String, entry: ObjectsMapEntry) { + ( + key: key, + entry: mapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: ObjectData(objectId: objectId), + ), + ) + } + + // MARK: - ObjectsMap Factory + + /// Creates an ObjectsMap with sensible defaults + static func objectsMap( + semantics: WireEnum = .known(.lww), + entries: [String: ObjectsMapEntry]? = nil, + clearTimeserial: String? = nil, + ) -> ObjectsMap { + ObjectsMap( + semantics: semantics, + entries: entries, + clearTimeserial: clearTimeserial, + ) + } + + /// Creates an ObjectsMap with string entries + static func objectsMapWithStringEntries( + entries: [String: String] = ["key1": "value1", "key2": "value2"], + ) -> ObjectsMap { + let mapEntries = entries.mapValues { value in + mapEntry(data: ObjectData(string: value)) + } + return objectsMap(entries: mapEntries) + } + + // MARK: - WireObjectsCounter Factory + + /// Creates a WireObjectsCounter with sensible defaults + static func wireObjectsCounter(count: Int? = 42) -> WireObjectsCounter { + WireObjectsCounter(count: count.map { NSNumber(value: $0) }) + } + + // MARK: - Operation Message Factories + + /// Creates an InboundObjectMessage with a MAP_SET operation + static func mapSetOperationMessage( + objectId: String = "map:test@123", + key: String = "testKey", + value: String = "testValue", + serial: String = "ts1", + siteCode: String = "site1", + ) -> InboundObjectMessage { + inboundObjectMessage( + operation: objectOperation( + action: .known(.mapSet), + objectId: objectId, + mapSet: MapSet( + key: key, + value: ObjectData(string: value), + ), + ), + serial: serial, + siteCode: siteCode, + ) + } + + /// Creates an InboundObjectMessage with a MAP_REMOVE operation + static func mapRemoveOperationMessage( + objectId: String = "map:test@123", + key: String = "testKey", + serial: String = "ts1", + siteCode: String = "site1", + ) -> InboundObjectMessage { + inboundObjectMessage( + operation: objectOperation( + action: .known(.mapRemove), + objectId: objectId, + mapRemove: WireMapRemove(key: key), + ), + serial: serial, + siteCode: siteCode, + ) + } + + /// Creates an InboundObjectMessage with a MAP_CLEAR operation + static func mapClearOperationMessage( + objectId: String = "map:test@123", + serial: String = "ts1", + siteCode: String = "site1", + ) -> InboundObjectMessage { + inboundObjectMessage( + operation: objectOperation( + action: .known(.mapClear), + objectId: objectId, + mapClear: WireMapClear(), + ), + serial: serial, + siteCode: siteCode, + ) + } + + /// Creates an InboundObjectMessage with a MAP_CREATE operation + static func mapCreateOperationMessage( + objectId: String = "map:test@123", + entries: [String: ObjectsMapEntry]? = nil, + serial: String = "ts1", + siteCode: String = "site1", + ) -> InboundObjectMessage { + inboundObjectMessage( + operation: mapCreateOperation( + objectId: objectId, + entries: entries, + ), + serial: serial, + siteCode: siteCode, + ) + } + + /// Creates an InboundObjectMessage with a COUNTER_CREATE operation + static func counterCreateOperationMessage( + objectId: String = "counter:test@123", + count: Int? = 42, + serial: String = "ts1", + siteCode: String = "site1", + ) -> InboundObjectMessage { + inboundObjectMessage( + operation: counterCreateOperation( + objectId: objectId, + count: count, + ), + serial: serial, + siteCode: siteCode, + ) + } + + /// Creates an InboundObjectMessage with a COUNTER_INC operation + static func counterIncOperationMessage( + objectId: String = "counter:test@123", + number: Int = 10, + serial: String = "ts1", + siteCode: String = "site1", + ) -> InboundObjectMessage { + inboundObjectMessage( + operation: objectOperation( + action: .known(.counterInc), + objectId: objectId, + counterInc: counterInc(number: number), + ), + serial: serial, + siteCode: siteCode, + ) + } + + // MARK: - Common Test Scenarios + + /// Creates a simple map object message with one string entry + static func simpleMapMessage( + objectId: String = "map:simple@123", + key: String = "testKey", + value: String = "testValue", + ) -> InboundObjectMessage { + let (entryKey, entry) = stringMapEntry(key: key, value: value) + return mapObjectMessage( + objectId: objectId, + entries: [entryKey: entry], + ) + } + + /// Creates a simple counter object message + static func simpleCounterMessage( + objectId: String = "counter:simple@123", + count: Int = 42, + ) -> InboundObjectMessage { + counterObjectMessage( + objectId: objectId, + count: count, + ) + } + + /// Creates a root object message with multiple entries + static func rootMessageWithEntries( + entries: [String: String] = ["key1": "value1", "key2": "value2"], + ) -> InboundObjectMessage { + let mapEntries = entries.mapValues { value in + mapEntry(data: ObjectData(string: value)) + } + return rootObjectMessage(entries: mapEntries) + } + + // MARK: - Test Queue Helpers + + /// Creates a unique internal queue for testing purposes. + /// This ensures that internalQueue is separate from userCallbackQueue (which is typically .main). + static func createInternalQueue(label: String? = nil) -> DispatchQueue { + let queueLabel = label ?? "test.internal.\(UUID().uuidString)" + return DispatchQueue(label: queueLabel, qos: .userInitiated) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift new file mode 100644 index 000000000..d38c3869b --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift @@ -0,0 +1,41 @@ +import _AblyPluginSupportPrivate +@testable import AblyLiveObjects +import os + +/// An implementation of `Logger` to use when testing internal components of the LiveObjects plugin. +final class TestLogger: NSObject, AblyLiveObjects.Logger { + // By default, we don't log in tests to keep the test logs easy to read. You can set this property to `true` to temporarily turn logging on if you want to debug a test. + static let loggingEnabled = false + + private let underlyingLogger = os.Logger() + + func log(_ message: String, level: LogLevel, codeLocation: CodeLocation) { + guard Self.loggingEnabled else { + return + } + + underlyingLogger.log(level: level.toOSLogType, "(\(codeLocation.fileID):\(codeLocation.line)): \(message)") + } +} + +private extension _AblyPluginSupportPrivate.LogLevel { + var toOSLogType: OSLogType { + // Not much thought has gone into this conversion + switch self { + case .verbose: + .debug + case .debug: + .debug + case .info: + .info + case .warn: + .error + case .error: + .error + case .none: + .debug + @unknown default: + .debug + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift new file mode 100644 index 000000000..a81046281 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -0,0 +1,750 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +struct InternalDefaultLiveCounterTests { + /// Tests for the `value` property, covering RTLC5 specification points + struct ValueTests { + // @spec RTLC5b + @Test(arguments: [.detached, .failed] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func valueThrowsIfChannelIsDetachedOrFailed(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState, internalQueue: internalQueue) + + #expect { + _ = try counter.value(coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @spec RTLC5c + @Test + func valueReturnsCurrentDataWhenChannelIsValid() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Set some test data + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 42), objectMessageSerialTimestamp: nil) + } + + #expect(try counter.value(coreSDK: coreSDK) == 42) + } + } + + /// Tests for the `replaceData` method, covering RTLC6 specification points + struct ReplaceDataTests { + // @spec RTLC6a + @Test + func replacesSiteTimeserials() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let state = TestFactories.counterObjectState( + siteTimeserials: ["site1": "ts1"], // Test value + ) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } + #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + /// Tests for the case where createOp is not present + struct WithoutCreateOpTests { + // @spec RTLC6b - Tests the case without createOp, as RTLC16b takes precedence when createOp exists + @Test + func setsCreateOperationIsMergedToFalse() { + // Given: A counter whose createOperationIsMerged is true + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = { + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + // Test setup: Manipulate counter so that its createOperationIsMerged gets set to true (we need to do this since we want to later assert that it gets set to false, but the default is false). + let state = TestFactories.counterObjectState( + createOp: TestFactories.objectOperation( + action: .known(.counterCreate), + ), + ) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } + #expect(counter.testsOnly_createOperationIsMerged) + + return counter + }() + + // When: + let state = TestFactories.counterObjectState( + createOp: nil, // Test value - must be nil to test RTLC6b + ) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } + + // Then: + #expect(!counter.testsOnly_createOperationIsMerged) + } + + // @specOneOf(1/4) RTLC6c - count but no createOp + @Test + func setsDataToCounterCount() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let state = TestFactories.counterObjectState( + count: 42, // Test value + ) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } + #expect(try counter.value(coreSDK: coreSDK) == 42) + } + + // @specOneOf(2/4) RTLC6c - no count, no createOp + @Test + func setsDataToZeroWhenCounterCountDoesNotExist() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState( + count: nil, // Test value - must be nil + ), objectMessageSerialTimestamp: nil) + } + + #expect(try counter.value(coreSDK: coreSDK) == 0) + } + } + + /// Tests for RTLC16 (merge initial value from createOp) + struct WithCreateOpTests { + // @spec RTLC16 - Tests that replaceData merges initial value when createOp is present + @Test + func mergesInitialValueWhenCreateOpPresent() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let state = TestFactories.counterObjectState( + createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist + count: 5, // Test value - must exist + ) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } + #expect(try counter.value(coreSDK: coreSDK) == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC16a) + #expect(counter.testsOnly_createOperationIsMerged) + } + } + + /// Tests for RTLC6h (diff calculation on replaceData) + struct DiffCalculationTests { + // @specOneOf(1/2) RTLC6h - Tests that replaceData returns the diff calculated via RTLC14 + @Test + func returnsCorrectDiffWithoutCreateOp() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + // Set initial data to 10 + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 10), objectMessageSerialTimestamp: nil) + } + #expect(try counter.value(coreSDK: coreSDK) == 10) + + // Replace data with count 25 (no createOp) + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 25), objectMessageSerialTimestamp: nil) + } + + // RTLC6h: Should return diff from previousData (10) to newData (25) = 15 + #expect(try #require(update.update).amount == 15) + #expect(try counter.value(coreSDK: coreSDK) == 25) + } + + // @specOneOf(2/2) RTLC6h - Tests that replaceData returns the diff after merging createOp + @Test + func returnsCorrectDiffWithCreateOp() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + // Set initial data to 10 + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 10), objectMessageSerialTimestamp: nil) + } + #expect(try counter.value(coreSDK: coreSDK) == 10) + + // Replace data with count 5 and createOp with count 8 + // This should set data to 5, then add 8 (mergeInitialValue), resulting in 13 + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_replaceData( + using: TestFactories.counterObjectState( + createOp: TestFactories.counterCreateOperation(count: 8), + count: 5, + ), + objectMessageSerialTimestamp: nil, + ) + } + + // RTLC6h: Should return diff from previousData (10) to newData (13) = 3 + #expect(try #require(update.update).amount == 3) + #expect(try counter.value(coreSDK: coreSDK) == 13) + } + } + } + + /// Tests for the `mergeInitialValue` method, covering RTLC16 specification points + struct MergeInitialValueTests { + // @specOneOf(1/3) RTLC16a - with count via counterCreate + // @specOneOf(1/2) RTLC16c + @Test + func addsCounterCountToData() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + // Set initial data + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) + } + #expect(try counter.value(coreSDK: coreSDK) == 5) + + // Apply merge operation + let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_mergeInitialValue(from: operation) + } + + #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 + + // Check return value + #expect(try #require(update.update).amount == 10) + } + + // @specOneOf(2/3) RTLC16a - with count via counterCreateWithObjectId.derivedFrom + // @specOneOf(2/2) RTLC16c + // @spec RTO12f16 + @Test + func addsCounterCountToDataFromDerivedFrom() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + // Set initial data + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) + } + #expect(try counter.value(coreSDK: coreSDK) == 5) + + // Apply merge operation with counterCreateWithObjectId.derivedFrom (no direct counterCreate) + let operation = TestFactories.objectOperation( + action: .known(.counterCreate), + counterCreateWithObjectId: .init( + initialValue: "arbitrary", + nonce: "arbitrary", + derivedFrom: WireCounterCreate(count: NSNumber(value: 10)), + ), + ) + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_mergeInitialValue(from: operation) + } + + #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 + + // Check return value + #expect(try #require(update.update).amount == 10) + } + + // @specOneOf(3/3) RTLC16a - no count + // @spec RTLC16d + @Test + func doesNotModifyDataWhenCounterCountDoesNotExist() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + // Set initial data + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) + } + #expect(try counter.value(coreSDK: coreSDK) == 5) + + // Apply merge operation with no count + let operation = TestFactories.objectOperation( + action: .known(.counterCreate), + counterCreate: nil, // Test value - must be nil + ) + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_mergeInitialValue(from: operation) + } + + #expect(try counter.value(coreSDK: coreSDK) == 5) // Unchanged + + // Check return value + #expect(update.isNoop) + } + + // @spec RTLC16b + @Test + func setsCreateOperationIsMergedToTrue() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Apply merge operation + let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_mergeInitialValue(from: operation) + } + + #expect(counter.testsOnly_createOperationIsMerged) + } + } + + /// Tests for `COUNTER_CREATE` operations, covering RTLC8 specification points + struct CounterCreateOperationTests { + // @spec RTLC8b + @Test + func discardsOperationWhenCreateOperationIsMerged() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + // Set initial data and mark create operation as merged + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) + _ = counter.nosync_mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) + } + #expect(counter.testsOnly_createOperationIsMerged) + + // Try to apply another COUNTER_CREATE operation + let operation = TestFactories.counterCreateOperation(count: 20) + let update = counter.testsOnly_applyCounterCreateOperation(operation) + + // Verify the operation was discarded - data unchanged + #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10, not 5 + 10 + 20 + + // Verify return value + #expect(update.isNoop) + } + + // @spec RTLC8c + // @spec RTLC8e + @Test + func mergesInitialValue() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + // Set initial data but don't mark create operation as merged + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) + } + #expect(!counter.testsOnly_createOperationIsMerged) + + // Apply COUNTER_CREATE operation + let operation = TestFactories.counterCreateOperation(count: 10) + let update = counter.testsOnly_applyCounterCreateOperation(operation) + + // Verify the operation was applied - initial value merged. (The full logic of RTLC16 is tested elsewhere; we just check for some of its side effects here.) + #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 + #expect(counter.testsOnly_createOperationIsMerged) + + // Verify return value per RTLC8e + #expect(try #require(update.update).amount == 10) + } + } + + /// Tests for `COUNTER_INC` operations, covering RTLC9 specification points + struct CounterIncOperationTests { + // @spec RTLC9f + // @spec RTLC9g + // @spec RTLC9h + @Test( + arguments: [ + ( + operation: TestFactories.counterInc(number: 10), + expectedValue: 15.0, // 5 + 10 + expectedUpdate: .update(.init(amount: 10)) // RTLC9g + ), + ( + operation: nil as WireCounterInc?, + expectedValue: 5.0, // unchanged + expectedUpdate: .noop // RTLC9h + ), + ] as [(operation: WireCounterInc?, expectedValue: Double, expectedUpdate: LiveObjectUpdate)], + ) + func addsAmountToData(operation: WireCounterInc?, expectedValue: Double, expectedUpdate: LiveObjectUpdate) throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + // Set initial data + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) + } + #expect(try counter.value(coreSDK: coreSDK) == 5) + + // Apply COUNTER_INC operation + let update = counter.testsOnly_applyCounterIncOperation(operation) + + // Verify the operation was applied correctly + #expect(try counter.value(coreSDK: coreSDK) == expectedValue) + + // Verify return value + #expect(update == expectedUpdate) + } + } + + /// Tests for the `apply(_ operation:, …)` method, covering RTLC7 specification points + struct ApplyOperationTests { + // @spec RTLC7b - Tests that an operation does not get applied when canApplyOperation returns nil + @Test + func discardsOperationWhenCannotBeApplied() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + // Set up the counter with an existing site timeserial that will cause the operation to be discarded + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState( + siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" + count: 5, + ), objectMessageSerialTimestamp: nil) + } + + let operation = TestFactories.objectOperation( + action: .known(.counterInc), + counterInc: TestFactories.counterInc(number: 10), + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Apply operation with serial "ts1" which is lexicographically less than existing "ts2" and thus will be applied per RTLO4a (this is a non-pathological case of RTOL4a, that spec point being fully tested elsewhere) + let applied = internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", // Less than existing "ts2" + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(!applied) + + // Check that the COUNTER_INC side-effects didn't happen: + // Verify the operation was discarded - data unchanged (should still be 5 from creation) + #expect(try counter.value(coreSDK: coreSDK) == 5) + // Verify site timeserials unchanged + #expect(counter.testsOnly_siteTimeserials == ["site1": "ts2"]) + } + + // @specOneOf(1/3) RTLC7c - We test this spec point for each possible operation + // @spec RTLC7d1 - Tests COUNTER_CREATE operation application + // @spec RTLC7d1a + // @spec RTLC7d1b + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func appliesCounterCreateOperation() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + let subscriber = Subscriber(callbackQueue: .main) + try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + let operation = TestFactories.counterCreateOperation(count: 15) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Apply COUNTER_CREATE operation + let applied = internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(applied) + + // 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) + #expect(counter.testsOnly_createOperationIsMerged) + // Verify RTLC7c side-effect: site timeserial was updated + #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Verify update was emitted per RTLC7d1a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(amount: 15)]) + } + + // @specOneOf(2/3) RTLC7c - We test this spec point for each possible operation + // @spec RTLC7d5 - Tests COUNTER_INC operation application + // @spec RTLC7d5a + // @spec RTLC7d5b + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func appliesCounterIncOperation() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + let subscriber = Subscriber(callbackQueue: .main) + try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + // Set initial data + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5), objectMessageSerialTimestamp: nil) + } + #expect(try counter.value(coreSDK: coreSDK) == 5) + + let operation = TestFactories.objectOperation( + action: .known(.counterInc), + counterInc: TestFactories.counterInc(number: 10), + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Apply COUNTER_INC operation + let applied = internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(applied) + + // 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 + // Verify RTLC7c side-effect: site timeserial was updated + #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Verify update was emitted per RTLC7d5a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(amount: 10)]) + } + + // @specOneOf(3/3) RTLC7c - Tests that siteTimeserials is NOT updated when source is LOCAL + @Test + func doesNotUpdateSiteTimeserialsForLocalSource() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + let operation = TestFactories.objectOperation( + action: .known(.counterInc), + counterInc: TestFactories.counterInc(number: 10), + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Apply COUNTER_INC operation with LOCAL source + let applied = internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + operation, + source: .local, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(applied) + + // Verify the operation was applied + #expect(try counter.value(coreSDK: coreSDK) == 10) + // Verify RTLC7c: siteTimeserials should NOT have been updated for LOCAL source + #expect(counter.testsOnly_siteTimeserials.isEmpty) + } + + // @spec RTLC7d3 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func noOpForOtherOperation() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + let subscriber = Subscriber(callbackQueue: .main) + try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + // Try to apply a MAP_CREATE to the counter (not supported) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let applied = internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + TestFactories.mapCreateOperation(), + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(!applied) + + // Check no update was emitted + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.isEmpty) + } + } + + /// Tests for the `increment` method, covering RTLC12 specification points + struct IncrementTests { + // @spec RTLC12c + @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects() + + await #expect { + try await counter.increment(amount: 10, coreSDK: coreSDK, realtimeObjects: realtimeObjects) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @spec RTLC12e1 - The only part that is relevant in Swift's type system is the finiteness check + @Test(arguments: [ + Double.nan, + Double.infinity, + -Double.infinity, + ] as [Double]) + func throwsErrorForInvalidAmount(amount: Double) async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects() + + await #expect { + try await counter.increment(amount: amount, coreSDK: coreSDK, realtimeObjects: realtimeObjects) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 40003 && errorInfo.statusCode == 400 + } + } + + // @spec RTLC12e2 + // @spec RTLC12e3 + // @spec RTLC12e5 + // @spec RTLC12g + @Test + func publishesCorrectObjectMessage() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects() + + var publishedMessages: [OutboundObjectMessage] = [] + realtimeObjects.setPublishAndApplyHandler { messages in + publishedMessages.append(contentsOf: messages) + return .success(()) + } + + try await counter.increment(amount: 10.5, coreSDK: coreSDK, realtimeObjects: realtimeObjects) + + let expectedMessage = OutboundObjectMessage( + operation: ObjectOperation( + // RTLC12e2 + action: .known(.counterInc), + // RTLC12e3 + objectId: "counter:test@123", + // RTLC12e5 + counterInc: WireCounterInc(number: NSNumber(value: 10.5)), + ), + ) + // RTLC12g + #expect(publishedMessages.count == 1) + #expect(publishedMessages[0] == expectedMessage) + } + + @Test + func throwsErrorWhenPublishFails() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects() + + realtimeObjects.setPublishAndApplyHandler { _ in + .failure(LiveObjectsError.other(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"])).toARTErrorInfo()) + } + + await #expect { + try await counter.increment(amount: 10, coreSDK: coreSDK, realtimeObjects: realtimeObjects) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.message.contains("Publish failed") + } + } + } + + /// Tests for the `decrement` method, covering RTLC13 specification points + struct DecrementTests { + // @spec RTLC13b + @Test + func isOppositeOfIncrement() async throws { + // This is just a smoke test; we assume that this just calls `increment`, which is tested elsewhere. + let internalQueue = TestFactories.createInternalQueue() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects() + + var publishedMessages: [OutboundObjectMessage] = [] + realtimeObjects.setPublishAndApplyHandler { messages in + publishedMessages.append(contentsOf: messages) + return .success(()) + } + + try await counter.decrement(amount: 10.5, coreSDK: coreSDK, realtimeObjects: realtimeObjects) + + // RTLC12g + #expect(publishedMessages.count == 1) + #expect(publishedMessages[0].operation?.counterInc?.number == -10.5 /* i.e. assert the amount gets negated */ ) + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift new file mode 100644 index 000000000..6d5a89203 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -0,0 +1,2018 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +struct InternalDefaultLiveMapTests { + /// Tests for the `get` method, covering RTLM5 specification points + struct GetTests { + // @spec RTLM5c + @Test(arguments: [.detached, .failed] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func getThrowsIfChannelIsDetachedOrFailed(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + #expect { + _ = try map.get(key: "test", coreSDK: MockCoreSDK(channelState: channelState, internalQueue: internalQueue), delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // MARK: - RTLM5d Tests + + // @spec RTLM5d1 + @Test + func returnsNilWhenNoEntryExists() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + #expect(try map.get(key: "nonexistent", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) == nil) + } + + // @spec RTLM5d2a + @Test + func returnsNilWhenEntryIsTombstoned() throws { + let logger = TestLogger() + let entry = TestFactories.internalMapEntry( + tombstonedAt: Date(), + data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned + ) + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) == nil) + } + + // @spec RTLM5d2b + @Test + func returnsBooleanValue() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let entry = TestFactories.internalMapEntry(data: ObjectData(boolean: true)) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) + #expect(result?.boolValue == true) + } + + // @spec RTLM5d2c + @Test + func returnsBytesValue() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let bytes = Data([0x01, 0x02, 0x03]) + let entry = TestFactories.internalMapEntry(data: ObjectData(bytes: bytes)) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) + #expect(result?.dataValue == bytes) + } + + // @spec RTLM5d2d + @Test + func returnsNumberValue() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let entry = TestFactories.internalMapEntry(data: ObjectData(number: NSNumber(value: 123.456))) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) + #expect(result?.numberValue == 123.456) + } + + // @spec RTLM5d2e + @Test + func returnsStringValue() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let entry = TestFactories.internalMapEntry(data: ObjectData(string: "test")) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) + #expect(result?.stringValue == "test") + } + + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + // Tests when `json` is a JSON array + @Test + func returnsJSONArrayValue() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let entry = TestFactories.internalMapEntry(data: ObjectData(json: .array(["foo"]))) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) + #expect(result?.jsonArrayValue == ["foo"]) + } + + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + // Tests when `json` is a JSON object + @Test + func returnsJSONObjectValue() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let entry = TestFactories.internalMapEntry(data: ObjectData(json: .object(["foo": "bar"]))) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) + #expect(result?.jsonObjectValue == ["foo": "bar"]) + } + + // @spec RTLM5d2f1 + @Test + func returnsNilWhenReferencedObjectDoesNotExist() throws { + let logger = TestLogger() + let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: "missing")) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) + } + + // @specOneOf(1/2) RTLM5d2f2 - Returns referenced map when it exists in pool + @Test + func returnsReferencedMap() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let objectId = "map1" + let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + delegate.objects[objectId] = .map(referencedMap) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) + let returnedMap = result?.liveMapValue + #expect(returnedMap as AnyObject === referencedMap as AnyObject) + } + + // @specOneOf(2/2) RTLM5d2f2 - Returns referenced counter when it exists in pool + @Test + func returnsReferencedCounter() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let objectId = "counter1" + let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + delegate.objects[objectId] = .counter(referencedCounter) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) + let returnedCounter = result?.liveCounterValue + #expect(returnedCounter as AnyObject === referencedCounter as AnyObject) + } + + // @spec RTLM5d2g + @Test + func returnsNullOtherwise() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let entry = TestFactories.internalMapEntry(data: ObjectData()) + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) + } + } + + /// Tests for the `replaceData` method, covering RTLM6 specification points + struct ReplaceDataTests { + // @spec RTLM6a + @Test + func replacesSiteTimeserials() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let state = TestFactories.objectState( + objectId: "arbitrary-id", + siteTimeserials: ["site1": "ts1", "site2": "ts2"], + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) + } + + // @spec RTLM6b + @Test + func setsCreateOperationIsMergedToFalseWhenCreateOpAbsent() { + // Given: + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let map = { + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Test setup: Manipulate map so that its createOperationIsMerged gets set to true (we need to do this since we want to later assert that it gets set to false, but the default is false). + let state = TestFactories.objectState( + createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), + ) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + #expect(map.testsOnly_createOperationIsMerged) + + return map + }() + + // When: + let state = TestFactories.objectState(objectId: "arbitrary-id", createOp: nil) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + + // Then: + #expect(!map.testsOnly_createOperationIsMerged) + } + + // @specOneOf(1/2) RTLM6c + @Test + func setsDataToMapEntries() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "test") + let state = TestFactories.mapObjectState( + objectId: "arbitrary-id", + entries: [key: entry], + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + let newData = map.testsOnly_data + #expect(newData.count == 1) + #expect(Set(newData.keys) == ["key1"]) + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "test") + } + + // @specOneOf(2/2) RTLM6c - Tests that the map entries get combined with the createOp + // @spec RTLM6d + @Test + func mergesInitialValueWhenCreateOpPresent() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let state = TestFactories.objectState( + objectId: "arbitrary-id", + createOp: TestFactories.mapCreateOperation( + objectId: "arbitrary-id", + entries: [ + "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, + ], + ), + map: ObjectsMap( + semantics: .known(.lww), + entries: [ + "keyFromMapEntries": TestFactories.stringMapEntry(key: "keyFromMapEntries", value: "valueFromMapEntries").entry, + ], + ), + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + // Note that we just check for some basic expected side effects of merging the initial value; RTLM23 is tested in more detail elsewhere + // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d) + #expect(try map.get(key: "keyFromMapEntries", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromMapEntries") + #expect(try map.get(key: "keyFromCreateOp", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromCreateOp") + #expect(map.testsOnly_createOperationIsMerged) + } + + // @specOneOf(1/2) RTLM6i + @Test + func setsClearTimeserialFromObjectState() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let state = TestFactories.objectState( + objectId: "arbitrary-id", + map: TestFactories.objectsMap(clearTimeserial: "01234567890@abcdefghijklm"), + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + #expect(map.testsOnly_clearTimeserial == "01234567890@abcdefghijklm") + } + + // @specOneOf(2/2) RTLM6i + @Test + func setsClearTimeserialToNilWhenNotProvided() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // First, set a clearTimeserial + let stateWithClear = TestFactories.objectState( + objectId: "arbitrary-id", + map: TestFactories.objectsMap(clearTimeserial: "01234567890@abcdefghijklm"), + ) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: stateWithClear, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + #expect(map.testsOnly_clearTimeserial == "01234567890@abcdefghijklm") + + // Then, replace with state that has no clearTimeserial + let stateWithoutClear = TestFactories.objectState(objectId: "arbitrary-id") + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: stateWithoutClear, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + #expect(map.testsOnly_clearTimeserial == nil) + } + + /// Tests for RTLM6h (diff calculation on replaceData) + struct DiffCalculationTests { + // @specOneOf(1/2) RTLM6h - Tests that replaceData returns the diff calculated via RTLM22 + @Test + func returnsCorrectDiffWithoutCreateOp() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Set initial data + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData( + using: TestFactories.mapObjectState( + objectId: "arbitrary-id", + entries: [ + "key1": TestFactories.stringMapEntry(key: "key1", value: "value1").entry, + "key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry, + ], + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + + // Replace data with modified entries (no createOp) + let update = internalQueue.ably_syncNoDeadlock { + map.nosync_replaceData( + using: TestFactories.mapObjectState( + objectId: "arbitrary-id", + entries: [ + "key1": TestFactories.stringMapEntry(key: "key1", value: "updatedValue").entry, + "key3": TestFactories.stringMapEntry(key: "key3", value: "value3").entry, + ], + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + + // RTLM6h: Should return diff per RTLM22 + // key1: updated (changed value), key2: removed, key3: added + let updateDict = try #require(update.update).update + #expect(updateDict["key1"] == .updated) // value changed + #expect(updateDict["key2"] == .removed) // removed + #expect(updateDict["key3"] == .updated) // added + } + + // @specOneOf(2/2) RTLM6h - Tests that replaceData returns the diff after merging createOp + @Test + func returnsCorrectDiffWithCreateOp() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Set initial data + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData( + using: TestFactories.mapObjectState( + objectId: "arbitrary-id", + entries: [ + "existing": TestFactories.stringMapEntry(key: "existing", value: "value").entry, + ], + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + + // Replace data with entries and createOp + let update = internalQueue.ably_syncNoDeadlock { + map.nosync_replaceData( + using: TestFactories.objectState( + objectId: "arbitrary-id", + createOp: TestFactories.mapCreateOperation( + objectId: "arbitrary-id", + entries: [ + "fromCreateOp": TestFactories.stringMapEntry(key: "fromCreateOp", value: "value").entry, + ], + ), + map: ObjectsMap( + semantics: .known(.lww), + entries: [ + "fromEntries": TestFactories.stringMapEntry(key: "fromEntries", value: "value").entry, + ], + ), + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + + // RTLM6h: Should return diff from previousData to final data (after createOp merge) + let updateDict = try #require(update.update).update + #expect(updateDict["existing"] == .removed) // removed + #expect(updateDict["fromEntries"] == .updated) // added + #expect(updateDict["fromCreateOp"] == .updated) // added via createOp + } + } + } + + /// Tests for the `size`, `entries`, `keys`, and `values` properties, covering RTLM10, RTLM11, RTLM12, and RTLM13 specification points + struct AccessPropertiesTests { + // MARK: - Error Throwing Tests (RTLM10c, RTLM11c, RTLM12b, RTLM13b) + + // @spec RTLM10c + // @spec RTLM11c + // @spec RTLM12b + // @spec RTLM13b + @Test(arguments: [.detached, .failed] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState, internalQueue: internalQueue) + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + + // Define actions to test + let actions: [(String, () throws -> Any)] = [ + ("size", { try map.size(coreSDK: coreSDK, delegate: delegate) }), + ("entries", { try map.entries(coreSDK: coreSDK, delegate: delegate) }), + ("keys", { try map.keys(coreSDK: coreSDK, delegate: delegate) }), + ("values", { try map.values(coreSDK: coreSDK, delegate: delegate) }), + ] + + // Test each property throws the expected error + for (propertyName, action) in actions { + #expect("\(propertyName) should throw") { + _ = try action() + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + } + + // MARK: - Tombstone Filtering Tests (RTLM10d, RTLM11d1, RTLM12b, RTLM13b) + + // @specOneOf(1/2) RTLM10d - Tests the "non-tombstoned" part of spec point + // @spec RTLM11d1 + // @specOneOf(1/2) RTLM12b - Tests the "non-tombstoned" part of RTLM10d + // @specOneOf(1/2) RTLM13b - Tests the "non-tombstoned" part of RTLM10d + // @spec RTLM14 + @Test + func allPropertiesFilterOutTombstonedEntries() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let map = InternalDefaultLiveMap( + testsOnly_data: [ + // tombstonedAt is nil, so not considered tombstoned + "active1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + // tombstonedAt is false, so not considered tombstoned + "tombstoned": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "tombstoned")), + "tombstoned2": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "tombstoned2")), + ], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + + // Test size - should only count non-tombstoned entries + let size = try map.size(coreSDK: coreSDK, delegate: delegate) + #expect(size == 1) + + // Test entries - should only return non-tombstoned entries + let entries = try map.entries(coreSDK: coreSDK, delegate: delegate) + #expect(entries.count == 1) + #expect(Set(entries.map(\.key)) == ["active1"]) + #expect(entries.first { $0.key == "active1" }?.value.stringValue == "value1") + + // Test keys - should only return keys from non-tombstoned entries + let keys = try map.keys(coreSDK: coreSDK, delegate: delegate) + #expect(keys.count == 1) + #expect(Set(keys) == ["active1"]) + + // Test values - should only return values from non-tombstoned entries + let values = try map.values(coreSDK: coreSDK, delegate: delegate) + #expect(values.count == 1) + #expect(Set(values.compactMap(\.stringValue)) == Set(["value1"])) + } + + // MARK: - Consistency Tests + + // @specOneOf(2/2) RTLM10d + // @specOneOf(2/2) RTLM12b + // @specOneOf(2/2) RTLM13b + @Test + func allAccessPropertiesReturnExpectedValuesAndAreConsistentWithEachOther() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let map = InternalDefaultLiveMap( + testsOnly_data: [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + "key3": TestFactories.internalMapEntry(data: ObjectData(string: "value3")), + ], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + + let size = try map.size(coreSDK: coreSDK, delegate: delegate) + let entries = try map.entries(coreSDK: coreSDK, delegate: delegate) + let keys = try map.keys(coreSDK: coreSDK, delegate: delegate) + let values = try map.values(coreSDK: coreSDK, delegate: delegate) + + // All properties should return the same count + #expect(size == 3) + #expect(entries.count == 3) + #expect(keys.count == 3) + #expect(values.count == 3) + + // Keys should match the keys from entries + #expect(Set(keys) == Set(entries.map(\.key))) + + // Values should match the values from entries + #expect(Set(values.compactMap(\.stringValue)) == Set(entries.compactMap(\.value.stringValue))) + } + + // MARK: - `entries` handling of different value types, per RTLM5d2 + + // @spec RTLM11d + @Test + func entriesHandlesAllValueTypes() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + // Create referenced objects for testing + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + delegate.objects["map:ref@123"] = .map(referencedMap) + delegate.objects["counter:ref@456"] = .counter(referencedCounter) + + let map = InternalDefaultLiveMap( + testsOnly_data: [ + "boolean": TestFactories.internalMapEntry(data: ObjectData(boolean: true)), // RTLM5d2b + "bytes": TestFactories.internalMapEntry(data: ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c + "number": TestFactories.internalMapEntry(data: ObjectData(number: NSNumber(value: 42))), // RTLM5d2d + "string": TestFactories.internalMapEntry(data: ObjectData(string: "hello")), // RTLM5d2e + "jsonArray": TestFactories.internalMapEntry(data: ObjectData(json: .array(["foo"]))), // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + "jsonObject": TestFactories.internalMapEntry(data: ObjectData(json: .object(["foo": "bar"]))), // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + "mapRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 + "counterRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 + ], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + + let size = try map.size(coreSDK: coreSDK, delegate: delegate) + let entries = try map.entries(coreSDK: coreSDK, delegate: delegate) + let keys = try map.keys(coreSDK: coreSDK, delegate: delegate) + let values = try map.values(coreSDK: coreSDK, delegate: delegate) + + #expect(size == 8) + #expect(entries.count == 8) + #expect(keys.count == 8) + #expect(values.count == 8) + + // Verify the correct values are returned by `entries` + let booleanEntry = entries.first { $0.key == "boolean" } // RTLM5d2b + let bytesEntry = entries.first { $0.key == "bytes" } // RTLM5d2c + let numberEntry = entries.first { $0.key == "number" } // RTLM5d2d + let stringEntry = entries.first { $0.key == "string" } // RTLM5d2e + let jsonArrayEntry = entries.first { $0.key == "jsonArray" } // RTLM5d2e + let jsonObjectEntry = entries.first { $0.key == "jsonObject" } // RTLM5d2e + let mapRefEntry = entries.first { $0.key == "mapRef" } // RTLM5d2f2 + let counterRefEntry = entries.first { $0.key == "counterRef" } // RTLM5d2f2 + + #expect(booleanEntry?.value.boolValue == true) // RTLM5d2b + #expect(bytesEntry?.value.dataValue == Data([0x01, 0x02, 0x03])) // RTLM5d2c + #expect(numberEntry?.value.numberValue == 42) // RTLM5d2d + #expect(stringEntry?.value.stringValue == "hello") // RTLM5d2e + #expect(jsonArrayEntry?.value.jsonArrayValue == ["foo"]) // RTLM5d2e + #expect(jsonObjectEntry?.value.jsonObjectValue == ["foo": "bar"]) // RTLM5d2e + #expect(mapRefEntry?.value.liveMapValue as AnyObject === referencedMap as AnyObject) // RTLM5d2f2 + #expect(counterRefEntry?.value.liveCounterValue as AnyObject === referencedCounter as AnyObject) // RTLM5d2f2 + } + } + + /// Tests for `MAP_SET` operations, covering RTLM7 specification points + struct MapSetOperationTests { + // MARK: - RTLM7h Tests (clearTimeserial check) + + // @spec RTLM7h + @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: allow + (operationSerial: "ts6" as String?, clearTimeserial: "ts5", expectedApplied: true), + // serial is nil: discard + (operationSerial: nil as String?, clearTimeserial: "ts5", expectedApplied: false), + ] as [(operationSerial: String?, clearTimeserial: String, expectedApplied: Bool)]) + func checksClearTimeserialBeforeApplying(operationSerial: String?, clearTimeserial: String, expectedApplied: Bool) throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + // Given: a map with the specified clearTimeserial + let map = InternalDefaultLiveMap( + testsOnly_data: [:], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData( + using: TestFactories.objectState( + map: TestFactories.objectsMap(clearTimeserial: clearTimeserial), + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + + // When: applying a MAP_SET operation with the specified serial + let update = map.testsOnly_applyMapSetOperation( + key: "key1", + operationTimeserial: operationSerial, + operationData: ObjectData(string: "new"), + objectsPool: &pool, + ) + + // Then: the operation is applied or discarded as expected + #expect(update.isNoop == !expectedApplied) + if expectedApplied { + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") + } else { + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) + } + } + + // MARK: - RTLM7a Tests (Existing Entry) + + struct ExistingEntryTests { + // @spec RTLM7a1 + @Test + func discardsOperationWhenCannotBeApplied() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap( + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: "existing"))], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Try to apply operation with lower timeserial (ts1 < ts2) + let update = map.testsOnly_applyMapSetOperation( + key: "key1", + operationTimeserial: "ts1", + operationData: ObjectData(objectId: "new"), + objectsPool: &pool, + ) + + // Verify the operation was discarded - existing data unchanged + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") + // Verify that RTLM7g1 didn't happen (i.e. that we didn't create a zero-value object in the pool for object ID "new") + #expect(Set(pool.entries.keys) == ["root"]) + // Verify return value + #expect(update.isNoop) + } + + // @spec RTLM7a2 + // @specOneOf(1/2) RTLM7g1 + // @specOneOf(1/2) RTLM7f + @Test(arguments: [ + // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectsPool per RTLM7g) + (operationData: ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), + // Case 2: ObjectData refers to an object value but the object ID is an empty string (shouldn't modify the ObjectsPool per RTLM7g) + (operationData: ObjectData(objectId: ""), expectedCreatedObjectID: nil), + // Case 3: ObjectData refers to an object value (should modify the ObjectsPool per RTLM7g and RTLM7g1) + (operationData: ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), + ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) + func appliesOperationWhenCanBeApplied(operationData: ObjectData, expectedCreatedObjectID: String?) throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap( + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstonedAt: Date(), timeserial: "ts1", data: ObjectData(string: "existing"))], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let update = map.testsOnly_applyMapSetOperation( + key: "key1", + operationTimeserial: "ts2", + operationData: operationData, + objectsPool: &pool, + ) + + // Update the delegate's pool to include any objects created by the MAP_SET operation (so that when we verify RTLM7b4 using map.get it can return a referenced object) + if let expectedCreatedObjectID { + delegate.objects[expectedCreatedObjectID] = pool.entries[expectedCreatedObjectID] + } + + // Verify the operation was applied + let result = try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) + if let numberValue = operationData.number { + #expect(result?.numberValue == numberValue.doubleValue) + } else if expectedCreatedObjectID != nil { + #expect(result?.liveMapValue != nil) + } + + // RTLM7a2e: Set ObjectsMapEntry.data to the ObjectData from the operation + #expect(map.testsOnly_data["key1"]?.data?.number == operationData.number) + #expect(map.testsOnly_data["key1"]?.data?.objectId == operationData.objectId) + + // RTLM7a2b: Set ObjectsMapEntry.timeserial to the operation's serial + #expect(map.testsOnly_data["key1"]?.timeserial == "ts2") + + // RTLM7a2c: Set ObjectsMapEntry.tombstone to false + #expect(map.testsOnly_data["key1"]?.tombstone == false) + + // RTLM7g/RTLM7g1: Check if zero-value object was created in pool + if let expectedCreatedObjectID { + let createdObject = pool.entries[expectedCreatedObjectID] + #expect(createdObject != nil) + #expect(createdObject?.mapValue != nil) + } else { + // For number values, no object should be created + #expect(Set(pool.entries.keys) == ["root"]) + } + + // RTLM7f: Check return value + #expect(try #require(update.update).update == ["key1": .updated]) + } + } + + // MARK: - RTLM7b Tests (No Existing Entry) + + struct NoExistingEntryTests { + // @spec RTLM7b4 + // @spec RTLM7b2 + // @specOneOf(2/2) RTLM7g1 + // @specOneOf(2/2) RTLM7f + @Test(arguments: [ + // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectsPool per RTLM7g) + (operationData: ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), + // Case 2: ObjectData refers to an object value but the object ID is an empty string (shouldn't modify the ObjectsPool per RTLM7g) + (operationData: ObjectData(objectId: ""), expectedCreatedObjectID: nil), + // Case 3: ObjectData refers to an object value (should modify the ObjectsPool per RTLM7g and RTLM7g1) + (operationData: ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), + ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) + func createsNewEntryWhenNoExistingEntry(operationData: ObjectData, expectedCreatedObjectID: String?) throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let update = map.testsOnly_applyMapSetOperation( + key: "newKey", + operationTimeserial: "ts1", + operationData: operationData, + objectsPool: &pool, + ) + + // Update the delegate's pool to include any objects created by the MAP_SET operation (so that when we verify RTLM7b4 using map.get it can return a referenced object) + if let expectedCreatedObjectID { + delegate.objects[expectedCreatedObjectID] = pool.entries[expectedCreatedObjectID] + } + + // Verify new entry was created + // RTLM7b4 + let result = try map.get(key: "newKey", coreSDK: coreSDK, delegate: delegate) + if let numberValue = operationData.number { + #expect(result?.numberValue == numberValue.doubleValue) + } else if expectedCreatedObjectID != nil { + #expect(result?.liveMapValue != nil) + } + let entry = try #require(map.testsOnly_data["newKey"]) + #expect(entry.timeserial == "ts1") + // RTLM7b2 + #expect(entry.tombstone == false) + + // RTLM7g/RTLM7g1: Check if zero-value object was created in pool + if let expectedCreatedObjectID { + let createdObject = try #require(pool.entries[expectedCreatedObjectID]) + #expect(createdObject.mapValue != nil) + } else { + // For number values, no object should be created + #expect(Set(pool.entries.keys) == ["root"]) + } + + // RTLM7f: Check return value + #expect(try #require(update.update).update == ["newKey": .updated]) + } + } + + // MARK: - RTLM7g1 Standalone Test (RTO6a Integration) + + // This is a sense check to convince ourselves that when applying a MAP_SET operation that references an object, then, because of RTO6a, if the referenced object already exists in the pool it is not replaced when RTLM7g1 is applied. + @Test + func doesNotReplaceExistingObjectWhenReferencedByMapSet() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Create an existing object in the pool with some data + let existingObjectId = "map:existing@123" + let existingObject = InternalDefaultLiveMap( + testsOnly_data: [:], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + var pool = ObjectsPool( + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + testsOnly_otherEntries: [existingObjectId: .map(existingObject)], + ) + // Populate the delegate so that when we "verify the MAP_SET operation was applied correctly" using map.get below it returns the referenced object + delegate.objects[existingObjectId] = pool.entries[existingObjectId] + + // Apply MAP_SET operation that references the existing object + _ = map.testsOnly_applyMapSetOperation( + key: "referenceKey", + operationTimeserial: "ts1", + operationData: ObjectData(objectId: existingObjectId), + objectsPool: &pool, + ) + + // RTO6a: Verify that the existing object was NOT replaced + let objectAfterMapSetValue = try #require(pool.entries[existingObjectId]?.mapValue) + #expect(objectAfterMapSetValue as AnyObject === existingObject as AnyObject) + + // Verify the MAP_SET operation was applied correctly (creates reference in the map) + let referenceValue = try map.get(key: "referenceKey", coreSDK: coreSDK, delegate: delegate) + #expect(referenceValue?.liveMapValue != nil) + } + } + + /// Tests for `MAP_REMOVE` operations, covering RTLM8 specification points + struct MapRemoveOperationTests { + // MARK: - RTLM8g Tests (clearTimeserial check) + + // @spec RTLM8g + @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: allow + (operationSerial: "ts6" as String?, clearTimeserial: "ts5", expectedApplied: true), + // serial is nil: discard + (operationSerial: nil as String?, clearTimeserial: "ts5", expectedApplied: false), + ] as [(operationSerial: String?, clearTimeserial: String, expectedApplied: Bool)]) + func checksClearTimeserialBeforeApplying(operationSerial: String?, clearTimeserial: String, expectedApplied: Bool) throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + // Given: a map with an existing entry and the specified clearTimeserial + let map = InternalDefaultLiveMap( + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts1", data: ObjectData(string: "existing"))], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData( + using: TestFactories.objectState( + map: TestFactories.objectsMap( + entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "existing").entry], + clearTimeserial: clearTimeserial, + ), + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + + // When: applying a MAP_REMOVE operation with the specified serial + let update = map.testsOnly_applyMapRemoveOperation( + key: "key1", + operationTimeserial: operationSerial, + operationSerialTimestamp: nil, + ) + + // Then: the operation is applied or discarded as expected + #expect(update.isNoop == !expectedApplied) + if expectedApplied { + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) + } else { + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") + } + } + + // MARK: - RTLM8a Tests (Existing Entry) + + struct ExistingEntryTests { + // @spec RTLM8a1 + @Test + func discardsOperationWhenCannotBeApplied() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap( + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: "existing"))], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + + // 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) + + // Verify the operation was discarded - existing data unchanged + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") + // Verify return value + #expect(update.isNoop) + } + + // @spec RTLM8a2a + // @spec RTLM8a2b + // @spec RTLM8a2c + // @specOneOf(1/2) RTLM8e + @Test + func appliesOperationWhenCanBeApplied() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap( + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstonedAt: nil, timeserial: "ts1", data: ObjectData(string: "existing"))], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + + // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 + let update = map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts2", operationSerialTimestamp: nil) + + // Verify the operation was applied + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) + + // RTLM8a2a: Set ObjectsMapEntry.data to undefined/null + #expect(map.testsOnly_data["key1"]?.data == nil) + + // RTLM8a2b: Set ObjectsMapEntry.timeserial to the operation's serial + #expect(map.testsOnly_data["key1"]?.timeserial == "ts2") + + // RTLM8a2c: Set ObjectsMapEntry.tombstone to true + #expect(map.testsOnly_data["key1"]?.tombstone == true) + + // RTLM8e: Check return value + #expect(try #require(update.update).update == ["key1": .removed]) + } + } + + // MARK: - RTLM8b Tests (No Existing Entry) + + struct NoExistingEntryTests { + // @spec RTLM8b1 - Create new entry with ObjectsMapEntry.data set to undefined/null and operation's serial + // @specOneOf(1/2) RTLM8e + @Test + func createsNewEntryWhenNoExistingEntry() throws { + let logger = TestLogger() + 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) + + // Verify new entry was created + let entry = map.testsOnly_data["newKey"] + #expect(entry != nil) + #expect(entry?.timeserial == "ts1") + #expect(entry?.data == nil) + + // RTLM8e: Check return value + #expect(try #require(update.update).update == ["newKey": .removed]) + } + + // @spec RTLM8b2 - Set ObjectsMapEntry.tombstone for new entry to true + @Test + func setsNewEntryTombstoneToTrue() throws { + let logger = TestLogger() + 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) + + // Verify tombstone is true for new entry + #expect(map.testsOnly_data["newKey"]?.tombstone == true) + } + } + } + + /// Tests for map operation applicability, covering RTLM9 specification points + struct MapOperationApplicabilityTests { + // @spec RTLM9a + // @spec RTLM9b + // @spec RTLM9c + // @spec RTLM9d + // @spec RTLM9e + @Test(arguments: [ + // RTLM9a, RTLM9e: LWW lexicographical comparison - operation can be applied + // Standard case: ts2 > ts1 + (entrySerial: "ts1", operationSerial: "ts2", shouldApply: true), + // Simple lexicographical: b > a + (entrySerial: "a", operationSerial: "b", shouldApply: true), + // Numeric strings: 2 > 1 + (entrySerial: "1", operationSerial: "2", shouldApply: true), + // Longer string comparison: ts10 > ts1 + (entrySerial: "ts1", operationSerial: "ts10", shouldApply: true), + + // RTLM9a, RTLM9e: LWW lexicographical comparison - operation cannot be applied + // Standard case: ts1 < ts2 + (entrySerial: "ts2", operationSerial: "ts1", shouldApply: false), + // Simple lexicographical: a < b + (entrySerial: "b", operationSerial: "a", shouldApply: false), + // Numeric strings: 1 < 2 + (entrySerial: "2", operationSerial: "1", shouldApply: false), + // Longer string comparison: ts1 < ts10 + (entrySerial: "ts10", operationSerial: "ts1", shouldApply: false), + // Equal case: ts1 == ts1 + (entrySerial: "ts1", operationSerial: "ts1", shouldApply: false), + + // RTLM9b: Both serials null or empty - operation cannot be applied + // Both null + (entrySerial: nil, operationSerial: nil, shouldApply: false), + // Both empty strings + (entrySerial: "", operationSerial: "", shouldApply: false), + + // RTLM9c: Only entry serial exists - operation cannot be applied + // Entry has serial, operation doesn't + (entrySerial: "ts1", operationSerial: nil, shouldApply: false), + // Entry has serial, operation empty + (entrySerial: "ts1", operationSerial: "", shouldApply: false), + + // RTLM9d: Only operation serial exists - operation can be applied + // Entry no serial, operation has serial + (entrySerial: nil, operationSerial: "ts1", shouldApply: true), + // Entry empty, operation has serial + (entrySerial: "", operationSerial: "ts1", shouldApply: true), + ] as [(entrySerial: String?, operationSerial: String?, shouldApply: Bool)]) + func mapOperationApplicability(entrySerial: String?, operationSerial: String?, shouldApply: Bool) throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap( + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: entrySerial, data: ObjectData(string: "existing"))], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + _ = map.testsOnly_applyMapSetOperation( + key: "key1", + operationTimeserial: operationSerial, + operationData: ObjectData(string: "new"), + objectsPool: &pool, + ) + + // We check whether the side effects of the MAP_SET operation have occurred or not as our proxy for checking that the appropriate applicability rules were applied. + + if shouldApply { + // Verify operation was applied + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") + } else { + // Verify operation was discarded + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") + } + } + } + + /// Tests for the `mergeInitialValue` method, covering RTLM23 specification points + struct MergeInitialValueTests { + // @specOneOf(1/2) RTLM23a1 - via mapCreate + @Test + func appliesMapSetOperationsFromOperation() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Apply merge operation with MAP_SET entries + let operation = TestFactories.mapCreateOperation( + objectId: "arbitrary-id", + entries: [ + "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, + ], + ) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_mergeInitialValue(from: operation, objectsPool: &pool) + } + + // Note that we just check for some basic expected side effects of applying MAP_SET; RTLM7 is tested in more detail elsewhere + // Check that it contains the data from the operation (per RTLM23a1) + #expect(try map.get(key: "keyFromCreateOp", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromCreateOp") + } + + // @specOneOf(2/2) RTLM23a1 - via mapCreateWithObjectId.derivedFrom + // @spec RTO11f18 + @Test + func appliesMapSetOperationsFromDerivedFrom() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Apply merge operation with mapCreateWithObjectId.derivedFrom (no direct mapCreate) + let operation = TestFactories.objectOperation( + action: .known(.mapCreate), + mapCreateWithObjectId: .init( + initialValue: "arbitrary", + nonce: "arbitrary", + derivedFrom: MapCreate( + semantics: .known(.lww), + entries: [ + "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, + ], + ), + ), + ) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_mergeInitialValue(from: operation, objectsPool: &pool) + } + + // Check that it contains the data from the derived operation (per RTLM23a1) + #expect(try map.get(key: "keyFromCreateOp", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromCreateOp") + } + + // @spec RTLM23a2 + @Test + func appliesMapRemoveOperationsFromOperation() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap( + testsOnly_data: ["key1": TestFactories.internalStringMapEntry().entry], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Confirm that the initial data is there + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) != nil) + + // Apply merge operation with MAP_REMOVE entry + let entry = TestFactories.mapEntry( + tombstone: true, + timeserial: "ts2", // Must be greater than existing entry's timeserial "ts1" + data: ObjectData(), + ) + let operation = TestFactories.mapCreateOperation( + objectId: "arbitrary-id", + entries: ["key1": entry], + ) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_mergeInitialValue(from: operation, objectsPool: &pool) + } + + // Verify the MAP_REMOVE operation was applied + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) + } + + // @spec RTLM23c + @Test + func returnedUpdateMergesOperationUpdates() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap( + testsOnly_data: [ + "keyThatWillBeRemoved": TestFactories.internalStringMapEntry(timeserial: "ts1").entry, + "keyThatWillNotBeRemoved": TestFactories.internalStringMapEntry(timeserial: "ts1").entry, + ], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Apply merge operation with MAP_CREATE and MAP_REMOVE entries (copied from RTLM23a1 and RTLM23a2 test cases) + let operation = TestFactories.mapCreateOperation( + objectId: "arbitrary-id", + entries: [ + "keyThatWillBeRemoved": TestFactories.mapEntry( + tombstone: true, + timeserial: "ts2", // Must be greater than existing entry's timeserial "ts1" + data: ObjectData(), + ), + "keyThatWillNotBeRemoved": TestFactories.mapEntry( + tombstone: true, + timeserial: "ts0", // Less than existing entry's timeserial "ts1" so MAP_REMOVE will be a no-op (this lets us test that no-ops are excluded from return value per RTLM23c) + data: ObjectData(), + ), + "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, + ], + ) + let update = internalQueue.ably_syncNoDeadlock { + map.nosync_mergeInitialValue(from: operation, objectsPool: &pool) + } + + // Verify merged return value per RTLM23c + #expect(try #require(update.update).update == ["keyThatWillBeRemoved": .removed, "keyFromCreateOp": .updated]) + } + + // @spec RTLM23b + @Test + func setsCreateOperationIsMergedToTrue() { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Apply merge operation + let operation = TestFactories.mapCreateOperation(objectId: "arbitrary-id") + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_mergeInitialValue(from: operation, objectsPool: &pool) + } + + #expect(map.testsOnly_createOperationIsMerged) + } + } + + /// Tests for `MAP_CREATE` operations, covering RTLM16 specification points + struct MapCreateOperationTests { + // @spec RTLM16b + @Test + func discardsOperationWhenCreateOperationIsMerged() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Set initial data and mark create operation as merged + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_mergeInitialValue(from: TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]), objectsPool: &pool) + } + #expect(map.testsOnly_createOperationIsMerged) + + // Try to apply another MAP_CREATE operation + let operation = TestFactories.mapCreateOperation(entries: ["key3": TestFactories.stringMapEntry(key: "key3", value: "value3").entry]) + let update = map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) + + // Verify the operation was discarded - data unchanged + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "testValue") // Original data + #expect(try map.get(key: "key2", coreSDK: coreSDK, delegate: delegate)?.stringValue == "value2") // From first merge + #expect(try map.get(key: "key3", coreSDK: coreSDK, delegate: delegate) == nil) // Not added by second operation + + // Verify the return value + #expect(update.isNoop) + } + + // @spec RTLM16d + // @spec RTLM16f + @Test + func mergesInitialValue() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Set initial data but don't mark create operation as merged + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectMessageSerialTimestamp: nil, objectsPool: &pool) + } + #expect(!map.testsOnly_createOperationIsMerged) + + // Apply MAP_CREATE operation + let operation = TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]) + let update = map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) + + // Verify the operation was applied - initial value merged. (The full logic of RTLM23 is tested elsewhere; we just check for some of its side effects here.) + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "testValue") // Original data + #expect(try map.get(key: "key2", coreSDK: coreSDK, delegate: delegate)?.stringValue == "value2") // From merge + #expect(map.testsOnly_createOperationIsMerged) + + // Verify return value per RTLM16f + #expect(try #require(update.update).update == ["key2": .updated]) + } + } + + /// Tests for `MAP_CLEAR` operations, covering RTLM24 specification points + struct MapClearOperationTests { + // MARK: - RTLM24c Tests (clearTimeserial check) + + // @spec RTLM24c + @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: allow + (operationSerial: "ts6" as String?, clearTimeserial: "ts5", expectedApplied: true), + // serial is nil: discard + (operationSerial: nil as String?, clearTimeserial: "ts5", expectedApplied: false), + ] as [(operationSerial: String?, clearTimeserial: String, expectedApplied: Bool)]) + func checksClearTimeserialBeforeApplying(operationSerial: String?, clearTimeserial: String, expectedApplied: Bool) throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + // Given: a map with an existing entry and the specified clearTimeserial + let map = InternalDefaultLiveMap( + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts1", data: ObjectData(string: "existing"))], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData( + using: TestFactories.objectState( + map: TestFactories.objectsMap( + entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "existing").entry], + clearTimeserial: clearTimeserial, + ), + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + + // When: applying a MAP_CLEAR operation with the specified serial + let update = map.testsOnly_applyMapClearOperation(serial: operationSerial) + + // Then: the operation is applied or discarded as expected + #expect(update.isNoop == !expectedApplied) + if expectedApplied { + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) + } else { + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") + } + } + + // MARK: - RTLM24 Tests (MAP_CLEAR operation application) + + // @spec RTLM24 + // @spec RTLM24d + // @spec RTLM24e + // @spec RTLM24e1 + // @spec RTLM24e1a + // @spec RTLM24e1b + // @spec RTLM24f + @Test + func appliesMapClearOperation() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + + // Given: a map with multiple entries at different timeserials, including one with nil timeserial + let map = InternalDefaultLiveMap( + testsOnly_data: [ + "olderThanClear": TestFactories.internalMapEntry(timeserial: "ts1", data: ObjectData(string: "value1")), + // Note that this shouldn't happen in real life — timeserials are unique + "equalToClear": TestFactories.internalMapEntry(timeserial: "ts3", data: ObjectData(string: "value2")), + "newerThanClear": TestFactories.internalMapEntry(timeserial: "ts5", data: ObjectData(string: "value3")), + "nilTimeserial": TestFactories.internalMapEntry(timeserial: nil, data: ObjectData(string: "value4")), + ], + objectID: "arbitrary", + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + ) + + // When: applying a MAP_CLEAR operation with serial "ts3" + let update = map.testsOnly_applyMapClearOperation(serial: "ts3") + + // Then: entries with timeserial < "ts3" or nil are removed from internal data, others remain + #expect(Set(map.testsOnly_data.keys) == ["equalToClear", "newerThanClear"]) + + // RTLM24f: update contains exactly the removed keys + let mapUpdate = try #require(update.update) + #expect(mapUpdate.update == [ + "olderThanClear": .removed, + "nilTimeserial": .removed, + ]) + + // RTLM24d: clearTimeserial should be set + #expect(map.testsOnly_clearTimeserial == "ts3") + } + } + + /// Tests for the `apply(_ operation:, …)` method, covering RTLM15 specification points + struct ApplyOperationTests { + // @spec RTLM15b - Tests that an operation does not get applied when canApplyOperation returns nil + @Test + func discardsOperationWhenCannotBeApplied() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Set up the map with an existing site timeserial that will cause the operation to be discarded + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData( + using: TestFactories.mapObjectState( + siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" + entries: [key1: entry1], + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + + let operation = TestFactories.objectOperation( + action: .known(.mapSet), + mapSet: MapSet(key: "key1", value: ObjectData(string: "new")), + ) + + // Apply operation with serial "ts1" which is lexicographically less than existing "ts2" and thus will be applied per RTLO4a (this is a non-pathological case of RTOL4a, that spec point being fully tested elsewhere) + let applied = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", // Less than existing "ts2" + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(!applied) + + // Check that the MAP_SET side-effects didn't happen: + // Verify the operation was discarded - data unchanged (should still be "existing" from creation) + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") + // Verify site timeserials unchanged + #expect(map.testsOnly_siteTimeserials == ["site1": "ts2"]) + } + + // @specOneOf(1/5) RTLM15c - We test this spec point for each possible operation + // @spec RTLM15d1 - Tests MAP_CREATE operation application + // @spec RTLM15d1a + // @spec RTLM15d1b + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func appliesMapCreateOperation() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + let operation = TestFactories.mapCreateOperation( + entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "value1").entry], + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Apply MAP_CREATE operation + let applied = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(applied) + + // 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") + #expect(map.testsOnly_createOperationIsMerged) + // Verify RTLM15c side-effect: site timeserial was updated + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Verify update was emitted per RTLM15d1a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .updated])]) + } + + // @specOneOf(2/5) RTLM15c - We test this spec point for each possible operation + // @spec RTLM15d6 - Tests MAP_SET operation application + // @spec RTLM15d6a + // @spec RTLM15d6b + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func appliesMapSetOperation() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + // Set initial data + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData( + using: TestFactories.mapObjectState( + siteTimeserials: [:], + entries: [key1: entry1], + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") + + let operation = TestFactories.objectOperation( + action: .known(.mapSet), + mapSet: MapSet(key: "key1", value: ObjectData(string: "new")), + ) + + // Apply MAP_SET operation + let applied = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(applied) + + // 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") + // Verify RTLM15c side-effect: site timeserial was updated + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Verify update was emitted per RTLM15d6a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .updated])]) + } + + // @specOneOf(3/5) RTLM15c - We test this spec point for each possible operation + // @spec RTLM15d7 - Tests MAP_REMOVE operation application + // @spec RTLM15d7a + // @spec RTLM15d7b + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func appliesMapRemoveOperation() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + // Set initial data + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData( + using: TestFactories.mapObjectState( + siteTimeserials: [:], + entries: [key1: entry1], + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") + + let operation = TestFactories.objectOperation( + action: .known(.mapRemove), + mapRemove: WireMapRemove(key: "key1"), + ) + + // Apply MAP_REMOVE operation + let applied = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(applied) + + // 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) + // Verify RTLM15c side-effect: site timeserial was updated + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Verify update was emitted per RTLM15d7a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .removed])]) + } + + // @specOneOf(4/5) RTLM15c - We test this spec point for each possible operation + // @spec RTLM15d8 + // @spec RTLM15d8a + // @spec RTLM15d8b + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func appliesMapClearOperation() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + // Set initial data + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData( + using: TestFactories.mapObjectState( + siteTimeserials: [:], + entries: [key1: entry1], + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") + + let operation = TestFactories.objectOperation( + action: .known(.mapClear), + mapClear: WireMapClear(), + ) + + // Apply MAP_CLEAR operation + let applied = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(applied) + + // 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) + #expect(map.testsOnly_clearTimeserial == "ts1") + // Verify RTLM15c side-effect: site timeserial was updated + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Verify update was emitted per RTLM15d8a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .removed])]) + } + + // @specOneOf(5/5) RTLM15c - Tests that siteTimeserials is NOT updated when source is LOCAL + @Test + func doesNotUpdateSiteTimeserialsForLocalSource() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let operation = TestFactories.objectOperation( + action: .known(.mapSet), + mapSet: MapSet(key: "key1", value: ObjectData(string: "new")), + ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + // Apply MAP_SET operation with LOCAL source + let applied = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + source: .local, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(applied) + + // Verify the operation was applied + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") + // Verify RTLM15c: siteTimeserials should NOT have been updated for LOCAL source + #expect(map.testsOnly_siteTimeserials.isEmpty) + } + + // @spec RTLM15d4 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func noOpForOtherOperation() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + // Try to apply a COUNTER_CREATE to the map (not supported) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let applied = internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + TestFactories.counterCreateOperation(), + source: .channel, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } + #expect(!applied) + + // Check no update was emitted + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.isEmpty) + } + } + + /// Tests for the `set` method, covering RTLM20 specification points + struct SetTests { + // @spec RTLM20c + @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects() + + await #expect { + try await map.set(key: "test", value: .string("value"), coreSDK: coreSDK, realtimeObjects: realtimeObjects) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @spec RTLM20e + // @specUntested RTLM20e1 - Not needed with Swift's type system + // @spec RTLM20e2 + // @spec RTLM20e3 + // @spec RTLM20e6 + // @spec RTLM20e7a + // @spec RTLM20e7b + // @spec RTLM20e7c + // @spec RTLM20e7d + // @spec RTLM20e7e + // @spec RTLM20e7f + // @spec RTLM20g + @Test(arguments: [ + // RTLM20e7a + (value: { @Sendable internalQueue in .liveMap(.createZeroValued(objectID: "map:test@123", logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())) }, expectedData: .init(objectId: "map:test@123")), + (value: { @Sendable internalQueue in .liveCounter(.createZeroValued(objectID: "counter:test@123", logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())) }, expectedData: .init(objectId: "counter:test@123")), + // RTLM20e7b + (value: { @Sendable _ in .jsonArray(["test"]) }, expectedData: .init(json: .array(["test"]))), + (value: { @Sendable _ in .jsonObject(["foo": "bar"]) }, expectedData: .init(json: .object(["foo": "bar"]))), + // RTLM20e7c + (value: { @Sendable _ in .string("test") }, expectedData: .init(string: "test")), + // RTLM20e7d + (value: { @Sendable _ in .number(42.5) }, expectedData: .init(number: NSNumber(value: 42.5))), + // RTLM20e7e + (value: { @Sendable _ in .bool(true) }, expectedData: .init(boolean: true)), + // RTLM20e7f + (value: { @Sendable _ in .data(Data([0x01, 0x02])) }, expectedData: .init(bytes: Data([0x01, 0x02]))), + ] as [(value: @Sendable (DispatchQueue) -> InternalLiveMapValue, expectedData: ObjectData)]) + func publishesCorrectObjectMessageForDifferentValueTypes(value: @escaping @Sendable (DispatchQueue) -> InternalLiveMapValue, expectedData: ObjectData) async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects() + + var publishedMessage: OutboundObjectMessage? + realtimeObjects.setPublishAndApplyHandler { messages in + publishedMessage = messages.first + return .success(()) + } + + try await map.set(key: "testKey", value: value(internalQueue), coreSDK: coreSDK, realtimeObjects: realtimeObjects) + + let expectedMessage = OutboundObjectMessage( + operation: ObjectOperation( + // RTLM20e2 + action: .known(.mapSet), + // RTLM20e3 + objectId: "map:test@123", + mapSet: MapSet( + // RTLM20e6 + key: "testKey", + // RTLM20e7 + value: expectedData, + ), + ), + ) + // RTLM20g + let message = try #require(publishedMessage) + #expect(message == expectedMessage) + } + + @Test + func throwsErrorWhenPublishFails() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects() + + realtimeObjects.setPublishAndApplyHandler { _ in + .failure(LiveObjectsError.other(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"])).toARTErrorInfo()) + } + + await #expect { + try await map.set(key: "testKey", value: .string("testValue"), coreSDK: coreSDK, realtimeObjects: realtimeObjects) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.message.contains("Publish failed") + } + } + } + + /// Tests for the `remove` method, covering RTLM21 specification points + struct RemoveTests { + // @spec RTLM21c + @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects() + + await #expect { + try await map.remove(key: "test", coreSDK: coreSDK, realtimeObjects: realtimeObjects) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @specUntested RTLM21e + // @specUntested RTLM21e1 - Not needed with Swift's type system + // @spec RTLM21e2 + // @spec RTLM21e3 + // @spec RTLM21e5 + // @spec RTLM21g + @Test + func publishesCorrectObjectMessage() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects() + + var publishedMessages: [OutboundObjectMessage] = [] + realtimeObjects.setPublishAndApplyHandler { messages in + publishedMessages.append(contentsOf: messages) + return .success(()) + } + + try await map.remove(key: "testKey", coreSDK: coreSDK, realtimeObjects: realtimeObjects) + + let expectedMessage = OutboundObjectMessage( + operation: ObjectOperation( + // RTLM21e2 + action: .known(.mapRemove), + // RTLM21e3 + objectId: "map:test@123", + mapRemove: WireMapRemove( + // RTLM21e5 + key: "testKey", + ), + ), + ) + // RTLM21g + #expect(publishedMessages.count == 1) + #expect(publishedMessages[0] == expectedMessage) + } + + @Test + func throwsErrorWhenPublishFails() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = MockRealtimeObjects() + + realtimeObjects.setPublishAndApplyHandler { _ in + .failure(LiveObjectsError.other(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"])).toARTErrorInfo()) + } + + await #expect { + try await map.remove(key: "testKey", coreSDK: coreSDK, realtimeObjects: realtimeObjects) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.message.contains("Publish failed") + } + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift new file mode 100644 index 000000000..f37595450 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -0,0 +1,2091 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Testing + +/// Tests for `InternalDefaultRealtimeObjects`. +struct InternalDefaultRealtimeObjectsTests { + // MARK: - Test Helpers + + /// Creates a InternalDefaultRealtimeObjects instance for testing + static func createDefaultRealtimeObjects( + clock: SimpleClock = MockSimpleClock(), + internalQueue: DispatchQueue = TestFactories.createInternalQueue(), + ) -> InternalDefaultRealtimeObjects { + let logger = TestLogger() + return InternalDefaultRealtimeObjects( + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: clock, + ) + } + + /// Tests for `InternalDefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. + struct HandleObjectSyncProtocolMessageTests { + // MARK: - RTO5a5: Single ProtocolMessage Sync Tests + + // @spec RTO5a5 + @Test + func handlesSingleProtocolMessageSync() 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"), + ] + + // Verify no sync sequence before handling + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + + // Call with no channelSerial (RTO5a5 case) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: nil, + ) + } + + // Verify sync was applied immediately and sequence was cleared (RTO5c3) + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + + // Verify objects were added to pool (side effect of applySyncObjectsPool per RTO5c1b1b) + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["map:1@123"] != nil) + #expect(pool.entries["map:2@456"] != nil) + } + + // MARK: - RTO5a1, RTO5a3, RTO5a4: Multi-ProtocolMessage Sync Tests + + // @spec RTO5a1 + // @spec RTO5a3 + // @spec RTO5a4 + // @spec RTO5f + // @spec RTO5c3 + // @spec RTO5c4 + // @spec RTO5c5 + @Test + func handlesMultiProtocolMessageSync() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let sequenceId = "seq123" + + // First message in sequence + let firstMessages = [TestFactories.simpleMapMessage(objectId: "map:1@123")] + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: firstMessages, + protocolMessageChannelSerial: "\(sequenceId):cursor1", + ) + } + + // Verify sync sequence is active (RTO5a1, RTO5a3) + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Verify objects not yet applied to pool + let poolAfterFirst = realtimeObjects.testsOnly_objectsPool + #expect(poolAfterFirst.entries["map:1@123"] == nil) + + // Second message in sequence + let secondMessages = [TestFactories.simpleMapMessage(objectId: "map:2@456")] + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: secondMessages, + protocolMessageChannelSerial: "\(sequenceId):cursor2", + ) + } + + // Verify sync sequence still active + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Verify objects still not applied to pool + let poolAfterSecond = realtimeObjects.testsOnly_objectsPool + #expect(poolAfterSecond.entries["map:1@123"] == nil) + #expect(poolAfterSecond.entries["map:2@456"] == nil) + + // Final message in sequence (end of sequence per RTO5a4) + let finalMessages = [TestFactories.simpleMapMessage(objectId: "map:3@789")] + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: finalMessages, + protocolMessageChannelSerial: "\(sequenceId):", // Empty cursor indicates end + ) + } + + // Verify sync sequence is cleared and there is no SyncObjectsPool or BufferedObjectOperations (RTO5c3, RTO5c4, RTO5c5) + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + + // Verify all objects were applied to pool (side effect of applySyncObjectsPool per RTO5c1b1b) + let finalPool = realtimeObjects.testsOnly_objectsPool + #expect(finalPool.entries["map:1@123"] != nil) + #expect(finalPool.entries["map:2@456"] != nil) + #expect(finalPool.entries["map:3@789"] != nil) + } + + // MARK: - RTO5a2: New Sync Sequence Tests + + // @spec RTO5a2 + // @spec RTO5a2a + @Test + func newSequenceIdDiscardsInFlightSync() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let firstSequenceId = "seq1" + let secondSequenceId = "seq2" + + // Start first sequence + let firstMessages = [TestFactories.simpleMapMessage(objectId: "map:1@123")] + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: firstMessages, + protocolMessageChannelSerial: "\(firstSequenceId):cursor1", + ) + } + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Start new sequence with different ID (RTO5a2) + let secondMessages = [TestFactories.simpleMapMessage(objectId: "map:2@456")] + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: secondMessages, + protocolMessageChannelSerial: "\(secondSequenceId):cursor1", + ) + } + + // Verify sync sequence is still active but with new ID + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Complete the new sequence + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [], + protocolMessageChannelSerial: "\(secondSequenceId):", + ) + } + + // Verify only the second sequence's objects were applied (RTO5a2a - previous cleared) + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["map:1@123"] == nil) // From discarded first sequence + #expect(pool.entries["map:2@456"] != nil) // From completed second sequence + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + } + + // MARK: - RTO5c: Post-Sync Behavior Tests + + // A smoke test that the RTO5c post-sync behaviours performed inside ObjectsPool.applySyncObjectsPool get performed (they are tested in more detail there). Also the primary test for post-sync behaviours that exist outside of applySyncObjectsPool, such as RTO5c9. + // @spec RTO5c9 + @Test + func performsPostSyncSteps() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Transition to synced state so that createCounter can work + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode("site1") + } + + // Populate appliedOnAckSerials by performing a local operation (RTO5c9 setup) + coreSDK.setPublishHandler { messages in + PublishResult(serials: messages.map { _ in "serial_abc" }) + } + _ = try await realtimeObjects.createCounter(count: 1, coreSDK: coreSDK) + #expect(!realtimeObjects.testsOnly_appliedOnAckSerials.isEmpty) + + // Perform sync with only one object (RTO5a5 case) + let syncMessages = [TestFactories.mapObjectMessage(objectId: "map:synced@1")] + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: syncMessages, + protocolMessageChannelSerial: nil, + ) + } + + // Verify root is preserved (RTO5c2a) and sync completed (RTO5c3) + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + let finalPool = realtimeObjects.testsOnly_objectsPool + #expect(finalPool.entries["root"] != nil) // Root preserved + #expect(finalPool.entries["map:synced@1"] != nil) // Synced object added + + // Verify appliedOnAckSerials is cleared (RTO5c9) + #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) + } + + // 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 { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + + let mixedMessages = [ + TestFactories.mapObjectMessage(objectId: "map:1@123"), + TestFactories.counterObjectMessage(objectId: "counter:1@456"), + TestFactories.mapObjectMessage(objectId: "map:2@789"), + ] + + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: mixedMessages, + protocolMessageChannelSerial: nil, // Single message sync + ) + } + + // Verify all object types were processed + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["map:1@123"] != nil) + #expect(pool.entries["counter:1@456"] != nil) + #expect(pool.entries["map:2@789"] != nil) + #expect(pool.entries.count == 4) // root + 3 objects + } + + /// Test continuation of sync after interruption by new sequence + @Test + func handlesSequenceInterruptionCorrectly() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + + // Start first sequence + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [TestFactories.mapObjectMessage(objectId: "map:old@1")], + protocolMessageChannelSerial: "oldSeq:cursor1", + ) + } + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Interrupt with new sequence + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [TestFactories.mapObjectMessage(objectId: "map:new@1")], + protocolMessageChannelSerial: "newSeq:cursor1", + ) + } + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Continue new sequence + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [TestFactories.mapObjectMessage(objectId: "map:new@2")], + protocolMessageChannelSerial: "newSeq:cursor2", + ) + } + + // Complete new sequence + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [], + protocolMessageChannelSerial: "newSeq:", + ) + } + + // Verify only new sequence objects were applied + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries["map:old@1"] == nil) // From interrupted sequence + #expect(pool.entries["map:new@1"] != nil) // From completed sequence + #expect(pool.entries["map:new@2"] != nil) // From completed sequence + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + } + } + + /// Tests for `InternalDefaultRealtimeObjects.onChannelAttached`, covering RTO4 specification points. + /// + /// Note: These tests use `OBJECT_SYNC` messages to populate the initial state of objects pools + /// and sync sequences. This approach is more realistic than directly manipulating internal state, + /// as it simulates how objects actually enter pools during normal operation. + struct OnChannelAttachedTests { + // MARK: - RTO4a Tests + + // @spec RTO4a - Checks that when the `HAS_OBJECTS` flag is 1 (i.e. the server will shortly perform an `OBJECT_SYNC` sequence) we don't modify the objects pool or sync sequence + // @specOneOf(1/2) RTO4d + @Test + func handlesHasObjectTrue() { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + + // Set up initial state with additional objects by using the createZeroValueObject method + let originalPool = realtimeObjects.testsOnly_objectsPool + let originalRootObject = originalPool.root + _ = realtimeObjects.testsOnly_createZeroValueLiveObject(forObjectID: "map:test@123") + + // Set up an in-progress sync sequence + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:sync@456"), + ], + protocolMessageChannelSerial: "seq1:cursor1", + ) + } + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Inject a buffered OBJECT operation + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [ + TestFactories.mapCreateOperationMessage(objectId: "map:buffered@789"), + ]) + } + + #expect(realtimeObjects.testsOnly_bufferedObjectOperationsCount == 1) + + // When: onChannelAttached is called with hasObjects = true + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + } + + // Then: + #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == true) + + // Verify ObjectsPool is unchanged + let poolAfter = realtimeObjects.testsOnly_objectsPool + #expect(poolAfter.root as AnyObject === originalRootObject as AnyObject) + #expect(poolAfter.entries.count == 2) // root + additional map + #expect(poolAfter.entries["map:test@123"] != nil) + + // Verify sync sequence is still active + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // RTO4d: Verify buffered object operations were cleared + #expect(realtimeObjects.testsOnly_bufferedObjectOperationsCount == 0) + } + + // MARK: - RTO4b Tests + + // @spec RTO4b1 + // @spec RTO4b2 + // @spec RTO4b2a + // @spec RTO4b3 + // @spec RTO4b4 + // @specOneOf(2/2) RTO4d + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func handlesHasObjectsFalse() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + + // Set up initial state with additional objects in the pool using sync + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "root", entries: [ + "existingMap": TestFactories.objectReferenceMapEntry(key: "existingMap", objectId: "map:existing@123").entry, + "existingCounter": TestFactories.objectReferenceMapEntry(key: "existingCounter", objectId: "counter:existing@456").entry, + ]), + TestFactories.mapObjectMessage(objectId: "map:existing@123"), + TestFactories.counterObjectMessage(objectId: "counter:existing@456"), + ], + protocolMessageChannelSerial: nil, // Complete sync immediately + ) + } + + let originalPool = realtimeObjects.testsOnly_objectsPool + #expect(Set(originalPool.root.testsOnly_data.keys) == ["existingMap", "existingCounter"]) + + // 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") + #expect(originalPool.root.testsOnly_clearTimeserial == "aaa") + + let rootSubscriber = Subscriber(callbackQueue: .main) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + try originalPool.root.subscribe(listener: rootSubscriber.createListener(), coreSDK: coreSDK) + + // Populate appliedOnAckSerials by performing a local operation (RTO5c9 setup) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_setSiteCode("site1") + } + coreSDK.setPublishHandler { messages in + PublishResult(serials: messages.map { _ in "serial_abc" }) + } + _ = try await realtimeObjects.createCounter(count: 1, coreSDK: coreSDK) + #expect(!realtimeObjects.testsOnly_appliedOnAckSerials.isEmpty) + + // Set up an in-progress sync sequence + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:sync@789"), + ], + protocolMessageChannelSerial: "seq1:cursor1", + ) + } + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + #expect(originalPool.entries.count == 3) // root + 2 additional objects + + // When: onChannelAttached is called with hasObjects = false + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } + + // Then: Verify the expected behavior per RTO4b + #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == false) + + // RTO4b1, RTO4b2: All objects except root must be removed, root must be cleared to zero-value + let newPool = realtimeObjects.testsOnly_objectsPool + #expect(newPool.entries.count == 1) // Only root should remain + #expect(newPool.entries["root"] != nil) + #expect(newPool.entries["map:existing@123"] == nil) // Should be removed + #expect(newPool.entries["counter:existing@456"] == nil) // Should be removed + // Verify that `removed` was emitted for root's existing keys per RTO4b2a + let subscriberInvocations = await rootSubscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(update: ["existingMap": .removed, "existingCounter": .removed])]) + + // Verify root is the same object, but with data cleared (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. + let newRoot = newPool.root + #expect(newRoot as AnyObject === originalPool.root as AnyObject) // Should be same instance + #expect(newRoot.testsOnly_data.isEmpty) // Should be zero-valued (empty) + #expect(newRoot.testsOnly_clearTimeserial == nil) // RTLM4: zero-value LiveMap has clearTimeserial set to null + + // RTO4b3, RTO4b4, RTO4d: SyncObjectsPool must be cleared, sync sequence cleared, BufferedObjectOperations cleared, appliedOnAckSerials cleared + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + #expect(realtimeObjects.testsOnly_appliedOnAckSerials.isEmpty) + } + + // MARK: - Edge Cases and Integration Tests + + /// Test that multiple calls to onChannelAttached work correctly + @Test + func handlesMultipleCallsCorrectly() { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + + // First call with hasObjects = true (should do nothing) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + } + #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == true) + let originalPool = realtimeObjects.testsOnly_objectsPool + let originalRoot = originalPool.root + + // Second call with hasObjects = false (should reset) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } + #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == false) + let newPool = realtimeObjects.testsOnly_objectsPool + #expect(newPool.root as AnyObject === originalRoot as AnyObject) + #expect(newPool.entries.count == 1) + + // Third call with hasObjects = true again (should do nothing) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + } + #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == true) + let finalPool = realtimeObjects.testsOnly_objectsPool + #expect(finalPool.root as AnyObject === originalRoot as AnyObject) // Should be unchanged + } + + /// Test that sync sequence is properly discarded even with complex sync state + @Test + func discardsComplexSyncSequence() { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + + // Create a complex sync sequence using OBJECT_SYNC messages + // (This simulates realistic multi-message sync scenarios) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:sync1@123"), + ], + protocolMessageChannelSerial: "seq1:cursor1", + ) + } + + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.counterObjectMessage(objectId: "counter:sync1@456"), + ], + protocolMessageChannelSerial: "seq1:cursor2", + ) + } + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // When: onChannelAttached is called with hasObjects = false + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } + + // Then: All sync data should be discarded + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + let pool = realtimeObjects.testsOnly_objectsPool + #expect(pool.entries.count == 1) // Only root + #expect(pool.entries["map:sync1@123"] == nil) + #expect(pool.entries["counter:sync1@456"] == nil) + } + + /// Test behavior when there's no sync sequence in progress + @Test + func handlesNoSyncSequenceCorrectly() { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + + // Add some objects to the pool using OBJECT_SYNC messages + // (This is the realistic way objects enter the pool, not through direct manipulation) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:test@123"), + ], + protocolMessageChannelSerial: nil, // Complete sync immediately + ) + } + + let pool = realtimeObjects.testsOnly_objectsPool + + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + #expect(pool.entries.count == 2) // root + additional map + + // When: onChannelAttached is called with hasObjects = false + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } + + // Then: Should still reset the pool correctly + let newPool = realtimeObjects.testsOnly_objectsPool + #expect(newPool.entries.count == 1) // Only root + #expect(newPool.entries["map:test@123"] == nil) + #expect(!realtimeObjects.testsOnly_hasSyncSequence) // Should remain false + } + + /// Test that the root object's delegate is correctly set after reset + @Test + func setsCorrectDelegateOnNewRoot() { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + + // When: onChannelAttached is called with hasObjects = false + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } + + // Then: The new root should be properly initialized + let newRoot = realtimeObjects.testsOnly_objectsPool.root + #expect(newRoot.testsOnly_data.isEmpty) // Should be zero-valued (empty) + } + } + + /// Tests for `InternalDefaultRealtimeObjects.getRoot`, covering RTO1 specification points + struct GetRootTests { + // MARK: - RTO1c Tests + + // @specOneOf(1/4) RTO1c - getRoot waits for sync completion when sync completes via ATTACHED with `HAS_OBJECTS` false (RTO4b) + @Test + func waitsForSyncCompletionViaAttachedHasObjectsFalse() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Start getRoot call - it should wait for sync completion + async let getRootTask = realtimeObjects.getRoot(coreSDK: coreSDK) + + // Wait for getRoot to start waiting for sync + _ = try #require(await realtimeObjects.testsOnly_waitingForSyncEvents.first { _ in true }) + + // Complete sync via ATTACHED with HAS_OBJECTS false (RTO4b) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } + + // getRoot should now complete + _ = try await getRootTask + } + + // @specOneOf(2/4) RTO1c - getRoot waits for sync completion when sync completes via single `OBJECT_SYNC` with no channelSerial (RTO5a5) + @Test + func waitsForSyncCompletionViaSingleObjectSync() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Start getRoot call - it should wait for sync completion + async let getRootTask = realtimeObjects.getRoot(coreSDK: coreSDK) + + // Wait for getRoot to start waiting for sync + _ = try #require(await realtimeObjects.testsOnly_waitingForSyncEvents.first { _ in true }) + + // Complete sync via single OBJECT_SYNC with no channelSerial (RTO5a5) + let (testKey, testEntry) = TestFactories.stringMapEntry(key: "testKey", value: "testValue") + let (referencedKey, referencedEntry) = TestFactories.objectReferenceMapEntry(key: "referencedObject", objectId: "map:test@123") + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.rootObjectMessage(entries: [ + testKey: testEntry, + referencedKey: referencedEntry, + ]), + TestFactories.mapObjectMessage(objectId: "map:test@123"), + ], + protocolMessageChannelSerial: nil, // RTO5a5 case + ) + } + + // getRoot should now complete + let root = try await getRootTask + + // Verify the root object contains the expected entries from the sync + let testValue = try root.get(key: "testKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue + #expect(testValue == "testValue") + + // Verify the root object contains a reference to the other LiveObject + let referencedObject = try root.get(key: "referencedObject", coreSDK: coreSDK, delegate: realtimeObjects) + #expect(referencedObject != nil) + } + + // @specOneOf(3/4) RTO1c - getRoot waits for sync completion when sync completes via multiple `OBJECT_SYNC` messages (RTO5a4) + @Test + func waitsForSyncCompletionViaMultipleObjectSync() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let sequenceId = "seq123" + + // Start getRoot call - it should wait for sync completion + async let getRootTask = realtimeObjects.getRoot(coreSDK: coreSDK) + + // Wait for getRoot to start waiting for sync + _ = try #require(await realtimeObjects.testsOnly_waitingForSyncEvents.first { _ in true }) + + // Start multi-message sync sequence (RTO5a1, RTO5a3) + let (firstKey, firstEntry) = TestFactories.stringMapEntry(key: "firstKey", value: "firstValue") + let (firstObjectKey, firstObjectEntry) = TestFactories.objectReferenceMapEntry(key: "firstObject", objectId: "map:first@123") + let (secondObjectKey, secondObjectEntry) = TestFactories.objectReferenceMapEntry(key: "secondObject", objectId: "map:second@456") + let (finalObjectKey, finalObjectEntry) = TestFactories.objectReferenceMapEntry(key: "finalObject", objectId: "map:final@789") + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.rootObjectMessage(entries: [ + firstKey: firstEntry, + firstObjectKey: firstObjectEntry, + secondObjectKey: secondObjectEntry, + finalObjectKey: finalObjectEntry, + ]), + TestFactories.mapObjectMessage(objectId: "map:first@123"), + ], + protocolMessageChannelSerial: "\(sequenceId):cursor1", + ) + } + + // Continue sync sequence - add more objects but don't redefine root + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:second@456"), + ], + protocolMessageChannelSerial: "\(sequenceId):cursor2", + ) + } + + // Complete sync sequence (RTO5a4) - add final object + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:final@789"), + ], + protocolMessageChannelSerial: "\(sequenceId):", // Empty cursor indicates end + ) + } + + // getRoot should now complete + let root = try await getRootTask + + // Verify the root object contains the expected entries from the sync sequence + let firstValue = try root.get(key: "firstKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue + let firstObject = try root.get(key: "firstObject", coreSDK: coreSDK, delegate: realtimeObjects) + let secondObject = try root.get(key: "secondObject", coreSDK: coreSDK, delegate: realtimeObjects) + let finalObject = try root.get(key: "finalObject", coreSDK: coreSDK, delegate: realtimeObjects) + #expect(firstValue == "firstValue") + #expect(firstObject != nil) + #expect(secondObject != nil) + #expect(finalObject != nil) + } + + // @specOneOf(4/4) RTO1c - getRoot returns immediately when sync is already complete + @Test + func returnsImmediatelyWhenSyncAlreadyComplete() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Complete sync first + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } + + // getRoot should return + _ = try await realtimeObjects.getRoot(coreSDK: coreSDK) + + // Verify no waiting events were emitted + realtimeObjects.testsOnly_finishAllTestHelperStreams() + let waitingEvents: [Void] = await realtimeObjects.testsOnly_waitingForSyncEvents.reduce(into: []) { result, _ in + result.append(()) + } + #expect(waitingEvents.isEmpty) + } + + // MARK: - RTO1d Tests + + // @spec RTO1d + @Test + func returnsRootObjectFromObjectsPool() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Complete sync first + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } + + // Call getRoot + let root = try await realtimeObjects.getRoot(coreSDK: coreSDK) + + // Verify it's the same object as the one in the pool with key "root" + let poolRoot = realtimeObjects.testsOnly_objectsPool.entries["root"]?.mapValue + #expect(root as AnyObject === poolRoot as AnyObject) + } + + // MARK: - RTO1b Tests + + // @spec RTO1b + @Test(arguments: [.detached, .failed] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func getRootThrowsIfChannelIsDetachedOrFailed(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: channelState, internalQueue: internalQueue) + + await #expect { + _ = try await realtimeObjects.getRoot(coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + } + + /// Tests for `InternalDefaultRealtimeObjects.handleObjectProtocolMessage`, covering RTO8 specification points. + struct HandleObjectProtocolMessageTests { + // Tests that when an OBJECT ProtocolMessage is received and there isn't a sync in progress, its operations are handled per RTO8b. + struct ApplyOperationTests { + // @specUntested RTO9a1 - There is no way to check that it was a no-op since there are no side effects that this spec point tells us not to apply + // @specUntested RTO9a2b - There is no way to check that it was a no-op since there are no side effects that this spec point tells us not to apply + + // MARK: - RTO9a3 Tests + + // @spec RTO9a3 - Tests that an OBJECT message whose serial is in appliedOnAckSerials is discarded, and the serial is removed from the set + @Test + func skipsObjectMessageAlreadyAppliedOnAck() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Transition to synced state + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode("site1") + } + + // Populate appliedOnAckSerials by performing a local createCounter + let serial = "serial_abc" + coreSDK.setPublishHandler { messages in + PublishResult(serials: messages.map { _ in serial }) + } + let counter = try await realtimeObjects.createCounter(count: 42, coreSDK: coreSDK) + #expect(realtimeObjects.testsOnly_appliedOnAckSerials.contains(serial)) + + // Send an echoed OBJECT message with the same serial + let echoMessage = TestFactories.counterIncOperationMessage( + objectId: counter.testsOnly_objectID, + number: 10, + serial: serial, + ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [echoMessage]) + } + + // Verify the operation was skipped (counter value unchanged) + #expect(try counter.value(coreSDK: coreSDK) == 42) + + // Verify the serial was removed from appliedOnAckSerials + #expect(!realtimeObjects.testsOnly_appliedOnAckSerials.contains(serial)) + } + + // MARK: - RTO9a2a4 Tests + + // @spec RTO9a2a4 - Tests that when source is LOCAL but the operation is not applied, the serial is not added to appliedOnAckSerials. + // + // This covers the scenario where an operation's echo is received from the server (as a channel-sourced OBJECT message) + // before the ACK arrives from publish. The echo updates the object's siteTimeserials (per RTLC7c / RTLM15c), so when + // the ACK subsequently triggers a local apply, canApplyOperation (RTLO4a6) rejects it because the serial is not newer + // than the existing siteTimeserial. + // + // It's important that the serial is not added to appliedOnAckSerials in this case, because the echo has already been + // processed as a normal channel-sourced message, so no future echo will arrive to trigger RTO9a3 to clear it. Adding + // it would cause appliedOnAckSerials to grow unboundedly. + @Test + func doesNotAddToAppliedOnAckSerialsWhenOperationIsNotApplied() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Transition to synced state + let siteCode = "site1" + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode(siteCode) + } + + // Create a counter so we have an object to operate on + coreSDK.setPublishHandler { messages in + PublishResult(serials: messages.map { _ in "serial_create" }) + } + let counter = try await realtimeObjects.createCounter(count: 42, coreSDK: coreSDK) + let objectId = counter.testsOnly_objectID + + // Simulate the echo arriving before the ACK: a channel-sourced COUNTER_INC + // with the same serial that the ACK will return. This sets siteTimeserials + // on the counter (RTLC7c). + let incrementSerial = "serial_inc" + let echoMessage = TestFactories.counterIncOperationMessage( + objectId: objectId, + number: 5, + serial: incrementSerial, + siteCode: siteCode, + ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [echoMessage]) + } + + // Now perform the local increment whose ACK returns the same serial. + // canApplyOperation (RTLO4a6) will reject it because the serial is not + // newer than the siteTimeserial that the echo already set. + coreSDK.setPublishHandler { messages in + PublishResult(serials: messages.map { _ in incrementSerial }) + } + try await counter.increment(amount: 5, coreSDK: coreSDK, realtimeObjects: realtimeObjects) + + // Verify the serial was NOT added to appliedOnAckSerials + #expect(!realtimeObjects.testsOnly_appliedOnAckSerials.contains(incrementSerial)) + + // Verify the increment was only applied once (via the echo), not double-applied + #expect(try counter.value(coreSDK: coreSDK) == 47) + } + + // MARK: - RTO9a2a1 Tests + + // @spec RTO9a2a1 - Tests that if necessary it creates an object in the ObjectsPool + @Test + func createsObjectInObjectsPoolWhenNecessary() { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let objectId = "map:new@123" + + // Verify the object doesn't exist in the pool initially + let initialPool = realtimeObjects.testsOnly_objectsPool + #expect(initialPool.entries[objectId] == nil) + + // Create a MAP_SET operation message for a non-existent object + let operationMessage = TestFactories.mapSetOperationMessage( + objectId: objectId, + key: "testKey", + value: "testValue", + ) + + // Handle the object protocol message + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [operationMessage]) + } + + // Verify the object was created in the ObjectsPool (RTO9a2a1) + let finalPool = realtimeObjects.testsOnly_objectsPool + #expect(finalPool.entries[objectId] != nil) + } + + // MARK: - RTO9a2a3 Tests for MAP_CREATE + + // TODO: Understand what to do with OBJECT_DELETE (https://github.com/ably/specification/pull/343#discussion_r2193126548) + + // @specOneOf(1/6) RTO9a2a3 - Tests MAP_CREATE operation application + @Test + func appliesMapCreateOperation() throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let objectId = "map:test@123" + + // Create a map object in the pool first + let (entryKey, entry) = TestFactories.stringMapEntry(key: "existingKey", value: "existingValue") + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage( + objectId: objectId, + siteTimeserials: ["site1": "ts1"], + entries: [entryKey: entry], + ), + ], + protocolMessageChannelSerial: nil, + ) + } + + // Verify the object exists and has initial data + let map = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.mapValue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) + #expect(initialValue == "existingValue") + + // Create a MAP_CREATE operation message + let (createKey, createEntry) = TestFactories.stringMapEntry(key: "createKey", value: "createValue") + let operationMessage = TestFactories.mapCreateOperationMessage( + objectId: objectId, + entries: [createKey: createEntry], + serial: "ts2", // Higher than existing "ts1" + siteCode: "site1", + ) + + // Handle the object protocol message + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [operationMessage]) + } + + // Verify the operation was applied by checking for side effects + // The full logic of applying the operation is tested in RTLM15; we just check for some of its side effects here + let finalValue = try #require(map.get(key: "createKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) + #expect(finalValue == "createValue") + #expect(map.testsOnly_createOperationIsMerged) + #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") + } + + // MARK: - RTO9a2a3 Tests for MAP_SET + + // @specOneOf(2/6) RTO9a2a3 - Tests MAP_SET operation application + @Test + func appliesMapSetOperation() throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let objectId = "map:test@123" + + // Create a map object in the pool first + let (entryKey, entry) = TestFactories.stringMapEntry(key: "existingKey", value: "existingValue") + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage( + objectId: objectId, + siteTimeserials: ["site1": "ts1"], + entries: [entryKey: entry], + ), + ], + protocolMessageChannelSerial: nil, + ) + } + + // Verify the object exists and has initial data + let map = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.mapValue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) + #expect(initialValue == "existingValue") + + // Create a MAP_SET operation message + let operationMessage = TestFactories.mapSetOperationMessage( + objectId: objectId, + key: "existingKey", + value: "newValue", + serial: "ts2", // Higher than existing "ts1" + siteCode: "site1", + ) + + // Handle the object protocol message + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [operationMessage]) + } + + // Verify the operation was applied by checking for side effects + // The full logic of applying the operation is tested in RTLM15; we just check for some of its side effects here + let finalValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) + #expect(finalValue == "newValue") + #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") + } + + // MARK: - RTO9a2a3 Tests for MAP_REMOVE + + // @specOneOf(3/6) RTO9a2a3 - Tests MAP_REMOVE operation application + @Test + func appliesMapRemoveOperation() throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let objectId = "map:test@123" + + // Create a map object in the pool first + let (entryKey, entry) = TestFactories.stringMapEntry(key: "existingKey", value: "existingValue") + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage( + objectId: objectId, + siteTimeserials: ["site1": "ts1"], + entries: [entryKey: entry], + ), + ], + protocolMessageChannelSerial: nil, + ) + } + + // Verify the object exists and has initial data + let map = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.mapValue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) + #expect(initialValue == "existingValue") + + // Create a MAP_REMOVE operation message + let operationMessage = TestFactories.mapRemoveOperationMessage( + objectId: objectId, + key: "existingKey", + serial: "ts2", // Higher than existing "ts1" + siteCode: "site1", + ) + + // Handle the object protocol message + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [operationMessage]) + } + + // Verify the operation was applied by checking for side effects + // The full logic of applying the operation is tested in RTLM15; we just check for some of its side effects here + let finalValue = try map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects) + #expect(finalValue == nil) // Key should be removed/tombstoned + #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") + } + + // MARK: - RTO9a2a3 Tests for COUNTER_CREATE + + // @specOneOf(4/6) RTO9a2a3 - Tests COUNTER_CREATE operation application + @Test + func appliesCounterCreateOperation() throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let objectId = "counter:test@123" + + // Create a counter object in the pool first + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.counterObjectMessage( + objectId: objectId, + siteTimeserials: ["site1": "ts1"], + count: 5, + ), + ], + protocolMessageChannelSerial: nil, + ) + } + + // Verify the object exists and has initial data + let counter = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.counterValue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let initialValue = try counter.value(coreSDK: coreSDK) + #expect(initialValue == 5) + + // Create a COUNTER_CREATE operation message + let operationMessage = TestFactories.counterCreateOperationMessage( + objectId: objectId, + count: 10, + serial: "ts2", // Higher than existing "ts1" + siteCode: "site1", + ) + + // Handle the object protocol message + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [operationMessage]) + } + + // Verify the operation was applied by checking for side effects + // The full logic of applying the operation is tested in RTLC7; we just check for some of its side effects here + let finalValue = try counter.value(coreSDK: coreSDK) + #expect(finalValue == 15) // 5 + 10 (initial value merged) + #expect(counter.testsOnly_siteTimeserials["site1"] == "ts2") + } + + // MARK: - RTO9a2a3 Tests for COUNTER_INC + + // @specOneOf(5/6) RTO9a2a3 - Tests COUNTER_INC operation application + @Test + func appliesCounterIncOperation() throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let objectId = "counter:test@123" + + // Create a counter object in the pool first + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.counterObjectMessage( + objectId: objectId, + siteTimeserials: ["site1": "ts1"], + count: 5, + ), + ], + protocolMessageChannelSerial: nil, + ) + } + + // Verify the object exists and has initial data + let counter = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.counterValue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let initialValue = try counter.value(coreSDK: coreSDK) + #expect(initialValue == 5) + + // Create a COUNTER_INC operation message + let operationMessage = TestFactories.counterIncOperationMessage( + objectId: objectId, + number: 10, + serial: "ts2", // Higher than existing "ts1" + siteCode: "site1", + ) + + // Handle the object protocol message + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [operationMessage]) + } + + // Verify the operation was applied by checking for side effects + // The full logic of applying the operation is tested in RTLC7; we just check for some of its side effects here + let finalValue = try counter.value(coreSDK: coreSDK) + #expect(finalValue == 15) // 5 + 10 + #expect(counter.testsOnly_siteTimeserials["site1"] == "ts2") + } + + // MARK: - RTO9a2a3 Tests for MAP_CLEAR + + // @specOneOf(6/6) RTO9a2a3 - Tests MAP_CLEAR operation application + @Test + func appliesMapClearOperation() throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let objectId = "map:test@123" + + // Create a map object in the pool first + let (entryKey, entry) = TestFactories.stringMapEntry(key: "existingKey", value: "existingValue") + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage( + objectId: objectId, + siteTimeserials: ["site1": "ts1"], + entries: [entryKey: entry], + ), + ], + protocolMessageChannelSerial: nil, + ) + } + + // Verify the object exists and has initial data + let map = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.mapValue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) + #expect(initialValue == "existingValue") + + // Create a MAP_CLEAR operation message + let operationMessage = TestFactories.mapClearOperationMessage( + objectId: objectId, + serial: "ts2", // Higher than existing "ts1" + siteCode: "site1", + ) + + // Handle the object protocol message + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [operationMessage]) + } + + // Verify the operation was applied by checking for side effects + // The full logic of applying the operation is tested in RTLM15; we just check for some of its side effects here + let finalValue = try map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects) + #expect(finalValue == nil) // Key should be removed by MAP_CLEAR + #expect(map.testsOnly_clearTimeserial == "ts2") + #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") + } + } + + // Tests that when an OBJECT ProtocolMessage is received during a sync sequence, its operations are buffered per RTO8a and applied after sync completion per RTO5c6. + struct BufferOperationTests { + // @spec RTO8a + // @spec RTO5c6 + @Test + func buffersObjectOperationsDuringSyncAndAppliesAfterCompletion() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let sequenceId = "seq123" + + // Start sync sequence with first OBJECT_SYNC message + let (entryKey, entry) = TestFactories.stringMapEntry(key: "existingKey", value: "existingValue") + let firstSyncMessages = [ + TestFactories.mapObjectMessage( + objectId: "map:1@123", + siteTimeserials: ["site1": "ts1"], // Explicit sync data siteCode and serial + entries: [entryKey: entry], + ), + ] + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: firstSyncMessages, + protocolMessageChannelSerial: "\(sequenceId):cursor1", + ) + } + + // Verify sync sequence is active + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Inject first OBJECT ProtocolMessage during sync (RTO8a) + let firstObjectMessage = TestFactories.mapSetOperationMessage( + objectId: "map:1@123", + key: "key1", + value: "value1", + serial: "ts3", // Higher than sync data "ts1" + siteCode: "site1", + ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [firstObjectMessage]) + } + + // Verify the operation was buffered and not applied yet + let poolAfterFirstObject = realtimeObjects.testsOnly_objectsPool + #expect(poolAfterFirstObject.entries["map:1@123"] == nil) // Object not yet created from sync + + // Inject second OBJECT ProtocolMessage during sync (RTO8a) + let secondObjectMessage = TestFactories.counterIncOperationMessage( + objectId: "counter:1@456", + number: 10, + serial: "ts4", // Higher than sync data "ts2" + siteCode: "site1", + ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [secondObjectMessage]) + } + + // Verify the second operation was also buffered and not applied yet + let poolAfterSecondObject = realtimeObjects.testsOnly_objectsPool + #expect(poolAfterSecondObject.entries["counter:1@456"] == nil) // Object not yet created from sync + + // Complete sync sequence with final OBJECT_SYNC message + let finalSyncMessages = [ + TestFactories.counterObjectMessage( + objectId: "counter:1@456", + siteTimeserials: ["site1": "ts2"], + count: 5, + ), + ] + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: finalSyncMessages, + protocolMessageChannelSerial: "\(sequenceId):", // Empty cursor indicates end + ) + } + + // Verify sync sequence is cleared + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + + // Verify all objects were applied to pool from sync + let finalPool = realtimeObjects.testsOnly_objectsPool + let map = try #require(finalPool.entries["map:1@123"]?.mapValue) + let counter = try #require(finalPool.entries["counter:1@456"]?.counterValue) + + // Note: the siteTimeserials check here is a side-effect of RTLM15c and RTLC7c that shows us that the CHANNEL source is being used per RTO5c6 + + // Verify the buffered operations were applied after sync completion (RTO5c6) + // Check that MAP_SET operation was applied to the map + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let mapValue = try #require(map.get(key: "key1", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) + #expect(mapValue == "value1") + #expect(map.testsOnly_siteTimeserials["site1"] == "ts3") + + // Check that COUNTER_INC operation was applied to the counter + let counterValue = try counter.value(coreSDK: coreSDK) + #expect(counterValue == 15) // 5 (from sync) + 10 (from buffered operation) + #expect(counter.testsOnly_siteTimeserials["site1"] == "ts4") + } + } + } + + /// Tests for `InternalDefaultRealtimeObjects.createMap`, covering RTO11 specification points (these are largely a smoke test, the rest being tested in ObjectCreationHelpers tests) + struct CreateMapTests { + // @spec RTO11d + @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func throwsIfChannelIsInInvalidState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: channelState, internalQueue: internalQueue) + let entries: [String: InternalLiveMapValue] = ["testKey": .string("testValue")] + + await #expect { + _ = try await realtimeObjects.createMap(entries: entries, coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @spec RTO11f7 + // @spec RTO11i + @Test + func publishesObjectMessageAndCreatesMap() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, serverTime: .init(timeIntervalSince1970: 1_754_042_434), internalQueue: internalQueue) + + // Transition to synced state (required for publishAndApply) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode("site1") + } + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + return PublishResult(serials: messages.map { _ in "serial_\(UUID().uuidString)" }) + } + + // Call createMap + let returnedMap = try await realtimeObjects.createMap( + entries: [ + "stringKey": .string("stringValue"), + ], + coreSDK: coreSDK, + ) + + // Verify ObjectMessage was published (RTO11i) + #expect(publishedMessages.count == 1) + let publishedMessage = publishedMessages[0] + + // Sense check of ObjectMessage structure per RTO11f9-13 + #expect(publishedMessage.operation?.action == .known(.mapCreate)) + let objectID = try #require(publishedMessage.operation?.objectId) + #expect(objectID.hasPrefix("map:")) + #expect(objectID.contains("1754042434000")) // check contains the server timestamp in milliseconds per RTO11f7 + #expect(publishedMessage.operation?.mapCreateWithObjectId != nil) + let mapInitialValueString = try #require(publishedMessage.operation?.mapCreateWithObjectId?.initialValue) + let mapInitialValue = try #require(try JSONObjectOrArray(jsonString: mapInitialValueString).objectValue) + #expect(mapInitialValue["entries"] == .object(["stringKey": .object(["data": .object(["string": "stringValue"])])])) + + // Sense check that create op was applied locally by publishAndApply ( + #expect(returnedMap.testsOnly_data == ["stringKey": InternalObjectsMapEntry(data: ObjectData(string: "stringValue"))]) + #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.mapValue === returnedMap) + } + + // @specUntested RTO11h3d - This spec point covers a logic error so let's not try to test + + // @spec RTO11f14b + @Test + func withNoEntriesArgumentCreatesEmptyMap() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Transition to synced state (required for publishAndApply) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode("site1") + } + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + return PublishResult(serials: messages.map { _ in "serial_\(UUID().uuidString)" }) + } + + // Call createMap with no entries + let result = try await realtimeObjects.createMap(entries: [:], coreSDK: coreSDK) + + // Verify ObjectMessage was published + #expect(publishedMessages.count == 1) + let publishedMessage = publishedMessages[0] + + // Verify map operation has empty entries per RTO11f14b + let mapInitialValueString = try #require(publishedMessage.operation?.mapCreateWithObjectId?.initialValue) + let mapInitialValue = try #require(try JSONObjectOrArray(jsonString: mapInitialValueString).objectValue) + #expect(mapInitialValue["entries"] == .object([:])) + + // Verify LiveMap has expected entries + #expect(result.testsOnly_data.isEmpty) + } + + // @spec RTO11h2 + @Test + func returnsExistingObjectIfAlreadyInPool() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Transition to synced state (required for publishAndApply) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode("site1") + } + + // Track published messages and the generated objectId + var publishedMessages: [OutboundObjectMessage] = [] + var maybeGeneratedObjectID: String? + var maybeExistingObject: AnyObject? + + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + + // Extract the generated objectId from the published message + if let objectID = messages.first?.operation?.objectId { + maybeGeneratedObjectID = objectID + + // Create an object with this exact ID in the pool + // This simulates the object already existing when createMap tries to get it, before the publish operation completes (e.g. because it has been populated by receipt of an OBJECT) + maybeExistingObject = realtimeObjects.testsOnly_createZeroValueLiveObject(forObjectID: objectID)?.mapValue + } + return PublishResult(serials: messages.map { _ in nil }) + } + + // Call createMap - the publishHandler will create the object with the generated ID + let result = try await realtimeObjects.createMap(entries: ["testKey": .string("testValue")], coreSDK: coreSDK) + + // Verify ObjectMessage was published + #expect(publishedMessages.count == 1) + + // Extract the variables that we populated based on the generated object ID + let generatedObjectID = try #require(maybeGeneratedObjectID) + let existingObject = try #require(maybeExistingObject) + + // Verify the returned object is the same as the existing one + #expect(result === existingObject) + + // Check that the existing object has not been replaced in the pool + #expect(realtimeObjects.testsOnly_objectsPool.entries[generatedObjectID]?.mapValue === existingObject) + } + } + + /// Tests for `InternalDefaultRealtimeObjects.createCounter`, covering RTO12 specification points (these are largely a smoke test, the rest being tested in ObjectCreationHelpers tests) + struct CreateCounterTests { + // @spec RTO12d + @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func throwsIfChannelIsInInvalidState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: channelState, internalQueue: internalQueue) + + await #expect { + _ = try await realtimeObjects.createCounter(count: 10.5, coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + // @spec RTO12f5 + // @spec RTO12i + @Test + func publishesObjectMessageAndCreatesCounter() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, serverTime: .init(timeIntervalSince1970: 1_754_042_434), internalQueue: internalQueue) + + // Transition to synced state (required for publishAndApply) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode("site1") + } + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + return PublishResult(serials: messages.map { _ in "serial_\(UUID().uuidString)" }) + } + + // Call createCounter + let returnedCounter = try await realtimeObjects.createCounter(count: 10.5, coreSDK: coreSDK) + + // Verify ObjectMessage was published (RTO12i) + #expect(publishedMessages.count == 1) + let publishedMessage = publishedMessages[0] + + // Sense check of ObjectMessage structure per RTO12f7-11 + #expect(publishedMessage.operation?.action == .known(.counterCreate)) + let objectID = try #require(publishedMessage.operation?.objectId) + #expect(objectID.hasPrefix("counter:")) + #expect(objectID.contains("1754042434000")) // check contains the server timestamp in milliseconds per RTO12f5 + #expect(publishedMessage.operation?.counterCreateWithObjectId != nil) + let counterInitialValueString = try #require(publishedMessage.operation?.counterCreateWithObjectId?.initialValue) + let counterInitialValue = try #require(try JSONObjectOrArray(jsonString: counterInitialValueString).objectValue) + #expect(counterInitialValue["count"] == .number(10.5)) + + // Sense check that create op was applied locally by publishAndApply + #expect(try returnedCounter.value(coreSDK: coreSDK) == 10.5) + #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.counterValue === returnedCounter) + } + + // @specUntested RTO12h3d - This spec point covers a logic error so let's not try to test + + // @specOneOf(2/2) RTO12f12a + @Test + func withNoEntriesArgumentCreatesWithZeroValue() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Transition to synced state (required for publishAndApply) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode("site1") + } + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + return PublishResult(serials: messages.map { _ in "serial_\(UUID().uuidString)" }) + } + + // Call createCounter with no count + let result = try await realtimeObjects.createCounter(coreSDK: coreSDK) + + // Verify ObjectMessage was published + #expect(publishedMessages.count == 1) + let publishedMessage = publishedMessages[0] + + // Verify counter operation has zero count per RTO12f12a + let counterInitialValueString = try #require(publishedMessage.operation?.counterCreateWithObjectId?.initialValue) + let counterInitialValue = try #require(try JSONObjectOrArray(jsonString: counterInitialValueString).objectValue) + #expect(counterInitialValue["count"] == .number(0)) + + // Verify LiveCounter has zero value + #expect(try result.value(coreSDK: coreSDK) == 0) + } + + // @spec RTO12h2 + @Test + func returnsExistingObjectIfAlreadyInPool() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Transition to synced state (required for publishAndApply) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode("site1") + } + + // Track published messages and the generated objectId + var publishedMessages: [OutboundObjectMessage] = [] + var maybeGeneratedObjectID: String? + var maybeExistingObject: AnyObject? + + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + + // Extract the generated objectId from the published message + if let objectID = messages.first?.operation?.objectId { + maybeGeneratedObjectID = objectID + + // Create an object with this exact ID in the pool + // This simulates the object already existing when createMap tries to get it, before the publish operation completes (e.g. because it has been populated by receipt of an OBJECT) + maybeExistingObject = realtimeObjects.testsOnly_createZeroValueLiveObject(forObjectID: objectID)?.counterValue + } + return PublishResult(serials: messages.map { _ in nil }) + } + + // Call createCounter - the publishHandler will create the object with the generated ID + let result = try await realtimeObjects.createCounter(count: 10.5, coreSDK: coreSDK) + + // Verify ObjectMessage was published + #expect(publishedMessages.count == 1) + + // Extract the variables that we populated based on the generated object ID + let generatedObjectID = try #require(maybeGeneratedObjectID) + let existingObject = try #require(maybeExistingObject) + + // Verify the returned object is the same as the existing one + #expect(result === existingObject) + + // Check that the existing object has not been replaced in the pool + #expect(realtimeObjects.testsOnly_objectsPool.entries[generatedObjectID]?.counterValue === existingObject) + } + } + + struct SyncEventsTests { + enum ChannelEvent { + case attached(hasObjects: Bool) + case objectSync(channelSerial: String?) + } + + struct Scenario { + /// A human-readable description of the test scenario. + var description: String + + /// The channel events to be applied in sequence. + var channelEvents: [ChannelEvent] + + /// The expected sync events that the RealtimeObjects should emit, in the order they are expected to be emitted. + var expectedSyncEvents: [ObjectsEvent] + } + + // @spec RTO17b + // @spec RTO4c + // @spec RTO5e + // @spec RTO5c8 + @MainActor + @Test( + arguments: [ + // 1. ATTACHED with HAS_OBJECTS false + + .init( + description: "The first ATTACHED should always provoke a SYNCING even when HAS_OBJECTS is false, so that the SYNCED is preceded by SYNCING", + channelEvents: [.attached(hasObjects: false)], + expectedSyncEvents: [.syncing, .synced], + ), + + .init( + description: "ATTACHED with HAS_OBJECTS false once SYNCED emits SYNCING and then SYNCED", + channelEvents: [.attached(hasObjects: false), .attached(hasObjects: false)], + expectedSyncEvents: [ + .syncing, .synced, // The initial SYNCED + .syncing, .synced, // From the subsequent ATTACHED + ], + ), + + .init( + description: "If we're in SYNCING awaiting an OBJECT_SYNC but then instead get an ATTACHED with HAS_OBJECTS false, we should emit a SYNCED", + channelEvents: [.attached(hasObjects: true), .attached(hasObjects: false)], + expectedSyncEvents: [.syncing, .synced], + ), + + // 2. ATTACHED with HAS_OBJECTS true + + .init( + description: "An initial ATTACHED with HAS_OBJECTS true provokes a SYNCING", + channelEvents: [.attached(hasObjects: true)], + expectedSyncEvents: [.syncing], + ), + + .init( + description: "ATTACHED with HAS_OBJECTS true when SYNCED should provoke another SYNCING, because we're waiting to receive the updated objects in an OBJECT_SYNC", + channelEvents: [.attached(hasObjects: false), .attached(hasObjects: true)], + expectedSyncEvents: [.syncing, .synced, .syncing], + ), + + .init( + description: "If we're in SYNCING awaiting an OBJECT_SYNC but then instead get another ATTACHED with HAS_OBJECTS true, we should remain SYNCING (i.e. not emit another event)", + channelEvents: [.attached(hasObjects: true), .attached(hasObjects: true)], + expectedSyncEvents: [.syncing], + ), + + // 3. OBJECT_SYNC straight after ATTACHED + + .init( + description: "A complete multi-message OBJECT_SYNC sequence after ATTACHED emits SYNCING and then SYNCED", + channelEvents: [ + .attached(hasObjects: true), + .objectSync(channelSerial: "foo:1"), + .objectSync(channelSerial: "foo:2"), + .objectSync(channelSerial: "foo:"), + ], + expectedSyncEvents: [.syncing, .synced], + ), + .init( + description: "A complete single-message OBJECT_SYNC after ATTACHED emits SYNCING and then SYNCED", + channelEvents: [ + .attached(hasObjects: true), + .objectSync(channelSerial: "foo:"), + ], + expectedSyncEvents: [.syncing, .synced], + ), + .init( + description: "SYNCED is not emitted midway through a multi-message OBJECT_SYNC sequence", + channelEvents: [ + .attached(hasObjects: true), + .objectSync(channelSerial: "foo:1"), + .objectSync(channelSerial: "foo:2"), + ], + expectedSyncEvents: [.syncing], + ), + + // 4. OBJECT_SYNC when already SYNCED + + .init( + description: "A complete multi-message OBJECT_SYNC sequence when already SYNCED emits SYNCING and then SYNCED", + channelEvents: [ + .attached(hasObjects: false), // to get us to SYNCED + .objectSync(channelSerial: "foo:1"), + .objectSync(channelSerial: "foo:2"), + .objectSync(channelSerial: "foo:"), + ], + expectedSyncEvents: [ + .syncing, .synced, // The initial SYNCED + .syncing, .synced, // From the complete OBJECT_SYNC + ], + ), + .init( + description: "A complete single-message OBJECT_SYNC when already SYNCED emits SYNCING and then SYNCED", + channelEvents: [ + .attached(hasObjects: false), // to get us to SYNCED + .objectSync(channelSerial: "foo:"), + ], + expectedSyncEvents: [ + .syncing, .synced, // The initial SYNCED + .syncing, .synced, // From the complete OBJECT_SYNC + ], + ), + + // 5. New sync sequence in the middle of a sync sequence + + .init( + description: "A new OBJECT_SYNC sequence in the middle of a sync sequence does not provoke another SYNCING", + channelEvents: [ + .attached(hasObjects: true), + .objectSync(channelSerial: "foo:1"), + .objectSync(channelSerial: "foo:2"), + .objectSync(channelSerial: "bar:1"), + ], + expectedSyncEvents: [.syncing], + ), + ] as [Scenario], + ) + func emitsCorrectSyncEvents(scenario: Scenario) async throws { + // Given: An InternalDefaultRealtimeObjects + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + + var receivedSyncEvents: [ObjectsEvent] = [] + for event in [ObjectsEvent.syncing, .synced] { + realtimeObjects.on(event: event) { _ in + MainActor.assumeIsolated { + receivedSyncEvents.append(event) + } + } + } + + // When: The sequence of channel events described by the scenario is received + internalQueue.ably_syncNoDeadlock { + for channelEvent in scenario.channelEvents { + switch channelEvent { + case let .attached(hasObjects): + realtimeObjects.nosync_onChannelAttached(hasObjects: hasObjects) + case let .objectSync(channelSerial): + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [], + protocolMessageChannelSerial: channelSerial, + ) + } + } + } + + // Give event callbacks a chance to happen on the main queue + await Task { @MainActor in }.value + + // Then: It emits the expected sequence of sync events + #expect(receivedSyncEvents == scenario.expectedSyncEvents) + } + } + + /// Tests for `nosync_publishAndApply`, covering RTO20 specification points. + struct PublishAndApplyTests { + // @spec RTO20b + @Test + func publishFailurePropagatesError() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Configure publish to throw an error + let publishError = ARTErrorInfo.create(withCode: 40000, message: "publish failed") + coreSDK.setPublishHandler { _ throws(ARTErrorInfo) in + throw publishError + } + + let thrownError = try await #require(throws: ARTErrorInfo.self) { + try await realtimeObjects.createMap(entries: ["key": .string("value")], coreSDK: coreSDK) + } + #expect(thrownError === publishError) + } + + // @spec RTO20c1 + @Test + func missingSiteCodeSkipsLocalApply() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Transition to synced state without setting siteCode + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } + + coreSDK.setPublishHandler { messages in + PublishResult(serials: messages.map { _ in "serial_abc" }) + } + + // The publish succeeds, but the local apply is skipped because siteCode is + // missing. createMap then fails because the object isn't in the pool. + let error = try await #require(throws: ARTErrorInfo.self) { + try await realtimeObjects.createMap(entries: ["key": .string("value")], coreSDK: coreSDK) + } + #expect(error.code == 50000) + #expect(error.statusCode == 500) + } + + // @spec RTO20c2 + @Test + func mismatchedSerialsLengthSkipsLocalApply() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Transition to synced state + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode("site1") + } + + // Return wrong number of serials + coreSDK.setPublishHandler { _ in + PublishResult(serials: ["serial1", "serial2"]) + } + + // The publish succeeds, but the local apply is skipped because the serials + // length doesn't match. createMap then fails because the object isn't in the pool. + let error = try await #require(throws: ARTErrorInfo.self) { + try await realtimeObjects.createMap(entries: ["key": .string("value")], coreSDK: coreSDK) + } + #expect(error.code == 50000) + #expect(error.statusCode == 500) + } + + // @spec RTO20d1 + @Test + func nullSerialSkipsLocalApplication() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Transition to synced state + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode("site1") + } + + // Pre-create a map in the pool so that createMap's RTO11h2 lookup succeeds + // even when the operation is skipped due to nil serial + var preCreatedObjectID: String? + coreSDK.setPublishHandler { messages in + // Extract the generated objectId and pre-create the object in the pool + if let objectID = messages.first?.operation?.objectId { + preCreatedObjectID = objectID + _ = realtimeObjects.testsOnly_createZeroValueLiveObject(forObjectID: objectID) + } + // Return nil serial (conflation) — RTO20d1 says the operation should be skipped + return PublishResult(serials: [nil]) + } + + let returnedMap = try await realtimeObjects.createMap(entries: ["key": .string("value")], coreSDK: coreSDK) + + let objectID = try #require(preCreatedObjectID) + + // The map exists in the pool (pre-created) but its data should be empty because + // the MAP_CREATE was never applied locally (serial was nil) + #expect(returnedMap.testsOnly_data.isEmpty) + #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.mapValue === returnedMap) + } + + // @spec RTO20b + // @spec RTO20d2 + // @spec RTO20d2a + // @spec RTO20d2b + // @spec RTO20d3 + // @spec RTO20f + @Test + func constructsSyntheticMessageAndAppliesWithLocalSource() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Transition to synced state + let serial = "serial_abc" + let siteCode = "site1" + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + realtimeObjects.nosync_setSiteCode(siteCode) + } + + // RTO20b: Capture the outbound message published via the core SDK, and return a real serial + var capturedOutboundMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + capturedOutboundMessages = messages + return PublishResult(serials: messages.map { _ in serial }) + } + + let returnedMap = try await realtimeObjects.createMap(entries: ["key": .string("value")], coreSDK: coreSDK) + + // RTO20b: Verify the outbound message was published with the expected operation + // and without serial or siteCode (those are populated on the synthetic message) + let outboundMessage = try #require(capturedOutboundMessages.first) + let outboundOperation = try #require(outboundMessage.operation) + #expect(outboundOperation.action == .known(.mapCreate)) + #expect(outboundMessage.serial == nil) + #expect(outboundMessage.siteCode == nil) + + // RTO20f: The synthetic message was applied (data is present) + #expect(returnedMap.testsOnly_data == ["key": InternalObjectsMapEntry(data: ObjectData(string: "value"))]) + + // RTO20f: The synthetic message was applied with source LOCAL + // (confirmed because siteTimeserials were not updated, per RTLM15c / RTLC7c) + #expect(returnedMap.testsOnly_siteTimeserials.isEmpty) + + // RTO20d2a: The serial from PublishResult was used in the synthetic message + // (confirmed because RTO9a2a4 adds it to appliedOnAckSerials upon successful apply) + #expect(realtimeObjects.testsOnly_appliedOnAckSerials.contains(serial)) + + // RTO20d2b: The siteCode from connectionDetails was used in the synthetic message + // (confirmed because canApplyOperation (RTLO4a3) requires a non-empty siteCode for + // the apply to succeed, which it did per the checks above) + } + + // @spec RTO20e + @Test + func waitsForSyncStateToBecomeSynced() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Set siteCode but leave sync state as SYNCING (hasObjects: true triggers SYNCING, and + // without completing the sync sequence we remain in SYNCING) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_setSiteCode("site1") + } + + // Simulate the sync completing after the publish ACK. + coreSDK.setPublishCallbackHandler { messages, callback in + let result = PublishResult(serials: messages.map { _ in "serial_xyz" }) + + internalQueue.async { + callback(.success(result)) + + internalQueue.async { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [], + protocolMessageChannelSerial: nil, + ) + } + } + } + let returnedCounter = try await realtimeObjects.createCounter(count: 42, coreSDK: coreSDK) + + // Verify the operation was applied + #expect(try returnedCounter.value(coreSDK: coreSDK) == 42) + } + + // @spec RTO20e1 + @Test(arguments: [ + _AblyPluginSupportPrivate.RealtimeChannelState.detached, + _AblyPluginSupportPrivate.RealtimeChannelState.suspended, + _AblyPluginSupportPrivate.RealtimeChannelState.failed, + ]) + func channelStateChangeWhileWaitingForSync(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + + // Set siteCode but leave sync state as SYNCING + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + realtimeObjects.nosync_setSiteCode("site1") + } + + // Simulate a channel state change arriving after the publish ACK. + coreSDK.setPublishCallbackHandler { messages, callback in + let result = PublishResult(serials: messages.map { _ in "serial_xyz" }) + + internalQueue.async { + callback(.success(result)) + + internalQueue.async { + realtimeObjects.nosync_onChannelStateChanged(toState: channelState, reason: nil) + } + } + } + let error = try await #require(throws: ARTErrorInfo.self) { + try await realtimeObjects.createCounter(count: 5, coreSDK: coreSDK) + } + #expect(error.code == 92008) + #expect(error.statusCode == 400) + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift new file mode 100644 index 000000000..34cd3b18c --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift @@ -0,0 +1,595 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation + +// This file is copied from the file objects.test.js in ably-js. + +/// This is a Swift port of the JavaScript ObjectsHelper class used for testing. +final class ObjectsHelper: Sendable { + // MARK: - Constants + + /// Object operation actions + enum Actions: Int { + case mapCreate = 0 + case mapSet = 1 + case mapRemove = 2 + case counterCreate = 3 + case counterInc = 4 + case objectDelete = 5 + case mapClear = 6 + + var stringValue: String { + switch self { + case .mapCreate: + "MAP_CREATE" + case .mapSet: + "MAP_SET" + case .mapRemove: + "MAP_REMOVE" + case .counterCreate: + "COUNTER_CREATE" + case .counterInc: + "COUNTER_INC" + case .objectDelete: + "OBJECT_DELETE" + case .mapClear: + "MAP_CLEAR" + } + } + } + + // MARK: - Properties + + private let rest: ARTRest + + // MARK: - Initialization + + init() async throws { + let options = try await ARTClientOptions(key: Sandbox.fetchSharedAPIKey()) + options.useBinaryProtocol = false + options.environment = "sandbox" + rest = ARTRest(options: options) + } + + // MARK: - Static Properties and Methods + + /// Static access to the Actions enum (equivalent to JavaScript static ACTIONS) + static let ACTIONS = Actions.self + + /// Returns the root keys used in the fixture objects tree + static func fixtureRootKeys() -> [String] { + ["emptyCounter", "initialValueCounter", "referencedCounter", "emptyMap", "referencedMap", "valuesMap"] + } + + // MARK: - Channel Initialization + + /// Sends Objects REST API requests to create objects tree on a provided channel: + /// + /// - root "emptyMap" -> Map#1 {} -- empty map + /// - root "referencedMap" -> Map#2 { "counterKey": } + /// - root "valuesMap" -> Map#3 { "stringKey": "stringValue", "emptyStringKey": "", "bytesKey": , "emptyBytesKey": , "numberKey": 1, "zeroKey": 0, "trueKey": true, "falseKey": false, "mapKey": } + /// - root "emptyCounter" -> Counter#1 -- no initial value counter, should be 0 + /// - root "initialValueCounter" -> Counter#2 count=10 + /// - root "referencedCounter" -> Counter#3 count=20 + func initForChannel(_ channelName: String) async throws { + _ = try await createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "emptyCounter", + createOp: counterCreateRestOp(), + ) + + _ = try await createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "initialValueCounter", + createOp: counterCreateRestOp(number: 10), + ) + + let referencedCounter = try await createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "referencedCounter", + createOp: counterCreateRestOp(number: 20), + ) + + _ = try await createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "emptyMap", + createOp: mapCreateRestOp(), + ) + + let referencedMapData: [String: JSONValue] = [ + "counterKey": .object(["objectId": .string(referencedCounter.objectId)]), + ] + let referencedMap = try await createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "referencedMap", + createOp: mapCreateRestOp(data: referencedMapData), + ) + + let valuesMapData: [String: JSONValue] = [ + "stringKey": .object(["string": .string("stringValue")]), + "emptyStringKey": .object(["string": .string("")]), + "bytesKey": .object(["bytes": .string("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")]), + "emptyBytesKey": .object(["bytes": .string("")]), + "numberKey": .object(["number": .number(1)]), + "zeroKey": .object(["number": .number(0)]), + "trueKey": .object(["boolean": .bool(true)]), + "falseKey": .object(["boolean": .bool(false)]), + "mapKey": .object(["objectId": .string(referencedMap.objectId)]), + ] + _ = try await createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "valuesMap", + createOp: mapCreateRestOp(data: valuesMapData), + ) + } + + // MARK: - Wire Object Messages + + /// Creates a map create operation + func mapCreateOp(objectId: String? = nil, entries: [String: WireValue]? = nil) -> [String: WireValue] { + var mapCreate: [String: WireValue] = [ + "semantics": .number(NSNumber(value: 0)), + ] + + mapCreate["entries"] = .object(entries ?? [:]) + + var operation: [String: WireValue] = [ + "action": .number(NSNumber(value: Actions.mapCreate.rawValue)), + "mapCreate": .object(mapCreate), + ] + + if let objectId { + operation["objectId"] = .string(objectId) + } + + return ["operation": .object(operation)] + } + + /// Creates a map set operation + func mapSetOp(objectId: String, key: String, data: WireValue) -> [String: WireValue] { + [ + "operation": .object([ + "action": .number(NSNumber(value: Actions.mapSet.rawValue)), + "objectId": .string(objectId), + "mapSet": .object([ + "key": .string(key), + "value": data, + ]), + ]), + ] + } + + /// Creates a map remove operation + func mapRemoveOp(objectId: String, key: String) -> [String: WireValue] { + [ + "operation": .object([ + "action": .number(NSNumber(value: Actions.mapRemove.rawValue)), + "objectId": .string(objectId), + "mapRemove": .object([ + "key": .string(key), + ]), + ]), + ] + } + + /// Creates a counter create operation + func counterCreateOp(objectId: String? = nil, count: Int? = nil) -> [String: WireValue] { + var counterCreate: [String: WireValue] = [:] + if let count { + counterCreate["count"] = .number(NSNumber(value: count)) + } + + var operation: [String: WireValue] = [ + "action": .number(NSNumber(value: Actions.counterCreate.rawValue)), + "counterCreate": .object(counterCreate), + ] + + if let objectId { + operation["objectId"] = .string(objectId) + } + + return ["operation": .object(operation)] + } + + /// Creates a counter increment operation + func counterIncOp(objectId: String, number: Int) -> [String: WireValue] { + [ + "operation": .object([ + "action": .number(NSNumber(value: Actions.counterInc.rawValue)), + "objectId": .string(objectId), + "counterInc": .object([ + "number": .number(NSNumber(value: number)), + ]), + ]), + ] + } + + /// Creates an object delete operation + func objectDeleteOp(objectId: String) -> [String: WireValue] { + [ + "operation": .object([ + "action": .number(NSNumber(value: Actions.objectDelete.rawValue)), + "objectId": .string(objectId), + "objectDelete": .object([:]), + ]), + ] + } + + /// Sends a MAP_CLEAR operation to the server via `testsOnly_publish`. + /// + /// MAP_CLEAR is server-initiated and has no production client-side API, + /// but it is enabled over realtime connections on non-prod clusters for testing. + func sendMapClearOnChannel(objects: any RealtimeObjects, objectId: String) async throws { + guard let internallyTypedObjects = objects as? PublicDefaultRealtimeObjects else { + preconditionFailure("Expected PublicDefaultRealtimeObjects") + } + try await internallyTypedObjects.testsOnly_publish(objectMessages: [ + OutboundObjectMessage( + operation: ObjectOperation( + action: .known(.mapClear), + objectId: objectId, + mapClear: WireMapClear(), + ), + ), + ]) + } + + /// Creates a map clear operation + func mapClearOp(objectId: String) -> [String: WireValue] { + [ + "operation": .object([ + "action": .number(NSNumber(value: Actions.mapClear.rawValue)), + "objectId": .string(objectId), + "mapClear": .object([:]), + ]), + ] + } + + /// Creates a map object structure + func mapObject( + objectId: String, + siteTimeserials: [String: String], + initialEntries: [String: WireValue]? = nil, + materialisedEntries: [String: WireValue]? = nil, + tombstone: Bool = false, + clearTimeserial: String? = nil, + ) -> [String: WireValue] { + var mapDict: [String: WireValue] = [ + "semantics": .number(NSNumber(value: 0)), + "entries": .object(materialisedEntries ?? [:]), + ] + + if let clearTimeserial { + mapDict["clearTimeserial"] = .string(clearTimeserial) + } + + var object: [String: WireValue] = [ + "objectId": .string(objectId), + "siteTimeserials": .object(siteTimeserials.mapValues { .string($0) }), + "tombstone": .bool(tombstone), + "map": .object(mapDict), + ] + + if let initialEntries { + let createOp = mapCreateOp(objectId: objectId, entries: initialEntries) + object["createOp"] = createOp["operation"]! + } + + return ["object": .object(object)] + } + + /// Creates a counter object structure + func counterObject( + objectId: String, + siteTimeserials: [String: String], + initialCount: Int? = nil, + materialisedCount: Int? = nil, + tombstone: Bool = false, + ) -> [String: WireValue] { + let materialisedCountValue: WireValue = if let materialisedCount { + .number(NSNumber(value: materialisedCount)) + } else { + .null + } + + var object: [String: WireValue] = [ + "objectId": .string(objectId), + "siteTimeserials": .object(siteTimeserials.mapValues { .string($0) }), + "tombstone": .bool(tombstone), + "counter": .object([ + "count": materialisedCountValue, + ]), + ] + + if let initialCount { + let createOp = counterCreateOp(objectId: objectId, count: initialCount) + object["createOp"] = createOp["operation"]! + } + + return ["object": .object(object)] + } + + /// Creates an object operation message + func objectOperationMessage( + channelName: String, + serial: String, + siteCode: String, + state: [[String: WireValue]]? = nil, + ) -> [String: WireValue] { + let stateWithSerials = state?.map { objectMessage in + var message = objectMessage + message["serial"] = .string(serial) + message["siteCode"] = .string(siteCode) + return message + } + + let stateArray = stateWithSerials?.map { dict in WireValue.object(dict) } ?? [] + + return [ + "action": .number(NSNumber(value: 19)), // OBJECT + "channel": .string(channelName), + "channelSerial": .string(serial), + "state": .array(stateArray), + ] + } + + /// Creates an object state message + func objectStateMessage( + channelName: String, + syncSerial: String, + state: [[String: WireValue]]? = nil, + ) -> [String: WireValue] { + let stateArray = state?.map { dict in WireValue.object(dict) } ?? [] + return [ + "action": .number(NSNumber(value: 20)), // OBJECT_SYNC + "channel": .string(channelName), + "channelSerial": .string(syncSerial), + "state": .array(stateArray), + ] + } + + /// This is the equivalent of the JS ObjectHelper's channel.processMessage(createPM(…)). + private func processDeserializedProtocolMessage( + _ deserialized: [String: WireValue], + channel: ARTRealtimeChannel, + ) async { + await withCheckedContinuation { continuation in + channel.internal.queue.async { + let useBinaryProtocol = channel.realtimeInternal.options.useBinaryProtocol + let jsonLikeEncoderDelegate: ARTJsonLikeEncoderDelegate = useBinaryProtocol ? ARTMsgPackEncoder() : ARTJsonEncoder() + + let encoder = ARTJsonLikeEncoder( + rest: channel.internal.realtime!.rest, + delegate: jsonLikeEncoderDelegate, + logger: channel.internal.logger, + ) + + let foundationObject = deserialized.toPluginSupportDataDictionary + let protocolMessage = withExtendedLifetime(jsonLikeEncoderDelegate) { + encoder.protocolMessage(from: foundationObject)! + } + + channel.internal.onChannelMessage(protocolMessage) + continuation.resume() + } + } + } + + /// Processes an object operation message on a channel + func processObjectOperationMessageOnChannel( + channel: ARTRealtimeChannel, + serial: String, + siteCode: String, + state: [[String: WireValue]]? = nil, + ) async { + await processDeserializedProtocolMessage( + objectOperationMessage( + channelName: channel.name, + serial: serial, + siteCode: siteCode, + state: state, + ), + channel: channel, + ) + } + + /// Processes an object state message on a channel + func processObjectStateMessageOnChannel( + channel: ARTRealtimeChannel, + syncSerial: String, + state: [[String: WireValue]]? = nil, + ) async { + await processDeserializedProtocolMessage( + objectStateMessage( + channelName: channel.name, + syncSerial: syncSerial, + state: state, + ), + channel: channel, + ) + } + + // MARK: - REST API Operations + + /// Result of a REST API operation + struct OperationResult { + let objectId: String + let success: Bool + } + + /// Creates an object and sets it on a map + func createAndSetOnMap( + channelName: String, + mapObjectId: String, + key: String, + createOp: [String: JSONValue], + ) async throws -> OperationResult { + let createResult = try await operationRequest(channelName: channelName, opBody: createOp) + let objectId = createResult.objectId + + let setOp = mapSetRestOp( + objectId: mapObjectId, + key: key, + value: ["objectId": .string(objectId)], + ) + _ = try await operationRequest(channelName: channelName, opBody: setOp) + + return createResult + } + + /// Creates a map create REST operation + func mapCreateRestOp(objectId: String? = nil, nonce: String? = nil, data: [String: JSONValue]? = nil) -> [String: JSONValue] { + var mapCreate: [String: JSONValue] = [ + "semantics": .number(0), + ] + + if let data { + // Wrap each entry value in { "data": value } to match v6 format + let entries = Dictionary(uniqueKeysWithValues: data.map { key, value in + (key, JSONValue.object(["data": value])) + }) + mapCreate["entries"] = .object(entries) + } + + var opBody: [String: JSONValue] = [ + "mapCreate": .object(mapCreate), + ] + + if let objectId { + opBody["objectId"] = .string(objectId) + opBody["nonce"] = .string(nonce ?? "") + } + + return opBody + } + + /// Creates a map set REST operation + func mapSetRestOp(objectId: String, key: String, value: [String: JSONValue]) -> [String: JSONValue] { + [ + "objectId": .string(objectId), + "mapSet": .object([ + "key": .string(key), + "value": .object(value), + ]), + ] + } + + /// Creates a map remove REST operation + func mapRemoveRestOp(objectId: String, key: String) -> [String: JSONValue] { + [ + "objectId": .string(objectId), + "mapRemove": .object([ + "key": .string(key), + ]), + ] + } + + /// Creates a counter create REST operation + func counterCreateRestOp(objectId: String? = nil, nonce: String? = nil, number: Double? = nil) -> [String: JSONValue] { + var counterCreate: [String: JSONValue] = [:] + + if let number { + counterCreate["count"] = .number(number) + } + + var opBody: [String: JSONValue] = [ + "counterCreate": .object(counterCreate), + ] + + if let objectId { + opBody["objectId"] = .string(objectId) + opBody["nonce"] = .string(nonce ?? "") + } + + return opBody + } + + /// Creates a counter increment REST operation + func counterIncRestOp(objectId: String, number: Double) -> [String: JSONValue] { + [ + "objectId": .string(objectId), + "counterInc": .object(["number": .number(number)]), + ] + } + + /// Sends an operation request to the REST API + func operationRequest(channelName: String, opBody: [String: JSONValue]) async throws -> OperationResult { + let path = "/channels/\(channelName)/objects" + + do { + let response = try await rest.requestAsync("POST", path: path, params: nil, body: opBody.toJSONSerializationInput, headers: nil) + + guard (200 ..< 300).contains(response.statusCode) else { + throw NSError( + domain: "ObjectsHelper", + code: response.statusCode, + userInfo: [ + NSLocalizedDescriptionKey: "REST API request failed", + "path": path, + "operation": opBody.toJSONSerializationInput, + ], + ) + } + + guard let firstItem = response.items.first as? [String: Any] else { + throw NSError( + domain: "ObjectsHelper", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "Invalid response format - no items"], + ) + } + + // Extract objectId from the response + let objectId: String + if let objectIds = firstItem["objectIds"] as? [String], let firstObjectId = objectIds.first { + objectId = firstObjectId + } else if let directObjectId = firstItem["objectId"] as? String { + objectId = directObjectId + } else { + throw NSError( + domain: "ObjectsHelper", + code: -1, + userInfo: [NSLocalizedDescriptionKey: "No objectId found in response"], + ) + } + + return OperationResult(objectId: objectId, success: true) + } catch let error as ARTErrorInfo { + throw error + } catch { + throw error + } + } + + // MARK: - Utility Methods + + /// Generates a fake map object ID + func fakeMapObjectId() -> String { + "map:\(randomString())@\(Int(Date().timeIntervalSince1970 * 1000))" + } + + /// Generates a fake counter object ID + func fakeCounterObjectId() -> String { + "counter:\(randomString())@\(Int(Date().timeIntervalSince1970 * 1000))" + } + + // MARK: - Private Methods + + /// Generates a random nonce + private func nonce() -> String { + randomString() + } + + /// Generates a random string + private func randomString() -> String { + let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + return String((0 ..< 16).map { _ in letters.randomElement()! }) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift new file mode 100644 index 000000000..c19bc8fa5 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -0,0 +1,5684 @@ +import Ably +@testable import AblyLiveObjects +import Testing + +// This file is copied from the file objects.test.js in ably-js. + +// Disable trailing_closure so that we can pass `action:` to the TestScenario initializer, consistent with the JS code +// swiftlint:disable trailing_closure + +// MARK: - Top-level helpers + +private func realtimeWithObjects(options: ClientHelper.PartialClientOptions) async throws -> ARTRealtime { + try await ClientHelper.realtimeWithObjects(options: options) +} + +private func channelOptionsWithObjects() -> ARTRealtimeChannelOptions { + ClientHelper.channelOptionsWithObjects() +} + +// Swift version of the JS lexicoTimeserial function +// +// Example: +// +// 01726585978590-001@abcdefghij:001 +// |____________| |_| |________| |_| +// | | | | +// timestamp counter seriesId idx +private func lexicoTimeserial(seriesId: String, timestamp: Int64, counter: Int, index: Int? = nil) -> String { + let paddedTimestamp = String(format: "%014d", timestamp) + let paddedCounter = String(format: "%03d", counter) + + var result = "\(paddedTimestamp)-\(paddedCounter)@\(seriesId)" + + if let index { + let paddedIndex = String(format: "%03d", index) + result += ":\(paddedIndex)" + } + + return result +} + +func monitorConnectionThenCloseAndFinishAsync(_ realtime: ARTRealtime, action: @escaping @Sendable () async throws -> Void) async throws { + defer { realtime.connection.close() } + + try await withThrowingTaskGroup { group in + // Monitor connection state + for state in [ARTRealtimeConnectionEvent.failed, .suspended] { + group.addTask { + let (stream, continuation) = AsyncThrowingStream.makeStream() + + let subscription = realtime.connection.on(state) { _ in + realtime.close() + + let error = NSError( + domain: "IntegrationTestsError", + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Connection monitoring: state changed to \(state), aborting test", + ], + ) + continuation.finish(throwing: error) + } + continuation.onTermination = { _ in + realtime.connection.off(subscription) + } + + try await stream.first { _ in true } + } + } + + // Perform the action + group.addTask { + try await action() + } + + // Wait for either connection monitoring to throw an error or for the action to complete + guard let result = await group.nextResult() else { + return + } + + group.cancelAll() + try result.get() + } +} + +func waitFixtureChannelIsReady(_: ARTRealtime) async throws { + // TODO: Implement this using the subscription APIs once we've got a spec for those, but this should be fine for now + try await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC) +} + +func waitForMapKeyUpdate(_ updates: AsyncStream, _ key: String) async { + _ = await updates.first { $0.update[key] != nil } +} + +func waitForCounterUpdate(_ updates: AsyncStream) async { + _ = await updates.first { _ in true } +} + +/// Waits for a MAP_CLEAR operation to be applied to a LiveMap, by waiting for an update where +/// all of the specified keys have `.removed` status. +/// +/// The JS equivalent subscribes and checks `message.operation.action === 'map.clear'`, but Swift's +/// `LiveMapUpdate` doesn't expose the operation action — it only provides per-key changes via +/// `update: [String: LiveMapUpdateAction]`. So we use the per-key `.removed` entries as a proxy +/// instead. +func waitForMapClear(_ updates: AsyncStream, expectedRemovedKeys: Set) async { + _ = await updates.first { update in + expectedRemovedKeys.allSatisfy { update.update[$0] == .removed } + } +} + +// Note that Cursor decided to implement this in a different way to the waitForObjectSync that I'd already implemented; TODO pick one of the two approaches (this one might be cleaner). +func waitForObjectOperation(_ objects: any RealtimeObjects, _ action: ObjectOperationAction) async throws { + // Cast to access internal API for testing + let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) + let objectMessages = internallyTypedObjects.testsOnly_receivedObjectProtocolMessages + + // Wait for an object protocol message containing the specified action + _ = await objectMessages.first { messages in + messages.contains { message in + message.operation?.action == .known(action) + } + } +} + +// I added this @MainActor as an "I don't understand what's going on there; let's try this" when observing that for some reason the setter of setListenerAfterProcessingIncomingMessage was hanging inside `-[ARTSRDelegateController dispatchQueue]`. This seems to avoid it and I have not investigated more deeply 🤷 +@MainActor +func waitForObjectSync(_ realtime: ARTRealtime) async throws { + let testProxyTransport = try #require(realtime.internal.transport as? TestProxyTransport) + + await withCheckedContinuation { (continuation: CheckedContinuation) in + testProxyTransport.setListenerAfterProcessingIncomingMessage { protocolMessage in + if protocolMessage.action == .objectSync { + testProxyTransport.setListenerAfterProcessingIncomingMessage(nil) + continuation.resume() + } + } + } +} + +// MARK: - ARTProtocolMessage test-only extension + +extension ARTProtocolMessage { + /// Extract `InboundObjectMessage`s from the protocol message's state array. + var testsOnly_inboundObjectMessages: [InboundObjectMessage] { + // Claude's explanation, which I don't really have the time to verify or try and do better than — it's only test code so good enough: + // + // > The `state` property on `ARTProtocolMessage` is behind `#ifdef ABLY_SUPPORTS_PLUGINS`, + // > which is defined as a C flag on the ably-cocoa SPM target. However, this define is not + // > visible when the Clang module is built for the `Ably.Private` submodule, so the property + // > is not importable into Swift. We use KVC to access it instead. + guard let stateArray = value(forKey: "state") as? [AnyObject] else { + return [] + } + return stateArray.compactMap { item -> InboundObjectMessage? in + guard let box = item as? DefaultInternalPlugin.ObjectMessageBox + else { return nil } + return box.objectMessage + } + } +} + +// MARK: - Echo/ACK interceptors for apply-on-ACK tests + +/// Intercepts OBJECT messages (action 19) arriving from Realtime, holding them so tests can +/// control the timing of echo delivery, to test ACK-before-echo scenarios. Call when CONNECTING +/// or CONNECTED to intercept messages on the active transport. +/// +/// An echo is a subset of OBJECT messages — specifically, OBJECT messages that originated from +/// this client and are being echoed back. The name "echo" reflects how this interceptor is +/// intended to be used in tests, but it doesn't actually filter for echoes — it intercepts all +/// OBJECT messages. +/// +/// - ``waitForEcho()`` / ``waitForEchoCount(_:)``: resolve immediately if enough OBJECT messages +/// have already been intercepted, otherwise wait for one. Only one call can be pending at a +/// time; subsequent calls will trap. +/// - ``releaseAll()``: replays all held OBJECT messages through the channel's internal message +/// handler. +/// - ``releaseFirst()``: replays only the first held OBJECT message. +/// - ``restore()``: removes the interceptor, restoring normal message handling. +private final class EchoInterceptor: @unchecked Sendable { + private let transport: TestProxyTransport + private let channel: ARTRealtimeChannel + private let lock = NSLock() + private var _heldEchoes: [ARTProtocolMessage] = [] + private var echoContinuation: CheckedContinuation? + + var heldEchoes: [ARTProtocolMessage] { + lock.withLock { _heldEchoes } + } + + init(client: ARTRealtime, channel: ARTRealtimeChannel) { + // swiftlint:disable:next force_cast + transport = client.internal.transport as! TestProxyTransport + self.channel = channel + + transport.setBeforeIncomingMessageModifier { [weak self] message in + guard let self else { + return message + } + + if message.action == .object { + lock.withLock { + _heldEchoes.append(message) + echoContinuation?.resume() + echoContinuation = nil + } + return nil // suppress the OBJECT message + } + return message + } + } + + /// Waits until at least one new OBJECT message has been intercepted since the last + /// ``releaseAll()`` / ``releaseFirst()`` (or since construction). + func waitForEcho() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.withLock { + if !_heldEchoes.isEmpty { + continuation.resume() + } else { + precondition(echoContinuation == nil, "Only one waitForEcho/waitForEchoCount call can be pending at a time") + echoContinuation = continuation + } + } + } + } + + /// Waits until at least `count` OBJECT messages have been intercepted. + func waitForEchoCount(_ count: Int) async { + while true { + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.withLock { + if _heldEchoes.count >= count { + continuation.resume() + } else { + precondition(echoContinuation == nil, "Only one waitForEcho/waitForEchoCount call can be pending at a time") + echoContinuation = continuation + } + } + } + let current = lock.withLock { _heldEchoes.count } + if current >= count { break } + } + } + + /// Replays all held OBJECT messages through the channel's internal message handler. + func releaseAll() async { + let echoes = lock.withLock { + let e = _heldEchoes + _heldEchoes.removeAll() + return e + } + for echo in echoes { + nonisolated(unsafe) let echo = echo + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.internal.queue.async { + self.channel.internal.onChannelMessage(echo) + continuation.resume() + } + } + } + } + + /// Replays only the first held OBJECT message. + func releaseFirst() async { + let echo: ARTProtocolMessage? = lock.withLock { + _heldEchoes.isEmpty ? nil : _heldEchoes.removeFirst() + } + guard let echo else { + return + } + + nonisolated(unsafe) let unsafeEcho = echo + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.internal.queue.async { + self.channel.internal.onChannelMessage(unsafeEcho) + continuation.resume() + } + } + } + + /// Removes the interceptor, restoring normal message handling. + func restore() { + transport.setBeforeIncomingMessageModifier(nil) + } +} + +/// Intercepts ACK messages (action 1) arriving from Realtime, holding them so tests can control +/// the timing of ACK delivery, to test echo-before-ACK scenarios. Call when CONNECTING or +/// CONNECTED to intercept messages on the active transport. +/// +/// - ``waitForAck()``: resolves immediately if an ACK has already been intercepted, otherwise +/// waits for one. Only one call can be pending at a time; subsequent calls will trap. +/// - ``releaseAll()``: replays all held ACKs through the client's internal ACK handler. +/// - ``restore()``: removes the interceptor, restoring normal message handling. +private final class AckInterceptor: @unchecked Sendable { + private let transport: TestProxyTransport + private let client: ARTRealtime + private let lock = NSLock() + private var _heldAcks: [ARTProtocolMessage] = [] + private var ackContinuation: CheckedContinuation? + + var heldAcks: [ARTProtocolMessage] { + lock.withLock { _heldAcks } + } + + init(client: ARTRealtime) { + self.client = client + // swiftlint:disable:next force_cast + transport = client.internal.transport as! TestProxyTransport + + transport.setBeforeIncomingMessageModifier { [weak self] message in + guard let self else { + return message + } + + if message.action == .ack { + lock.withLock { + _heldAcks.append(message) + ackContinuation?.resume() + ackContinuation = nil + } + return nil // suppress the ACK + } + return message + } + } + + /// Waits until at least one ACK has been intercepted. + func waitForAck() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.withLock { + if !_heldAcks.isEmpty { + continuation.resume() + } else { + precondition(ackContinuation == nil, "Only one waitForAck call can be pending at a time") + ackContinuation = continuation + } + } + } + } + + /// Replays all held ACK messages through the client's internal ACK handler. + func releaseAll() async { + let acks = lock.withLock { + let a = _heldAcks + _heldAcks.removeAll() + return a + } + for ack in acks { + nonisolated(unsafe) let ack = ack + await withCheckedContinuation { (continuation: CheckedContinuation) in + client.internal.queue.async { + self.client.internal.onAck(ack) + continuation.resume() + } + } + } + } + + /// Removes the interceptor, restoring normal message handling. + func restore() { + transport.setBeforeIncomingMessageModifier(nil) + } +} + +/// Injects an ATTACHED protocol message into the channel with the given flags. +private func injectAttachedMessage(channel: ARTRealtimeChannel, flags: ARTProtocolMessageFlag = []) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.internal.queue.async { + let pm = ARTProtocolMessage() + pm.action = .attached + pm.channel = channel.name + pm.flags = Int64(flags.rawValue) + channel.internal.onChannelMessage(pm) + continuation.resume() + } + } +} + +// MARK: - Constants + +private let objectsFixturesChannel = "objects_fixtures" + +// MARK: - Top-level fixtures (ported from JS objects.test.js) + +// The value of JS's `Number.MAX_SAFE_INTEGER` — the maximum integer that a `Double` can represent exactly. +private let maxSafeInteger = Double((1 << 53) - 1) + +// Primitive key data fixture used across multiple test scenarios +// liveMapValue field contains the value as LiveMapValue for use in map operations +private let primitiveKeyData: [(key: String, data: [String: JSONValue], liveMapValue: LiveMapValue)] = [ + ( + key: "stringKey", + data: ["string": .string("stringValue")], + liveMapValue: "stringValue" + ), + ( + key: "emptyStringKey", + data: ["string": .string("")], + liveMapValue: "" + ), + ( + key: "bytesKey", + data: ["bytes": .string("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")], + liveMapValue: .data(Data(base64Encoded: "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")!) + ), + ( + key: "emptyBytesKey", + data: ["bytes": .string("")], + liveMapValue: .data(Data(base64Encoded: "")!) + ), + ( + key: "maxSafeIntegerKey", + data: ["number": .number(maxSafeInteger)], + liveMapValue: .number(maxSafeInteger) + ), + ( + key: "negativeMaxSafeIntegerKey", + data: ["number": .number(-maxSafeInteger)], + liveMapValue: .number(-maxSafeInteger) + ), + ( + key: "numberKey", + data: ["number": .number(1)], + liveMapValue: 1 + ), + ( + key: "zeroKey", + data: ["number": .number(0)], + liveMapValue: 0 + ), + ( + key: "trueKey", + data: ["boolean": .bool(true)], + liveMapValue: true + ), + ( + key: "falseKey", + data: ["boolean": .bool(false)], + liveMapValue: false + ), +] + +// Primitive maps fixtures used in map creation and write API scenarios +// entries field contains the map entries in the format expected by ObjectsHelper +// restData field contains the data in the format expected by REST API operations +// liveMapEntries field contains entries as LiveMapValue for direct map operations +private let primitiveMapsFixtures: [(name: String, entries: [String: [String: JSONValue]]?, restData: [String: JSONValue]?, liveMapEntries: [String: LiveMapValue]?)] = [ + (name: "emptyMap", entries: nil, restData: nil, liveMapEntries: nil), + (name: "valuesMap", + entries: Dictionary(uniqueKeysWithValues: primitiveKeyData.map { ($0.key, ["data": .object($0.data)]) }), + restData: Dictionary(uniqueKeysWithValues: primitiveKeyData.map { ($0.key, .object($0.data)) }), + liveMapEntries: Dictionary(uniqueKeysWithValues: primitiveKeyData.map { ($0.key, $0.liveMapValue) })), +] + +// Counters fixtures used in counter creation and write API scenarios +// count field supports both Int and Double types depending on the test scenario +private let countersFixtures: [(name: String, count: Double?)] = [ + (name: "emptyCounter", count: nil), + (name: "zeroCounter", count: 0), + (name: "valueCounter", count: 10), + (name: "negativeValueCounter", count: -10), + (name: "maxSafeIntegerCounter", count: Double(Int.max)), + (name: "negativeMaxSafeIntegerCounter", count: -Double(Int.max)), +] + +// MARK: - Support for parameterised tests + +/// The output of `forScenarios`. One element of the one-dimensional arguments array that is passed to a Swift Testing test. +private struct TestCase: Identifiable, CustomStringConvertible { + var disabled: Bool + var scenario: TestScenario + var options: ClientHelper.PartialClientOptions + var channelName: String + + /// This `Identifiable` conformance allows us to re-run individual test cases from the Xcode UI (https://developer.apple.com/documentation/testing/parameterizedtesting#Run-selected-test-cases) + var id: TestCaseID { + .init(description: scenario.description, options: options) + } + + /// This seems to determine the nice name that you see for this when it's used as a test case parameter. (I can't see anywhere that this is documented; found it by experimentation). + var description: String { + var result = scenario.description + + if let useBinaryProtocol = options.useBinaryProtocol { + result += " (\(useBinaryProtocol ? "binary" : "text"))" + } + + return result + } +} + +/// Enables `TestCase`'s conformance to `Identifiable`. +private struct TestCaseID: Encodable, Hashable { + var description: String + var options: ClientHelper.PartialClientOptions? +} + +/// The input to `forScenarios`. +private struct TestScenario { + var disabled: Bool + var allTransportsAndProtocols: Bool + var description: String + var action: @Sendable (Context) async throws -> Void +} + +private func forScenarios(_ scenarios: [TestScenario]) -> [TestCase] { + scenarios.map { scenario -> [TestCase] in + var clientOptions = ClientHelper.PartialClientOptions(logIdentifier: "client1") + + if scenario.allTransportsAndProtocols { + return [true, false].map { useBinaryProtocol -> TestCase in + clientOptions.useBinaryProtocol = useBinaryProtocol + + return .init( + disabled: scenario.disabled, + scenario: scenario, + options: clientOptions, + channelName: "\(scenario.description) \(useBinaryProtocol ? "binary" : "text")", + ) + } + } else { + return [.init(disabled: scenario.disabled, scenario: scenario, options: clientOptions, channelName: scenario.description)] + } + } + .flatMap(\.self) +} + +private protocol Scenarios { + associatedtype Context + static var scenarios: [TestScenario] { get } +} + +private extension Scenarios { + static var testCases: [TestCase] { + forScenarios(scenarios) + } +} + +// MARK: - Test lifecycle + +/// Creates the fixtures on ``objectsFixturesChannel`` if not yet created. +/// +/// This fulfils the role of JS's `before` hook. +private actor ObjectsFixturesTrait: SuiteTrait, TestScoping { + private actor SetupManager { + private var setupTask: Task? + + func setUpFixtures() async throws { + let setupTask: Task = if let existingSetupTask = self.setupTask { + existingSetupTask + } else { + Task { + let helper = try await ObjectsHelper() + try await helper.initForChannel(objectsFixturesChannel) + } + } + self.setupTask = setupTask + + try await setupTask.value + } + } + + private static let setupManager = SetupManager() + + func provideScope(for _: Test, testCase _: Test.Case?, performing function: () async throws -> Void) async throws { + try await Self.setupManager.setUpFixtures() + try await function() + } +} + +extension Trait where Self == ObjectsFixturesTrait { + static var objectsFixtures: Self { Self() } +} + +// MARK: - Utility types + +/// A class that isolates arbitrary mutable state to the main actor. +/// +/// Intended for allowing a subscription callback to mutate some state that is shared between multiple callbacks. This allows us to port the JS pattern where callbacks synchronously mutate some local variable that's stored outside the callback (in Swift, local variables cannot be isolated to an actor). +@MainActor +class MainActorStorage { + var value: T + + init(value: T) { + self.value = value + } +} + +// MARK: - Test suite + +@Suite( + .tags(.integration), + .objectsFixtures, + // These tests exhibit flakiness (hanging, timeouts, occasional Realtime + // connection limits) when run concurrently, where I think that we had up to + // 100 ARTRealtime instances active at the same time. So we're running them in + // serial to unblock CI builds until we can understand the issue better. See + // https://github.com/ably/ably-liveobjects-swift-plugin/issues/72. + .serialized, +) +private struct ObjectsIntegrationTests { + // TODO: Add the non-parameterised tests + + enum FirstSetOfScenarios: Scenarios { + struct Context { + var objects: any RealtimeObjects + var root: any LiveMap + var objectsHelper: ObjectsHelper + var channelName: String + var channel: ARTRealtimeChannel + var client: ARTRealtime + var clientOptions: ClientHelper.PartialClientOptions + } + + static let scenarios: [TestScenario] = { + let objectSyncSequenceScenarios: [TestScenario] = [ + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "on ATTACHED without HAS_OBJECTS clears local state", + action: { ctx in + // set a key on root so we can verify it gets cleared after ATTACHED + try await ctx.root.set(key: "foo", value: "bar") + #expect(try #require(ctx.root.get(key: "foo")?.stringValue) == "bar", "Check root has key before ATTACHED") + + // inject ATTACHED without HAS_OBJECTS flag + await injectAttachedMessage(channel: ctx.channel) + + // local state should be cleared — root should have no keys + #expect(try ctx.root.size == 0, "Check root has no keys after ATTACHED without HAS_OBJECTS") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "OBJECT_SYNC sequence builds object tree on channel attachment", + action: { ctx in + let client = ctx.client + + try await waitFixtureChannelIsReady(client) + + let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + let counterKeys = ["emptyCounter", "initialValueCounter", "referencedCounter"] + let mapKeys = ["emptyMap", "referencedMap", "valuesMap"] + let rootKeysCount = counterKeys.count + mapKeys.count + + #expect(try root.size == rootKeysCount, "Check root has correct number of keys") + + for key in counterKeys { + let counter = try #require(try root.get(key: key)) + #expect(counter.liveCounterValue != nil, "Check counter at key=\"\(key)\" in root is of type LiveCounter") + } + + for key in mapKeys { + let map = try #require(try root.get(key: key)) + #expect(map.liveMapValue != nil, "Check map at key=\"\(key)\" in root is of type LiveMap") + } + + let valuesMap = try #require(root.get(key: "valuesMap")?.liveMapValue) + let valueMapKeys = [ + "stringKey", + "emptyStringKey", + "bytesKey", + "emptyBytesKey", + "numberKey", + "zeroKey", + "trueKey", + "falseKey", + "mapKey", + ] + #expect(try valuesMap.size == valueMapKeys.count, "Check nested map has correct number of keys") + for key in valueMapKeys { + #expect(try valuesMap.get(key: key) != nil, "Check value at key=\"\(key)\" in nested map exists") + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "OBJECT_SYNC sequence builds object tree with all operations applied", + action: { ctx in + let root = ctx.root + let objects = ctx.objects + + // MAP_CREATE + let map = try await objects.createMap(entries: ["shouldStay": "foo", "shouldDelete": "bar"]) + // COUNTER_CREATE + let counter = try await objects.createCounter(count: 1) + + // Set the values + async let setMapPromise: Void = root.set(key: "map", value: .liveMap(map)) + async let setCounterPromise: Void = root.set(key: "counter", value: .liveCounter(counter)) + _ = try await (setMapPromise, setCounterPromise) + + // Perform the operations + async let setAnotherKeyPromise: Void = map.set(key: "anotherKey", value: "baz") + async let removeKeyPromise: Void = map.remove(key: "shouldDelete") + async let incrementPromise: Void = counter.increment(amount: 10) + _ = try await (setAnotherKeyPromise, removeKeyPromise, incrementPromise) + + // create a new client and check it syncs with the aggregated data + let client2 = try await realtimeWithObjects(options: ctx.clientOptions) + + try await monitorConnectionThenCloseAndFinishAsync(client2) { + let channel2 = client2.channels.get(ctx.channelName, options: channelOptionsWithObjects()) + let objects2 = channel2.objects + + try await channel2.attachAsync() + let root2 = try await objects2.getRoot() + + let counter2 = try #require(root2.get(key: "counter")?.liveCounterValue) + #expect(try counter2.value == 11, "Check counter has correct value") + + let map2 = try #require(root2.get(key: "map")?.liveMapValue) + #expect(try map2.size == 2, "Check map has correct number of keys") + #expect(try #require(map2.get(key: "shouldStay")?.stringValue) == "foo", "Check map has correct value for \"shouldStay\" key") + #expect(try #require(map2.get(key: "anotherKey")?.stringValue) == "baz", "Check map has correct value for \"anotherKey\" key") + #expect(try map2.get(key: "shouldDelete") == nil, "Check map does not have \"shouldDelete\" key") + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "OBJECT_SYNC sequence does not change references to existing objects", + action: { ctx in + let root = ctx.root + let objects = ctx.objects + let channel = ctx.channel + let client = ctx.client + + let map = try await objects.createMap() + let counter = try await objects.createCounter() + + // Set the values + async let setMapPromise: Void = root.set(key: "map", value: .liveMap(map)) + async let setCounterPromise: Void = root.set(key: "counter", value: .liveCounter(counter)) + _ = try await (setMapPromise, setCounterPromise) + + try await channel.detachAsync() + + // wait for the actual OBJECT_SYNC message to confirm it was received and processed + async let objectSyncPromise: Void = waitForObjectSync(client) + try await channel.attachAsync() + try await objectSyncPromise + + let newRootRef = try await channel.objects.getRoot() + let newMapRefMap = try #require(newRootRef.get(key: "map")?.liveMapValue) + let newCounterRef = try #require(newRootRef.get(key: "counter")?.liveCounterValue) + + #expect(newRootRef === root, "Check root reference is the same after OBJECT_SYNC sequence") + #expect(newMapRefMap === map, "Check map reference is the same after OBJECT_SYNC sequence") + #expect(newCounterRef === counter, "Check counter reference is the same after OBJECT_SYNC sequence") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "OBJECT_SYNC sequence builds object tree across multiple sync messages", + action: { ctx throws in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + let counterId = objectsHelper.fakeCounterObjectId() + let mapId = objectsHelper.fakeMapObjectId() + + // send three separate OBJECT_SYNC messages: one for root, one for counter, one for map + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:cursor1", + state: [ + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [ + "stringKey": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["string": .string("hello")]), + ]), + "counter": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["objectId": .string(counterId)]), + ]), + "map": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["objectId": .string(mapId)]), + ]), + ], + ), + ], + ) + + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:cursor2", + state: [ + objectsHelper.counterObject( + objectId: counterId, + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialCount: 10, + materialisedCount: 5, + ), + ], + ) + + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", // end sync sequence + state: [ + objectsHelper.mapObject( + objectId: mapId, + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [ + "foo": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + ], + materialisedEntries: [ + "baz": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("qux")]), + ]), + ], + ), + ], + ) + + #expect(try #require(root.get(key: "stringKey")?.stringValue) == "hello", "Check root has correct string value") + let counter = try #require(root.get(key: "counter")?.liveCounterValue) + #expect(try counter.value == 15, "Check counter has correct aggregated value") + let map = try #require(root.get(key: "map")?.liveMapValue) + #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check map has initial entries") + #expect(try #require(map.get(key: "baz")?.stringValue) == "qux", "Check map has materialised entries") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "partial OBJECT_SYNC merges map entries across multiple messages for the same objectId", + action: { ctx throws in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + let mapId = objectsHelper.fakeMapObjectId() + + // assign map object to root + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:cursor1", + state: [ + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [ + "map": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["objectId": .string(mapId)]), + ]), + ], + ), + ], + ) + + // send partial sync messages for the same map object, each with different materialised entries. + // initialEntries are identical across all partial messages for the same object — a server guarantee. + let partialMessages: [(syncSerial: String, materialisedEntries: [String: WireValue])] = [ + ( + syncSerial: "serial:cursor2", + materialisedEntries: [ + "key1": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["number": .number(1)]), + ]), + "key2": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["string": .string("two")]), + ]), + ] + ), + ( + syncSerial: "serial:cursor3", + materialisedEntries: [ + "key3": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["number": .number(3)]), + ]), + "key4": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["boolean": .bool(true)]), + ]), + ] + ), + ( + syncSerial: "serial:", // end sync sequence + materialisedEntries: [ + "key5": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["string": .string("five")]), + ]), + ] + ), + ] + + for partial in partialMessages { + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: partial.syncSerial, + state: [ + objectsHelper.mapObject( + objectId: mapId, + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [ + "initialKey": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["string": .string("initial")]), + ]), + ], + materialisedEntries: partial.materialisedEntries, + ), + ], + ) + } + + let map = try #require(root.get(key: "map")?.liveMapValue) + + #expect(try #require(map.get(key: "initialKey")?.stringValue) == "initial", "Check keys from the create operation are present") + + // check that materialised entries from all partial messages were merged + #expect(try #require(map.get(key: "key1")?.numberValue) == 1, "Check key1 from first partial sync") + #expect(try #require(map.get(key: "key2")?.stringValue) == "two", "Check key2 from first partial sync") + #expect(try #require(map.get(key: "key3")?.numberValue) == 3, "Check key3 from second partial sync") + #expect(try #require(map.get(key: "key4")?.boolValue as Bool?) == true, "Check key4 from second partial sync") + #expect(try #require(map.get(key: "key5")?.stringValue) == "five", "Check key5 from third partial sync") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "OBJECT_SYNC does not break when receiving an unknown object type", + action: { ctx throws in + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + let objects = ctx.objects + + // first message: unknown object type (no counter or map field set) + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:cursor", + state: [ + [ + "object": .object([ + "objectId": .string("unknown:object123"), + "siteTimeserials": .object(["aaa": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0))]), + "tombstone": .bool(false), + // intentionally not setting counter or map fields + ]), + ], + ], + ) + + // second message: root with a key, ends sync sequence + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", + state: [ + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [ + "foo": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + ], + ), + ], + ) + + let root = try await objects.getRoot() + + // verify root has the expected key — SDK should not break due to unknown object type + #expect(try #require(root.get(key: "foo")?.stringValue) == "bar", "Check root has correct value after unknown object type in sync") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "LiveCounter is initialized with initial value from OBJECT_SYNC sequence", + action: { ctx in + let client = ctx.client + + try await waitFixtureChannelIsReady(client) + + let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + let counters = [ + (key: "emptyCounter", value: 0), + (key: "initialValueCounter", value: 10), + (key: "referencedCounter", value: 20), + ] + + for counter in counters { + let counterObj = try #require(root.get(key: counter.key)?.liveCounterValue) + #expect(try counterObj.value == Double(counter.value), "Check counter at key=\"\(counter.key)\" in root has correct value") + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "LiveMap is initialized with initial value from OBJECT_SYNC sequence", + action: { ctx in + let client = ctx.client + + try await waitFixtureChannelIsReady(client) + + let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + let emptyMap = try #require(root.get(key: "emptyMap")?.liveMapValue) + #expect(try emptyMap.size == 0, "Check empty map in root has no keys") + + let referencedMap = try #require(root.get(key: "referencedMap")?.liveMapValue) + #expect(try referencedMap.size == 1, "Check referenced map in root has correct number of keys") + + let counterFromReferencedMap = try #require(referencedMap.get(key: "counterKey")?.liveCounterValue) + #expect(try counterFromReferencedMap.value == 20, "Check nested counter has correct value") + + let valuesMap = try #require(root.get(key: "valuesMap")?.liveMapValue) + #expect(try valuesMap.size == 9, "Check values map in root has correct number of keys") + + #expect(try #require(valuesMap.get(key: "stringKey")?.stringValue) == "stringValue", "Check values map has correct string value key") + #expect(try #require(valuesMap.get(key: "emptyStringKey")?.stringValue).isEmpty, "Check values map has correct empty string value key") + #expect(try #require(valuesMap.get(key: "bytesKey")?.dataValue) == Data(base64Encoded: "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9"), "Check values map has correct bytes value key") + #expect(try #require(valuesMap.get(key: "emptyBytesKey")?.dataValue) == Data(base64Encoded: ""), "Check values map has correct empty bytes values key") + #expect(try #require(valuesMap.get(key: "numberKey")?.numberValue) == 1, "Check values map has correct number value key") + #expect(try #require(valuesMap.get(key: "zeroKey")?.numberValue) == 0, "Check values map has correct zero number value key") + #expect(try #require(valuesMap.get(key: "trueKey")?.boolValue as Bool?) == true, "Check values map has correct 'true' value key") + #expect(try #require(valuesMap.get(key: "falseKey")?.boolValue as Bool?) == false, "Check values map has correct 'false' value key") + + let mapFromValuesMap = try #require(valuesMap.get(key: "mapKey")?.liveMapValue) + #expect(try mapFromValuesMap.size == 1, "Check nested map has correct number of keys") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "LiveMap can reference the same object in their keys", + action: { ctx in + let client = ctx.client + + try await waitFixtureChannelIsReady(client) + + let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + let referencedCounter = try #require(root.get(key: "referencedCounter")?.liveCounterValue) + let referencedMap = try #require(root.get(key: "referencedMap")?.liveMapValue) + let valuesMap = try #require(root.get(key: "valuesMap")?.liveMapValue) + + let counterFromReferencedMap = try #require(referencedMap.get(key: "counterKey")?.liveCounterValue, "Check nested counter is of type LiveCounter") + #expect(counterFromReferencedMap === referencedCounter, "Check nested counter is the same object instance as counter on the root") + #expect(try counterFromReferencedMap.value == 20, "Check nested counter has correct value") + + let mapFromValuesMap = try #require(valuesMap.get(key: "mapKey")?.liveMapValue, "Check nested map is of type LiveMap") + #expect(try mapFromValuesMap.size == 1, "Check nested map has correct number of keys") + #expect(mapFromValuesMap === referencedMap, "Check nested map is the same object instance as map on the root") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "OBJECT_SYNC sequence with object state \"tombstone\" property creates tombstoned object", + action: { ctx throws in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + let mapId = objectsHelper.fakeMapObjectId() + let counterId = objectsHelper.fakeCounterObjectId() + + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", // empty serial so sync sequence ends immediately + // add object states with tombstone=true + state: [ + objectsHelper.mapObject( + objectId: mapId, + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [:], + tombstone: true, + ), + objectsHelper.counterObject( + objectId: counterId, + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialCount: 1, + tombstone: true, + ), + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [ + "map": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["objectId": .string(mapId)]), + ]), + "counter": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["objectId": .string(counterId)]), + ]), + "foo": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + ], + ), + ], + ) + + #expect(try root.get(key: "map") == nil, "Check map does not exist on root after OBJECT_SYNC with \"tombstone=true\" for a map object") + #expect(try root.get(key: "counter") == nil, "Check counter does not exist on root after OBJECT_SYNC with \"tombstone=true\" for a counter object") + // control check that OBJECT_SYNC was applied at all + #expect(try root.get(key: "foo") != nil, "Check property exists on root after OBJECT_SYNC") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "OBJECT_SYNC sequence with object state \"tombstone\" property deletes existing object", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let channel = ctx.channel + + let counterCreatedPromiseUpdates = try root.updates() + async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") + let counterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter", + createOp: objectsHelper.counterCreateRestOp(number: 1), + ) + _ = await counterCreatedPromise + + #expect(try root.get(key: "counter") != nil, "Check counter exists on root before OBJECT_SYNC sequence with \"tombstone=true\"") + + // inject an OBJECT_SYNC message where a counter is now tombstoned + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", // empty serial so sync sequence ends immediately + state: [ + objectsHelper.counterObject( + objectId: counterResult.objectId, + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialCount: 1, + tombstone: true, + ), + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [ + "counter": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["objectId": .string(counterResult.objectId)]), + ]), + "foo": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + ], + ), + ], + ) + + #expect(try root.get(key: "counter") == nil, "Check counter does not exist on root after OBJECT_SYNC with \"tombstone=true\" for an existing counter object") + // control check that OBJECT_SYNC was applied at all + #expect(try root.get(key: "foo") != nil, "Check property exists on root after OBJECT_SYNC") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "OBJECT_SYNC sequence with object state \"tombstone\" property triggers subscription callback for existing object", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let channel = ctx.channel + + let counterCreatedPromiseUpdates = try root.updates() + async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") + let counterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter", + createOp: objectsHelper.counterCreateRestOp(number: 1), + ) + _ = await counterCreatedPromise + + let counterSubPromiseUpdates = try #require(root.get(key: "counter")?.liveCounterValue).updates() + async let counterSubPromise: Void = { + let update = try await #require(counterSubPromiseUpdates.first { _ in true }) + #expect(update.amount == -1, "Check counter subscription callback is called with an expected update object after OBJECT_SYNC sequence with \"tombstone=true\"") + }() + + // inject an OBJECT_SYNC message where a counter is now tombstoned + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", // empty serial so sync sequence ends immediately + state: [ + objectsHelper.counterObject( + objectId: counterResult.objectId, + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialCount: 1, + tombstone: true, + ), + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [ + "counter": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["objectId": .string(counterResult.objectId)]), + ]), + ], + ), + ], + ) + + _ = try await counterSubPromise + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "OBJECT_SYNC sequence with clearTimeserial records the clearTimeserial", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + let clearTimeserial = lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0) + + // send OBJECT_SYNC with a map that has clearTimeserial and no entries + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", // empty cursor so sync completes immediately + state: [ + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0)], + clearTimeserial: clearTimeserial, + ), + ], + ) + + #expect(try root.size == 0, "Check root is empty after sync") + + // verify subsequent MAP_SETs are filtered based on clearTimeserial. + // use different sites so operations pass siteTimeserials check + let ops: [(serial: String, siteCode: String, key: String, applied: Bool)] = [ + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 4, counter: 0), siteCode: "bbb", key: "earlyKey", applied: false), // < clearTimeserial + (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 6, counter: 0), siteCode: "ccc", key: "laterKey", applied: true), // > clearTimeserial + ] + + for op in ops { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: op.serial, + siteCode: op.siteCode, + state: [objectsHelper.mapSetOp(objectId: "root", key: op.key, data: .object(["string": .string("value")]))], + ) + + if op.applied { + let value = try #require(root.get(key: op.key)?.stringValue) + #expect(value == "value", "Check MAP_SET for \"\(op.key)\" is applied") + } else { + let value = try root.get(key: op.key) + #expect(value == nil, "Check MAP_SET for \"\(op.key)\" is rejected") + } + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "OBJECT_SYNC sequence with clearTimeserial does not surface initial entries from createOp", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + let clearTimeserial = lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0) + + // send OBJECT_SYNC with a map that has clearTimeserial and initial entries via createOp. + // initial entries do not have timeserials set, so they should all be considered + // as predating the clear and not be surfaced to the end user. + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", // empty cursor so sync completes immediately + state: [ + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0)], + initialEntries: [ + "foo": .object(["data": .object(["string": .string("bar")])]), + "baz": .object(["data": .object(["number": .number(NSNumber(value: 123))])]), + ], + clearTimeserial: clearTimeserial, + ), + ], + ) + + let fooValue = try root.get(key: "foo") + #expect(fooValue == nil, "Check \"foo\" from initialEntries is not visible") + let bazValue = try root.get(key: "baz") + #expect(bazValue == nil, "Check \"baz\" from initialEntries is not visible") + #expect(try root.size == 0, "Check root has no visible keys") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "OBJECT_SYNC sequence with clearTimeserial and materialised entries processes entries correctly", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + let clearTimeserial = lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0) + + // check that even with clearTimeserial set, the entries from materialised entries are + // processed correctly. + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", // empty cursor so sync completes immediately + state: [ + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 8, counter: 0)], + materialisedEntries: [ + // entry set after the clear - should be visible + "lateKey": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 8, counter: 0)), + "data": .object(["string": .string("late")]), + ]), + ], + clearTimeserial: clearTimeserial, + ), + ], + ) + + let lateKeyValue = try #require(root.get(key: "lateKey")?.stringValue) + #expect(lateKeyValue == "late", "Check lateKey is visible (postdates clear)") + #expect(try root.size == 1, "Check root has 1 visible key") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "reattach with HAS_OBJECTS=false resets clearTimeserial to null on root map", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // apply MAP_CLEAR to set clearTimeserial on root + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapClearOp(objectId: "root")], + ) + + // verify clearTimeserial is set + let internallyTypedRoot = try #require(root as? PublicDefaultLiveMap) + let internalRoot = internallyTypedRoot.proxied + #expect(internalRoot.testsOnly_clearTimeserial == lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), "Check clearTimeserial is set after MAP_CLEAR") + + // simulate reattach with HAS_OBJECTS=false, which resets root to a zero-value + await injectAttachedMessage(channel: channel) + + // clearTimeserial should be now set to null for root + #expect(internalRoot.testsOnly_clearTimeserial == nil, "Check clearTimeserial is null after reattach with HAS_OBJECTS=false") + }, + ), + ] + + let applyOperationsScenarios: [TestScenario] = [ + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "can apply MAP_CREATE with primitives object operation messages", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + + // Check no maps exist on root + for fixture in primitiveMapsFixtures { + let key = fixture.name + #expect(try root.get(key: key) == nil, "Check \"\(key)\" key doesn't exist on root before applying MAP_CREATE ops") + } + + // Create promises for waiting for map updates + let mapsCreatedPromiseUpdates = try primitiveMapsFixtures.map { _ in try root.updates() } + async let mapsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + for (i, fixture) in primitiveMapsFixtures.enumerated() { + group.addTask { + await waitForMapKeyUpdate(mapsCreatedPromiseUpdates[i], fixture.name) + } + } + while try await group.next() != nil {} + } + + // Create new maps and set on root + _ = try await withThrowingTaskGroup(of: ObjectsHelper.OperationResult.self) { group in + for fixture in primitiveMapsFixtures { + group.addTask { + try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: fixture.name, + createOp: objectsHelper.mapCreateRestOp(data: fixture.restData), + ) + } + } + var results: [ObjectsHelper.OperationResult] = [] + while let result = try await group.next() { + results.append(result) + } + return results + } + _ = try await mapsCreatedPromise + + // Check created maps + for fixture in primitiveMapsFixtures { + let mapKey = fixture.name + let mapObj = try #require(root.get(key: mapKey)?.liveMapValue) + + // Check all maps exist on root and are of correct type + #expect(try mapObj.size == (fixture.entries?.count ?? 0), "Check map \"\(mapKey)\" has correct number of keys") + + if let entries = fixture.entries { + for (key, keyData) in entries { + let data = keyData["data"]!.objectValue! + + if let bytesString = data["bytes"]?.stringValue { + let expectedData = Data(base64Encoded: bytesString) + #expect(try mapObj.get(key: key)?.dataValue == expectedData, "Check map \"\(mapKey)\" has correct value for \"\(key)\" key") + } else if let numberValue = data["number"]?.numberValue { + #expect(try mapObj.get(key: key)?.numberValue == numberValue, "Check map \"\(mapKey)\" has correct value for \"\(key)\" key") + } else if let stringValue = data["string"]?.stringValue { + #expect(try mapObj.get(key: key)?.stringValue == stringValue, "Check map \"\(mapKey)\" has correct value for \"\(key)\" key") + } else if let boolValue = data["boolean"]?.boolValue { + #expect(try mapObj.get(key: key)?.boolValue == boolValue, "Check map \"\(mapKey)\" has correct value for \"\(key)\" key") + } + } + } + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "can apply MAP_CREATE with object ids object operation messages", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let withReferencesMapKey = "withReferencesMap" + + // Check map does not exist on root + #expect(try root.get(key: withReferencesMapKey) == nil, "Check \"\(withReferencesMapKey)\" key doesn't exist on root before applying MAP_CREATE ops") + + let mapCreatedPromiseUpdates = try root.updates() + async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, withReferencesMapKey) + + // Create map with references - need to create referenced objects first to obtain their object ids + // We'll create them separately first, then reference them + let tempMapUpdates = try root.updates() + let tempCounterUpdates = try root.updates() + async let tempObjectsPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(tempMapUpdates, "tempMap") + } + group.addTask { + await waitForMapKeyUpdate(tempCounterUpdates, "tempCounter") + } + while try await group.next() != nil {} + } + + let referencedMapResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "tempMap", + createOp: objectsHelper.mapCreateRestOp(data: ["stringKey": .object(["string": .string("stringValue")])]), + ) + let referencedCounterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "tempCounter", + createOp: objectsHelper.counterCreateRestOp(number: 1), + ) + _ = try await tempObjectsPromise + + _ = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: withReferencesMapKey, + createOp: objectsHelper.mapCreateRestOp(data: [ + "mapReference": .object(["objectId": .string(referencedMapResult.objectId)]), + "counterReference": .object(["objectId": .string(referencedCounterResult.objectId)]), + ]), + ) + _ = await mapCreatedPromise + + // Check map with references exist on root + let withReferencesMap = try #require(root.get(key: withReferencesMapKey)?.liveMapValue) + #expect(try withReferencesMap.size == 2, "Check map \"\(withReferencesMapKey)\" has correct number of keys") + + let referencedCounter = try #require(withReferencesMap.get(key: "counterReference")?.liveCounterValue) + #expect(try referencedCounter.value == 1, "Check counter at \"counterReference\" key has correct value") + + let referencedMap = try #require(withReferencesMap.get(key: "mapReference")?.liveMapValue) + #expect(try referencedMap.size == 1, "Check map at \"mapReference\" key has correct number of keys") + #expect(try #require(referencedMap.get(key: "stringKey")?.stringValue) == "stringValue", "Check map at \"mapReference\" key has correct \"stringKey\" value") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "MAP_CREATE object operation messages are applied based on the site timeserials vector of the object", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Need to use multiple maps as MAP_CREATE op can only be applied once to a map object + let mapIds = [ + objectsHelper.fakeMapObjectId(), + objectsHelper.fakeMapObjectId(), + objectsHelper.fakeMapObjectId(), + objectsHelper.fakeMapObjectId(), + objectsHelper.fakeMapObjectId(), + ] + + // Send MAP_SET ops first to create zero-value maps with forged site timeserials vector + for (i, mapId) in mapIds.enumerated() { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), + siteCode: "bbb", + state: [objectsHelper.mapSetOp(objectId: mapId, key: "foo", data: .object(["string": .string("bar")]))], + ) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: mapId, data: .object(["objectId": .string(mapId)]))], + ) + } + + // Inject operations with various timeserial values + let timeserialTestCases = [ + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later CGO, applied + (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier CGO, applied + (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later CGO, applied + ] + + for (i, testCase) in timeserialTestCases.enumerated() { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: testCase.serial, + siteCode: testCase.siteCode, + state: [ + objectsHelper.mapCreateOp( + objectId: mapIds[i], + entries: [ + "baz": .object([ + "timeserial": .string(testCase.serial), + "data": .object(["string": .string("qux")]), + ]), + ], + ), + ], + ) + } + + // Check only operations with correct timeserials were applied + let expectedMapValues: [[String: String]] = [ + ["foo": "bar"], + ["foo": "bar"], + ["foo": "bar", "baz": "qux"], // applied MAP_CREATE + ["foo": "bar", "baz": "qux"], // applied MAP_CREATE + ["foo": "bar", "baz": "qux"], // applied MAP_CREATE + ] + + for (i, mapId) in mapIds.enumerated() { + let expectedMapValue = expectedMapValues[i] + let expectedKeysCount = expectedMapValue.count + + let mapObj = try #require(root.get(key: mapId)?.liveMapValue) + #expect(try mapObj.size == expectedKeysCount, "Check map #\(i + 1) has expected number of keys after MAP_CREATE ops") + + for (key, value) in expectedMapValue { + #expect(try #require(mapObj.get(key: key)?.stringValue) == value, "Check map #\(i + 1) has expected value for \"\(key)\" key after MAP_CREATE ops") + } + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "can apply MAP_SET with primitives object operation messages", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + + // Check root is empty before ops + for keyData in primitiveKeyData { + #expect(try root.get(key: keyData.key) == nil, "Check \"\(keyData.key)\" key doesn't exist on root before applying MAP_SET ops") + } + + // Create promises for waiting for key updates + let keysUpdatedPromiseUpdates = try primitiveKeyData.map { _ in try root.updates() } + async let keysUpdatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + for (i, keyData) in primitiveKeyData.enumerated() { + group.addTask { + await waitForMapKeyUpdate(keysUpdatedPromiseUpdates[i], keyData.key) + } + } + while try await group.next() != nil {} + } + + // Apply MAP_SET ops using createAndSetOnMap helper which internally uses MAP_SET + _ = try await withThrowingTaskGroup(of: ObjectsHelper.OperationResult.self) { group in + for keyData in primitiveKeyData { + group.addTask { + // We'll create dummy objects and set them, which uses MAP_SET internally + try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: keyData.key, + createOp: objectsHelper.mapCreateRestOp(data: ["value": .object(keyData.data)]), + ) + } + } + var results: [ObjectsHelper.OperationResult] = [] + while let result = try await group.next() { + results.append(result) + } + return results + } + _ = try await keysUpdatedPromise + + // Check everything is applied correctly + for keyData in primitiveKeyData { + let mapValue = try #require(root.get(key: keyData.key)?.liveMapValue) + + if let bytesString = keyData.data["bytes"]?.stringValue { + let expectedData = Data(base64Encoded: bytesString) + #expect(try mapValue.get(key: "value")?.dataValue == expectedData, "Check root has correct value for \"\(keyData.key)\" key after MAP_SET op") + } else if let numberValue = keyData.data["number"]?.numberValue { + #expect(try mapValue.get(key: "value")?.numberValue == numberValue, "Check root has correct value for \"\(keyData.key)\" key after MAP_SET op") + } else if let stringValue = keyData.data["string"]?.stringValue { + #expect(try mapValue.get(key: "value")?.stringValue == stringValue, "Check root has correct value for \"\(keyData.key)\" key after MAP_SET op") + } else if let boolValue = keyData.data["boolean"]?.boolValue { + #expect(try mapValue.get(key: "value")?.boolValue == boolValue, "Check root has correct value for \"\(keyData.key)\" key after MAP_SET op") + } + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "can apply MAP_SET with object ids object operation messages", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + + // Check no object ids are set on root + #expect(try root.get(key: "keyToCounter") == nil, "Check \"keyToCounter\" key doesn't exist on root before applying MAP_SET ops") + #expect(try root.get(key: "keyToMap") == nil, "Check \"keyToMap\" key doesn't exist on root before applying MAP_SET ops") + + let objectsCreatedPromiseUpdates1 = try root.updates() + let objectsCreatedPromiseUpdates2 = try root.updates() + async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "keyToCounter") + } + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "keyToMap") + } + while try await group.next() != nil {} + } + + // Create new objects and set on root + _ = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "keyToCounter", + createOp: objectsHelper.counterCreateRestOp(number: 1), + ) + + _ = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "keyToMap", + createOp: objectsHelper.mapCreateRestOp(data: ["stringKey": .object(["string": .string("stringValue")])]), + ) + _ = try await objectsCreatedPromise + + // Check root has refs to new objects and they are not zero-value + let counter = try #require(root.get(key: "keyToCounter")?.liveCounterValue) + #expect(try counter.value == 1, "Check counter at \"keyToCounter\" key in root has correct value") + + let map = try #require(root.get(key: "keyToMap")?.liveMapValue) + #expect(try map.size == 1, "Check map at \"keyToMap\" key in root has correct number of keys") + #expect(try #require(map.get(key: "stringKey")?.stringValue) == "stringValue", "Check map at \"keyToMap\" key in root has correct \"stringKey\" value") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "can apply COUNTER_CREATE object operation messages", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + + // Check no counters exist on root + for fixture in countersFixtures { + let key = fixture.name + #expect(try root.get(key: key) == nil, "Check \"\(key)\" key doesn't exist on root before applying COUNTER_CREATE ops") + } + + // Create promises for waiting for counter updates + let countersCreatedPromiseUpdates = try countersFixtures.map { _ in try root.updates() } + async let countersCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + for (i, fixture) in countersFixtures.enumerated() { + group.addTask { + await waitForMapKeyUpdate(countersCreatedPromiseUpdates[i], fixture.name) + } + } + while try await group.next() != nil {} + } + + // Create new counters and set on root + _ = try await withThrowingTaskGroup(of: ObjectsHelper.OperationResult.self) { group in + for fixture in countersFixtures { + group.addTask { + try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: fixture.name, + createOp: objectsHelper.counterCreateRestOp(number: fixture.count), + ) + } + } + var results: [ObjectsHelper.OperationResult] = [] + while let result = try await group.next() { + results.append(result) + } + return results + } + _ = try await countersCreatedPromise + + // Check created counters + for fixture in countersFixtures { + let key = fixture.name + let counterObj = try #require(root.get(key: key)?.liveCounterValue) + + // Check counters have correct values + let expectedValue = fixture.count ?? 0 + #expect(try counterObj.value == expectedValue, "Check counter at \"\(key)\" key in root has correct value") + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "can apply COUNTER_INC object operation messages", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let counterKey = "counter" + var expectedCounterValue = 0.0 + + let counterCreatedPromiseUpdates = try root.updates() + async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, counterKey) + + // Create new counter and set on root + let counterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: counterKey, + createOp: objectsHelper.counterCreateRestOp(number: expectedCounterValue), + ) + _ = await counterCreatedPromise + + let counter = try #require(root.get(key: counterKey)?.liveCounterValue) + // Check counter has expected value before COUNTER_INC + #expect(try counter.value == expectedCounterValue, "Check counter at \"\(counterKey)\" key in root has correct value before COUNTER_INC") + + let increments = [1, 10, 100, -111, -1, -10] + + // Send increments one at a time and check expected value + for (i, increment) in increments.enumerated() { + expectedCounterValue += Double(increment) + + // Use the public API to increment - this will send COUNTER_INC internally + try await counter.increment(amount: Double(increment)) + + #expect(try counter.value == expectedCounterValue, "Check counter at \"\(counterKey)\" key in root has correct value after \(i + 1) COUNTER_INC ops") + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "can apply OBJECT_DELETE object operation messages", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let channel = ctx.channel + + let objectsCreatedPromiseUpdates1 = try root.updates() + let objectsCreatedPromiseUpdates2 = try root.updates() + async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "map") + } + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "counter") + } + while try await group.next() != nil {} + } + + // Create initial objects and set on root + let mapResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "map", + createOp: objectsHelper.mapCreateRestOp(), + ) + let counterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter", + createOp: objectsHelper.counterCreateRestOp(), + ) + _ = try await objectsCreatedPromise + + #expect(try root.get(key: "map") != nil, "Check map exists on root before OBJECT_DELETE") + #expect(try root.get(key: "counter") != nil, "Check counter exists on root before OBJECT_DELETE") + + // Inject OBJECT_DELETE operations + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.objectDeleteOp(objectId: mapResult.objectId)], + ) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), + siteCode: "aaa", + state: [objectsHelper.objectDeleteOp(objectId: counterResult.objectId)], + ) + + #expect(try root.get(key: "map") == nil, "Check map is not accessible on root after OBJECT_DELETE") + #expect(try root.get(key: "counter") == nil, "Check counter is not accessible on root after OBJECT_DELETE") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "can apply MAP_REMOVE object operation messages", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let mapKey = "map" + + let mapCreatedPromiseUpdates = try root.updates() + async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, mapKey) + + // Create new map and set on root + let mapResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: mapKey, + createOp: objectsHelper.mapCreateRestOp(data: [ + "shouldStay": .object(["string": .string("foo")]), + "shouldDelete": .object(["string": .string("bar")]), + ]), + ) + _ = await mapCreatedPromise + + let map = try #require(root.get(key: mapKey)?.liveMapValue) + // Check map has expected keys before MAP_REMOVE ops + #expect(try map.size == 2, "Check map at \"\(mapKey)\" key in root has correct number of keys before MAP_REMOVE") + #expect(try #require(map.get(key: "shouldStay")?.stringValue) == "foo", "Check map at \"\(mapKey)\" key in root has correct \"shouldStay\" value before MAP_REMOVE") + #expect(try #require(map.get(key: "shouldDelete")?.stringValue) == "bar", "Check map at \"\(mapKey)\" key in root has correct \"shouldDelete\" value before MAP_REMOVE") + + // Send MAP_REMOVE op using the public API + try await map.remove(key: "shouldDelete") + + // Check map has correct keys after MAP_REMOVE ops + #expect(try map.size == 1, "Check map at \"\(mapKey)\" key in root has correct number of keys after MAP_REMOVE") + #expect(try #require(map.get(key: "shouldStay")?.stringValue) == "foo", "Check map at \"\(mapKey)\" key in root has correct \"shouldStay\" value after MAP_REMOVE") + #expect(try map.get(key: "shouldDelete") == nil, "Check map at \"\(mapKey)\" key in root has no \"shouldDelete\" key after MAP_REMOVE") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "OBJECT_DELETE for unknown object id creates zero-value tombstoned object", + action: { ctx throws in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + let counterId = objectsHelper.fakeCounterObjectId() + // Inject OBJECT_DELETE - should create a zero-value tombstoned object which can't be modified + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.objectDeleteOp(objectId: counterId)], + ) + + // Try to create and set tombstoned object on root + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), + siteCode: "bbb", + state: [objectsHelper.counterCreateOp(objectId: counterId)], + ) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), + siteCode: "bbb", + state: [objectsHelper.mapSetOp(objectId: "root", key: "counter", data: .object(["objectId": .string(counterId)]))], + ) + + #expect(try root.get(key: "counter") == nil, "Check counter is not accessible on root") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "MAP_SET with reference to a tombstoned object results in undefined value on key", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let channel = ctx.channel + + let objectCreatedPromiseUpdates = try root.updates() + async let objectCreatedPromise: Void = waitForMapKeyUpdate(objectCreatedPromiseUpdates, "foo") + + // Create initial objects and set on root + let counterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "foo", + createOp: objectsHelper.counterCreateRestOp(), + ) + _ = await objectCreatedPromise + + #expect(try root.get(key: "foo") != nil, "Check counter exists on root before OBJECT_DELETE") + + // Inject OBJECT_DELETE + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.objectDeleteOp(objectId: counterResult.objectId)], + ) + + // Set tombstoned counter to another key on root + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: "bar", data: .object(["objectId": .string(counterResult.objectId)]))], + ) + + #expect(try root.get(key: "bar") == nil, "Check counter is not accessible on new key in root after OBJECT_DELETE") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "object operation message on a tombstoned object does not revive it", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let channel = ctx.channel + + let objectsCreatedPromiseUpdates1 = try root.updates() + let objectsCreatedPromiseUpdates2 = try root.updates() + let objectsCreatedPromiseUpdates3 = try root.updates() + async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "map1") + } + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "map2") + } + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates3, "counter1") + } + while try await group.next() != nil {} + } + + // Create initial objects and set on root + let mapResult1 = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "map1", + createOp: objectsHelper.mapCreateRestOp(), + ) + let mapResult2 = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "map2", + createOp: objectsHelper.mapCreateRestOp(data: ["foo": .object(["string": .string("bar")])]), + ) + let counterResult1 = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter1", + createOp: objectsHelper.counterCreateRestOp(), + ) + _ = try await objectsCreatedPromise + + #expect(try root.get(key: "map1") != nil, "Check map1 exists on root before OBJECT_DELETE") + #expect(try root.get(key: "map2") != nil, "Check map2 exists on root before OBJECT_DELETE") + #expect(try root.get(key: "counter1") != nil, "Check counter1 exists on root before OBJECT_DELETE") + + // Inject OBJECT_DELETE operations + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.objectDeleteOp(objectId: mapResult1.objectId)], + ) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), + siteCode: "aaa", + state: [objectsHelper.objectDeleteOp(objectId: mapResult2.objectId)], + ) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 2, counter: 0), + siteCode: "aaa", + state: [objectsHelper.objectDeleteOp(objectId: counterResult1.objectId)], + ) + + // Inject object operations on tombstoned objects + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 3, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: mapResult1.objectId, key: "baz", data: .object(["string": .string("qux")]))], + ) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 4, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapRemoveOp(objectId: mapResult2.objectId, key: "foo")], + ) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0), + siteCode: "aaa", + state: [objectsHelper.counterIncOp(objectId: counterResult1.objectId, number: 1)], + ) + + // Objects should still be deleted + #expect(try root.get(key: "map1") == nil, "Check map1 does not exist on root after OBJECT_DELETE and another object op") + #expect(try root.get(key: "map2") == nil, "Check map2 does not exist on root after OBJECT_DELETE and another object op") + #expect(try root.get(key: "counter1") == nil, "Check counter1 does not exist on root after OBJECT_DELETE and another object op") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "MAP_SET object operation messages are applied based on the site timeserials vector of the object", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Create new map and set it on a root with forged timeserials + let mapId = objectsHelper.fakeMapObjectId() + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), + siteCode: "bbb", + state: [ + objectsHelper.mapCreateOp( + objectId: mapId, + entries: [ + "foo1": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo2": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo3": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo4": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo5": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo6": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + ], + ), + ], + ) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: "map", data: .object(["objectId": .string(mapId)]))], + ) + + // Inject operations with various timeserial values + let timeserialTestCases = [ + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier site CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same site CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later site CGO, applied, site timeserials updated + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, same site CGO (updated from last op), not applied + (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier entry CGO, not applied + (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later entry CGO, applied + ] + + for (i, testCase) in timeserialTestCases.enumerated() { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: testCase.serial, + siteCode: testCase.siteCode, + state: [objectsHelper.mapSetOp(objectId: mapId, key: "foo\(i + 1)", data: .object(["string": .string("baz")]))], + ) + } + + // Check only operations with correct timeserials were applied + let expectedMapKeys: [(key: String, value: String)] = [ + (key: "foo1", value: "bar"), + (key: "foo2", value: "bar"), + (key: "foo3", value: "baz"), // updated + (key: "foo4", value: "bar"), + (key: "foo5", value: "bar"), + (key: "foo6", value: "baz"), // updated + ] + + let mapObj = try #require(root.get(key: "map")?.liveMapValue) + for expectedMapKey in expectedMapKeys { + #expect(try #require(mapObj.get(key: expectedMapKey.key)?.stringValue) == expectedMapKey.value, "Check \"\(expectedMapKey.key)\" key on map has expected value after MAP_SET ops") + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "COUNTER_INC object operation messages are applied based on the site timeserials vector of the object", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Create new counter and set it on a root with forged timeserials + let counterId = objectsHelper.fakeCounterObjectId() + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), + siteCode: "bbb", + state: [objectsHelper.counterCreateOp(objectId: counterId, count: 1)], + ) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: "counter", data: .object(["objectId": .string(counterId)]))], + ) + + // Inject operations with various timeserial values + let timeserialTestCases = [ + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb", amount: 10), // existing site, earlier CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb", amount: 100), // existing site, same CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb", amount: 1000), // existing site, later CGO, applied, site timeserials updated + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb", amount: 10000), // existing site, same CGO (updated from last op), not applied + (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa", amount: 100_000), // different site, earlier CGO, applied + (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc", amount: 1_000_000), // different site, later CGO, applied + ] + + for testCase in timeserialTestCases { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: testCase.serial, + siteCode: testCase.siteCode, + state: [objectsHelper.counterIncOp(objectId: counterId, number: testCase.amount)], + ) + } + + // Check only operations with correct timeserials were applied + let counter = try #require(root.get(key: "counter")?.liveCounterValue) + let expectedValue = 1.0 + 1000.0 + 100_000.0 + 1_000_000.0 // sum of passing operations and the initial value + #expect(try counter.value == expectedValue, "Check counter has expected value after COUNTER_INC ops") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "MAP_REMOVE object operation messages are applied based on the site timeserials vector of the object", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Create new map and set it on a root with forged timeserials + let mapId = objectsHelper.fakeMapObjectId() + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), + siteCode: "bbb", + state: [ + objectsHelper.mapCreateOp( + objectId: mapId, + entries: [ + "foo1": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo2": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo3": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo4": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo5": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo6": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + ], + ), + ], + ) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: "map", data: .object(["objectId": .string(mapId)]))], + ) + + // Inject operations with various timeserial values + let timeserialTestCases = [ + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier site CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same site CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later site CGO, applied, site timeserials updated + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, same site CGO (updated from last op), not applied + (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier entry CGO, not applied + (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later entry CGO, applied + ] + + for (i, testCase) in timeserialTestCases.enumerated() { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: testCase.serial, + siteCode: testCase.siteCode, + state: [objectsHelper.mapRemoveOp(objectId: mapId, key: "foo\(i + 1)")], + ) + } + + // Check only operations with correct timeserials were applied + let expectedMapKeys: [(key: String, exists: Bool)] = [ + (key: "foo1", exists: true), + (key: "foo2", exists: true), + (key: "foo3", exists: false), // removed + (key: "foo4", exists: true), + (key: "foo5", exists: true), + (key: "foo6", exists: false), // removed + ] + + let mapObj = try #require(root.get(key: "map")?.liveMapValue) + for expectedMapKey in expectedMapKeys { + if expectedMapKey.exists { + #expect(try mapObj.get(key: expectedMapKey.key) != nil, "Check \"\(expectedMapKey.key)\" key on map still exists after MAP_REMOVE ops") + } else { + #expect(try mapObj.get(key: expectedMapKey.key) == nil, "Check \"\(expectedMapKey.key)\" key on map does not exist after MAP_REMOVE ops") + } + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "COUNTER_CREATE object operation messages are applied based on the site timeserials vector of the object", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Need to use multiple counters as COUNTER_CREATE op can only be applied once to a counter object + let counterIds = [ + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + ] + + // Send COUNTER_INC ops first to create zero-value counters with forged site timeserials vector + for (i, counterId) in counterIds.enumerated() { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), + siteCode: "bbb", + state: [objectsHelper.counterIncOp(objectId: counterId, number: 1)], + ) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: counterId, data: .object(["objectId": .string(counterId)]))], + ) + } + + // Inject operations with various timeserial values + let timeserialTestCases = [ + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later CGO, applied + (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier CGO, applied + (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later CGO, applied + ] + + for (i, testCase) in timeserialTestCases.enumerated() { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: testCase.serial, + siteCode: testCase.siteCode, + state: [objectsHelper.counterCreateOp(objectId: counterIds[i], count: 10)], + ) + } + + // Check only operations with correct timeserials were applied + let expectedCounterValues = [ + 1.0, + 1.0, + 11.0, // applied COUNTER_CREATE + 11.0, // applied COUNTER_CREATE + 11.0, // applied COUNTER_CREATE + ] + + for (i, counterId) in counterIds.enumerated() { + let expectedValue = expectedCounterValues[i] + let counter = try #require(root.get(key: counterId)?.liveCounterValue) + #expect(try counter.value == expectedValue, "Check counter #\(i + 1) has expected value after COUNTER_CREATE ops") + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "OBJECT_DELETE object operation messages are applied based on the site timeserials vector of the object", + action: { ctx throws in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Need to use multiple objects as OBJECT_DELETE op can only be applied once to an object + let counterIds = [ + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + objectsHelper.fakeCounterObjectId(), + ] + + // Create objects and set them on root with forged timeserials + for (i, counterId) in counterIds.enumerated() { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), + siteCode: "bbb", + state: [objectsHelper.counterCreateOp(objectId: counterId)], + ) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: counterId, data: .object(["objectId": .string(counterId)]))], + ) + } + + // Inject OBJECT_DELETE operations with various timeserial values + let timeserialTestCases = [ + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later CGO, applied + (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier CGO, applied + (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later CGO, applied + ] + + for (i, testCase) in timeserialTestCases.enumerated() { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: testCase.serial, + siteCode: testCase.siteCode, + state: [objectsHelper.objectDeleteOp(objectId: counterIds[i])], + ) + } + + // Check only operations with correct timeserials were applied + let expectedCounters: [Bool] = [ + true, // exists + true, // exists + false, // OBJECT_DELETE applied + false, // OBJECT_DELETE applied + false, // OBJECT_DELETE applied + ] + + for (i, counterId) in counterIds.enumerated() { + let exists = expectedCounters[i] + + if exists { + #expect(try root.get(key: counterId) != nil, "Check counter #\(i + 1) exists on root as OBJECT_DELETE op was not applied") + } else { + #expect(try root.get(key: counterId) == nil, "Check counter #\(i + 1) does not exist on root as OBJECT_DELETE op was applied") + } + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "OBJECT_DELETE triggers subscription callback with deleted data", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let channel = ctx.channel + + let objectsCreatedPromiseUpdates1 = try root.updates() + let objectsCreatedPromiseUpdates2 = try root.updates() + async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "map") + } + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "counter") + } + while try await group.next() != nil {} + } + + // Create initial objects and set on root + let mapResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "map", + createOp: objectsHelper.mapCreateRestOp(data: [ + "foo": .object(["string": .string("bar")]), + "baz": .object(["number": .number(1)]), + ]), + ) + let counterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter", + createOp: objectsHelper.counterCreateRestOp(number: 1), + ) + _ = try await objectsCreatedPromise + + let mapSubPromiseUpdates = try #require(root.get(key: "map")?.liveMapValue).updates() + let counterSubPromiseUpdates = try #require(root.get(key: "counter")?.liveCounterValue).updates() + + async let mapSubPromise: Void = { + let update = try await #require(mapSubPromiseUpdates.first { _ in true }) + #expect(update.update["foo"] == .removed, "Check map subscription callback is called with an expected update object after OBJECT_DELETE operation for 'foo' key") + #expect(update.update["baz"] == .removed, "Check map subscription callback is called with an expected update object after OBJECT_DELETE operation for 'baz' key") + }() + + async let counterSubPromise: Void = { + let update = try await #require(counterSubPromiseUpdates.first { _ in true }) + #expect(update.amount == -1, "Check counter subscription callback is called with an expected update object after OBJECT_DELETE operation") + }() + + // Inject OBJECT_DELETE + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.objectDeleteOp(objectId: mapResult.objectId)], + ) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), + siteCode: "aaa", + state: [objectsHelper.objectDeleteOp(objectId: counterResult.objectId)], + ) + + _ = try await (mapSubPromise, counterSubPromise) + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "can apply MAP_CLEAR object operation messages on root", + action: { ctx in + let root = ctx.root + let objects = ctx.objects + let objectsHelper = ctx.objectsHelper + + // set some keys on root + try await root.set(key: "foo", value: "bar") + try await root.set(key: "baz", value: 42) + + // verify keys exist before clear + let fooValue = try #require(root.get(key: "foo")?.stringValue) + #expect(fooValue == "bar", "Check foo exists before MAP_CLEAR") + let bazValue = try #require(root.get(key: "baz")?.numberValue) + #expect(bazValue == 42, "Check baz exists before MAP_CLEAR") + #expect(try root.size == 2, "Check root has 2 keys before MAP_CLEAR") + + // send MAP_CLEAR + let clearAppliedPromiseUpdates = try root.updates() + async let clearAppliedPromise: Void = waitForMapClear(clearAppliedPromiseUpdates, expectedRemovedKeys: ["foo", "baz"]) + try await objectsHelper.sendMapClearOnChannel(objects: objects, objectId: "root") + await clearAppliedPromise + + // verify all keys are cleared + #expect(try root.size == 0, "Check root has 0 keys after MAP_CLEAR") + let fooAfterClear = try root.get(key: "foo") + #expect(fooAfterClear == nil, "Check foo does not exist after MAP_CLEAR") + let bazAfterClear = try root.get(key: "baz") + #expect(bazAfterClear == nil, "Check baz does not exist after MAP_CLEAR") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + // MAP_CLEAR is currently server-initiated and only emitted for root objects, + // but the client must be future-proof and support it for any map object. + description: "can apply MAP_CLEAR object operation messages on non-root map objects", + action: { ctx in + let root = ctx.root + let objects = ctx.objects + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // create a non-root map with entries + try await root.set(key: "map", value: .liveMap(objects.createMap(entries: ["foo": "bar", "baz": 42]))) + + let map = try #require(try root.get(key: "map")?.liveMapValue) + #expect(try map.size == 2, "Check map has 2 keys before MAP_CLEAR") + let mapFooValue = try #require(map.get(key: "foo")?.stringValue) + #expect(mapFooValue == "bar", "Check \"foo\" key has correct value") + let mapBazValue = try #require(map.get(key: "baz")?.numberValue) + #expect(mapBazValue == 42, "Check \"baz\" key has correct value") + + // apply MAP_CLEAR on non-root map via internal API call, + // as the server won't accept MAP_CLEAR for non-root object ids. + let internalMap = try #require(map as? PublicDefaultLiveMap) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "zzz", timestamp: 99_999_999_999_999, counter: 0), + siteCode: "zzz", + state: [objectsHelper.mapClearOp(objectId: internalMap.proxied.testsOnly_objectID)], + ) + + // verify all keys are cleared + #expect(try map.size == 0, "Check map has 0 keys after MAP_CLEAR") + let mapFooAfterClear = try map.get(key: "foo") + #expect(mapFooAfterClear == nil, "Check \"foo\" key does not exist after MAP_CLEAR") + let mapBazAfterClear = try map.get(key: "baz") + #expect(mapBazAfterClear == nil, "Check \"baz\" key does not exist after MAP_CLEAR") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "MAP_CLEAR with older serial than current clearTimeserial is a noop", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], + ) + + // apply a sequence of operations and check expected state after each + let steps: [(op: [String: WireValue], serial: String, siteCode: String, description: String, expectedSize: Int, expectedKeys: [String: String])] = [ + ( + op: objectsHelper.mapClearOp(objectId: "root"), + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), + siteCode: "aaa", + description: "first MAP_CLEAR", + expectedSize: 0, + expectedKeys: [:] + ), + ( + op: objectsHelper.mapSetOp(objectId: "root", key: "key1", data: .object(["string": .string("value")])), + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 11, counter: 0), + siteCode: "aaa", + description: "MAP_SET #1 after clear", + expectedSize: 1, + expectedKeys: ["key1": "value"] + ), + ( + op: objectsHelper.mapClearOp(objectId: "root"), + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), // different site so siteTimeserials check passes, older than first clear - noop + siteCode: "bbb", + description: "second MAP_CLEAR with older serial (noop)", + expectedSize: 1, + expectedKeys: ["key1": "value"] + ), + ] + + for step in steps { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: step.serial, + siteCode: step.siteCode, + state: [step.op], + ) + + #expect(try root.size == step.expectedSize, "Check map size after \(step.description)") + for (key, value) in step.expectedKeys { + let keyValue = try #require(root.get(key: key)?.stringValue) + #expect(keyValue == value, "Check \"\(key)\" after \(step.description)") + } + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "MAP_CLEAR does not remove entries with serial greater than clearTimeserial", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // set keys with different timeserials + let keys: [(key: String, serial: String, siteCode: String, survivesClear: Bool)] = [ + (key: "key1", serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa", survivesClear: false), // different site, earlier CGO, cleared + (key: "key2", serial: lexicoTimeserial(seriesId: "aaa", timestamp: 999, counter: 0), siteCode: "aaa", survivesClear: true), // different site, later CGO, survives + (key: "key3", serial: lexicoTimeserial(seriesId: "bbb", timestamp: 5, counter: 0), siteCode: "bbb", survivesClear: false), // same site, earlier CGO, cleared + (key: "key4", serial: lexicoTimeserial(seriesId: "ccc", timestamp: 0, counter: 0), siteCode: "ccc", survivesClear: false), // different site, earlier CGO, cleared + (key: "key5", serial: lexicoTimeserial(seriesId: "ccc", timestamp: 999, counter: 0), siteCode: "ccc", survivesClear: true), // different site, later CGO, survives + ] + + for keyEntry in keys { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: keyEntry.serial, + siteCode: keyEntry.siteCode, + state: [objectsHelper.mapSetOp(objectId: "root", key: keyEntry.key, data: .object(["string": .string(keyEntry.key)]))], + ) + } + + #expect(try root.size == keys.count, "Check map has correct number of keys before MAP_CLEAR") + + // apply MAP_CLEAR with serial between existing keys + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 6, counter: 0), + siteCode: "bbb", + state: [objectsHelper.mapClearOp(objectId: "root")], + ) + + var expectedToSurviveCount = 0 + for keyEntry in keys { + expectedToSurviveCount += keyEntry.survivesClear ? 1 : 0 + if keyEntry.survivesClear { + let keyValue = try #require(root.get(key: keyEntry.key)?.stringValue) + #expect(keyValue == keyEntry.key, "Check \(keyEntry.key) survives MAP_CLEAR") + } else { + let keyValue = try root.get(key: keyEntry.key) + #expect(keyValue == nil, "Check \(keyEntry.key) is cleared") + } + } + #expect(try root.size == expectedToSurviveCount, "Check map has \(expectedToSurviveCount) keys after MAP_CLEAR") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "MAP_CLEAR object operation messages are applied based on the site timeserials vector of the object", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + let mapIds = [ + objectsHelper.fakeMapObjectId(), + objectsHelper.fakeMapObjectId(), + objectsHelper.fakeMapObjectId(), + objectsHelper.fakeMapObjectId(), + objectsHelper.fakeMapObjectId(), + ] + + for (i, mapId) in mapIds.enumerated() { + // for each map, send two operations: + // 1. create a map with visible data that can be verified after MAP_CLEAR. + // use earliest possible timeserial to ensure entries can be cleared by MAP_CLEAR ops + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [ + objectsHelper.mapCreateOp( + objectId: mapId, + entries: ["foo": .object(["timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), "data": .object(["string": .string("bar")])])], + ), + ], + ) + // 2. send a no-op remove to establish site 'ccc' in the map's siteTimeserials at a known serial, + // which the MAP_CLEAR ops below will be compared against + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "ccc", timestamp: 5, counter: 0), + siteCode: "ccc", + state: [objectsHelper.mapRemoveOp(objectId: mapId, key: "baz")], + ) + + // set map on root + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: mapId, data: .object(["objectId": .string(mapId)]))], + ) + } + + // inject MAP_CLEAR operations with various timeserial values + // relative to the lexicoTimeserial('ccc', 5, 0) from the remove op above + let ops: [(serial: String, siteCode: String, cleared: Bool)] = [ + (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 4, counter: 0), siteCode: "ccc", cleared: false), // existing site, earlier CGO, not applied + (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 5, counter: 0), siteCode: "ccc", cleared: false), // existing site, same CGO, not applied + (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 6, counter: 0), siteCode: "ccc", cleared: true), // existing site, later CGO, applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb", cleared: true), // different site, earlier CGO, applied + (serial: lexicoTimeserial(seriesId: "ddd", timestamp: 9, counter: 0), siteCode: "ddd", cleared: true), // different site, later CGO, applied + ] + + for (i, op) in ops.enumerated() { + let mapId = mapIds[i] + + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: op.serial, + siteCode: op.siteCode, + state: [objectsHelper.mapClearOp(objectId: mapId)], + ) + + let map = try #require(try root.get(key: mapId)?.liveMapValue) + if op.cleared { + #expect(try map.size == 0, "Check map #\(i + 1) is cleared") + } else { + #expect(try map.size == 1, "Check map #\(i + 1) is not cleared") + let fooValue = try #require(map.get(key: "foo")?.stringValue) + #expect(fooValue == "bar", "Check map #\(i + 1) retains \"foo\" key") + } + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "MAP_SET with serial <= clearTimeserial is ignored after MAP_CLEAR", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // apply MAP_CLEAR, stores clearTimeserial on a map + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapClearOp(objectId: "root")], + ) + + // inject MAP_SET operations with various serials relative to clearTimeserial. + // use different site codes to pass siteTimeserials check + let ops: [(serial: String, siteCode: String, key: String, applied: Bool)] = [ + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 5, counter: 0), siteCode: "bbb", key: "early", applied: false), // earlier than clear, ignored + (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), siteCode: "aaa", key: "equal", applied: false), // equal to clear, ignored + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 999, counter: 0), siteCode: "bbb", key: "later", applied: true), // later than clear, applied + ] + + for op in ops { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: op.serial, + siteCode: op.siteCode, + state: [objectsHelper.mapSetOp(objectId: "root", key: op.key, data: .object(["string": .string("value")]))], + ) + + if op.applied { + let value = try #require(root.get(key: op.key)?.stringValue) + #expect(value == "value", "Check MAP_SET for \"\(op.key)\" is applied") + } else { + let value = try root.get(key: op.key) + #expect(value == nil, "Check MAP_SET for \"\(op.key)\" is ignored") + } + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "MAP_REMOVE with serial <= clearTimeserial is ignored after MAP_CLEAR", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // apply MAP_CLEAR, stores clearTimeserial on a map + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapClearOp(objectId: "root")], + ) + + // inject MAP_REMOVE operations with various serials relative to clearTimeserial. + // use different site codes to pass siteTimeserials check + let ops: [(serial: String, siteCode: String, key: String, applied: Bool)] = [ + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 5, counter: 0), siteCode: "bbb", key: "early", applied: false), // earlier than clear, ignored + (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), siteCode: "aaa", key: "equal", applied: false), // equal to clear, ignored + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 999, counter: 0), siteCode: "bbb", key: "later", applied: true), // later than clear, applied + ] + + for op in ops { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: op.serial, + siteCode: op.siteCode, + state: [objectsHelper.mapRemoveOp(objectId: "root", key: op.key)], + ) + + let internallyTypedRoot = try #require(root as? PublicDefaultLiveMap) + let internalRoot = internallyTypedRoot.proxied + let underlyingData = internalRoot.testsOnly_data + if op.applied { + let mapEntry = try #require(underlyingData[op.key]) + #expect(mapEntry.tombstone == true, "Check MAP_REMOVE for \"\(op.key)\" is tombstoned") + } else { + #expect(underlyingData[op.key] == nil, "Check MAP_REMOVE for \"\(op.key)\" is ignored") + } + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "MAP_CLEAR removes entries from internal data map", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // set a key on root so the clear has something to remove + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], + ) + #expect(try root.get(key: "foo") != nil, "Check \"foo\" exists before clear") + + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapClearOp(objectId: "root")], + ) + + // entry should be fully removed from internal data + let internallyTypedRoot = try #require(root as? PublicDefaultLiveMap) + let internalRoot = internallyTypedRoot.proxied + let underlyingData = internalRoot.testsOnly_data + #expect(underlyingData["foo"] == nil, "Check \"foo\" is removed from internal data") + }, + ), + ] + + let applyOperationsDuringSyncScenarios: [TestScenario] = [ + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "object operation messages are buffered during OBJECT_SYNC sequence", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + let client = ctx.client + + // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:cursor", + ) + + // Inject operations, they should not be applied as sync is in progress + // Note that unlike in the JS test we do not perform this concurrently because if we were to do that in Swift Concurrency we would not be able to guarantee that the operations are applied in the correct order (if they're not then messages will be discarded due to serials being out of order) + for keyData in primitiveKeyData { + var wireData = keyData.data.mapValues { WireValue(jsonValue: $0) } + + if let bytesValue = wireData["bytes"], client.internal.options.useBinaryProtocol { + let bytesString = try #require(bytesValue.stringValue) + wireData["bytes"] = try .data(#require(.init(base64Encoded: bytesString))) + } + + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: keyData.key, data: .object(wireData))], + ) + } + + // Check root doesn't have data from operations + for keyData in primitiveKeyData { + #expect(try root.get(key: keyData.key) == nil, "Check \"\(keyData.key)\" key doesn't exist on root during OBJECT_SYNC") + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "buffered object operation messages are applied when OBJECT_SYNC sequence ends", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + let client = ctx.client + + // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:cursor", + ) + + // Inject operations, they should be applied when sync ends + // Note that unlike in the JS test we do not perform this concurrently because if we were to do that in Swift Concurrency we would not be able to guarantee that the operations are applied in the correct order (if they're not then messages will be discarded due to serials being out of order) + for (i, keyData) in primitiveKeyData.enumerated() { + var wireData = keyData.data.mapValues { WireValue(jsonValue: $0) } + + if let bytesValue = wireData["bytes"], client.internal.options.useBinaryProtocol { + let bytesString = try #require(bytesValue.stringValue) + wireData["bytes"] = try .data(#require(.init(base64Encoded: bytesString))) + } + + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: keyData.key, data: .object(wireData))], + ) + } + + // End the sync with empty cursor + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", + ) + + // Check everything is applied correctly + for keyData in primitiveKeyData { + if let bytesValue = keyData.data["bytes"] { + if case let .string(base64String) = bytesValue { + let expectedData = Data(base64Encoded: base64String) + #expect(try #require(root.get(key: keyData.key)?.dataValue) == expectedData, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") + } + } else { + // Handle other value types + if let stringValue = keyData.data["string"] { + if case let .string(expectedString) = stringValue { + #expect(try #require(root.get(key: keyData.key)?.stringValue) == expectedString, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") + } + } else if let numberValue = keyData.data["number"] { + if case let .number(expectedNumber) = numberValue { + #expect(try #require(root.get(key: keyData.key)?.numberValue) == expectedNumber, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") + } + } else if let boolValue = keyData.data["boolean"] { + if case let .bool(expectedBool) = boolValue { + #expect(try #require(root.get(key: keyData.key)?.boolValue as Bool?) == expectedBool, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") + } + } + } + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "buffered object operation messages are discarded on ATTACHED", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:cursor", + ) + + // Inject operation during sync sequence, expect it to be discarded when ATTACHED arrives + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], + ) + + // Any ATTACHED message must clear buffered operations and start a new sync sequence + await injectAttachedMessage(channel: channel, flags: .hasObjects) + + // Inject another operation that should be applied when sync ends + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), + siteCode: "bbb", + state: [objectsHelper.mapSetOp(objectId: "root", key: "baz", data: .object(["string": .string("qux")]))], + ) + + // End sync + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", + ) + + // Check root doesn't have data from operations received before ATTACHED + let fooValue = try root.get(key: "foo") + #expect(fooValue == nil, "Check buffered ops before ATTACHED were discarded and not applied on root") + + // Check root has data from operations received after ATTACHED + #expect(try #require(root.get(key: "baz")?.stringValue) == "qux", "Check root has data from operations received after ATTACHED") + }, + ), + .init( + // Note: This comment re regression test is preserved from the JS test it's copied from, but this bug never actually existed in the Swift implementation. + // Regression test: an earlier implementation did not clear buffered operations when receiving + // an ATTACHED with RESUMED=true on an already-attached channel. The RESUMED flag is irrelevant + // — buffering is determined by HAS_OBJECTS, and any ATTACHED must clear buffered operations. + disabled: false, + allTransportsAndProtocols: false, + description: "buffered object operation messages are discarded when already-attached channel receives ATTACHED with RESUMED flag", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Channel is already attached from the test setup + #expect(channel.state == .attached, "Check channel is already attached before test begins") + + // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:cursor", + ) + + // Inject operation, expect it to be discarded when ATTACHED arrives (even with RESUMED) + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], + ) + + // The RESUMED flag is irrelevant for LiveObjects buffering — any ATTACHED must clear + // buffered operations and start a new sync sequence + await injectAttachedMessage(channel: channel, flags: [.hasObjects, .resumed]) + + // Inject another operation after ATTACHED + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), + siteCode: "bbb", + state: [objectsHelper.mapSetOp(objectId: "root", key: "baz", data: .object(["string": .string("qux")]))], + ) + + // End sync + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", + ) + + // Check root doesn't have data from operations received before ATTACHED + let fooValue = try root.get(key: "foo") + #expect(fooValue == nil, "Check buffered ops before RESUMED ATTACHED were discarded and not applied on root") + + // Check root has data from operations received after ATTACHED + #expect(try #require(root.get(key: "baz")?.stringValue) == "qux", "Check root has data from operations received after RESUMED ATTACHED") + }, + ), + .init( + // Regression test: an earlier implementation incorrectly cleared buffered operations when a new + // OBJECT_SYNC sequence started. Only an ATTACHED message should clear buffered operations, not + // a new OBJECT_SYNC sequence. + disabled: false, + allTransportsAndProtocols: false, + description: "buffered object operation messages are NOT discarded on new OBJECT_SYNC sequence", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:cursor", + ) + + // Inject operation during first sync sequence + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], + ) + + // Start new sync with new sequence id — buffered operations should NOT be discarded + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "otherserial:cursor", + ) + + // Inject another operation during second sync sequence + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), + siteCode: "bbb", + state: [objectsHelper.mapSetOp(objectId: "root", key: "baz", data: .object(["string": .string("qux")]))], + ) + + // End sync + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "otherserial:", + ) + + // Check root has data from operations received during first sync sequence + let fooStringValue = try #require(root.get(key: "foo")?.stringValue) + #expect(fooStringValue == "bar", "Check root has data from operations received during first OBJECT_SYNC sequence") + + // Check root has data from operations received during second sync + let bazStringValue = try #require(root.get(key: "baz")?.stringValue) + #expect(bazStringValue == "qux", "Check root has data from operations received during second OBJECT_SYNC sequence") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "operations are buffered when OBJECT_SYNC is received after completed sync without expected preceding ATTACHED", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Complete an initial sync sequence first + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", + ) + + // Simulate receiving OBJECT_SYNC without preceding ATTACHED. + // Normally, for server-initiated resync the server is expected to send ATTACHED with RESUMED=false first. + // However, if that doesn't happen, the client handles it as a best-effort case by starting to buffer from this point. + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "resync:cursor", + ) + + // Inject operations during this server-initiated resync — they should be buffered + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], + ) + + // Check root doesn't have data yet — operations should be buffered during resync + let fooValueDuringResync = try root.get(key: "foo") + #expect(fooValueDuringResync == nil, "Check \"foo\" key doesn't exist during server-initiated resync") + + // End the resync + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "resync:", + ) + + // Check buffered operations are now applied + let fooStringValue = try #require(root.get(key: "foo")?.stringValue) + #expect(fooStringValue == "bar", "Check root has correct value for \"foo\" key after server-initiated resync completed") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "buffered object operation messages are applied based on the site timeserials vector of the object", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages + let mapId = objectsHelper.fakeMapObjectId() + let counterId = objectsHelper.fakeCounterObjectId() + + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:cursor", + // Add object state messages with non-empty site timeserials + state: [ + // Next map and counter objects will be checked to have correct operations applied on them based on site timeserials + objectsHelper.mapObject( + objectId: mapId, + siteTimeserials: [ + "bbb": lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), + "ccc": lexicoTimeserial(seriesId: "ccc", timestamp: 5, counter: 0), + ], + materialisedEntries: [ + "foo1": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo2": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo3": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "ccc", timestamp: 5, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo4": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo5": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo6": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "ccc", timestamp: 2, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo7": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "ccc", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + "foo8": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "ccc", timestamp: 0, counter: 0)), + "data": .object(["string": .string("bar")]), + ]), + ], + ), + objectsHelper.counterObject( + objectId: counterId, + siteTimeserials: [ + "bbb": lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), + ], + initialCount: 1, + ), + // Add objects to the root so they're discoverable in the object tree + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], + initialEntries: [ + "map": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["objectId": .string(mapId)]), + ]), + "counter": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), + "data": .object(["objectId": .string(counterId)]), + ]), + ], + ), + ], + ) + + // Inject operations with various timeserial values + // Map: + let mapOperations: [(serial: String, siteCode: String)] = [ + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, earlier site CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, same site CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 3, counter: 0), siteCode: "bbb"), // existing site, later site CGO, earlier entry CGO, not applied but site timeserial updated + // message with later site CGO, same entry CGO case is not possible, as timeserial from entry would be set for the corresponding site code or be less than that + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 3, counter: 0), siteCode: "bbb"), // existing site, same site CGO (updated from last op), later entry CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 4, counter: 0), siteCode: "bbb"), // existing site, later site CGO, later entry CGO, applied + (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), siteCode: "aaa"), // different site, earlier entry CGO, not applied but site timeserial updated + (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), siteCode: "aaa"), // different site, same site CGO (updated from last op), later entry CGO, not applied + // different site with matching entry CGO case is not possible, as matching entry timeserial means that that timeserial is in the site timeserials vector + (serial: lexicoTimeserial(seriesId: "ddd", timestamp: 1, counter: 0), siteCode: "ddd"), // different site, later entry CGO, applied + ] + + for (i, operation) in mapOperations.enumerated() { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: operation.serial, + siteCode: operation.siteCode, + state: [objectsHelper.mapSetOp(objectId: mapId, key: "foo\(i + 1)", data: .object(["string": .string("baz")]))], + ) + } + + // Counter: + let counterOperations: [(serial: String, siteCode: String, amount: Double)] = [ + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb", amount: 10), // existing site, earlier CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb", amount: 100), // existing site, same CGO, not applied + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb", amount: 1000), // existing site, later CGO, applied, site timeserials updated + (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb", amount: 10000), // existing site, same CGO (updated from last op), not applied + (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa", amount: 100_000), // different site, earlier CGO, applied + (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc", amount: 1_000_000), // different site, later CGO, applied + ] + + for operation in counterOperations { + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: operation.serial, + siteCode: operation.siteCode, + state: [objectsHelper.counterIncOp(objectId: counterId, number: Int(operation.amount))], + ) + } + + // End sync + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", + ) + + // Check only operations with correct timeserials were applied + let expectedMapKeys: [(key: String, value: String)] = [ + (key: "foo1", value: "bar"), + (key: "foo2", value: "bar"), + (key: "foo3", value: "bar"), + (key: "foo4", value: "bar"), + (key: "foo5", value: "baz"), // updated + (key: "foo6", value: "bar"), + (key: "foo7", value: "bar"), + (key: "foo8", value: "baz"), // updated + ] + + let map = try #require(root.get(key: "map")?.liveMapValue) + for expectedMapKey in expectedMapKeys { + #expect(try #require(map.get(key: expectedMapKey.key)?.stringValue) == expectedMapKey.value, "Check \"\(expectedMapKey.key)\" key on map has expected value after OBJECT_SYNC has ended") + } + + let counter = try #require(root.get(key: "counter")?.liveCounterValue) + let expectedCounterValue = 1.0 + 1000.0 + 100_000.0 + 1_000_000.0 // sum of passing operations and the initial value + #expect(try counter.value == expectedCounterValue, "Check counter has expected value after OBJECT_SYNC has ended") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "subsequent object operation messages are applied immediately after OBJECT_SYNC ended and buffered operations are applied", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + let channelName = ctx.channelName + let client = ctx.client + + // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:cursor", + ) + + // Inject operations, they should be applied when sync ends + // Note that unlike in the JS test we do not perform this concurrently because if we were to do that in Swift Concurrency we would not be able to guarantee that the operations are applied in the correct order (if they're not then messages will be discarded due to serials being out of order) + for (i, keyData) in primitiveKeyData.enumerated() { + var wireData = keyData.data.mapValues { WireValue(jsonValue: $0) } + + if let bytesValue = wireData["bytes"], client.internal.options.useBinaryProtocol { + let bytesString = try #require(bytesValue.stringValue) + wireData["bytes"] = try .data(#require(.init(base64Encoded: bytesString))) + } + + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), + siteCode: "aaa", + state: [objectsHelper.mapSetOp(objectId: "root", key: keyData.key, data: .object(wireData))], + ) + } + + // End the sync with empty cursor + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", + ) + + let keyUpdatedPromiseUpdates = try root.updates() + async let keyUpdatedPromise: Void = waitForMapKeyUpdate(keyUpdatedPromiseUpdates, "foo") + + // Send some more operations + let operationResult = try await objectsHelper.operationRequest( + channelName: channelName, + opBody: objectsHelper.mapSetRestOp( + objectId: "root", + key: "foo", + value: ["string": .string("bar")], + ), + ) + await keyUpdatedPromise + + // Check buffered operations are applied, as well as the most recent operation outside of the sync sequence is applied + for keyData in primitiveKeyData { + if let bytesValue = keyData.data["bytes"] { + if case let .string(base64String) = bytesValue { + let expectedData = Data(base64Encoded: base64String) + #expect(try #require(root.get(key: keyData.key)?.dataValue) == expectedData, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") + } + } else { + // Handle other value types + if let stringValue = keyData.data["string"] { + if case let .string(expectedString) = stringValue { + #expect(try #require(root.get(key: keyData.key)?.stringValue) == expectedString, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") + } + } else if let numberValue = keyData.data["number"] { + if case let .number(expectedNumber) = numberValue { + #expect(try #require(root.get(key: keyData.key)?.numberValue) == expectedNumber, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") + } + } else if let boolValue = keyData.data["boolean"] { + if case let .bool(expectedBool) = boolValue { + #expect(try #require(root.get(key: keyData.key)?.boolValue as Bool?) == expectedBool, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") + } + } + } + } + + #expect(try #require(root.get(key: "foo")?.stringValue) == "bar", "Check root has correct value for \"foo\" key from operation received outside of OBJECT_SYNC after other buffered operations were applied") + }, + ), + ] + + let writeApiScenarios: [TestScenario] = [ + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "LiveCounter.increment sends COUNTER_INC operation", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + + let counterCreatedPromiseUpdates = try root.updates() + async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") + + let counterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter", + createOp: objectsHelper.counterCreateRestOp(), + ) + _ = await counterCreatedPromise + + let counter = try #require(root.get(key: "counter")?.liveCounterValue) + let increments: [Double] = [ + 1, // value=1 + 10, // value=11 + -11, // value=0 + -1, // value=-1 + -10, // value=-11 + 11, // value=0 + Double(Int.max), // value=9223372036854775807 + -Double(Int.max), // value=0 + -Double(Int.max), // value=-9223372036854775807 + ] + var expectedCounterValue = 0.0 + + for (i, increment) in increments.enumerated() { + expectedCounterValue += increment + + try await counter.increment(amount: increment) + + #expect(try counter.value == expectedCounterValue, "Check counter has correct value after \(i + 1) LiveCounter.increment calls") + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "LiveCounter.increment throws on invalid input", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + + let counterCreatedPromiseUpdates = try root.updates() + async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") + + let counterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter", + createOp: objectsHelper.counterCreateRestOp(), + ) + _ = await counterCreatedPromise + + let counter = try #require(root.get(key: "counter")?.liveCounterValue) + + // Test invalid numeric values - Swift type system prevents most invalid types + // OMITTED from JS tests due to Swift type system: increment(), increment(null), + // increment('foo'), increment(BigInt(1)), increment(true), increment(Symbol()), + // increment({}), increment([]), increment(counter) - all prevented by Swift's type system + await #expect(throws: Error.self, "Counter value increment should be a valid number") { + try await counter.increment(amount: Double.nan) + } + await #expect(throws: Error.self, "Counter value increment should be a valid number") { + try await counter.increment(amount: Double.infinity) + } + await #expect(throws: Error.self, "Counter value increment should be a valid number") { + try await counter.increment(amount: -Double.infinity) + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "LiveCounter.decrement sends COUNTER_INC operation", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + + let counterCreatedPromiseUpdates = try root.updates() + async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") + + let counterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter", + createOp: objectsHelper.counterCreateRestOp(), + ) + _ = await counterCreatedPromise + + let counter = try #require(root.get(key: "counter")?.liveCounterValue) + let decrements: [Double] = [ + 1, // value=-1 + 10, // value=-11 + -11, // value=0 + -1, // value=1 + -10, // value=11 + 11, // value=0 + Double(Int.max), // value=-9223372036854775807 + -Double(Int.max), // value=0 + -Double(Int.max), // value=9223372036854775807 + ] + var expectedCounterValue = 0.0 + + for (i, decrement) in decrements.enumerated() { + expectedCounterValue -= decrement + + try await counter.decrement(amount: decrement) + + #expect(try counter.value == expectedCounterValue, "Check counter has correct value after \(i + 1) LiveCounter.decrement calls") + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "LiveCounter.decrement throws on invalid input", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + + let counterCreatedPromiseUpdates = try root.updates() + async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") + + let counterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter", + createOp: objectsHelper.counterCreateRestOp(), + ) + _ = await counterCreatedPromise + + let counter = try #require(root.get(key: "counter")?.liveCounterValue) + + // Test invalid numeric values - Swift type system prevents most invalid types + // OMITTED from JS tests due to Swift type system: decrement(), decrement(null), + // decrement('foo'), decrement(BigInt(1)), decrement(true), decrement(Symbol()), + // decrement({}), decrement([]), decrement(counter) - all prevented by Swift's type system + await #expect(throws: Error.self, "Counter value decrement should be a valid number") { + try await counter.decrement(amount: Double.nan) + } + await #expect(throws: Error.self, "Counter value decrement should be a valid number") { + try await counter.decrement(amount: Double.infinity) + } + await #expect(throws: Error.self, "Counter value decrement should be a valid number") { + try await counter.decrement(amount: -Double.infinity) + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "LiveMap.set sends MAP_SET operation with primitive values", + action: { ctx in + let root = ctx.root + + _ = try await withThrowingTaskGroup(of: Void.self) { group in + for keyData in primitiveKeyData { + group.addTask { + try await root.set(key: keyData.key, value: keyData.liveMapValue) + } + } + while try await group.next() != nil {} + } + + // Check everything is applied correctly + for keyData in primitiveKeyData { + let actualValue = try #require(try root.get(key: keyData.key)) + + switch keyData.liveMapValue { + case let .data(expectedData): + let actualData = try #require(actualValue.dataValue) + #expect(actualData == expectedData, "Check root has correct value for \"\(keyData.key)\" key after LiveMap.set call") + case let .string(expectedString): + let actualString = try #require(actualValue.stringValue) + #expect(actualString == expectedString, "Check root has correct value for \"\(keyData.key)\" key after LiveMap.set call") + case let .number(expectedNumber): + let actualNumber = try #require(actualValue.numberValue) + #expect(actualNumber == expectedNumber, "Check root has correct value for \"\(keyData.key)\" key after LiveMap.set call") + case let .bool(expectedBool): + let actualBool = try #require(actualValue.boolValue as Bool?) + #expect(actualBool == expectedBool, "Check root has correct value for \"\(keyData.key)\" key after LiveMap.set call") + default: + Issue.record("Unexpected value type in test") + } + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "LiveMap.set sends MAP_SET operation with reference to another LiveObject", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + + let objectsCreatedPromiseUpdates1 = try root.updates() + let objectsCreatedPromiseUpdates2 = try root.updates() + async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "counter") + } + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "map") + } + while try await group.next() != nil {} + } + + _ = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter", + createOp: objectsHelper.counterCreateRestOp(), + ) + _ = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "map", + createOp: objectsHelper.mapCreateRestOp(), + ) + _ = try await objectsCreatedPromise + + let counter = try #require(root.get(key: "counter")?.liveCounterValue) + let map = try #require(root.get(key: "map")?.liveMapValue) + + async let setCounter2Promise: Void = root.set(key: "counter2", value: .liveCounter(counter)) + async let setMap2Promise: Void = root.set(key: "map2", value: .liveMap(map)) + _ = try await (setCounter2Promise, setMap2Promise) + + let counter2 = try #require(root.get(key: "counter2")?.liveCounterValue) + let map2 = try #require(root.get(key: "map2")?.liveMapValue) + + #expect(counter2 === counter, "Check can set a reference to a LiveCounter object on a root via a LiveMap.set call") + #expect(map2 === map, "Check can set a reference to a LiveMap object on a root via a LiveMap.set call") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "LiveMap.set throws on invalid input", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + + let mapCreatedPromiseUpdates = try root.updates() + async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") + + _ = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "map", + createOp: objectsHelper.mapCreateRestOp(), + ) + _ = await mapCreatedPromise + + let map = try #require(root.get(key: "map")?.liveMapValue) + + // OMITTED from JS tests due to Swift type system: + // Key validation: map.set(), map.set(null), map.set(1), map.set(BigInt(1)), + // map.set(true), map.set(Symbol()), map.set({}), map.set([]), map.set(map) + // Value validation: map.set('key'), map.set('key', null), map.set('key', BigInt(1)), + // map.set('key', Symbol()), map.set('key', {}), map.set('key', []) + // All prevented by Swift's type system - String keys and LiveMapValue values are enforced + + // Note: Swift's LiveMap.set(key:value:) method signature enforces String keys and + // LiveMapValue values at compile time, making most JS validation tests unnecessary + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "LiveMap.remove sends MAP_REMOVE operation", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + + let mapCreatedPromiseUpdates = try root.updates() + async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") + + _ = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "map", + createOp: objectsHelper.mapCreateRestOp(data: [ + "foo": .object(["number": .number(1)]), + "bar": .object(["number": .number(1)]), + "baz": .object(["number": .number(1)]), + ]), + ) + _ = await mapCreatedPromise + + let map = try #require(root.get(key: "map")?.liveMapValue) + + async let removeFooPromise: Void = map.remove(key: "foo") + async let removeBarPromise: Void = map.remove(key: "bar") + _ = try await (removeFooPromise, removeBarPromise) + + #expect(try map.get(key: "foo") == nil, "Check can remove a key from a root via a LiveMap.remove call") + #expect(try map.get(key: "bar") == nil, "Check can remove a key from a root via a LiveMap.remove call") + #expect(try #require(map.get(key: "baz")?.numberValue) == 1, "Check non-removed keys are still present on a root after LiveMap.remove call for another keys") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "LiveMap.remove throws on invalid input", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + + let mapCreatedPromiseUpdates = try root.updates() + async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") + + _ = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "map", + createOp: objectsHelper.mapCreateRestOp(), + ) + _ = await mapCreatedPromise + + let map = try #require(root.get(key: "map")?.liveMapValue) + + // OMITTED from JS tests due to Swift type system: + // map.remove(), map.remove(null), map.remove(1), map.remove(BigInt(1)), + // map.remove(true), map.remove(Symbol()), map.remove({}), map.remove([]), map.remove(map) + // All prevented by Swift's type system - String key parameter is enforced + + // Note: Swift's LiveMap.remove(key:) method signature enforces String keys at compile time, + // making JS key validation tests unnecessary + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "Objects.createCounter sends COUNTER_CREATE operation", + action: { ctx in + let objects = ctx.objects + + let counters = try await withThrowingTaskGroup(of: (index: Int, counter: any LiveCounter).self, returning: [any LiveCounter].self) { group in + for (index, fixture) in countersFixtures.enumerated() { + group.addTask { + let counter = if let count = fixture.count { + try await objects.createCounter(count: count) + } else { + try await objects.createCounter() + } + return (index: index, counter: counter) + } + } + + var results: [(index: Int, counter: any LiveCounter)] = [] + while let result = try await group.next() { + results.append(result) + } + return results.sorted { $0.index < $1.index }.map(\.counter) + } + + for (i, counter) in counters.enumerated() { + let fixture = countersFixtures[i] + + // Note: counter is guaranteed to exist by Swift type system + // Note: Type check omitted - guaranteed by Swift type system that counter is PublicLiveCounter + #expect(try counter.value == fixture.count ?? 0, "Check counter #\(i + 1) has expected initial value") + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "LiveCounter created with Objects.createCounter can be assigned to the object tree", + action: { ctx in + let root = ctx.root + let objects = ctx.objects + + let counterCreatedPromiseUpdates = try root.updates() + async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") + + let counter = try await objects.createCounter(count: 1) + try await root.set(key: "counter", value: .liveCounter(counter)) + _ = await counterCreatedPromise + + // Note: Type check omitted - guaranteed by Swift type system that counter is PublicLiveCounter + let rootCounter = try #require(root.get(key: "counter")?.liveCounterValue) + // Note: Type check omitted - guaranteed by Swift type system that rootCounter is PublicLiveCounter + #expect(rootCounter === counter, "Check counter object on root is the same as from create method") + #expect(try rootCounter.value == 1, "Check counter assigned to the object tree has the expected value") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "Objects.createCounter can return LiveCounter with initial value without applying CREATE operation", + action: { ctx in + let objects = ctx.objects + + // prevent publishing of ops to realtime so we guarantee that the initial value comes from local apply-on-ACK, not from a server echo + let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) + internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages in + PublishResult(serials: objectMessages.map { _ in "fake-serial" }) + }) + + let counter = try await objects.createCounter(count: 1) + #expect(try counter.value == 1, "Check counter has expected initial value") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "Objects.createCounter can return LiveCounter with initial value from applied CREATE operation", + action: { ctx in + let objects = ctx.objects + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Instead of sending CREATE op to the realtime, echo it immediately to the client + // with forged initial value so we can check that counter gets initialized with a value from a CREATE op + let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) + var capturedCounterId: String? + + internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages throws(ARTErrorInfo) in + do { + let counterId = try #require(objectMessages[0].operation?.objectId) + capturedCounterId = counterId + + // This should result in executing regular operation application procedure and create an object in the pool with forged initial value + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1), + siteCode: "aaa", + state: [objectsHelper.counterCreateOp(objectId: counterId, count: 10)], + ) + } catch { + throw LiveObjectsError.other(error).toARTErrorInfo() + } + return PublishResult(serials: objectMessages.map { _ in "fake-serial" }) + }) + + let counter = try await objects.createCounter(count: 1) + + // The injected CREATE op (value=10) is processed first in the override. + // Then publishAndApply's local CREATE (value=1) is rejected as a duplicate + // COUNTER_CREATE. So the counter retains the injected op's value. + #expect(try counter.value == 10, "Check counter value has the expected initial value from the injected CREATE operation") + #expect(capturedCounterId != nil, "Check that Objects.publish was called with counter ID") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "initial value is not double counted for LiveCounter from Objects.createCounter when CREATE op is received", + action: { ctx in + let objects = ctx.objects + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Prevent publishing of ops to realtime so we can guarantee order of operations + let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) + internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages in + // Prevent publishing to realtime but return serials so apply-on-ACK works + PublishResult(serials: objectMessages.map { _ in "fake-serial" }) + }) + + // Create counter locally via apply-on-ACK + let counter = try await objects.createCounter(count: 1) + let internalCounter = try #require(counter as? PublicDefaultLiveCounter) + let counterId = internalCounter.proxied.testsOnly_objectID + + // Now inject CREATE op for a counter with a forged value. it should not be applied + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1), + siteCode: "aaa", + state: [objectsHelper.counterCreateOp(objectId: counterId, count: 10)], + ) + + #expect(try counter.value == 1, "Check counter initial value is not double counted after being created and receiving CREATE operation") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "Objects.createCounter throws on invalid input", + action: { ctx in + let objects = ctx.objects + + // Test invalid numeric values - Swift type system prevents most invalid types + // OMITTED from JS tests due to Swift type system: objects.createCounter(null), + // objects.createCounter('foo'), objects.createCounter(BigInt(1)), objects.createCounter(true), + // objects.createCounter(Symbol()), objects.createCounter({}), objects.createCounter([]), + // objects.createCounter(root) - all prevented by Swift's type system + await #expect(throws: Error.self, "Counter value should be a valid number") { + try await objects.createCounter(count: Double.nan) + } + await #expect(throws: Error.self, "Counter value should be a valid number") { + try await objects.createCounter(count: Double.infinity) + } + await #expect(throws: Error.self, "Counter value should be a valid number") { + try await objects.createCounter(count: -Double.infinity) + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "Objects.createMap sends MAP_CREATE operation with primitive values", + action: { ctx in + let objects = ctx.objects + + let maps = try await withThrowingTaskGroup(of: (index: Int, map: any LiveMap).self, returning: [any LiveMap].self) { group in + for (index, mapFixture) in primitiveMapsFixtures.enumerated() { + group.addTask { + let map = if let entries = mapFixture.liveMapEntries { + try await objects.createMap(entries: entries) + } else { + try await objects.createMap() + } + return (index: index, map: map) + } + } + + var results: [(index: Int, map: any LiveMap)] = [] + while let result = try await group.next() { + results.append(result) + } + return results.sorted { $0.index < $1.index }.map(\.map) + } + + for (i, map) in maps.enumerated() { + let fixture = primitiveMapsFixtures[i] + + // Note: map is guaranteed to exist by Swift type system + // Note: Type check omitted - guaranteed by Swift type system that map is PublicLiveMap + + #expect(try map.size == (fixture.liveMapEntries?.count ?? 0), "Check map #\(i + 1) has correct number of keys") + + if let entries = fixture.liveMapEntries { + for (key, expectedValue) in entries { + let actualValue = try map.get(key: key) + + switch expectedValue { + case let .data(expectedData): + let actualData = try #require(actualValue?.dataValue) + #expect(actualData == expectedData, "Check map #\(i + 1) has correct value for \"\(key)\" key") + case let .string(expectedString): + let actualString = try #require(actualValue?.stringValue) + #expect(actualString == expectedString, "Check map #\(i + 1) has correct value for \"\(key)\" key") + case let .number(expectedNumber): + let actualNumber = try #require(actualValue?.numberValue) + #expect(actualNumber == expectedNumber, "Check map #\(i + 1) has correct value for \"\(key)\" key") + case let .bool(expectedBool): + let actualBool = try #require(actualValue?.boolValue as Bool?) + #expect(actualBool == expectedBool, "Check map #\(i + 1) has correct value for \"\(key)\" key") + case .jsonArray, .jsonObject: + Issue.record("JSON array/object primitives not expected in test data") + case .liveCounter, .liveMap: + Issue.record("Nested objects not expected in primitive test data") + } + } + } + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "Objects.createMap sends MAP_CREATE operation with reference to another LiveObject", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let objects = ctx.objects + + let objectsCreatedPromiseUpdates1 = try root.updates() + let objectsCreatedPromiseUpdates2 = try root.updates() + async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "counter") + } + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "map") + } + while try await group.next() != nil {} + } + + _ = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter", + createOp: objectsHelper.counterCreateRestOp(), + ) + _ = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "map", + createOp: objectsHelper.mapCreateRestOp(), + ) + _ = try await objectsCreatedPromise + + let counter = try #require(root.get(key: "counter")?.liveCounterValue) + let map = try #require(root.get(key: "map")?.liveMapValue) + + let newMap = try await objects.createMap(entries: ["counter": .liveCounter(counter), "map": .liveMap(map)]) + + // Note: newMap is guaranteed to exist by Swift type system + // Note: Type check omitted - guaranteed by Swift type system that newMap is PublicLiveMap + + let newMapCounter = try #require(newMap.get(key: "counter")?.liveCounterValue) + let newMapMap = try #require(newMap.get(key: "map")?.liveMapValue) + + #expect(newMapCounter === counter, "Check can set a reference to a LiveCounter object on a new map via a MAP_CREATE operation") + #expect(newMapMap === map, "Check can set a reference to a LiveMap object on a new map via a MAP_CREATE operation") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "LiveMap created with Objects.createMap can be assigned to the object tree", + action: { ctx in + let root = ctx.root + let objects = ctx.objects + + let mapCreatedPromiseUpdates = try root.updates() + async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") + + let counter = try await objects.createCounter() + let map = try await objects.createMap(entries: ["foo": "bar", "baz": .liveCounter(counter)]) + try await root.set(key: "map", value: .liveMap(map)) + _ = await mapCreatedPromise + + // Note: Type check omitted - guaranteed by Swift type system that map is PublicLiveMap + let rootMap = try #require(root.get(key: "map")?.liveMapValue) + // Note: Type check omitted - guaranteed by Swift type system that rootMap is PublicLiveMap + #expect(rootMap === map, "Check map object on root is the same as from create method") + #expect(try rootMap.size == 2, "Check map assigned to the object tree has the expected number of keys") + #expect(try #require(rootMap.get(key: "foo")?.stringValue) == "bar", "Check map assigned to the object tree has the expected value for its string key") + + let rootMapCounter = try #require(rootMap.get(key: "baz")?.liveCounterValue) + #expect(rootMapCounter === counter, "Check map assigned to the object tree has the expected value for its LiveCounter key") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "Objects.createMap can return LiveMap with initial value without applying CREATE operation", + action: { ctx in + let objects = ctx.objects + + let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) + internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages in + PublishResult(serials: objectMessages.map { _ in "fake-serial" }) + }) + + // prevent publishing of ops to realtime so we guarantee that the initial value comes from local apply-on-ACK + let map = try await objects.createMap(entries: ["foo": "bar"]) + #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check map has expected initial value") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "Objects.createMap can return LiveMap with initial value from applied CREATE operation", + action: { ctx in + let objects = ctx.objects + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Instead of sending CREATE op to the realtime, echo it immediately to the client + // with forged initial value so we can check that map gets initialized with a value from a CREATE op + let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) + var capturedMapId: String? + + internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages throws(ARTErrorInfo) in + do { + let mapId = try #require(objectMessages[0].operation?.objectId) + capturedMapId = mapId + + // This should result in executing regular operation application procedure and create an object in the pool with forged initial value + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1), + siteCode: "aaa", + state: [ + objectsHelper.mapCreateOp( + objectId: mapId, + entries: [ + "baz": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1)), + "data": .object(["string": .string("qux")]), + ]), + ], + ), + ], + ) + } catch { + throw LiveObjectsError.other(error).toARTErrorInfo() + } + return PublishResult(serials: objectMessages.map { _ in "fake-serial" }) + }) + + let map = try await objects.createMap(entries: ["foo": "bar"]) + + // The injected CREATE op (with entry "baz") is processed first in the override. + // Then publishAndApply's local CREATE (with entry "foo") is rejected as a duplicate + // MAP_CREATE. So the map retains the injected op's entries. + #expect(try map.get(key: "foo") == nil, "Check key \"foo\" was not set on a map client-side") + #expect(try #require(map.get(key: "baz")?.stringValue) == "qux", "Check key \"baz\" was set on a map from the injected CREATE operation") + #expect(capturedMapId != nil, "Check that Objects.publish was called with map ID") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "initial value is not double counted for LiveMap from Objects.createMap when CREATE op is received", + action: { ctx in + let objects = ctx.objects + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + // Prevent publishing of ops to realtime but return serials so apply-on-ACK works + let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) + internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages in + PublishResult(serials: objectMessages.map { _ in "fake-serial" }) + }) + + // Create map locally via apply-on-ACK + let map = try await objects.createMap(entries: ["foo": "bar"]) + let internalMap = try #require(map as? PublicDefaultLiveMap) + let mapId = internalMap.proxied.testsOnly_objectID + + // Now inject CREATE op for a map with a forged value. it should not be applied + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1), + siteCode: "aaa", + state: [ + objectsHelper.mapCreateOp( + objectId: mapId, + entries: [ + "foo": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1)), + "data": .object(["string": .string("qux")]), + ]), + "baz": .object([ + "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1)), + "data": .object(["string": .string("qux")]), + ]), + ], + ), + ], + ) + + #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check key \"foo\" was not overridden by a CREATE operation after creating a map locally") + #expect(try map.get(key: "baz") == nil, "Check key \"baz\" was not set by a CREATE operation after creating a map locally") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "Objects.createMap throws on invalid input", + action: { ctx in + let objects = ctx.objects + + // Test invalid input types - Swift type system prevents most invalid types + // OMITTED from JS tests due to Swift type system: objects.createMap(null), + // objects.createMap('foo'), objects.createMap(1), objects.createMap(BigInt(1)), + // objects.createMap(true), objects.createMap(Symbol()) - all prevented by Swift's type system + + // Test invalid map value types - these would be caught at runtime + // OMITTED from JS tests due to Swift type system: objects.createMap({ key: undefined }), + // objects.createMap({ key: null }), objects.createMap({ key: BigInt(1) }), + // objects.createMap({ key: Symbol() }), objects.createMap({ key: {} }), + // objects.createMap({ key: [] }) - all prevented by Swift's type system requiring specific LiveMapValue types + + // Note: Swift's Objects.createMap(initialData:) method signature enforces [String: Any] initialData + // and LiveMapValue enum cases at compile time, making most JS validation tests unnecessary. + // Any invalid values would be caught during the conversion to LiveMapValue enum cases. + }, + ), + ] + + let liveMapEnumerationScenarios: [TestScenario] = [ + // TODO: Implement these scenarios + ] + + return [ + objectSyncSequenceScenarios, + applyOperationsScenarios, + applyOperationsDuringSyncScenarios, + writeApiScenarios, + liveMapEnumerationScenarios, + ].flatMap(\.self) + }() + } + + @Test(arguments: FirstSetOfScenarios.testCases) + func firstSetOfScenarios(testCase: TestCase) async throws { + guard !testCase.disabled else { + withKnownIssue { + Issue.record("Test case is disabled") + } + return + } + + let objectsHelper = try await ObjectsHelper() + let client = try await realtimeWithObjects(options: testCase.options) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + let channel = client.channels.get(testCase.channelName, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + try await testCase.scenario.action( + .init( + objects: objects, + root: root, + objectsHelper: objectsHelper, + channelName: testCase.channelName, + channel: channel, + client: client, + clientOptions: testCase.options, + ), + ) + } + } + + @available(iOS 17.0.0, tvOS 17.0.0, *) + enum SubscriptionCallbacksScenarios: Scenarios { + struct Context { + var objects: any RealtimeObjects + var root: any LiveMap + var objectsHelper: ObjectsHelper + var channelName: String + var channel: ARTRealtimeChannel + var sampleMapKey: String + var sampleMapObjectId: String + var sampleCounterKey: String + var sampleCounterObjectId: String + } + + static let scenarios: [TestScenario] = [ + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "can subscribe to the incoming COUNTER_INC operation on a LiveCounter", + action: { ctx in + // Have split this #require into two because one of our formatting tools was trying to remove the parentheses from #require when it was a one-liner, making it invalid Swift 🤷 + let sampleCounterValue = try #require(try ctx.root.get(key: ctx.sampleCounterKey)) + let counter = try #require(sampleCounterValue.liveCounterValue) + + let updates = try counter.updates() + async let subscriptionPromise: Void = { + let update = try #require(await updates.first { _ in true }) + #expect(update.amount == 1, "Check counter subscription callback is called with an expected update object for COUNTER_INC operation") + }() + + _ = try await ctx.objectsHelper.operationRequest( + channelName: ctx.channelName, + opBody: ctx.objectsHelper.counterIncRestOp(objectId: ctx.sampleCounterObjectId, number: 1), + ) + + try await subscriptionPromise + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "can subscribe to multiple incoming operations on a LiveCounter", + action: { @MainActor ctx in + let counter = try #require(ctx.root.get(key: ctx.sampleCounterKey)?.liveCounterValue) + let expectedCounterIncrements = [100.0, -100.0, Double(Int.max), Double(-Int.max)] + let currentUpdateIndex = MainActorStorage(value: 0) + + let subscriber = Subscriber(callbackQueue: .main) + try counter.subscribe(listener: subscriber.createListener()) + async let subscriptionPromise: Void = withCheckedContinuation { continuation in + subscriber.addListener { update, _ in + MainActor.assumeIsolated { + let expectedInc = expectedCounterIncrements[currentUpdateIndex.value] + #expect(update.amount == expectedInc, "Check counter subscription callback is called with an expected update object for \(currentUpdateIndex.value + 1) times") + + if currentUpdateIndex.value == expectedCounterIncrements.count - 1 { + continuation.resume() + } + + currentUpdateIndex.value += 1 + } + } + } + + for increment in expectedCounterIncrements { + _ = try await ctx.objectsHelper.operationRequest( + channelName: ctx.channelName, + opBody: ctx.objectsHelper.counterIncRestOp(objectId: ctx.sampleCounterObjectId, number: increment), + ) + } + + await subscriptionPromise + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "can subscribe to the incoming MAP_SET operation on a LiveMap", + action: { ctx in + // Have split this #require into two because one of our formatting tools was trying to remove the parentheses from #require when it was a one-liner, making it invalid Swift 🤷 + let sampleMapValue = try #require(try ctx.root.get(key: ctx.sampleMapKey)) + let map = try #require(sampleMapValue.liveMapValue) + + let updates = try map.updates() + async let subscriptionPromise: Void = { + let update = try #require(await updates.first { _ in true }) + // Check that the update contains the expected key with "updated" status + #expect(update.update["stringKey"] == .updated, "Check map subscription callback is called with an expected update object for MAP_SET operation") + }() + + _ = try await ctx.objectsHelper.operationRequest( + channelName: ctx.channelName, + opBody: ctx.objectsHelper.mapSetRestOp( + objectId: ctx.sampleMapObjectId, + key: "stringKey", + value: ["string": "stringValue"], + ), + ) + + try await subscriptionPromise + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "can subscribe to the incoming MAP_REMOVE operation on a LiveMap", + action: { ctx in + // Have split this #require into two because one of our formatting tools was trying to remove the parentheses from #require when it was a one-liner, making it invalid Swift 🤷 + let sampleMapValue = try #require(try ctx.root.get(key: ctx.sampleMapKey)) + let map = try #require(sampleMapValue.liveMapValue) + + let updates = try map.updates() + async let subscriptionPromise: Void = { + let update = try #require(await updates.first { _ in true }) + // Check that the update contains the expected key with "removed" status + #expect(update.update["stringKey"] == .removed, "Check map subscription callback is called with an expected update object for MAP_REMOVE operation") + }() + + _ = try await ctx.objectsHelper.operationRequest( + channelName: ctx.channelName, + opBody: ctx.objectsHelper.mapRemoveRestOp( + objectId: ctx.sampleMapObjectId, + key: "stringKey", + ), + ) + + try await subscriptionPromise + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "can subscribe to multiple incoming operations on a LiveMap", + action: { @MainActor ctx in + let map = try #require(ctx.root.get(key: ctx.sampleMapKey)?.liveMapValue) + let expectedMapUpdates: [[String: LiveMapUpdateAction]] = [ + ["foo": .updated], + ["bar": .updated], + ["foo": .removed], + ["baz": .updated], + ["bar": .removed], + ] + let currentUpdateIndex = MainActorStorage(value: 0) + + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener()) + async let subscriptionPromise: Void = withCheckedContinuation { continuation in + subscriber.addListener { update, _ in + MainActor.assumeIsolated { + let expectedUpdate = expectedMapUpdates[currentUpdateIndex.value] + #expect(update.update == expectedUpdate, "Check map subscription callback is called with an expected update object for \(currentUpdateIndex.value + 1) times") + + if currentUpdateIndex.value == expectedMapUpdates.count - 1 { + continuation.resume() + } + + currentUpdateIndex.value += 1 + } + } + } + + _ = try await ctx.objectsHelper.operationRequest( + channelName: ctx.channelName, + opBody: ctx.objectsHelper.mapSetRestOp( + objectId: ctx.sampleMapObjectId, + key: "foo", + value: ["string": "something"], + ), + ) + + _ = try await ctx.objectsHelper.operationRequest( + channelName: ctx.channelName, + opBody: ctx.objectsHelper.mapSetRestOp( + objectId: ctx.sampleMapObjectId, + key: "bar", + value: ["string": "something"], + ), + ) + + _ = try await ctx.objectsHelper.operationRequest( + channelName: ctx.channelName, + opBody: ctx.objectsHelper.mapRemoveRestOp( + objectId: ctx.sampleMapObjectId, + key: "foo", + ), + ) + + _ = try await ctx.objectsHelper.operationRequest( + channelName: ctx.channelName, + opBody: ctx.objectsHelper.mapSetRestOp( + objectId: ctx.sampleMapObjectId, + key: "baz", + value: ["string": "something"], + ), + ) + + _ = try await ctx.objectsHelper.operationRequest( + channelName: ctx.channelName, + opBody: ctx.objectsHelper.mapRemoveRestOp( + objectId: ctx.sampleMapObjectId, + key: "bar", + ), + ) + + await subscriptionPromise + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + // Deviation from JS: The JS test checks event.message.operation.action === 'map.clear' + // (operation metadata), but Swift's LiveMapUpdate has no operation action field — it + // only exposes per-key changes via update: [String: LiveMapUpdateAction]. So instead + // of checking the operation action, this test verifies that the subscription update + // contains .removed entries for each key that existed on the map before the clear. + // This is something the JS test doesn't verify (it only checks operation metadata, + // not the per-key update entries). + description: "can subscribe to the incoming MAP_CLEAR operation on a LiveMap", + action: { ctx in + let root = ctx.root + let objects = ctx.objects + let objectsHelper = ctx.objectsHelper + + let updates = try root.updates() + async let subscriptionPromise: Void = { + let update = try #require(await updates.first { _ in true }) + // verify per-key removed entries for all keys that existed before the clear. + // the test setup creates sampleMap and sampleCounter on root, so those are + // the keys that should be removed. + #expect(update.update[ctx.sampleMapKey] == .removed, "Check \"\(ctx.sampleMapKey)\" key has .removed action after MAP_CLEAR") + #expect(update.update[ctx.sampleCounterKey] == .removed, "Check \"\(ctx.sampleCounterKey)\" key has .removed action after MAP_CLEAR") + }() + + try await objectsHelper.sendMapClearOnChannel(objects: objects, objectId: "root") + + try await subscriptionPromise + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "can unsubscribe from LiveCounter updates via returned unsubscribe callback", + action: { @MainActor ctx in + let counter = try #require(ctx.root.get(key: ctx.sampleCounterKey)?.liveCounterValue) + let callbackCalled = MainActorStorage(value: 0) + + let subscriber = Subscriber(callbackQueue: .main) + try counter.subscribe(listener: subscriber.createListener()) + async let subscriptionPromise: Void = withCheckedContinuation { continuation in + subscriber.addListener { _, subscriptionResponse in + MainActor.assumeIsolated { + callbackCalled.value += 1 + // unsubscribe from future updates after the first call + subscriptionResponse.unsubscribe() + continuation.resume() + } + } + } + + let increments = 3 + for i in 0 ..< increments { + let counterUpdatesStream = try counter.updates() + async let counterUpdatedPromise: Void = waitForCounterUpdate(counterUpdatesStream) + _ = try await ctx.objectsHelper.operationRequest( + channelName: ctx.channelName, + opBody: ctx.objectsHelper.counterIncRestOp(objectId: ctx.sampleCounterObjectId, number: 1), + ) + await counterUpdatedPromise + } + + await subscriptionPromise + + #expect(try counter.value == 3, "Check counter has final expected value after all increments") + #expect(callbackCalled.value == 1, "Check subscription callback was only called once") + }, + ), + // Have not implemented "can unsubscribe from LiveCounter updates via LiveCounter.unsubscribe() call" because this method doesn't exist in the Swift SDK (functions don't have identity) + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "can remove all LiveCounter update listeners via LiveCounter.unsubscribeAll() call", + action: { @MainActor ctx in + let counter = try #require(ctx.root.get(key: ctx.sampleCounterKey)?.liveCounterValue) + let callbacks = 3 + let callbacksCalled = MainActorStorage<[Int]>(value: Array(repeating: 0, count: callbacks)) + + // Create multiple subscribers synchronously + let subscribers = try (0 ..< callbacks).map { _ in + let subscriber = Subscriber(callbackQueue: .main) + try counter.subscribe(listener: subscriber.createListener()) + return subscriber + } + + // Set up subscription promises using TaskGroup + async let subscriptionPromises: Void = withTaskGroup(of: Void.self) { group in + for (index, subscriber) in subscribers.enumerated() { + group.addTask { + await withCheckedContinuation { continuation in + subscriber.addListener { _, _ in + MainActor.assumeIsolated { + callbacksCalled.value[index] += 1 + continuation.resume() + } + } + } + } + } + + // Wait for all subscription tasks to complete + for await _ in group {} + } + + let increments = 3 + for i in 0 ..< increments { + let counterUpdatesStream = try counter.updates() + async let counterUpdatedPromise: Void = waitForCounterUpdate(counterUpdatesStream) + _ = try await ctx.objectsHelper.operationRequest( + channelName: ctx.channelName, + opBody: ctx.objectsHelper.counterIncRestOp(objectId: ctx.sampleCounterObjectId, number: 1), + ) + await counterUpdatedPromise + + if i == 0 { + // unsub all after first operation + counter.unsubscribeAll() + } + } + + // Wait for all subscription promises to complete + await subscriptionPromises + + #expect(try counter.value == 3, "Check counter has final expected value after all increments") + for i in 0 ..< callbacks { + #expect(callbacksCalled.value[i] == 1, "Check subscription callback \(i) was called once") + } + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "can unsubscribe from LiveMap updates via returned unsubscribe callback", + action: { @MainActor ctx in + let map = try #require(ctx.root.get(key: ctx.sampleMapKey)?.liveMapValue) + let callbackCalled = MainActorStorage(value: 0) + + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener()) + async let subscriptionPromise: Void = withCheckedContinuation { continuation in + subscriber.addListener { _, subscriptionResponse in + MainActor.assumeIsolated { + callbackCalled.value += 1 + // unsubscribe from future updates after the first call + subscriptionResponse.unsubscribe() + continuation.resume() + } + } + } + + let mapSets = 3 + for i in 0 ..< mapSets { + let mapUpdatesStream = try map.updates() + async let mapUpdatedPromise: Void = waitForMapKeyUpdate(mapUpdatesStream, "foo-\(i)") + _ = try await ctx.objectsHelper.operationRequest( + channelName: ctx.channelName, + opBody: ctx.objectsHelper.mapSetRestOp( + objectId: ctx.sampleMapObjectId, + key: "foo-\(i)", + value: ["string": "exists"], + ), + ) + await mapUpdatedPromise + } + + await subscriptionPromise + + for i in 0 ..< mapSets { + let value = try #require(map.get(key: "foo-\(i)")?.stringValue) + #expect(value == "exists", "Check map has value for key \"foo-\(i)\" after all map sets") + } + #expect(callbackCalled.value == 1, "Check subscription callback was only called once") + }, + ), + // Have not implemented "can unsubscribe from LiveMap updates via LiveMap.unsubscribe() call" because this method doesn't exist in the Swift SDK (functions don't have identity) + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "can remove all LiveMap update listeners via LiveMap.unsubscribeAll() call", + action: { @MainActor ctx in + let map = try #require(ctx.root.get(key: ctx.sampleMapKey)?.liveMapValue) + let callbacks = 3 + let callbacksCalled = MainActorStorage<[Int]>(value: Array(repeating: 0, count: callbacks)) + + // Create multiple subscribers synchronously + let subscribers = try (0 ..< callbacks).map { _ in + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener()) + return subscriber + } + + // Set up subscription promises using TaskGroup + async let subscriptionPromises: Void = withTaskGroup(of: Void.self) { group in + for (index, subscriber) in subscribers.enumerated() { + group.addTask { + await withCheckedContinuation { continuation in + subscriber.addListener { _, _ in + MainActor.assumeIsolated { + callbacksCalled.value[index] += 1 + continuation.resume() + } + } + } + } + } + + // Wait for all subscription tasks to complete + for await _ in group {} + } + + let mapSets = 3 + for i in 0 ..< mapSets { + let mapUpdatesStream = try map.updates() + async let mapUpdatedPromise: Void = waitForMapKeyUpdate(mapUpdatesStream, "foo-\(i)") + _ = try await ctx.objectsHelper.operationRequest( + channelName: ctx.channelName, + opBody: ctx.objectsHelper.mapSetRestOp( + objectId: ctx.sampleMapObjectId, + key: "foo-\(i)", + value: ["string": "exists"], + ), + ) + await mapUpdatedPromise + + if i == 0 { + // unsub all after first operation + map.unsubscribeAll() + } + } + + // Wait for all subscription promises to complete + await subscriptionPromises + + for i in 0 ..< mapSets { + let value = try #require(map.get(key: "foo-\(i)")?.stringValue) + #expect(value == "exists", "Check map has value for key \"foo-\(i)\" after all map sets") + } + for i in 0 ..< callbacks { + #expect(callbacksCalled.value[i] == 1, "Check subscription callback \(i) was called once") + } + }, + ), + ] + } + + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test(arguments: SubscriptionCallbacksScenarios.testCases) + func subscriptionCallbacksScenarios(testCase: TestCase) async throws { + guard !testCase.disabled else { + withKnownIssue { + Issue.record("Test case is disabled") + } + return + } + + let objectsHelper = try await ObjectsHelper() + let client = try await realtimeWithObjects(options: testCase.options) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + let channel = client.channels.get(testCase.channelName, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + let sampleMapKey = "sampleMap" + let sampleCounterKey = "sampleCounter" + + // Create promises for waiting for object updates + let objectsCreatedPromiseUpdates1 = try root.updates() + let objectsCreatedPromiseUpdates2 = try root.updates() + async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, sampleMapKey) + } + group.addTask { + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, sampleCounterKey) + } + while try await group.next() != nil {} + } + + // Prepare map and counter objects for use by the scenario + let sampleMapResult = try await objectsHelper.createAndSetOnMap( + channelName: testCase.channelName, + mapObjectId: "root", + key: sampleMapKey, + createOp: objectsHelper.mapCreateRestOp(), + ) + let sampleCounterResult = try await objectsHelper.createAndSetOnMap( + channelName: testCase.channelName, + mapObjectId: "root", + key: sampleCounterKey, + createOp: objectsHelper.counterCreateRestOp(), + ) + _ = try await objectsCreatedPromise + + try await testCase.scenario.action( + .init( + objects: objects, + root: root, + objectsHelper: objectsHelper, + channelName: testCase.channelName, + channel: channel, + sampleMapKey: sampleMapKey, + sampleMapObjectId: sampleMapResult.objectId, + sampleCounterKey: sampleCounterKey, + sampleCounterObjectId: sampleCounterResult.objectId, + ), + ) + } + } + + // TODO: Implement the remaining scenarios + + // MARK: - GC Grace Period + + @Test("gcGracePeriod is set from connectionDetails.objectsGCGracePeriod") + func gcGracePeriod_isSetFromConnectionDetails() async throws { + let client = try await realtimeWithObjects(options: .init()) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + await client.connection.onceAsync(.connected) + + let channel = client.channels.get("channel", options: channelOptionsWithObjects()) + let objects = try #require(channel.objects as? PublicDefaultRealtimeObjects) + let connectionDetails = client.internal.latestConnectionDetails + + // gcGracePeriod should be set after the initial connection + let initialConnectionDetailsGracePeriod = try #require(connectionDetails?.objectsGCGracePeriod) + #expect(objects.testsOnly_gcGracePeriod == initialConnectionDetailsGracePeriod.doubleValue, "Check gcGracePeriod is set after initial connection from connectionDetails.objectsGCGracePeriod") + + let testProxyTransport = try #require(client.internal.transport as? TestProxyTransport) + let connectedProtocolMessage = ARTProtocolMessage() + connectedProtocolMessage.action = .connected + connectedProtocolMessage.connectionDetails = .init(clientId: nil, connectionKey: nil, maxMessageSize: 10, maxFrameSize: 10, maxInboundRate: 10, connectionStateTtl: 10, serverId: "", maxIdleInterval: 10, objectsGCGracePeriod: 0.999, siteCode: nil) // all arbitrary except objectsGCGracePeriod + client.internal.queue.ably_syncNoDeadlock { + testProxyTransport.receive(connectedProtocolMessage) + } + + #expect(objects.testsOnly_gcGracePeriod == 0.999, "Check gcGracePeriod is updated on new CONNECTED event") + } + } + + @Test("gcGracePeriod has a default value if connectionDetails.objectsGCGracePeriod is missing") + func gcGracePeriod_usesDefaultValue() async throws { + let client = try await realtimeWithObjects(options: .init()) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + await client.connection.onceAsync(.connected) + + let channel = client.channels.get("channel", options: channelOptionsWithObjects()) + let objects = try #require(channel.objects as? PublicDefaultRealtimeObjects) + + client.internal.queue.ably_syncNoDeadlock { + objects.testsOnly_proxied.nosync_setGarbageCollectionGracePeriod(0.999) + } + #expect(objects.testsOnly_gcGracePeriod == 0.999) + + // send a CONNECTED event without objectsGCGracePeriod, it should use the default value instead + let testProxyTransport = try #require(client.internal.transport as? TestProxyTransport) + let connectedProtocolMessage = ARTProtocolMessage() + connectedProtocolMessage.action = .connected + connectedProtocolMessage.connectionDetails = .init(clientId: nil, connectionKey: nil, maxMessageSize: 10, maxFrameSize: 10, maxInboundRate: 10, connectionStateTtl: 10, serverId: "", maxIdleInterval: 10, objectsGCGracePeriod: nil, siteCode: nil) // all arbitrary except objectsGCGracePeriod + client.internal.queue.ably_syncNoDeadlock { + testProxyTransport.receive(connectedProtocolMessage) + } + + #expect(objects.testsOnly_gcGracePeriod == InternalDefaultRealtimeObjects.GarbageCollectionOptions.defaultGracePeriod, "Check gcGracePeriod is set to a default value if connectionDetails.objectsGCGracePeriod is missing") + } + } + + // MARK: - Tombstones GC Scenarios + + enum TombstonesGCScenarios: Scenarios { + struct Context { + var root: any LiveMap + var objectsHelper: ObjectsHelper + var channelName: String + var channel: ARTRealtimeChannel + var objects: any RealtimeObjects + var client: ARTRealtime + var waitForTombstonedObjectsToBeCollected: @Sendable (Date) async throws -> Void + } + + static let scenarios: [TestScenario] = [ + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "tombstoned object is removed from the pool after the GC grace period", + action: { ctx in + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let channel = ctx.channel + let objects = ctx.objects + let waitForTombstonedObjectsToBeCollected = ctx.waitForTombstonedObjectsToBeCollected + + // Wait for counter creation + async let counterCreatedPromise: Void = waitForObjectOperation(ctx.objects, .counterCreate) + + // Send a CREATE op, this adds an object to the pool + let createResult = try await objectsHelper.operationRequest( + channelName: channelName, + opBody: objectsHelper.counterCreateRestOp(number: 1), + ) + let objectId = createResult.objectId + _ = try await counterCreatedPromise + + // Cast to access internal API for testing + let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) + + #expect(internallyTypedObjects.testsOnly_proxied.testsOnly_objectsPool.entries[objectId] != nil, "Check object exists in the pool after creation") + + // Inject OBJECT_DELETE for the object. This should tombstone the object and make it + // inaccessible to the end user, but still keep it in memory in the local pool + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), + siteCode: "aaa", + state: [objectsHelper.objectDeleteOp(objectId: objectId)], + ) + + #expect( + internallyTypedObjects.testsOnly_proxied.testsOnly_objectsPool.entries[objectId] != nil, + "Check object exists in the pool immediately after OBJECT_DELETE", + ) + + let poolEntry = try #require(internallyTypedObjects.testsOnly_proxied.testsOnly_objectsPool.entries[objectId]) + #expect( + poolEntry.testsOnly_isTombstone == true, + "Check object's \"tombstone\" flag is set to \"true\" after OBJECT_DELETE", + ) + + let tombstonedAt = try #require(poolEntry.testsOnly_tombstonedAt) + + // Wait for objects tombstoned at this time to be garbage collected + try await waitForTombstonedObjectsToBeCollected(tombstonedAt) + + // Object should be removed from the local pool entirely now, as the GC grace period has passed + #expect( + internallyTypedObjects.testsOnly_proxied.testsOnly_objectsPool.entries[objectId] == nil, + "Check object does not exist in the pool after the GC grace period expiration", + ) + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: true, + description: "tombstoned map entry is removed from the LiveMap after the GC grace period", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channelName = ctx.channelName + let waitForTombstonedObjectsToBeCollected = ctx.waitForTombstonedObjectsToBeCollected + + let keyUpdatedPromise = try root.updates() + async let keyUpdatedWait: Void = { + await waitForMapKeyUpdate(keyUpdatedPromise, "foo") + }() + + // Set a key on root + _ = try await objectsHelper.operationRequest( + channelName: channelName, + opBody: objectsHelper.mapSetRestOp( + objectId: "root", + key: "foo", + value: ["string": .string("bar")], + ), + ) + await keyUpdatedWait + + #expect( + try #require(root.get(key: "foo")?.stringValue) == "bar", + "Check key \"foo\" exists on root after MAP_SET", + ) + + let keyUpdatedPromise2 = try root.updates() + async let keyUpdatedWait2: Void = { + await waitForMapKeyUpdate(keyUpdatedPromise2, "foo") + }() + + // Remove the key from the root. This should tombstone the map entry and make it + // inaccessible to the end user, but still keep it in memory in the underlying map + _ = try await objectsHelper.operationRequest( + channelName: channelName, + opBody: objectsHelper.mapRemoveRestOp(objectId: "root", key: "foo"), + ) + await keyUpdatedWait2 + + #expect( + try root.get(key: "foo") == nil, + "Check key \"foo\" is inaccessible via public API on root after MAP_REMOVE", + ) + + // Cast to access internal API for testing + let internallyTypedRoot = try #require(root as? PublicDefaultLiveMap) + let internalRoot = internallyTypedRoot.proxied + let underlyingData = internalRoot.testsOnly_data + + #expect( + underlyingData["foo"] != nil, + "Check map entry for \"foo\" exists on root in the underlying data immediately after MAP_REMOVE", + ) + #expect( + underlyingData["foo"]?.tombstone == true, + "Check map entry for \"foo\" on root has \"tombstone\" flag set to \"true\" after MAP_REMOVE", + ) + + let tombstonedAt = try #require(underlyingData["foo"]?.tombstonedAt) + + // Wait for objects tombstoned at this time to be garbage collected + try await waitForTombstonedObjectsToBeCollected(tombstonedAt) + + // The entry should be removed from the underlying map now + let underlyingDataAfterGC = internalRoot.testsOnly_data + #expect( + underlyingDataAfterGC["foo"] == nil, + "Check map entry for \"foo\" does not exist on root in the underlying data after the GC grace period expiration", + ) + }, + ), + ] + } + + @Test(arguments: TombstonesGCScenarios.testCases) + func tombstonesGCScenarios(testCase: TestCase) async throws { + guard !testCase.disabled else { + withKnownIssue { + Issue.record("Test case is disabled") + } + return + } + + // Configure GC options with shorter intervals for testing + var options = testCase.options + let garbageCollectionOptions = InternalDefaultRealtimeObjects.GarbageCollectionOptions( + interval: 0.5, + gracePeriod: .fixed(0.25), + ) + options.garbageCollectionOptions = garbageCollectionOptions + + let objectsHelper = try await ObjectsHelper() + let client = try await realtimeWithObjects(options: options) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + let channel = client.channels.get(testCase.channelName, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + // Helper function to wait for enough GC cycles to occur such that objects tombstoned at a specific time should have been garbage collected. This is a slightly different approach to the JS tests, which wait for a certain number of GC cycles to occur, but I think that this is a bit more robust in the face of clock skew between the local clock and whatever was used to generate the tombstonedAt timestamps server-side. + let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) + let waitForTombstonedObjectsToBeCollected: @Sendable (Date) async throws -> Void = { (tombstonedAt: Date) in + // Sleep until we're sure we're past tombstonedAt + gracePeriod + let timeUntilGracePeriodExpires = (tombstonedAt + garbageCollectionOptions.gracePeriod.toTimeInterval).timeIntervalSince(.init()) + if timeUntilGracePeriodExpires > 0 { + try await Task.sleep(nanoseconds: UInt64(timeUntilGracePeriodExpires * Double(NSEC_PER_SEC))) + } + + // Wait for the next GC event + await internallyTypedObjects.testsOnly_proxied.testsOnly_completedGarbageCollectionEventsWithoutBuffering.first { _ in true } + } + + try await testCase.scenario.action( + .init( + root: root, + objectsHelper: objectsHelper, + channelName: testCase.channelName, + channel: channel, + objects: objects, + client: client, + waitForTombstonedObjectsToBeCollected: waitForTombstonedObjectsToBeCollected, + ), + ) + } + } + + // MARK: - Apply on ACK tests + + // MARK: Group 1: Operations applied locally on ACK (parameterized) + + enum ApplyOnAckScenarios: Scenarios { + struct Context { + var objects: any RealtimeObjects + var root: any LiveMap + var objectsHelper: ObjectsHelper + var channelName: String + var channel: ARTRealtimeChannel + var client: ARTRealtime + } + + static let scenarios: [TestScenario] = [ + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "creating a LiveCounter applies immediately on ACK", + action: { ctx in + let counter = try await ctx.objects.createCounter(count: 42) + try await ctx.root.set(key: "newCounter", value: .liveCounter(counter)) + + // Value should be visible immediately via apply-on-ACK, not from echo + #expect(try counter.value == 42, "Check counter value is applied immediately on ACK") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "LiveCounter.increment applies operation immediately on ACK", + action: { ctx in + let counter = try await ctx.objects.createCounter(count: 10) + try await ctx.root.set(key: "counter", value: .liveCounter(counter)) + #expect(try counter.value == 10, "Check counter has initial value of 10") + + try await counter.increment(amount: 5) + + #expect(try counter.value == 15, "Check counter value reflects increment applied on ACK") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "creating a LiveMap applies immediately on ACK", + action: { ctx in + let map = try await ctx.objects.createMap(entries: ["foo": "bar"]) + try await ctx.root.set(key: "newMap", value: .liveMap(map)) + + #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check map value is applied immediately on ACK") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "LiveMap.set applies operation immediately on ACK", + action: { ctx in + try await ctx.root.set(key: "key", value: "value") + + #expect(try #require(ctx.root.get(key: "key")?.stringValue) == "value", "Check map set is applied immediately on ACK") + }, + ), + .init( + disabled: false, + allTransportsAndProtocols: false, + description: "LiveMap.remove applies operation immediately on ACK", + action: { ctx in + try await ctx.root.set(key: "keyToRemove", value: "valueToRemove") + #expect(try #require(ctx.root.get(key: "keyToRemove")?.stringValue) == "valueToRemove", "Check key exists") + + try await ctx.root.remove(key: "keyToRemove") + + #expect(try ctx.root.get(key: "keyToRemove") == nil, "Check map remove is applied immediately on ACK") + }, + ), + ] + } + + @Test(arguments: ApplyOnAckScenarios.testCases) + func applyOnAckScenarios(testCase: TestCase) async throws { + guard !testCase.disabled else { + withKnownIssue { + Issue.record("Test case is disabled") + } + return + } + + let objectsHelper = try await ObjectsHelper() + let client = try await realtimeWithObjects(options: testCase.options) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + let channel = client.channels.get(testCase.channelName, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + // Hold echoes so we can verify value comes from ACK, not echo + let echoInterceptor = EchoInterceptor(client: client, channel: channel) + defer { echoInterceptor.restore() } + + try await testCase.scenario.action( + .init( + objects: objects, + root: root, + objectsHelper: objectsHelper, + channelName: testCase.channelName, + channel: channel, + client: client, + ), + ) + } + } + + // MARK: Group 2: Does not double-apply + + @Test + func echoAfterAckDoesNotDoubleApply() async throws { + let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + let channelName = "echoAfterAckDoesNotDoubleApply" + let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + // Create a counter with initial value 10 + let counter = try await objects.createCounter(count: 10) + try await root.set(key: "counter", value: .liveCounter(counter)) + + // Set up echo interceptor + let echoInterceptor = EchoInterceptor(client: client, channel: channel) + defer { echoInterceptor.restore() } + + // Increment by 5 — applied via ACK, echo held + try await counter.increment(amount: 5) + #expect(try counter.value == 15, "Check counter value after increment applied on ACK") + + // Wait for the echo to be intercepted + await echoInterceptor.waitForEcho() + + // Release the held echo + await echoInterceptor.releaseAll() + + // Value should still be 15 (not 20 from double-apply) + #expect(try counter.value == 15, "Check counter value is not double-applied after echo") + } + } + + @Test + func ackAfterEchoDoesNotDoubleApply() async throws { + let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + let channelName = "ackAfterEchoDoesNotDoubleApply" + let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + // Create a counter with initial value 10 + let counter = try await objects.createCounter(count: 10) + try await root.set(key: "counter", value: .liveCounter(counter)) + + // Set up ACK interceptor (holds ACKs, lets echoes through) + let ackInterceptor = AckInterceptor(client: client) + defer { ackInterceptor.restore() } + + // Deviation from JS: JS uses a callback-based `waitForCounterUpdate(counter)` + // promise. Swift uses an `AsyncStream`-based subscription via `counter.updates()` + // and `waitForCounterUpdate` to detect when the echo has been applied. + let counterUpdates = try counter.updates() + + // Start increment without awaiting (it won't complete until ACK arrives) + let incrementTask = Task { + try await counter.increment(amount: 5) + } + + // Wait for the echo to be applied + await waitForCounterUpdate(counterUpdates) + + // Value should be 15 from the echo + #expect(try counter.value == 15, "Check counter value after echo received") + + // Release the held ACK + await ackInterceptor.waitForAck() + await ackInterceptor.releaseAll() + + // Wait for increment to complete + try await incrementTask.value + + // Value should still be 15 (not 20 from double-apply) + #expect(try counter.value == 15, "Check counter value is not double-applied after ACK") + } + } + + // MARK: Group 3: Does not incorrectly skip operations + + @Test + func applyOnAckDoesNotUpdateSiteTimeserials() async throws { + let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + let channelName = "applyOnAckDoesNotUpdateSiteTimeserials" + let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) + let objects = channel.objects + let objectsHelper = try await ObjectsHelper() + + try await channel.attachAsync() + let root = try await objects.getRoot() + + // Step 1: Set up echo interceptor + let echoInterceptor = EchoInterceptor(client: client, channel: channel) + defer { echoInterceptor.restore() } + + // Step 2: Create a counter with initial value 10 — echo held + let counter = try await objects.createCounter(count: 10) + try await root.set(key: "counter", value: .liveCounter(counter)) + + // Step 3: Wait for both echoes, extract COUNTER_CREATE serial and siteCode. + // createCounter + root.set generate two echoes (COUNTER_CREATE and MAP_SET); + // search all held echoes to find the COUNTER_CREATE. + await echoInterceptor.waitForEchoCount(2) + let heldEchoes = echoInterceptor.heldEchoes + let allStateItems = heldEchoes.flatMap(\.testsOnly_inboundObjectMessages) + let counterCreate = try #require(allStateItems.first { $0.operation?.action == .known(.counterCreate) }) + let counterCreateSerial = try #require(counterCreate.serial) + let counterCreateSiteCode = try #require(counterCreate.siteCode) + + // Step 4: Release the create echo (so siteTimeserials gets set) + await echoInterceptor.releaseAll() + + // Step 5: Increment by 5 — applies via ACK, echo held + try await counter.increment(amount: 5) + #expect(try counter.value == 15, "Check counter value after increment applied on ACK") + + // Step 6: Wait for the increment echo, extract its serial + await echoInterceptor.waitForEcho() + let incrementEchoes = echoInterceptor.heldEchoes + let incrementEcho = incrementEchoes.last! + let incrementStateItems = incrementEcho.testsOnly_inboundObjectMessages + let incrementOp = try #require(incrementStateItems.first { $0.operation?.action == .known(.counterInc) }) + let incrementSerial = try #require(incrementOp.serial) + + // Step 7: Construct injectedSerial = counterCreateSerial + "a" + // This serial is between create and increment, so if siteTimeserials were + // updated by apply-on-ACK, this operation would be rejected + let injectedSerial = counterCreateSerial + "a" + + // Verify our assumptions + #expect(injectedSerial > counterCreateSerial, "injectedSerial > counterCreateSerial") + #expect(injectedSerial < incrementSerial, "injectedSerial < incrementSerial") + + // Step 8: Inject a COUNTER_INC operation with injectedSerial + let internalCounter = try #require(counter as? PublicDefaultLiveCounter) + let counterId = internalCounter.proxied.testsOnly_objectID + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: injectedSerial, + siteCode: counterCreateSiteCode, + state: [objectsHelper.counterIncOp(objectId: counterId, number: 100)], + ) + + // Step 9: Assert counter.value == 115 + // If siteTimeserials had been updated by apply-on-ACK, the injected operation + // would have been rejected, and counter would be 15 + #expect(try counter.value == 115, "Check injected operation was applied (proving siteTimeserials not updated by apply-on-ACK)") + } + } + + // MARK: Group 4: ACKs buffered during OBJECT_SYNC + + @Test + func operationBufferedDuringSyncIsAppliedAfterSyncCompletes() async throws { + let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + let channelName = "operationBufferedDuringSyncApplied" + let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) + let objects = channel.objects + let objectsHelper = try await ObjectsHelper() + + try await channel.attachAsync() + let root = try await objects.getRoot() + + // Create counter with value 10 + let counter = try await objects.createCounter(count: 10) + try await root.set(key: "counter", value: .liveCounter(counter)) + #expect(try counter.value == 10, "Check counter initial value") + + let internalCounter = try #require(counter as? PublicDefaultLiveCounter) + let counterId = internalCounter.proxied.testsOnly_objectID + + // Inject ATTACHED with HAS_OBJECTS to trigger SYNCING state + await injectAttachedMessage(channel: channel, flags: .hasObjects) + + // Set up ACK interceptor so we can control when the ACK is delivered + let ackInterceptor = AckInterceptor(client: client) + + // Increment while syncing — don't await because publishAndApply will + // wait for sync to complete, and we need to complete the sync below. + let incrementTask = Task { + try await counter.increment(amount: 5) + } + + // Wait for the ACK to be intercepted, then release it. + // releaseAll() dispatches onto the internal queue and waits for + // the ACK to be processed synchronously (onAck → publish callback + // → sync-wait entry all happen on the same queue dispatch), so by + // the time it returns, publishAndApply has entered the sync-wait. + // (publishAndApplyRejectsOnChannelStateChangeDuringSync uses the + // same mechanism and validates it is sufficient — if publishAndApply + // had not yet entered the sync-wait, the channel state change + // would not cause it to reject.) + await ackInterceptor.waitForAck() + await ackInterceptor.releaseAll() + ackInterceptor.restore() + + // Complete the sync sequence with an OBJECT_SYNC message containing counter=10 + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", + state: [ + objectsHelper.counterObject( + objectId: counterId, + siteTimeserials: [:], + materialisedCount: 10, + ), + ], + ) + + // Wait for the increment task to complete + try await incrementTask.value + + // After sync completes, the buffered ACK should be applied + #expect(try counter.value == 15, "Check counter value after sync completes with buffered ACK applied") + } + } + + @Test + func appliedOnAckSerialsIsClearedOnSync() async throws { + let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + let channelName = "appliedOnAckSerialsCleared" + let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) + let objects = channel.objects + let objectsHelper = try await ObjectsHelper() + + try await channel.attachAsync() + let root = try await objects.getRoot() + + // Create counter and increment via apply-on-ACK (echo held) + let counter = try await objects.createCounter(count: 10) + try await root.set(key: "counter", value: .liveCounter(counter)) + + let echoInterceptor = EchoInterceptor(client: client, channel: channel) + + try await counter.increment(amount: 5) + #expect(try counter.value == 15, "Check counter value after increment on ACK") + + // Wait for the echo to be intercepted + await echoInterceptor.waitForEcho() + + let internalCounter = try #require(counter as? PublicDefaultLiveCounter) + let counterId = internalCounter.proxied.testsOnly_objectID + + // Inject ATTACHED+HAS_OBJECTS to trigger sync + await injectAttachedMessage(channel: channel, flags: .hasObjects) + + // Complete sync with state that uses a fake siteCode. + // Using a clearly fake siteCode ensures the echo (which has the real siteCode) + // will pass the siteTimeserials check since there's no entry for it. + let fakeSiteSerial = lexicoTimeserial(seriesId: "fakeSite", timestamp: 0, counter: 0) + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "serial:", + state: [ + objectsHelper.mapObject( + objectId: "root", + siteTimeserials: ["fakeSite": fakeSiteSerial], + initialEntries: [ + "counter": .object([ + "timeserial": .string(fakeSiteSerial), + "data": .object(["objectId": .string(counterId)]), + ]), + ], + ), + objectsHelper.counterObject( + objectId: counterId, + siteTimeserials: ["fakeSite": fakeSiteSerial], + materialisedCount: 10, + ), + ], + ) + + // After sync, value should be 10 (from sync state, appliedOnAckSerials cleared) + #expect(try counter.value == 10, "Check counter value is reset to sync state") + + // Release the held echo — should be applied because appliedOnAckSerials was cleared + await echoInterceptor.releaseAll() + echoInterceptor.restore() + + #expect(try counter.value == 15, "Check counter value after releasing echo (proving appliedOnAckSerials was cleared on sync)") + } + } + + // Deviation from JS: JS uses `channel.requestState(targetState)` to trigger + // the channel state change. ably-cocoa doesn't expose `requestState` on channels, + // so we use `setSuspended`/`setFailed`/`detachChannel` on the internal queue + // instead (`setDetached` is not used because it initiates a reattach). We also + // use `Task.yield() + 100ms sleep` instead of JS's `setTimeout(resolve, 0)`. + @Test(arguments: [ + ARTRealtimeChannelState.detached, + ARTRealtimeChannelState.suspended, + ARTRealtimeChannelState.failed, + ]) + func publishAndApplyRejectsOnChannelStateChangeDuringSync(targetState: ARTRealtimeChannelState) async throws { + let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + let channelName = "publishAndApplyRejects_\(targetState.rawValue)" + let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) + let objects = channel.objects + + try await channel.attachAsync() + let root = try await objects.getRoot() + + // Create a counter + let counter = try await objects.createCounter(count: 10) + try await root.set(key: "counter", value: .liveCounter(counter)) + + // Inject ATTACHED+HAS_OBJECTS to trigger SYNCING state + await injectAttachedMessage(channel: channel, flags: .hasObjects) + + // Set up ACK interceptor and start increment + let ackInterceptor = AckInterceptor(client: client) + + let incrementTask = Task { + try await counter.increment(amount: 5) + } + + // Wait for the ACK to arrive + await ackInterceptor.waitForAck() + + // Release ACK so publishAndApply proceeds to sync-wait + await ackInterceptor.releaseAll() + ackInterceptor.restore() + + // Trigger channel state change + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.internal.queue.async { + switch targetState { + case .suspended: + let params = ChannelStateChangeParams(state: .ok) + channel.internal.setSuspended(params) + case .failed: + let params = ChannelStateChangeParams(state: .ok) + channel.internal.setFailed(params) + case .detached: + // Use detachChannel: directly instead of setDetached: because + // setDetached: initiates a reattach when the channel is attached. + let params = ChannelStateChangeParams(state: .ok) + channel.internal.detachChannel(params) + default: + break + } + continuation.resume() + } + } + + // The increment should throw with error 92008 + do { + try await incrementTask.value + Issue.record("Expected increment to throw during channel state change") + } catch let error as ARTErrorInfo { + #expect(error.code == 92008, "Check error code is 92008 for publishAndApply rejected during sync") + #expect(error.statusCode == 400, "Check statusCode is 400") + } + } + } + + // MARK: Group 5: Subscription events + + // Deviation from JS: JS asserts `{ action: 'counter.inc', number: N }` on each + // event. The Swift `LiveCounterUpdate` protocol only exposes `amount` (no `action` + // field), so we assert on the amount only. + @Test + func subscriptionCallbacksFireForBothLocallyAppliedAndRealtimeReceivedOperations() async throws { + let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) + + try await monitorConnectionThenCloseAndFinishAsync(client) { + let channelName = "subscriptionCallbacksApplyOnAck" + let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) + let objects = channel.objects + let objectsHelper = try await ObjectsHelper() + + try await channel.attachAsync() + let root = try await objects.getRoot() + + // Create counter + let counter = try await objects.createCounter(count: 0) + try await root.set(key: "counter", value: .liveCounter(counter)) + + // Subscribe to counter updates + let counterUpdates = try counter.updates() + + // Set up echo interceptor + let echoInterceptor = EchoInterceptor(client: client, channel: channel) + + // Perform increment via SDK — applied locally on ACK with echoes held + try await counter.increment(amount: 5) + #expect(try counter.value == 5, "Check counter value after local increment") + + // The subscription should have fired immediately from the ACK path + // Collect the event + var receivedEvents: [LiveCounterUpdate] = [] + if let event = await counterUpdates.first(where: { _ in true }) { + receivedEvents.append(event) + } + + #expect(receivedEvents.count == 1, "Check 1 subscription event received after local increment") + #expect(receivedEvents[0].amount == 5, "Check event from local apply has amount 5") + + // Restore echo handling + echoInterceptor.restore() + + // Release held echoes (shouldn't cause another event since already applied) + await echoInterceptor.releaseAll() + + // Trigger another increment via REST — this is received over Realtime + let internalCounter = try #require(counter as? PublicDefaultLiveCounter) + let counterId = internalCounter.proxied.testsOnly_objectID + _ = try await objectsHelper.operationRequest( + channelName: channelName, + opBody: objectsHelper.counterIncRestOp(objectId: counterId, number: 10), + ) + + // Wait for counter update from Realtime + if let event = await counterUpdates.first(where: { _ in true }) { + receivedEvents.append(event) + } + + #expect(receivedEvents.count == 2, "Check 2 subscription events received total") + #expect(receivedEvents[1].amount == 10, "Check event from Realtime receive has amount 10") + #expect(try counter.value == 15, "Check final counter value after both operations") + } + } +} + +// swiftlint:enable trailing_closure diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift b/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift new file mode 100644 index 000000000..d7d29e00f --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift @@ -0,0 +1,456 @@ +@preconcurrency import Ably.Private + +class TestProxyTransportFactory: RealtimeTransportFactory { + // This value will be used by all TestProxyTransportFactory instances created by this factory (including those created before this property is updated). + var fakeNetworkResponse: FakeNetworkResponse? + + // This value will be used by all TestProxyTransportFactory instances created by this factory (including those created before this property is updated). + var networkConnectEvent: ((ARTRealtimeTransport, URL) -> Void)? + + var transportCreatedEvent: ((ARTRealtimeTransport) -> Void)? + + func transport(withRest rest: ARTRestInternal, options: ARTClientOptions, resumeKey: String?, logger: InternalLog) -> ARTRealtimeTransport { + let webSocketFactory = WebSocketFactory() + + let testProxyTransport = TestProxyTransport( + factory: self, + rest: rest, + options: options, + resumeKey: resumeKey, + logger: logger, + webSocketFactory: webSocketFactory, + ) + + webSocketFactory.testProxyTransport = testProxyTransport + + transportCreatedEvent?(testProxyTransport) + + return testProxyTransport + } + + private class WebSocketFactory: Ably.WebSocketFactory { + weak var testProxyTransport: TestProxyTransport? + + func createWebSocket(with request: URLRequest, logger: InternalLog?) -> ARTWebSocket { + let webSocket = WebSocket(urlRequest: request, logger: logger) + webSocket.testProxyTransport = testProxyTransport + + return webSocket + } + } + + private class WebSocket: ARTSRWebSocket { + weak var testProxyTransport: TestProxyTransport? + + override func open() { + guard let testProxyTransport else { + preconditionFailure("Tried to fetch testProxyTransport but it's already been deallocated") + } + if !testProxyTransport.handleWebSocketOpen() { + super.open() + } + } + } +} + +/// Records each message for test purpose. +class TestProxyTransport: ARTWebSocketTransport, @unchecked Sendable { + /// The factory that created this TestProxyTransport instance. + private weak var _factory: TestProxyTransportFactory? + private var factory: TestProxyTransportFactory { + guard let _factory else { + preconditionFailure("Tried to fetch factory but it's already been deallocated") + } + return _factory + } + + init(factory: TestProxyTransportFactory, rest: ARTRestInternal, options: ARTClientOptions, resumeKey: String?, logger: InternalLog, webSocketFactory: WebSocketFactory) { + _factory = factory + super.init(rest: rest, options: options, resumeKey: resumeKey, logger: logger, webSocketFactory: webSocketFactory) + } + + fileprivate(set) var lastUrl: URL? + + private var _protocolMessagesReceived: [ARTProtocolMessage] = [] + var protocolMessagesReceived: [ARTProtocolMessage] { + var result: [ARTProtocolMessage] = [] + queue.sync { + result = self._protocolMessagesReceived + } + return result + } + + private var _protocolMessagesSent: [ARTProtocolMessage] = [] + var protocolMessagesSent: [ARTProtocolMessage] { + var result: [ARTProtocolMessage] = [] + queue.sync { + result = self._protocolMessagesSent + } + return result + } + + private var _protocolMessagesSentIgnored: [ARTProtocolMessage] = [] + var protocolMessagesSentIgnored: [ARTProtocolMessage] { + var result: [ARTProtocolMessage] = [] + queue.sync { + result = self._protocolMessagesSentIgnored + } + return result + } + + fileprivate(set) var rawDataSent = [Data]() + fileprivate(set) var rawDataReceived = [Data]() + + private var replacingAcksWithNacks: ARTErrorInfo? + + var ignoreWebSocket = false + var ignoreSends = false + var actionsIgnored = [ARTProtocolMessageAction]() + + var queue: DispatchQueue { + guard let delegateDispatchQueue = websocket?.delegateDispatchQueue else { + preconditionFailure("I don't know what queue to use in this case (in ably-cocoa they used AblyTests.queue); cross this bridge if we come to it") + } + return delegateDispatchQueue + } + + private var callbackBeforeProcessingIncomingMessage: ((ARTProtocolMessage) -> Void)? + private var callbackAfterProcessingIncomingMessage: ((ARTProtocolMessage) -> Void)? + private var callbackBeforeProcessingOutgoingMessage: ((ARTProtocolMessage) -> Void)? + private var callbackBeforeIncomingMessageModifier: ((ARTProtocolMessage) -> ARTProtocolMessage?)? + private var callbackAfterIncomingMessageModifier: ((ARTProtocolMessage) -> ARTProtocolMessage?)? + + // Represents a request to replace the implementation of a method. + private final class Hook: Sendable { + private let implementation: @Sendable () -> Void + + init(implementation: @escaping @Sendable () -> Void) { + self.implementation = implementation + } + + func performImplementation() { + implementation() + } + } + + /// The active request, if any, to replace the implementation of the ARTWebSocket#open method for all WebSocket objects created by this transport. Access must be synchronised using webSocketOpenHookSemaphore. + private var webSocketOpenHook: Hook? + /// Used for synchronising access to webSocketOpenHook. + private let webSocketOpenHookSempahore = DispatchSemaphore(value: 1) + + func setListenerBeforeProcessingIncomingMessage(_ callback: ((ARTProtocolMessage) -> Void)?) { + queue.sync { + self.callbackBeforeProcessingIncomingMessage = callback + } + } + + func setListenerAfterProcessingIncomingMessage(_ callback: ((ARTProtocolMessage) -> Void)?) { + queue.sync { + self.callbackAfterProcessingIncomingMessage = callback + } + } + + func setListenerBeforeProcessingOutgoingMessage(_ callback: ((ARTProtocolMessage) -> Void)?) { + queue.sync { + self.callbackBeforeProcessingOutgoingMessage = callback + } + } + + /// The modifier will be called on the internal queue. + /// + /// If `callback` returns nil, the message will be ignored. + func setBeforeIncomingMessageModifier(_ callback: ((ARTProtocolMessage) -> ARTProtocolMessage?)?) { + callbackBeforeIncomingMessageModifier = callback + } + + /// The modifier will be called on the internal queue. + /// + /// If `callback` returns nil, the message will be ignored. + func setAfterIncomingMessageModifier(_ callback: ((ARTProtocolMessage) -> ARTProtocolMessage?)?) { + callbackAfterIncomingMessageModifier = callback + } + + func enableReplaceAcksWithNacks(with errorInfo: ARTErrorInfo) { + queue.sync { + self.replacingAcksWithNacks = errorInfo + } + } + + func disableReplaceAcksWithNacks() { + queue.sync { + self.replacingAcksWithNacks = nil + } + } + + func emulateTokenRevokationBeforeConnected() { + setBeforeIncomingMessageModifier { protocolMessage in + if protocolMessage.action == .connected { + protocolMessage.action = .disconnected + protocolMessage.error = .create(withCode: Int(ARTErrorCode.tokenRevoked.rawValue), status: 401, message: "Test token revokation") + } + return protocolMessage + } + } + + // MARK: ARTWebSocket + + override func connect(withKey key: String) { + if let fakeResponse = factory.fakeNetworkResponse { + setupFakeNetworkResponse(fakeResponse) + } + super.connect(withKey: key) + performNetworkConnectEvent() + } + + override func connect(withToken token: String) { + if let fakeResponse = factory.fakeNetworkResponse { + setupFakeNetworkResponse(fakeResponse) + } + super.connect(withToken: token) + performNetworkConnectEvent() + } + + private func addWebSocketOpenHook(withImplementation implementation: @Sendable @escaping () -> Void) -> Hook { + webSocketOpenHookSempahore.wait() + let hook = Hook(implementation: implementation) + webSocketOpenHook = hook + webSocketOpenHookSempahore.signal() + return hook + } + + private func removeWebSocketOpenHook(_ hook: Hook) { + webSocketOpenHookSempahore.wait() + if webSocketOpenHook === hook { + webSocketOpenHook = nil + } + webSocketOpenHookSempahore.signal() + } + + /// If this transport has been configured with a replacement implementation of ARTWebSocket#open, then this performs that implementation and returns `true`. Else, returns `false`. + func handleWebSocketOpen() -> Bool { + let hook: Hook? + webSocketOpenHookSempahore.wait() + hook = webSocketOpenHook + webSocketOpenHookSempahore.signal() + + if let hook { + hook.performImplementation() + return true + } else { + return false + } + } + + private func setupFakeNetworkResponse(_ networkResponse: FakeNetworkResponse) { + nonisolated(unsafe) var hook: Hook? + hook = addWebSocketOpenHook { + if self.factory.fakeNetworkResponse == nil { + return + } + + func performFakeConnectionError(_ secondsForDelay: TimeInterval, error: ARTRealtimeTransportError) { + self.queue.asyncAfter(deadline: .now() + secondsForDelay) { + self.delegate?.realtimeTransportFailed(self, withError: error) + if let hook { + self.removeWebSocketOpenHook(hook) + } + } + } + + guard let url = self.lastUrl else { + fatalError("MockNetworkResponse: lastUrl should not be nil") + } + + switch networkResponse { + case .noInternet, + .hostUnreachable, + .hostInternalError, + .host400BadRequest, + .arbitraryError: + performFakeConnectionError(0.1, error: networkResponse.transportError(for: url)) + case let .requestTimeout(timeout): + performFakeConnectionError(0.1 + timeout, error: networkResponse.transportError(for: url)) + } + } + } + + private func performNetworkConnectEvent() { + guard let networkConnectEventHandler = factory.networkConnectEvent else { + return + } + if let lastUrl { + networkConnectEventHandler(self, lastUrl) + } else { + queue.asyncAfter(deadline: .now() + 0.1) { + // Repeat until `lastUrl` is assigned. + self.performNetworkConnectEvent() + } + } + } + + override func setupWebSocket(_ params: [String: URLQueryItem], with options: ARTClientOptions, resumeKey: String?) -> URL { + let url = super.setupWebSocket(params, with: options, resumeKey: resumeKey) + lastUrl = url + return url + } + + func send(_ message: ARTProtocolMessage) { + // swiftlint:disable:next force_try + let data = try! encoder.encode(message) + send(data, withSource: message) + } + + @discardableResult + override func send(_ data: Data, withSource decodedObject: Any?) -> Bool { + if let networkAnswer = factory.fakeNetworkResponse, let ws = websocket { + // Ignore it because it should fake a failure. + webSocket(ws, didFailWithError: networkAnswer.error) + return false + } + + if let msg = decodedObject as? ARTProtocolMessage { + if ignoreSends { + _protocolMessagesSentIgnored.append(msg) + return false + } + _protocolMessagesSent.append(msg) + if let performEvent = callbackBeforeProcessingOutgoingMessage { + DispatchQueue.main.async { + performEvent(msg) + } + } + } + rawDataSent.append(data) + return super.send(data, withSource: decodedObject) + } + + override func receive(_ original: ARTProtocolMessage) { + if original.action == .ack || original.action == .presence { + if let error = replacingAcksWithNacks { + original.action = .nack + original.error = error + } + } + _protocolMessagesReceived.append(original) + if actionsIgnored.contains(original.action) { + return + } + if let performEvent = callbackBeforeProcessingIncomingMessage { + DispatchQueue.main.async { + performEvent(original) + } + } + var msg = original + if let performEvent = callbackBeforeIncomingMessageModifier { + guard let modifiedMsg = performEvent(msg) else { + return + } + msg = modifiedMsg + } + super.receive(msg) + if let performEvent = callbackAfterIncomingMessageModifier { + guard let modifiedMsg = performEvent(msg) else { + return + } + msg = modifiedMsg + } + if let performEvent = callbackAfterProcessingIncomingMessage { + DispatchQueue.main.async { + performEvent(msg) + } + } + } + + override func receive(with data: Data) -> ARTProtocolMessage? { + rawDataReceived.append(data) + return super.receive(with: data) + } + + override func webSocketDidOpen(_ webSocket: ARTWebSocket) { + if !ignoreWebSocket { + super.webSocketDidOpen(webSocket) + } + } + + override func webSocket(_ webSocket: ARTWebSocket, didFailWithError error: Error) { + if !ignoreWebSocket { + super.webSocket(webSocket, didFailWithError: error) + } + } + + override func webSocket(_ webSocket: ARTWebSocket, didReceiveMessage message: Any?) { + if let networkAnswer = factory.fakeNetworkResponse, let ws = websocket { + // Ignore it because it should fake a failure. + self.webSocket(ws, didFailWithError: networkAnswer.error) + return + } + + if !ignoreWebSocket { + super.webSocket(webSocket, didReceiveMessage: message as Any) + } + } + + override func webSocket(_ webSocket: ARTWebSocket, didCloseWithCode code: Int, reason: String?, wasClean: Bool) { + if !ignoreWebSocket { + super.webSocket(webSocket, didCloseWithCode: code, reason: reason, wasClean: wasClean) + } + } + + // MARK: Helpers + + func simulateTransportSuccess(clientId: String? = nil) { + ignoreWebSocket = true + let msg = ARTProtocolMessage() + msg.action = .connected + msg.connectionId = "x-xxxxxxxx" + msg.connectionKey = "xxxxxxx-xxxxxxxxxxxxxx-xxxxxxxx" + msg.connectionDetails = ARTConnectionDetails(clientId: clientId, connectionKey: "a8c10!t-3D0O4ejwTdvLkl-b33a8c10", maxMessageSize: 16384, maxFrameSize: 262_144, maxInboundRate: 250, connectionStateTtl: 60, serverId: "testServerId", maxIdleInterval: 15000, objectsGCGracePeriod: 86_400_000, siteCode: nil) + super.receive(msg) + } +} + +// swiftlint:disable:next identifier_name +let AblyTestsErrorDomain = "test.ably.io" + +enum FakeNetworkResponse { + case noInternet + case hostUnreachable + case requestTimeout(timeout: TimeInterval) + case hostInternalError(code: Int) + case host400BadRequest + case arbitraryError + + var error: NSError { + switch self { + case .noInternet: + NSError(domain: NSPOSIXErrorDomain, code: 50, userInfo: [NSLocalizedDescriptionKey: "network is down", NSLocalizedFailureReasonErrorKey: AblyTestsErrorDomain + ".FakeNetworkResponse"]) + case .hostUnreachable: + NSError(domain: kCFErrorDomainCFNetwork as String, code: 2, userInfo: [NSLocalizedDescriptionKey: "host unreachable", NSLocalizedFailureReasonErrorKey: AblyTestsErrorDomain + ".FakeNetworkResponse"]) + case .requestTimeout: + NSError(domain: "com.squareup.SocketRocket", code: 504, userInfo: [NSLocalizedDescriptionKey: "timed out", NSLocalizedFailureReasonErrorKey: AblyTestsErrorDomain + ".FakeNetworkResponse"]) + case let .hostInternalError(code): + NSError(domain: AblyTestsErrorDomain, code: code, userInfo: [NSLocalizedDescriptionKey: "internal error", NSLocalizedFailureReasonErrorKey: AblyTestsErrorDomain + ".FakeNetworkResponse"]) + case .host400BadRequest: + NSError(domain: AblyTestsErrorDomain, code: 400, userInfo: [NSLocalizedDescriptionKey: "bad request", NSLocalizedFailureReasonErrorKey: AblyTestsErrorDomain + ".FakeNetworkResponse"]) + case .arbitraryError: + NSError(domain: AblyTestsErrorDomain, code: 1, userInfo: [NSLocalizedDescriptionKey: "error from FakeNetworkResponse.arbitraryError"]) + } + } + + func transportError(for url: URL) -> ARTRealtimeTransportError { + switch self { + case .noInternet: + ARTRealtimeTransportError(error: error, type: .noInternet, url: url) + case .hostUnreachable: + ARTRealtimeTransportError(error: error, type: .hostUnreachable, url: url) + case .requestTimeout: + ARTRealtimeTransportError(error: error, type: .timeout, url: url) + case let .hostInternalError(code): + ARTRealtimeTransportError(error: error, badResponseCode: code, url: url) + case .host400BadRequest: + ARTRealtimeTransportError(error: error, badResponseCode: 400, url: url) + case .arbitraryError: + ARTRealtimeTransportError(error: error, type: .other, url: url) + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/JSONValueTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/JSONValueTests.swift new file mode 100644 index 000000000..bdbef4f32 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/JSONValueTests.swift @@ -0,0 +1,157 @@ +@testable import AblyLiveObjects +import Foundation +import Testing + +struct JSONValueTests { + // MARK: Conversion from JSONSerialization output + + @Test(arguments: [ + // object + (jsonSerializationOutput: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), + // array + (jsonSerializationOutput: ["someElement"], expectedResult: ["someElement"]), + // string + (jsonSerializationOutput: "someString", expectedResult: "someString"), + // number + (jsonSerializationOutput: NSNumber(value: 0), expectedResult: 0), + (jsonSerializationOutput: NSNumber(value: 1), expectedResult: 1), + (jsonSerializationOutput: NSNumber(value: 123), expectedResult: 123), + (jsonSerializationOutput: NSNumber(value: 123.456), expectedResult: 123.456), + // bool + (jsonSerializationOutput: NSNumber(value: true), expectedResult: true), + (jsonSerializationOutput: NSNumber(value: false), expectedResult: false), + // null + (jsonSerializationOutput: NSNull(), expectedResult: .null), + ] as[(jsonSerializationOutput: Sendable, expectedResult: JSONValue?)]) + func initWithJSONSerializationOutput(jsonSerializationOutput: Sendable, expectedResult: JSONValue?) { + #expect(JSONValue(jsonSerializationOutput: jsonSerializationOutput) == expectedResult) + } + + // Tests that it correctly handles an object deserialized by `JSONSerialization`. + @Test + func initWithJSONSerializationOutput_endToEnd() throws { + let jsonString = """ + { + "someArray": [ + { + "someStringKey": "someString", + "zero": 0, + "one": 1, + "someIntegerKey": 123, + "someFloatKey": 123.456, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": null + }, + "someOtherArrayElement" + ], + "someNestedObject": { + "someOtherKey": "someOtherValue" + } + } + """ + + let jsonSerializationOutput = try JSONSerialization.jsonObject(with: #require(jsonString.data(using: .utf8))) + + let expected: JSONValue = [ + "someArray": [ + [ + "someStringKey": "someString", + "zero": 0, + "one": 1, + "someIntegerKey": 123, + "someFloatKey": 123.456, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": .null, + ], + "someOtherArrayElement", + ], + "someNestedObject": [ + "someOtherKey": "someOtherValue", + ], + ] + + #expect(JSONValue(jsonSerializationOutput: jsonSerializationOutput) == expected) + } + + // MARK: Conversion to JSONSerialization input + + @Test(arguments: [ + // object + (value: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), + // array + (value: ["someElement"], expectedResult: ["someElement"]), + // string + (value: "someString", expectedResult: "someString"), + // number + (value: 0, expectedResult: NSNumber(value: 0)), + (value: 1, expectedResult: NSNumber(value: 1)), + (value: 123, expectedResult: NSNumber(value: 123)), + (value: 123.456, expectedResult: NSNumber(value: 123.456)), + // bool + (value: true, expectedResult: NSNumber(value: true)), + (value: false, expectedResult: NSNumber(value: false)), + // null + (value: .null, expectedResult: NSNull()), + ] as[(value: JSONValue, expectedResult: Sendable)]) + func toJSONSerializationInput(value: JSONValue, expectedResult: Sendable) throws { + let resultAsNSObject = try #require(value.toJSONSerializationInputElement as? NSObject) + let expectedResultAsNSObject = try #require(expectedResult as? NSObject) + #expect(resultAsNSObject == expectedResultAsNSObject) + } + + // Tests that it creates an object that can be serialized by `JSONSerialization`, and that the result of this serialization is what we'd expect. + @Test + func toJSONSerializationInput_endToEnd() throws { + let value: [String: JSONValue] = [ + "someArray": [ + [ + "someStringKey": "someString", + "zero": 0, + "one": 1, + "someIntegerKey": 123, + "someFloatKey": 123.456, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": .null, + ], + "someOtherArrayElement", + ], + "someNestedObject": [ + "someOtherKey": "someOtherValue", + ], + ] + + let expectedJSONString = """ + { + "someArray": [ + { + "someStringKey": "someString", + "someIntegerKey": 123, + "someFloatKey": 123.456, + "zero": 0, + "one": 1, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": null + }, + "someOtherArrayElement" + ], + "someNestedObject": { + "someOtherKey": "someOtherValue" + } + } + """ + + let jsonSerializationOptions: JSONSerialization.WritingOptions = [.sortedKeys] + + let valueData = try JSONSerialization.data(withJSONObject: value.toJSONSerializationInput, options: jsonSerializationOptions) + let expectedData = try { + let serialized = try JSONSerialization.jsonObject(with: #require(expectedJSONString.data(using: .utf8))) + return try JSONSerialization.data(withJSONObject: serialized, options: jsonSerializationOptions) + }() + + #expect(valueData == expectedData) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift new file mode 100644 index 000000000..981f1d30e --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift @@ -0,0 +1,332 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Testing + +/// Tests for `LiveObjectMutableState`. +struct LiveObjectMutableStateTests { + /// Tests for `LiveObjectMutableState.canApplyOperation`, covering RTLO4 specification points. + struct CanApplyOperationTests { + /// Test case data for canApplyOperation tests + struct TestCase { + let description: String + let objectMessageSerial: String? + let objectMessageSiteCode: String? + let siteTimeserials: [String: String] + let expectedResult: LiveObjectMutableState.ApplicableOperation? + } + + // @spec RTLO4a3 + // @spec RTLO4a4 + // @spec RTLO4a5 + // @spec RTLO4a6 + @Test(arguments: [ + // RTLO4a3: Both ObjectMessage.serial and ObjectMessage.siteCode must be non-empty strings + TestCase( + description: "serial is nil, siteCode is valid - should return nil", + objectMessageSerial: nil, + objectMessageSiteCode: "site1", + siteTimeserials: [:], + expectedResult: nil, + ), + TestCase( + description: "serial is empty string, siteCode is valid - should return nil", + objectMessageSerial: "", + objectMessageSiteCode: "site1", + siteTimeserials: [:], + expectedResult: nil, + ), + TestCase( + description: "serial is valid, siteCode is nil - should return nil", + objectMessageSerial: "serial1", + objectMessageSiteCode: nil, + siteTimeserials: [:], + expectedResult: nil, + ), + TestCase( + description: "serial is valid, siteCode is empty string - should return nil", + objectMessageSerial: "serial1", + objectMessageSiteCode: "", + siteTimeserials: [:], + expectedResult: nil, + ), + TestCase( + description: "both serial and siteCode are invalid - should return nil", + objectMessageSerial: nil, + objectMessageSiteCode: "", + siteTimeserials: [:], + expectedResult: nil, + ), + + // RTLO4a5: If the siteSerial for this LiveObject is null or an empty string, return ApplicableOperation + TestCase( + description: "siteSerial is nil (siteCode doesn't exist) - should return ApplicableOperation", + objectMessageSerial: "serial2", + objectMessageSiteCode: "site1", + siteTimeserials: ["site2": "serial1"], // i.e. only has an entry for a different siteCode + expectedResult: LiveObjectMutableState.ApplicableOperation(objectMessageSerial: "serial2", objectMessageSiteCode: "site1"), + ), + TestCase( + description: "siteSerial is empty string - should return ApplicableOperation", + objectMessageSerial: "serial2", + objectMessageSiteCode: "site1", + siteTimeserials: ["site1": "", "site2": "serial1"], + expectedResult: LiveObjectMutableState.ApplicableOperation(objectMessageSerial: "serial2", objectMessageSiteCode: "site1"), + ), + + // RTLO4a6: If the siteSerial for this LiveObject is not an empty string, return ApplicableOperation if ObjectMessage.serial is greater than siteSerial when compared lexicographically + TestCase( + description: "serial is greater than siteSerial lexicographically - should return ApplicableOperation", + objectMessageSerial: "serial2", + objectMessageSiteCode: "site1", + siteTimeserials: ["site1": "serial1"], + expectedResult: LiveObjectMutableState.ApplicableOperation(objectMessageSerial: "serial2", objectMessageSiteCode: "site1"), + ), + TestCase( + description: "serial is less than siteSerial lexicographically - should return nil", + objectMessageSerial: "serial1", + objectMessageSiteCode: "site1", + siteTimeserials: ["site1": "serial2"], + expectedResult: nil, + ), + TestCase( + description: "serial equals siteSerial - should return nil", + objectMessageSerial: "serial1", + objectMessageSiteCode: "site1", + siteTimeserials: ["site1": "serial1"], + expectedResult: nil, + ), + ]) + func canApplyOperation(testCase: TestCase) { + let state = LiveObjectMutableState( + objectID: "test:object@123", + testsOnly_siteTimeserials: testCase.siteTimeserials, + ) + let logger = TestLogger() + + let result = state.canApplyOperation( + objectMessageSerial: testCase.objectMessageSerial, + objectMessageSiteCode: testCase.objectMessageSiteCode, + logger: logger, + ) + + #expect(result == testCase.expectedResult, "Expected \(String(describing: testCase.expectedResult)) for case: \(testCase.description)") + } + } + + struct SubscriptionTests { + // swiftlint:disable trailing_closure + + // @spec RTLO4b2 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test(arguments: [.detached, .failed] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func subscribeThrowsIfChannelIsDetachedOrFailed(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { + var mutableState = LiveObjectMutableState(objectID: "foo") + let queue = DispatchQueue.main + let subscriber = Subscriber(callbackQueue: queue) + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: channelState, internalQueue: internalQueue) + + #expect { + try internalQueue.ably_syncNoDeadlock { + try mutableState.nosync_subscribe(listener: subscriber.createListener(), coreSDK: coreSDK, updateSelfLater: { _ in fatalError("Not expected") }) + } + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 && errorInfo.statusCode == 400 + } + } + + struct EmitTests { + // @spec RTLO4b4c1 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func noop() async throws { + // Given + var mutableState = LiveObjectMutableState(objectID: "foo") + let queue = DispatchQueue.main + let subscriber = Subscriber(callbackQueue: queue) + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + try internalQueue.ably_syncNoDeadlock { + _ = try mutableState.nosync_subscribe(listener: subscriber.createListener(), coreSDK: coreSDK, updateSelfLater: { _ in fatalError("Not expected") }) + } + + // When + mutableState.emit(.noop, on: queue) + + // Then + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.isEmpty) + } + + // @spec RTLO4b4c2 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func update() async throws { + // Given + var mutableState = LiveObjectMutableState(objectID: "foo") + let queue = DispatchQueue.main + let subscriber = Subscriber(callbackQueue: queue) + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + try internalQueue.ably_syncNoDeadlock { + _ = try mutableState.nosync_subscribe(listener: subscriber.createListener(), coreSDK: coreSDK, updateSelfLater: { _ in fatalError("Not expected") }) + } + + // When + mutableState.emit(.update("bar"), on: queue) + + // Then + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == ["bar"]) + } + } + + struct UnsubscribeTests { + final class MutableStateStore: Sendable { + private let mutex: DispatchQueueMutex> + + init(stored: LiveObjectMutableState, internalQueue: DispatchQueue) { + mutex = .init(dispatchQueue: internalQueue, initialValue: stored) + } + + @discardableResult + func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> SubscribeResponse { + try mutex.withSync { stored throws(ARTErrorInfo) in + try stored.nosync_subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + guard let self else { + return + } + + mutex.withSync { stored in + action(&stored) + } + }) + } + } + + func emit(_ update: LiveObjectUpdate, on queue: DispatchQueue) { + mutex.withSync { stored in + stored.emit(update, on: queue) + } + } + + func unsubscribeAll() { + mutex.withSync { stored in + stored.unsubscribeAll() + } + } + } + + // @specOneOf(1/3) RTLO4b5b - Check we can unsubscribe using the response that's returned from `subscribe` + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func unsubscribeFromReturnValue() async throws { + // Given + let queue = DispatchQueue.main + let internalQueue = TestFactories.createInternalQueue() + let store = MutableStateStore(stored: .init(objectID: "foo"), internalQueue: internalQueue) + let subscriber = Subscriber(callbackQueue: queue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let subscription = try store.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + // When + store.emit(.update("bar"), on: queue) + subscription.unsubscribe() + store.emit(.update("baz"), on: queue) + + // Then + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == ["bar"]) + } + + // @specOneOf(2/3) RTLO4b5b - Check we can unsubscribe using the `response` that's passed to the listener, and that when two updates are emitted back-to-back, the unsubscribe in the first listener causes us to not recieve the second update + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test(.disabled("This doesn't currently work and I don't think it's a priority, nor do I want to dwell on it right now or rush trying to fix it; see https://github.com/ably/ably-liveobjects-swift-plugin/issues/28")) + func unsubscribeInsideCallback_backToBackUpdates() async throws { + // Given + let queue = DispatchQueue.main + let internalQueue = TestFactories.createInternalQueue() + let store = MutableStateStore(stored: .init(objectID: "foo"), internalQueue: internalQueue) + let subscriber = Subscriber(callbackQueue: queue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + // Create a listener that calls `unsubscribe` on the `response` that's passed to the listener + let listener = subscriber.createListener { _, response in + response.unsubscribe() + } + try store.subscribe(listener: listener, coreSDK: coreSDK) + + // When + store.emit(.update("bar"), on: queue) + store.emit(.update("baz"), on: queue) + + // Then + let subscriberInvocations = await subscriber.getInvocations() + // This is failing because it's still receiving "baz" too + #expect(subscriberInvocations.map(\.0) == ["bar"]) + } + + // @specOneOf(3/3) RTLO4b5b - Check we can unsubscribe using the `response` that's passed to the listener. This is a simpler version of the above test, in that there is an async pause between the unsubscribe-in-callback and the next `emit`. + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func unsubscribeInsideCallback_nonBackToBackUpdates() async throws { + // Given + let queue = DispatchQueue.main + let internalQueue = TestFactories.createInternalQueue() + let store = MutableStateStore(stored: .init(objectID: "foo"), internalQueue: internalQueue) + let subscriber = Subscriber(callbackQueue: queue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + // Create a listener that calls `unsubscribe` on the `response` that's passed to the listener + let listener = subscriber.createListener { _, response in + response.unsubscribe() + } + try store.subscribe(listener: listener, coreSDK: coreSDK) + + // When + store.emit(.update("bar"), on: queue) + // This is what distinguishes us from the previous test; the updates aren't back to back + _ = await subscriber.getInvocations() + store.emit(.update("baz"), on: queue) + + // Then + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == ["bar"]) + } + + // @spec RTLO4d + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func unsubscribeAll() async throws { + // Given + let queue = DispatchQueue.main + let internalQueue = TestFactories.createInternalQueue() + let store = MutableStateStore(stored: .init(objectID: "foo"), internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let subscribers: [Subscriber] = [ + .init(callbackQueue: queue), + .init(callbackQueue: queue), + ] + for subscriber in subscribers { + try store.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + } + + // When + store.emit(.update("bar"), on: queue) + store.unsubscribeAll() + store.emit(.update("baz"), on: queue) + + // Then + for subscriber in subscribers { + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == ["bar"]) + } + } + } + + // swiftlint:enable trailing_closure + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift new file mode 100644 index 000000000..669c4b017 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -0,0 +1,86 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects + +final class MockCoreSDK: CoreSDK { + /// Synchronizes access to `_publishHandler` and `_publishCallbackHandler`. + private let mutex = NSLock() + private nonisolated(unsafe) var _publishHandler: (([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult)? + private nonisolated(unsafe) var _publishCallbackHandler: (([OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void)? + + private let channelStateMutex: DispatchQueueMutex<_AblyPluginSupportPrivate.RealtimeChannelState> + private let serverTime: Date + + init(channelState: _AblyPluginSupportPrivate.RealtimeChannelState, serverTime: Date = .init(), internalQueue: DispatchQueue) { + channelStateMutex = DispatchQueueMutex(dispatchQueue: internalQueue, initialValue: channelState) + self.serverTime = serverTime + } + + func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { + // We can't return _publishHandler from `mutex.withLock` because we get "error: runtime support for typed throws function types is only available in macOS 15.0.0 or newer" + var asyncHandler: (([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult)? + var callbackHandler: (([OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void)? + mutex.withLock { + asyncHandler = _publishHandler + callbackHandler = _publishCallbackHandler + } + + if let callbackHandler { + callbackHandler(objectMessages, callback) + } else if let asyncHandler { + let queue = channelStateMutex.dispatchQueue + Task { + do throws(ARTErrorInfo) { + let publishResult = try await asyncHandler(objectMessages) + queue.async { callback(.success(publishResult)) } + } catch { + queue.async { callback(.failure(error)) } + } + } + } else { + protocolRequirementNotImplemented() + } + } + + func testsOnly_overridePublish(with _: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { + protocolRequirementNotImplemented() + } + + var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { + channelStateMutex.withoutSync { $0 } + } + + /// Sets a custom publish handler for testing. + /// + /// - Precondition: ``setPublishCallbackHandler(_:)`` must not have been called. + func setPublishHandler(_ handler: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { + mutex.withLock { + precondition(_publishCallbackHandler == nil, "Cannot set both publishHandler and publishCallbackHandler") + _publishHandler = handler + } + } + + /// Sets a callback-based publish handler for testing. + /// + /// Unlike ``setPublishHandler(_:)``, this variant receives the publish callback directly, + /// avoiding the `Task`-based dispatch used by the async variant. This makes it easier to + /// control the ordering of side effects relative to the publish callback — the handler can + /// dispatch the callback and follow-up work as separate blocks on the internal queue, giving + /// explicit control over their ordering. + /// + /// - Precondition: ``setPublishHandler(_:)`` must not have been called. + func setPublishCallbackHandler(_ handler: @escaping ([OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void) { + mutex.withLock { + // We use pattern matching instead of `== nil` to avoid "runtime support for typed + // throws function types is only available in macOS 15.0.0 or newer". + if case .some = _publishHandler { + preconditionFailure("Cannot set both publishHandler and publishCallbackHandler") + } + _publishCallbackHandler = handler + } + } + + func nosync_fetchServerTime(callback: @escaping @Sendable (Result) -> Void) { + callback(.success(serverTime)) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift new file mode 100644 index 000000000..a012c94cf --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift @@ -0,0 +1,41 @@ +@testable import AblyLiveObjects +import Foundation + +/// A mock delegate that can return predefined objects +final class MockLiveMapObjectsPoolDelegate: LiveMapObjectsPoolDelegate { + private let poolMutex: DispatchQueueMutex + + init(internalQueue: DispatchQueue) { + poolMutex = DispatchQueueMutex( + dispatchQueue: internalQueue, + initialValue: Self.createPool( + internalQueue: internalQueue, + otherEntries: [:], + ), + ) + } + + static func createPool(internalQueue: DispatchQueue, otherEntries: [String: ObjectsPool.Entry]) -> ObjectsPool { + .init( + // Only otherEntries matters; the others just control the creation of the object at the root key, which none of the tests that use this delegate care about + logger: TestLogger(), + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: MockSimpleClock(), + testsOnly_otherEntries: otherEntries, + ) + } + + var objects: [String: ObjectsPool.Entry] { + get { + poolMutex.withSync { $0.entries } + } + set { + poolMutex.withSync { $0 = Self.createPool(internalQueue: poolMutex.dispatchQueue, otherEntries: newValue) } + } + } + + var nosync_objectsPool: ObjectsPool { + poolMutex.withoutSync { $0 } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift new file mode 100644 index 000000000..e6904b1f4 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift @@ -0,0 +1,45 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects + +final class MockRealtimeObjects: InternalRealtimeObjectsProtocol { + private let objectsPoolDelegate: MockLiveMapObjectsPoolDelegate? + + /// Synchronizes access to `_publishAndApplyHandler`. + private let mutex = NSLock() + private nonisolated(unsafe) var _publishAndApplyHandler: (([OutboundObjectMessage]) -> Result)? + + init(objectsPoolDelegate: MockLiveMapObjectsPoolDelegate? = nil) { + self.objectsPoolDelegate = objectsPoolDelegate + } + + var nosync_objectsPool: ObjectsPool { + guard let objectsPoolDelegate else { + preconditionFailure("MockRealtimeObjects was not initialised with an objectsPoolDelegate") + } + return objectsPoolDelegate.nosync_objectsPool + } + + func setPublishAndApplyHandler(_ handler: @escaping ([OutboundObjectMessage]) -> Result) { + mutex.withLock { + _publishAndApplyHandler = handler + } + } + + func nosync_publishAndApply( + objectMessages: [OutboundObjectMessage], + coreSDK: CoreSDK, + callback: @escaping @Sendable (Result) -> Void, + ) { + var handler: (([OutboundObjectMessage]) -> Result)? + mutex.withLock { + handler = _publishAndApplyHandler + } + + if let handler { + callback(handler(objectMessages)) + } else { + callback(.success(())) + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift new file mode 100644 index 000000000..8e1fc1ef4 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift @@ -0,0 +1,39 @@ +@testable import AblyLiveObjects +import Foundation + +/// A mock implementation of SimpleClock for testing purposes. +final class MockSimpleClock: SimpleClock { + private let mutex = NSLock() + + /// The current time that this mock clock will return. + private nonisolated(unsafe) var currentTime: Date + + /// Creates a new MockSimpleClock with the specified current time. + /// - Parameter currentTime: The time that this clock should return. Defaults to the current system time. + init(currentTime: Date = Date()) { + self.currentTime = currentTime + } + + /// Returns the current time set for this mock clock. + var now: Date { + mutex.withLock { + currentTime + } + } + + /// Updates the current time of this mock clock. + /// - Parameter newTime: The new time to set. + func setTime(_ newTime: Date) { + mutex.withLock { + currentTime = newTime + } + } + + /// Advances the clock by the specified time interval. + /// - Parameter interval: The time interval to advance by. + func advance(by interval: TimeInterval) { + mutex.withLock { + currentTime = currentTime.addingTimeInterval(interval) + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift new file mode 100644 index 000000000..79a434b82 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift @@ -0,0 +1,215 @@ +@testable import AblyLiveObjects +import Foundation +import Testing + +struct ObjectCreationHelpersTests { + struct CreationOperationTests { + // @spec RTO11f14c + // @spec RTO11f14c1a + // @spec RTO11f14c1b + // @spec RTO11f14c1c + // @spec RTO11f14c1d + // @spec RTO11f14c1e + // @spec RTO11f14c1f + // @spec RTO11f14c2 + // @spec RTO11f9 + // @spec RTO11f16 + // @spec RTO11f17 + // @spec RTO11f15 + @available(iOS 16.0.0, tvOS 16.0.0, *) // because of using Regex + @Test + func map() throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_754_042_434) + let logger = TestLogger() + let clock = MockSimpleClock() + let internalQueue = TestFactories.createInternalQueue() + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "referencedMapID", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: clock) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "referencedCounterID", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: clock) + + // When + let creationOperation = internalQueue.ably_syncNoDeadlock { + ObjectCreationHelpers.nosync_creationOperationForLiveMap( + entries: [ + // RTO11f14c1a + "mapRef": .liveMap(referencedMap), + // RTO11f14c1a + "counterRef": .liveCounter(referencedCounter), + // RTO11f14c1b + "jsonArrayKey": .jsonArray([.string("arrayItem1"), .string("arrayItem2")]), + "jsonObjectKey": .jsonObject(["nestedKey": .string("nestedValue")]), + // RTO11f14c1c + "stringKey": .string("stringValue"), + // RTO11f14c1d + "numberKey": .number(42.5), + // RTO11f14c1e + "booleanKey": .bool(true), + // RTO11f14c1f + "dataKey": .data(Data([0x01, 0x02, 0x03])), + ], + timestamp: timestamp, + ) + } + + // Then + + // Check that objectMessage has mapCreateWithObjectId with derivedFrom (RTO11f18) + let mapCreateWithObjectId = try #require(creationOperation.objectMessage.operation?.mapCreateWithObjectId) + #expect(mapCreateWithObjectId.derivedFrom?.semantics == .known(creationOperation.semantics)) + + // Check that the initial value JSON is correctly populated on the mapCreateWithObjectId.initialValue property per RTO11f15, using the RTO11f14 partial ObjectOperation and correctly encoded per RTO11f15. Per RTO11f15, the initialValue is a JSON string of the MapCreate object itself (not wrapped in a type key). + let initialValueString = try #require(creationOperation.objectMessage.operation?.mapCreateWithObjectId?.initialValue) + let deserializedInitialValue = try #require(try JSONObjectOrArray(jsonString: initialValueString).objectValue) + #expect(deserializedInitialValue == [ + // RTO11f14a + "semantics": .number(Double(ObjectsMapSemantics.lww.rawValue)), + "entries": [ + // RTO11f14c1a + "mapRef": [ + "data": [ + "objectId": "referencedMapID", + ], + ], + "counterRef": [ + "data": [ + "objectId": "referencedCounterID", + ], + ], + // RTO11f14c1b + "jsonArrayKey": [ + "data": [ + "json": #"["arrayItem1","arrayItem2"]"#, + ], + ], + "jsonObjectKey": [ + "data": [ + "json": #"{"nestedKey":"nestedValue"}"#, + ], + ], + // RTO11f14c1c + "stringKey": [ + "data": [ + "string": "stringValue", + ], + ], + // RTO11f14c1d + "numberKey": [ + "data": [ + "number": 42.5, + ], + ], + // RTO11f14c1e + "booleanKey": [ + "data": [ + "boolean": true, + ], + ], + // RTO11f14c1f + "dataKey": [ + "data": [ + "bytes": .string(Data([0x01, 0x02, 0x03]).base64EncodedString()), + ], + ], + ], + ]) + + // Check that derivedFrom has the mapCreate properties + let derivedMapCreate = try #require(mapCreateWithObjectId.derivedFrom) + + #expect(derivedMapCreate.semantics == .known(.lww)) + + let expectedEntries: [String: ObjectsMapEntry] = [ + "mapRef": .init(data: .init(objectId: "referencedMapID")), + "counterRef": .init(data: .init(objectId: "referencedCounterID")), + "jsonArrayKey": .init(data: .init(json: .array(["arrayItem1", "arrayItem2"]))), + "jsonObjectKey": .init(data: .init(json: .object(["nestedKey": "nestedValue"]))), + "stringKey": .init(data: .init(string: "stringValue")), + "numberKey": .init(data: .init(number: 42.5)), + "booleanKey": .init(data: .init(boolean: true)), + "dataKey": .init(data: .init(bytes: Data([0x01, 0x02, 0x03]))), + ] + #expect(derivedMapCreate.entries == expectedEntries) + + // Check the other ObjectMessage.operation properties + let operation = try #require(creationOperation.objectMessage.operation) + + // RTO11f9 + #expect(operation.action == .known(.mapCreate)) + + // Check that objectId has been populated on ObjectMessage per RTO11f10, and do a quick sense check that its format is what we'd expect from RTO11f8 (RTO14 is properly tested elsewhere) + #expect(try /map:.*@1754042434000/.firstMatch(in: operation.objectId) != nil) + + // Check that nonce has been populated per RTO11f16, and has 16+ characters per RTO11f6 + #expect(mapCreateWithObjectId.nonce.count >= 16) + } + + // @specOneOf(1/2) RTO12f12a + // @spec RTO12f15 + // @spec RTO12f6 + // @spec RTO12f8 + // @spec RTO12f14 + // @spec RTO12f13 + @Test + @available(iOS 16.0.0, tvOS 16.0.0, *) // because of using Regex + func counter() throws { + // Given + let timestamp = Date(timeIntervalSince1970: 1_754_042_434) + + // When + let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter( + count: 10.5, + timestamp: timestamp, + ) + + // Then + + // Check that objectMessage has counterCreateWithObjectId with derivedFrom (RTO12f16) + let counterCreateWithObjectId = try #require(creationOperation.objectMessage.operation?.counterCreateWithObjectId) + + // Check that the initial value JSON is correctly populated on the counterCreateWithObjectId.initialValue property per RTO12f13, using the RTO12f12 partial ObjectOperation and correctly encoded per RTO12f13. Per RTO12f13, the initialValue is a JSON string of the CounterCreate object itself (not wrapped in a type key). + let initialValueString = try #require(creationOperation.objectMessage.operation?.counterCreateWithObjectId?.initialValue) + let deserializedInitialValue = try #require(try JSONObjectOrArray(jsonString: initialValueString).objectValue) + #expect(deserializedInitialValue == [ + // RTO12f12a + "count": 10.5, + ]) + + // Check that derivedFrom has the counterCreate properties + #expect(counterCreateWithObjectId.derivedFrom?.count == 10.5) + + // Check the other ObjectMessage.operation properties + let operation = try #require(creationOperation.objectMessage.operation) + + // RTO12f7 + #expect(operation.action == .known(.counterCreate)) + + // Check that objectId has been populated on ObjectMessage per RTO12f8, and do a quick sense check that its format is what we'd expect from RTO12f6 (RTO14 is properly tested elsewhere) + #expect(try /counter:.*@1754042434000/.firstMatch(in: operation.objectId) != nil) + + // Check that nonce has been populated per RTO12f14, and has 16+ characters per RTO12f4 + #expect(counterCreateWithObjectId.nonce.count >= 16) + } + } + + /// Tests for the RTO14 objectID generation. + struct ObjectIDTests { + // @spec RTO14 + // @spec RTO14b1 + // @spec RTO14b2 + // @spec RTO14c + @Test + func createObjectID() { + let objectID = ObjectCreationHelpers.testsOnly_createObjectID( + type: "counter", + initialValue: "arbitraryInitialValue", + nonce: "arbitraryNonceABC", // Chosen to provoke a Base64 encoding that contains Base64URL-prohibited characters +, /, and =; see below + timestamp: Date( + timeIntervalSince1970: 1_754_042_434, + ), + ) + + // (The Base64-encoded SHA-256 of "arbitraryInitialValue:arbitraryNonceABC" is X5cX5Wv32Wj84/tzyuj5XD/Qpa76E+JkjPPQMK5aouw=) + #expect(objectID == "counter:X5cX5Wv32Wj84_tzyuj5XD_Qpa76E-JkjPPQMK5aouw@1754042434000") + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift new file mode 100644 index 000000000..640d4d831 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift @@ -0,0 +1,181 @@ +@testable import AblyLiveObjects +import Foundation +import Testing + +struct ObjectDiffHelpersTests { + /// Tests for the `calculateCounterDiff` method, covering RTLC14 specification points + struct CalculateCounterDiffTests { + // @spec RTLC14b + @Test + func calculatesDifference() { + let update = ObjectDiffHelpers.calculateCounterDiff( + previousData: 10.0, + newData: 15.0, + ) + #expect(update.update?.amount == 5.0) + } + } + + /// Tests for the `calculateMapDiff` method, covering RTLM22 specification points + struct CalculateMapDiffTests { + // @spec RTLM22b1 + @Test + func detectsRemovedKeys() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + #expect(update.update?.update["key2"] == .removed) + #expect(update.update?.update["key1"] == nil) // key1 unchanged + } + + // @spec RTLM22b2 + @Test + func detectsAddedKeys() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + #expect(update.update?.update["key2"] == .updated) + #expect(update.update?.update["key1"] == nil) // key1 unchanged + } + + // @specOneOf(1/2) RTLM22b3 + @Test + func detectsUpdatedKeys() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "oldValue")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "newValue")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + #expect(update.update?.update["key1"] == .updated) + } + + // @specOneOf(2/2) RTLM22b3 + @Test + func ignoresUnchangedKeys() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + #expect(update.update?.update.isEmpty == true) + } + + // @specOneOf(1/3) RTLM22b - Ignores tombstoned entries in previousData + @Test + func ignoresTombstonedEntriesInPreviousData() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + 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) + } + + // @specOneOf(2/3) RTLM22b - Ignores tombstoned entries in newData + @Test + func ignoresTombstonedEntriesInNewData() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value1")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + // key1 became tombstoned in newData, so it's considered "removed" + #expect(update.update?.update["key1"] == .removed) + } + + // @specOneOf(3/3) RTLM22b - Tombstoned to tombstoned is not a change + @Test + func ignoresTombstonedToTombstonedTransition() { + let previousData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value1")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value2")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + // Both tombstoned, so no change + #expect(update.update?.update.isEmpty == true) + } + + // Test combined changes + @Test + func detectsMultipleChanges() { + let previousData: [String: InternalObjectsMapEntry] = [ + "removed": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "updated": TestFactories.internalMapEntry(data: ObjectData(string: "oldValue")), + "unchanged": TestFactories.internalMapEntry(data: ObjectData(string: "sameValue")), + ] + let newData: [String: InternalObjectsMapEntry] = [ + "added": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + "updated": TestFactories.internalMapEntry(data: ObjectData(string: "newValue")), + "unchanged": TestFactories.internalMapEntry(data: ObjectData(string: "sameValue")), + ] + + let update = ObjectDiffHelpers.calculateMapDiff( + previousData: previousData, + newData: newData, + ) + + #expect(update.update?.update["removed"] == .removed) + #expect(update.update?.update["added"] == .updated) + #expect(update.update?.update["updated"] == .updated) + #expect(update.update?.update["unchanged"] == nil) + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift new file mode 100644 index 000000000..d4a4cbba1 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift @@ -0,0 +1,238 @@ +import Ably.Private +@testable import AblyLiveObjects +import Testing + +@Suite(.tags(.integration)) +struct ObjectLifetimesTests { + @Test("LiveObjects functionality works with only a strong reference to channel's public objects property") + func withStrongReferenceToPublicObjectsProperty() async throws { + // The objects that we'll create. + struct CreatedObjects { + /// The queue on which we expect ably-cocoa's QueuedDealloc mechanism to enqueue the relinquishing of `weakInternalRealtime`. + var realtimeDeallocQueue: DispatchQueue + + weak var weakPublicRealtime: ARTRealtime? + weak var weakInternalRealtime: ARTRealtimeInternal? + weak var weakPublicChannel: ARTRealtimeChannel? + weak var weakInternalChannel: ARTRealtimeChannelInternal? + var strongPublicRealtimeObjects: PublicDefaultRealtimeObjects + weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? + } + + // What we're left with after discarding a CreatedObjects. + struct RemainingObjects { + /// The queue on which we expect ably-cocoa's QueuedDealloc mechanism to enqueue the relinquishing of `weakInternalRealtime`. + var realtimeDeallocQueue: DispatchQueue + + // weakPublicRealtime is gone now + weak var weakInternalRealtime: ARTRealtimeInternal? + // weakPublicChannel is gone now + weak var weakInternalChannel: ARTRealtimeChannelInternal? + weak var weakPublicRealtimeObjects: PublicDefaultRealtimeObjects? + weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? + } + + func createAndDiscardObjects() async throws -> RemainingObjects { + func createObjects() async throws -> CreatedObjects { + // We disable autoConnect since being connected extends the internal Realtime instance's lifetime (it stays alive whilst connected), and I don't want that interfering with this test. + let realtime = try await ClientHelper.realtimeWithObjects(options: .init(autoConnect: false)) + let channel = realtime.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) + let anyObjects = channel.objects + // For some reason putting `channel.objects as? PublicDefaultRealtimeObjects` inside the #require gives "no calls to throwing functions occur within 'try' expression" 🤷 + let objects = try #require(anyObjects as? PublicDefaultRealtimeObjects) + + return .init( + realtimeDeallocQueue: realtime.internal.queue, + weakPublicRealtime: realtime, + weakInternalRealtime: realtime.internal, + weakPublicChannel: channel, + weakInternalChannel: channel.internal, + strongPublicRealtimeObjects: objects, + weakInternalRealtimeObjects: objects.testsOnly_proxied, + ) + } + + let createdObjects = try await createObjects() + + // The only public object we have a strong reference to is strongPublicRealtimeObjects, so the other public objects should have already been deallocated + #expect(createdObjects.weakPublicRealtime == nil) + #expect(createdObjects.weakPublicChannel == nil) + + // Now we check that, since we still have a strong reference to strongPublicRealtimeObjects, none of the dependencies that it needs in order to function have been deallocated. + await withCheckedContinuation { continuation in + // We wait for everything on realtimeDeallocQueue to execute, to be sure that we'd catch a dealloc that had been enqueued via ably-cocoa's QueuedDealloc mechanism. + createdObjects.realtimeDeallocQueue.async { + continuation.resume() + } + } + #expect(createdObjects.weakInternalRealtime != nil) + #expect(createdObjects.weakInternalChannel != nil) + #expect(createdObjects.weakInternalRealtimeObjects != nil) + + // TODO: test that we can receive events on a LiveObject (https://github.com/ably/ably-liveobjects-swift-plugin/issues/30) + + // Note that after this return we no longer have a reference to createdObjects and thus no longer have a strong reference to our public RealtimeObjects instance + return .init( + realtimeDeallocQueue: createdObjects.realtimeDeallocQueue, + weakInternalRealtime: createdObjects.weakInternalRealtime, + weakInternalChannel: createdObjects.weakInternalChannel, + weakPublicRealtimeObjects: createdObjects.strongPublicRealtimeObjects, + weakInternalRealtimeObjects: createdObjects.weakInternalRealtimeObjects, + ) + } + + let remainingObjects = try await createAndDiscardObjects() + + // Check that the public RealtimeObjects has been deallocated now that we've no longer got a strong reference to it + #expect(remainingObjects.weakPublicRealtimeObjects == nil) + + // Check that the internal objects that the public RealtimeObjects needed in order to function have now been deallocated + await withCheckedContinuation { continuation in + // We wait for everything on realtimeDeallocQueue to execute, to be sure that we'd catch a dealloc that had been enqueued via ably-cocoa's QueuedDealloc mechanism. + remainingObjects.realtimeDeallocQueue.async { + continuation.resume() + } + } + + #expect(remainingObjects.weakInternalRealtime == nil) + #expect(remainingObjects.weakInternalChannel == nil) + #expect(remainingObjects.weakInternalRealtimeObjects == nil) + } + + @Test("LiveObjects functionality works with only a strong reference to a public LiveObject") + func withStrongReferenceToPublicLiveObject() async throws { + // Note: This test is very similar to withStrongReferenceToPublicObjectsProperty but "one layer down" — i.e. it checks that instead of a RealtimeObjects reference keeping everything working, a LiveObject reference keeps everything working. Keep these two tests in sync. + + // The objects that we'll create. + struct CreatedObjects { + /// The queue on which we expect ably-cocoa's QueuedDealloc mechanism to enqueue the relinquishing of `weakInternalRealtime`. + var realtimeDeallocQueue: DispatchQueue + + weak var weakPublicRealtime: ARTRealtime? + weak var weakInternalRealtime: ARTRealtimeInternal? + weak var weakPublicChannel: ARTRealtimeChannel? + weak var weakInternalChannel: ARTRealtimeChannelInternal? + weak var weakPublicRealtimeObjects: PublicDefaultRealtimeObjects? + weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? + var strongPublicLiveObject: PublicDefaultLiveMap + weak var weakInternalLiveObject: InternalDefaultLiveMap? + } + + // What we're left with after discarding a CreatedObjects. + struct RemainingObjects { + /// The queue on which we expect ably-cocoa's QueuedDealloc mechanism to enqueue the relinquishing of `weakInternalRealtime`. + var realtimeDeallocQueue: DispatchQueue + + // weakPublicRealtime is gone now + weak var weakInternalRealtime: ARTRealtimeInternal? + // weakPublicChannel is gone now + weak var weakInternalChannel: ARTRealtimeChannelInternal? + // weakPublicRealtimeObjects is gone now + weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? + weak var weakPublicLiveObject: PublicDefaultLiveMap? + weak var weakInternalLiveObject: InternalDefaultLiveMap? + } + + func createAndDiscardObjects() async throws -> RemainingObjects { + func createObjects() async throws -> CreatedObjects { + // We disable autoConnect since being connected extends the internal Realtime instance's lifetime (it stays alive whilst connected), and I don't want that interfering with this test. + let realtime = try await ClientHelper.realtimeWithObjects() + // Unlike in withStrongReferenceToPublicObjectsProperty, we'll have to allow it to connect, because we need to attach so that getRoot() returns. We'll instead manually close the connection before proceeding with the test + let channel = realtime.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) + try await channel.attachAsync() + let anyObjects = channel.objects + // For some reason putting `channel.objects as? PublicDefaultRealtimeObjects` inside the #require gives "no calls to throwing functions occur within 'try' expression" 🤷 + let objects = try #require(anyObjects as? PublicDefaultRealtimeObjects) + let root = try #require(try await anyObjects.getRoot() as? PublicDefaultLiveMap) + + // Wait for the connection to close, as mentioned above + async let connectionClosedPromise: Void = withCheckedContinuation { continuation in + realtime.connection.on(.closed) { _ in + continuation.resume() + } + } + realtime.connection.close() + _ = await connectionClosedPromise + + return .init( + realtimeDeallocQueue: realtime.internal.queue, + weakPublicRealtime: realtime, + weakInternalRealtime: realtime.internal, + weakPublicChannel: channel, + weakInternalChannel: channel.internal, + weakPublicRealtimeObjects: objects, + weakInternalRealtimeObjects: objects.testsOnly_proxied, + strongPublicLiveObject: root, + weakInternalLiveObject: root.proxied, + ) + } + + let createdObjects = try await createObjects() + + // The only public object we have a strong reference to is strongPublicLiveObject, so the other public objects should have already been deallocated + #expect(createdObjects.weakPublicRealtime == nil) + #expect(createdObjects.weakPublicChannel == nil) + #expect(createdObjects.weakPublicRealtimeObjects == nil) + + // Now we check that, since we still have a strong reference to strongPublicLiveObject, none of the dependencies that it needs in order to function have been deallocated. + await withCheckedContinuation { continuation in + // We wait for everything on realtimeDeallocQueue to execute, to be sure that we'd catch a dealloc that had been enqueued via ably-cocoa's QueuedDealloc mechanism. + createdObjects.realtimeDeallocQueue.async { + continuation.resume() + } + } + #expect(createdObjects.weakInternalRealtime != nil) + #expect(createdObjects.weakInternalChannel != nil) + #expect(createdObjects.weakInternalRealtimeObjects != nil) + #expect(createdObjects.weakInternalLiveObject != nil) + + // TODO: test that we can receive events on a LiveObject (https://github.com/ably/ably-liveobjects-swift-plugin/issues/30) + + // Note that after this return we no longer have a reference to createdObjects and thus no longer have a strong reference to our public LiveObject instance + return .init( + realtimeDeallocQueue: createdObjects.realtimeDeallocQueue, + weakInternalRealtime: createdObjects.weakInternalRealtime, + weakInternalChannel: createdObjects.weakInternalChannel, + weakInternalRealtimeObjects: createdObjects.weakInternalRealtimeObjects, + weakPublicLiveObject: createdObjects.strongPublicLiveObject, + weakInternalLiveObject: createdObjects.weakInternalLiveObject, + ) + } + + let remainingObjects = try await createAndDiscardObjects() + + // Check that the public LiveObject has been deallocated now that we've no longer got a strong reference to it + #expect(remainingObjects.weakPublicLiveObject == nil) + + // Check that the internal objects that the public LiveObject needed in order to function have now been deallocated + await withCheckedContinuation { continuation in + // We wait for everything on realtimeDeallocQueue to execute, to be sure that we'd catch a dealloc that had been enqueued via ably-cocoa's QueuedDealloc mechanism. + remainingObjects.realtimeDeallocQueue.async { + continuation.resume() + } + } + + #expect(remainingObjects.weakInternalRealtime == nil) + #expect(remainingObjects.weakInternalChannel == nil) + #expect(remainingObjects.weakInternalRealtimeObjects == nil) + #expect(remainingObjects.weakInternalLiveObject == nil) + } + + @Test("Public objects have a stable identity") + func publicObjectIdentity() async throws { + let realtime = try await ClientHelper.realtimeWithObjects() + defer { realtime.close() } + let channel = realtime.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) + try await channel.attachAsync() + + let objects = try #require(channel.objects as? PublicDefaultRealtimeObjects) + let root = try #require(try await objects.getRoot() as? PublicDefaultLiveMap) + + let objectsAgain = try #require(channel.objects as? PublicDefaultRealtimeObjects) + let rootAgain = try #require(try await objectsAgain.getRoot() as? PublicDefaultLiveMap) + + #expect(objects as AnyObject === objectsAgain as AnyObject) + #expect(root === rootAgain) + // TODO: when we have an easy way of populating the ObjectsPool (i.e. once we have a write API) then also test with a non-root LiveMap and a counter (https://github.com/ably/ably-liveobjects-swift-plugin/issues/30) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift new file mode 100644 index 000000000..3fe457bf6 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift @@ -0,0 +1,472 @@ +import _AblyPluginSupportPrivate +import Ably +@testable import AblyLiveObjects +import Foundation +import Testing + +// Note that the usage of rawValue when referring to an EncodingFormat in a parameterised test argument in this test file is a workaround for an Xcode issue that means that, if you use the struct value directly, you get a runtime crash of "Internal inconsistency: No test reporter for test case argumentIDs"; seems like it's an issue that people report happening with various combinations of test arguments, I guess this is one that triggers it. See if fixed in a future Xcode version (I tested in Xcode 16.4 and it wasn't). + +struct ObjectMessageTests { + struct ObjectDataTests { + struct EncodingTests { + struct MessagePackTests { + // @spec OD4c1 + @Test + func boolean() { + let objectData = ObjectData(boolean: true) + let wireData = objectData.toWire(format: .messagePack) + + // OD4c1: A boolean payload is encoded as a MessagePack boolean type, and the result is set on the ObjectData.boolean attribute + #expect(wireData.boolean == true) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == nil) + #expect(wireData.json == nil) + } + + // @spec OD4c2 + @Test + func binary() { + let testData = Data([1, 2, 3, 4]) + let objectData = ObjectData(bytes: testData) + let wireData = objectData.toWire(format: .messagePack) + + // OD4c2: A binary payload is encoded as a MessagePack binary type, and the result is set on the ObjectData.bytes attribute + #expect(wireData.boolean == nil) + switch wireData.bytes { + case let .data(data): + #expect(data == testData) + default: + Issue.record("Expected .data case") + } + #expect(wireData.number == nil) + #expect(wireData.string == nil) + #expect(wireData.json == nil) + } + + // @spec OD4c3 + @Test(arguments: [15, 42.0]) + func number(testNumber: NSNumber) throws { + let objectData = ObjectData(number: testNumber) + let wireData = objectData.toWire(format: .messagePack) + + // OD4c3 A number payload is encoded as a MessagePack float64 type, and the result is set on the ObjectData.number attribute + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + CFNumberGetType(testNumber) + let number = try #require(wireData.number) + #expect(CFNumberGetType(number) == .float64Type) + #expect(number == testNumber) + #expect(wireData.number == testNumber) + #expect(wireData.string == nil) + #expect(wireData.json == nil) + } + + // @spec OD4c4 + @Test + func string() { + let testString = "hello world" + let objectData = ObjectData(string: testString) + let wireData = objectData.toWire(format: .messagePack) + + // OD4c4: A string payload is encoded as a MessagePack string type, and the result is set on the ObjectData.string attribute + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == testString) + #expect(wireData.json == nil) + } + + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + @Test(arguments: [ + // We intentionally use a single-element object so that we get a stable encoding to JSON + (jsonObjectOrArray: ["key": "value"] as JSONObjectOrArray, expectedJSONString: #"{"key":"value"}"#), + (jsonObjectOrArray: [123, "hello world"] as JSONObjectOrArray, expectedJSONString: #"[123,"hello world"]"#), + ]) + func json(jsonObjectOrArray: JSONObjectOrArray, expectedJSONString: String) { + let objectData = ObjectData(json: jsonObjectOrArray) + let wireData = objectData.toWire(format: .messagePack) + + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == nil) + #expect(wireData.json == expectedJSONString) + } + } + + struct JSONTests { + // @spec OD4d1 + @Test + func boolean() { + let objectData = ObjectData(boolean: true) + let wireData = objectData.toWire(format: .json) + + // OD4d1: A boolean payload is represented as a JSON boolean and set on the ObjectData.boolean attribute + #expect(wireData.boolean == true) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == nil) + #expect(wireData.json == nil) + } + + // @spec OD4d2 + @Test + func binary() { + let testData = Data([1, 2, 3, 4]) + let objectData = ObjectData(bytes: testData) + let wireData = objectData.toWire(format: .json) + + // OD4d2: A binary payload is Base64-encoded and represented as a JSON string; the result is set on the ObjectData.bytes attribute + #expect(wireData.boolean == nil) + switch wireData.bytes { + case let .string(base64String): + #expect(base64String == testData.base64EncodedString()) + default: + Issue.record("Expected .string case") + } + #expect(wireData.number == nil) + #expect(wireData.string == nil) + #expect(wireData.json == nil) + } + + // @spec OD4d3 + @Test + func number() { + let testNumber = NSNumber(value: 42) + let objectData = ObjectData(number: testNumber) + let wireData = objectData.toWire(format: .json) + + // OD4d3: A number payload is represented as a JSON number and set on the ObjectData.number attribute + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + #expect(wireData.number == testNumber) + #expect(wireData.string == nil) + #expect(wireData.json == nil) + } + + // @spec OD4d4 + @Test + func string() { + let testString = "hello world" + let objectData = ObjectData(string: testString) + let wireData = objectData.toWire(format: .json) + + // OD4d4: A string payload is represented as a JSON string and set on the ObjectData.string attribute + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == testString) + #expect(wireData.json == nil) + } + + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + @Test(arguments: [ + // We intentionally use a single-element object so that we get a stable encoding to JSON + (jsonObjectOrArray: ["key": "value"] as JSONObjectOrArray, expectedJSONString: #"{"key":"value"}"#), + (jsonObjectOrArray: [123, "hello world"] as JSONObjectOrArray, expectedJSONString: #"[123,"hello world"]"#), + ]) + func json(jsonObjectOrArray: JSONObjectOrArray, expectedJSONString: String) { + let objectData = ObjectData(json: jsonObjectOrArray) + let wireData = objectData.toWire(format: .json) + + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == nil) + #expect(wireData.json == expectedJSONString) + } + } + } + + struct DecodingTests { + struct MessagePackTests { + // @specOneOf(1/5) OD5a1 + @Test + func boolean() throws { + let wireData = WireObjectData(boolean: true) + let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + + // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types + #expect(objectData.boolean == true) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + #expect(objectData.string == nil) + #expect(objectData.json == nil) + } + + // @specOneOf(2/5) OD5a1 + @Test + func binary() throws { + let testData = Data([1, 2, 3, 4]) + let wireData = WireObjectData(bytes: .data(testData)) + let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + + // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types + #expect(objectData.boolean == nil) + #expect(objectData.bytes == testData) + #expect(objectData.number == nil) + #expect(objectData.string == nil) + #expect(objectData.json == nil) + } + + // @specOneOf(3/5) OD5a1 - The spec isn't clear about what's meant to happen if you get string data in the `bytes` field; I'm choosing to ignore it but I think it's a bit moot - shouldn't happen. The only reason I'm considering it here is because of our slightly weird WireObjectData.bytes type which is typed as a string or data; might be good to at some point figure out how to rule out the string case earlier when using MessagePack, but it's not a big issue + @Test + func whenBytesIsString() throws { + let testData = Data([1, 2, 3, 4]) + let base64String = testData.base64EncodedString() + let wireData = WireObjectData(bytes: .string(base64String)) + let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + + // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + #expect(objectData.string == nil) + #expect(objectData.json == nil) + } + + // @specOneOf(4/5) OD5a1 + @Test + func number() throws { + let testNumber = NSNumber(value: 42) + let wireData = WireObjectData(number: testNumber) + let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + + // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == testNumber) + #expect(objectData.string == nil) + #expect(objectData.json == nil) + } + + // @specOneOf(5/5) OD5a1 + @Test + func string() throws { + let testString = "hello world" + let wireData = WireObjectData(string: testString) + let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + + // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + #expect(objectData.string == testString) + #expect(objectData.json == nil) + } + + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + @Test + func json() throws { + let jsonString = "{\"key\":\"value\",\"number\":123}" + let wireData = WireObjectData(json: jsonString) + let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + #expect(objectData.string == nil) + #expect(objectData.json == ["key": "value", "number": 123]) + } + + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + // The spec doesn't say what to do if JSON parsing fails; I'm choosing to treat it as an error + @Test + func json_invalidJson() { + let invalidJsonString = "invalid json" + let wireData = WireObjectData(json: invalidJsonString) + + // Should throw when JSON parsing fails, even in MessagePack format + #expect(throws: ARTErrorInfo.self) { + _ = try ObjectData(wireObjectData: wireData, format: .messagePack) + } + } + + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + // The spec doesn't say what to do if given serialized JSON that contains a non-object-or-array value; I'm choosing to treat it as an error + @Test(arguments: [ + // string + "\"hello world\"", + // number + "42", + // boolean true + "true", + // boolean false + "false", + // null + "null", + ]) + func json_validJsonButNotObjectOrArray(jsonString: String) { + let wireData = WireObjectData(json: jsonString) + + // Should throw when JSON is valid but not an object or array + #expect(throws: ARTErrorInfo.self) { + _ = try ObjectData(wireObjectData: wireData, format: .messagePack) + } + } + } + + struct JSONTests { + // @specOneOf(1/3) OD5b1 + @Test + func boolean() throws { + let wireData = WireObjectData(boolean: true) + let objectData = try ObjectData(wireObjectData: wireData, format: .json) + + // OD5b1: The payloads in ObjectData.boolean, ObjectData.number, and ObjectData.string are decoded as their corresponding JSON types + #expect(objectData.boolean == true) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + #expect(objectData.string == nil) + #expect(objectData.json == nil) + } + + // @specOneOf(2/3) OD5b1 + @Test + func number() throws { + let testNumber = NSNumber(value: 42) + let wireData = WireObjectData(number: testNumber) + let objectData = try ObjectData(wireObjectData: wireData, format: .json) + + // OD5b1: The payloads in ObjectData.boolean, ObjectData.number, and ObjectData.string are decoded as their corresponding JSON types + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == testNumber) + #expect(objectData.string == nil) + #expect(objectData.json == nil) + } + + // @specOneOf(3/3) OD5b1 + @Test + func string() throws { + let testString = "hello world" + let wireData = WireObjectData(string: testString) + let objectData = try ObjectData(wireObjectData: wireData, format: .json) + + // OD5b1: The payloads in ObjectData.boolean, ObjectData.number, and ObjectData.string are decoded as their corresponding JSON types + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + #expect(objectData.string == testString) + #expect(objectData.json == nil) + } + + // @specOneOf(1/2) OB5b2 + @Test + func binary() throws { + let testData = Data([1, 2, 3, 4]) + let base64String = testData.base64EncodedString() + let wireData = WireObjectData(bytes: .string(base64String)) + let objectData = try ObjectData(wireObjectData: wireData, format: .json) + + // OD5b2: The ObjectData.bytes payload is Base64-decoded into a binary value + #expect(objectData.boolean == nil) + #expect(objectData.bytes == testData) + #expect(objectData.number == nil) + #expect(objectData.string == nil) + #expect(objectData.json == nil) + } + + // @specOneOf(2/2) OB5b2 - The spec doesn't say what to do if Base64 decoding fails; we're choosing to treat it as an error + @Test + func binary_invalidBase64() { + let invalidBase64String = "not base64!" + let wireData = WireObjectData(bytes: .string(invalidBase64String)) + + // Should throw when Base64 decoding fails + #expect(throws: ARTErrorInfo.self) { + _ = try ObjectData(wireObjectData: wireData, format: .json) + } + } + + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + @Test + func json() throws { + let jsonString = "{\"key\":\"value\",\"number\":123}" + let wireData = WireObjectData(json: jsonString) + let objectData = try ObjectData(wireObjectData: wireData, format: .json) + + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + #expect(objectData.string == nil) + #expect(objectData.json == ["key": "value", "number": 123]) + } + + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + // The spec doesn't say what to do if JSON parsing fails; I'm choosing to treat it as an error + @Test + func json_invalidJson() { + let invalidJsonString = "invalid json" + let wireData = WireObjectData(json: invalidJsonString) + + // Should throw when JSON parsing fails + #expect(throws: ARTErrorInfo.self) { + _ = try ObjectData(wireObjectData: wireData, format: .json) + } + } + + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + // The spec doesn't say what to do if given serialized JSON that contains a non-object-or-array value; I'm choosing to treat it as an error + @Test(arguments: [ + // string + "\"hello world\"", + // number + "42", + // boolean true + "true", + // boolean false + "false", + // null + "null", + ]) + func json_validJsonButNotObjectOrArray(jsonString: String) { + let wireData = WireObjectData(json: jsonString) + + // Should throw when JSON is valid but not an object or array + #expect(throws: ARTErrorInfo.self) { + _ = try ObjectData(wireObjectData: wireData, format: .json) + } + } + } + } + } + + struct RoundTripTests { + @Test(arguments: [ + // Test formats + EncodingFormat.json.rawValue, + EncodingFormat.messagePack.rawValue, + ], [ + // Test each property type individually + ObjectData(boolean: true), + ObjectData(bytes: Data([1, 2, 3, 4])), + ObjectData(number: NSNumber(value: 42)), + ObjectData(string: "hello world"), + ObjectData(json: .object(["key": "value", "number": 123])), + ObjectData(json: .array([123, "hello world"])), + ]) + func roundTrip(formatRawValue: EncodingFormat.RawValue, originalData: ObjectData) throws { + let format = try #require(EncodingFormat(rawValue: formatRawValue)) + let wireData = originalData.toWire(format: format) + let decodedData = try ObjectData(wireObjectData: wireData, format: format) + + // Compare boolean values + #expect(decodedData.boolean == originalData.boolean) + + // Compare bytes values + #expect(decodedData.bytes == originalData.bytes) + + // Compare number values + #expect(decodedData.number == originalData.number) + + // Compare string values + #expect(decodedData.string == originalData.string) + + // Compare JSON values + #expect(decodedData.json == originalData.json) + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift new file mode 100644 index 000000000..6bb3cbafe --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -0,0 +1,403 @@ +import _AblyPluginSupportPrivate +@testable import AblyLiveObjects +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: 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 { + // @spec RTO6a + @Test + func returnsExistingObject() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) + + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let map = try #require(result?.mapValue) + #expect(map as AnyObject === existingMap as AnyObject) + } + + // @spec RTO6b2 + @Test + func createsZeroValueMap() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let map = try #require(result?.mapValue) + + // Verify it was added to the pool + #expect(pool.entries["map:123@456"]?.mapValue != nil) + + // Verify the objectID is correctly set + #expect(map.testsOnly_objectID == "map:123@456") + } + + // @spec RTO6b3 + @Test + func createsZeroValueCounter() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let result = pool.createZeroValueObject(forObjectID: "counter:123@456", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let counter = try #require(result?.counterValue) + #expect(try counter.value(coreSDK: coreSDK) == 0) + + // Verify it was added to the pool + #expect(pool.entries["counter:123@456"]?.counterValue != nil) + // Verify the objectID is correctly set + #expect(counter.testsOnly_objectID == "counter:123@456") + } + + // Sense check to see how it behaves when given an object ID not in the format of RTO6b1 (spec isn't prescriptive here) + @Test + func returnsNilForInvalidObjectId() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let result = pool.createZeroValueObject(forObjectID: "invalid", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + #expect(result == nil) + } + + // Sense check to see how it behaves when given an object ID not covered by RTO6b2 or RTO6b3 + @Test + func returnsNilForUnknownType() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + #expect(result == nil) + #expect(pool.entries["unknown:123@456"] == nil) + } + } + + /// Tests for the `applySyncObjectsPool` method, covering RTO5c1 and RTO5c2 specification points + struct ApplySyncObjectsPoolTests { + // MARK: - RTO5c1 Tests + + // @specOneOf(1/2) RTO5c1a1 - Override the internal data for existing map objects + // @specOneOf(1/2) RTO5c1a2 - Check we store the update for existing map objects + // @specOneOf(1/2) RTO5c7 - Check we emit the update for existing map objects + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func updatesExistingMapObject() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let existingMapSubscriber = Subscriber(callbackQueue: .main) + try existingMap.subscribe(listener: existingMapSubscriber.createListener(), coreSDK: coreSDK) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) + + let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "updated_value") + let objectState = TestFactories.mapObjectState( + objectId: "map:hash@123", + siteTimeserials: ["site1": "ts1"], + createOp: TestFactories.mapCreateOperation(objectId: "map:hash@123", entries: [ + "createOpKey": TestFactories.stringMapEntry(value: "bar").entry, + ]), + entries: [key: entry], + ) + + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool(.testsOnly_fromStates([(state: objectState, serialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + // Verify the existing map was updated by checking side effects of InternalDefaultLiveMap.replaceData(using:) + let updatedMap = try #require(pool.entries["map:hash@123"]?.mapValue) + #expect(updatedMap === existingMap) + // Checking map data to verify replaceData was called successfully + #expect(try updatedMap.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "updated_value") + // Checking site timeserials to verify they were updated by replaceData + #expect(updatedMap.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Check that the update was stored and emitted per RTO5c1a2 and RTO5c7 + // Per RTLM6h, the update should reflect the diff from previous state (empty) to new state (key1 + createOpKey) + let subscriberInvocations = await existingMapSubscriber.getInvocations() + let emittedUpdate = try #require(subscriberInvocations.first?.0.update) + #expect(emittedUpdate["key1"] == .updated) + #expect(emittedUpdate["createOpKey"] == .updated) + } + + // @specOneOf(2/2) RTO5c1a1 - Override the internal data for existing counter objects + // @specOneOf(2/2) RTO5c1a2 - Check we store the update for existing counter objects + // @specOneOf(2/2) RTO5c7 - Check we emit the update for existing counter objects + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func updatesExistingCounterObject() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let existingCounterSubscriber = Subscriber(callbackQueue: .main) + try existingCounter.subscribe(listener: existingCounterSubscriber.createListener(), coreSDK: coreSDK) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) + + let objectState = TestFactories.counterObjectState( + objectId: "counter:hash@123", + siteTimeserials: ["site1": "ts1"], + createOp: TestFactories.counterCreateOperation(objectId: "counter:hash@123", count: 5), + count: 10, + ) + + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool(.testsOnly_fromStates([(state: objectState, serialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + // Verify the existing counter was updated by checking side effects of InternalDefaultLiveCounter.replaceData(using:) + let updatedCounter = try #require(pool.entries["counter:hash@123"]?.counterValue) + #expect(updatedCounter === existingCounter) + // Checking counter value to verify replaceData was called successfully + #expect(try updatedCounter.value(coreSDK: coreSDK) == 15) // 10 (state) + 5 (createOp) + // Checking site timeserials to verify they were updated by replaceData + #expect(updatedCounter.testsOnly_siteTimeserials == ["site1": "ts1"]) + + // Check that the update was stored and emitted per RTO5c1a2 and RTO5c7 + // Per RTLC6h, the update should reflect the diff from previous state (0) to new state (15) + let subscriberInvocations = await existingCounterSubscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(amount: 15)]) // Diff from 0 to 15 + } + + // @spec RTO5c1b1a + @Test + func createsNewCounterObject() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let objectState = TestFactories.counterObjectState( + objectId: "counter:hash@456", + siteTimeserials: ["site2": "ts2"], + count: 100, + ) + + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool(.testsOnly_fromStates([(state: objectState, serialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + // Verify a new counter was created and data was set by checking side effects of InternalDefaultLiveCounter.replaceData(using:) + let newCounter = try #require(pool.entries["counter:hash@456"]?.counterValue) + // Checking counter value to verify the new counter was created and replaceData was called + #expect(try newCounter.value(coreSDK: coreSDK) == 100) + // Checking site timeserials to verify they were set by replaceData + #expect(newCounter.testsOnly_siteTimeserials == ["site2": "ts2"]) + // Verify the objectID is correctly set per RTO5c1b1a + #expect(newCounter.testsOnly_objectID == "counter:hash@456") + } + + // @spec RTO5c1b1b + @Test + func createsNewMapObject() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let (key, entry) = TestFactories.stringMapEntry(key: "key2", value: "new_value") + let objectState = TestFactories.mapObjectState( + objectId: "map:hash@789", + siteTimeserials: ["site3": "ts3"], + entries: [key: entry], + ) + + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool(.testsOnly_fromStates([(state: objectState, serialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + // Verify a new map was created and data was set by checking side effects of InternalDefaultLiveMap.replaceData(using:) + let newMap = try #require(pool.entries["map:hash@789"]?.mapValue) + // Checking map data to verify the new map was created and replaceData was called + #expect(try newMap.get(key: "key2", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new_value") + // Checking site timeserials to verify they were set by replaceData + #expect(newMap.testsOnly_siteTimeserials == ["site3": "ts3"]) + // Verify the objectID and semantics are correctly set per RTO5c1b1b + #expect(newMap.testsOnly_objectID == "map:hash@789") + #expect(newMap.testsOnly_semantics == .known(.lww)) + } + + // MARK: - RTO5c2 Tests + + // @spec RTO5c2 + @Test + func removesObjectsNotInSync() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let existingMap1 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let existingMap2 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: [ + "map:hash@1": .map(existingMap1), + "map:hash@2": .map(existingMap2), + "counter:hash@1": .counter(existingCounter), + ]) + + // Only sync one of the existing objects + let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") + + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool(.testsOnly_fromStates([(state: objectState, serialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + // Verify only synced object and root remain + #expect(pool.entries.count == 2) // root + map:hash@1 + #expect(pool.entries["root"] != nil) + #expect(pool.entries["map:hash@1"] != nil) + #expect(pool.entries["map:hash@2"] == nil) // Should be removed + #expect(pool.entries["counter:hash@1"] == nil) // Should be removed + } + + // @spec RTO5c2a + @Test + func doesNotRemoveRootObject() throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) + + // Sync with empty list (no objects) + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool(.init(), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + // Verify root is preserved but other objects are removed + #expect(pool.entries.count == 1) // Only root + #expect(pool.entries["root"] != nil) + #expect(pool.entries["map:hash@1"] == nil) // Should be removed + } + + // A more complete example of the behaviours described in RTO5c1, RTO5c2, and RTO5c7. + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func handlesComplexSyncScenario() async throws { + let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + + let existingMapSubscriber = Subscriber(callbackQueue: .main) + try existingMap.subscribe(listener: existingMapSubscriber.createListener(), coreSDK: coreSDK) + let existingCounterSubscriber = Subscriber(callbackQueue: .main) + try existingCounter.subscribe(listener: existingCounterSubscriber.createListener(), coreSDK: coreSDK) + + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: [ + "map:existing@1": .map(existingMap), + "counter:existing@1": .counter(existingCounter), + "map:toremove@1": .map(toBeRemovedMap), + ]) + + let syncObjects = [ + // Update existing map + TestFactories.mapObjectState( + objectId: "map:existing@1", + siteTimeserials: ["site1": "ts1"], + createOp: TestFactories.mapCreateOperation(objectId: "map:existing@1", entries: [ + "createOpKey": TestFactories.stringMapEntry(value: "bar").entry, + ]), + entries: ["updated": TestFactories.mapEntry(data: ObjectData(string: "updated"))], + ), + // Update existing counter + TestFactories.counterObjectState( + objectId: "counter:existing@1", + siteTimeserials: ["site2": "ts2"], + createOp: TestFactories.counterCreateOperation(objectId: "counter:existing@1", count: 5), + count: 100, + ), + // Create new map + TestFactories.mapObjectState( + objectId: "map:new@1", + siteTimeserials: ["site3": "ts3"], + entries: ["new": TestFactories.mapEntry(data: ObjectData(string: "new"))], + ), + // Create new counter + TestFactories.counterObjectState( + objectId: "counter:new@1", + siteTimeserials: ["site4": "ts4"], + count: 50, + ), + // Note: "map:toremove@1" is not in sync, so it should be removed + ] + + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool(.testsOnly_fromStates(syncObjects.map { (state: $0, serialTimestamp: nil) }), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } + + // Verify final state + #expect(pool.entries.count == 5) // root + 4 synced objects + + // Root should remain + #expect(pool.entries["root"] != nil) + + // Updated existing objects - verify by checking side effects of replaceData calls + let updatedMap = try #require(pool.entries["map:existing@1"]?.mapValue) + // Checking map data to verify replaceData was called successfully + #expect(try updatedMap.get(key: "updated", coreSDK: coreSDK, delegate: delegate)?.stringValue == "updated") + + // Check update emitted by existing map per RTO5c7 + // Per RTLM6h, the update should reflect the diff from previous state (empty) to new state (updated + createOpKey) + let existingMapSubscriberInvocations = await existingMapSubscriber.getInvocations() + let existingMapUpdate = try #require(existingMapSubscriberInvocations.first?.0.update) + #expect(existingMapUpdate["createOpKey"] == .updated) + #expect(existingMapUpdate["updated"] == .updated) + + let updatedCounter = try #require(pool.entries["counter:existing@1"]?.counterValue) + // Checking counter value to verify replaceData was called successfully + #expect(try updatedCounter.value(coreSDK: coreSDK) == 105) + + // Check update emitted by existing counter per RTO5c7 + // Per RTLC6h, the update should reflect the diff from previous state (0) to new state (105) + let existingCounterInvocations = await existingCounterSubscriber.getInvocations() + #expect(existingCounterInvocations.map(\.0) == [.init(amount: 105)]) // Diff from 0 to 105 + + // New objects - verify by checking side effects of replaceData calls + let newMap = try #require(pool.entries["map:new@1"]?.mapValue) + // Checking map data to verify the new map was created and replaceData was called + #expect(try newMap.get(key: "new", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") + // Verify the objectID and semantics are correctly set per RTO5c1b1b + #expect(newMap.testsOnly_objectID == "map:new@1") + #expect(newMap.testsOnly_semantics == .known(.lww)) + + let newCounter = try #require(pool.entries["counter:new@1"]?.counterValue) + // Checking counter value to verify the new counter was created and replaceData was called + #expect(try newCounter.value(coreSDK: coreSDK) == 50) + // Verify the objectID is correctly set per RTO5c1b1a + #expect(newCounter.testsOnly_objectID == "counter:new@1") + + // Removed object + #expect(pool.entries["map:toremove@1"] == nil) + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/SyncCursorTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/SyncCursorTests.swift new file mode 100644 index 000000000..be056fba1 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/SyncCursorTests.swift @@ -0,0 +1,105 @@ +import Ably +@testable import AblyLiveObjects +import Testing + +struct SyncCursorTests { + // The parsing described in RTO5a1 + @Test + func validChannelSerialWithCursorValue() throws { + // Given + let channelSerial = "sequence123:cursor456" + + // When + let cursor = try SyncCursor(channelSerial: channelSerial) + + // Then + #expect(cursor.sequenceID == "sequence123") + #expect(cursor.cursorValue == "cursor456") + #expect(!cursor.isEndOfSequence) + } + + // The scenario described in RTO5a2 + @Test + func validChannelSerialAtEndOfSequence() throws { + // Given + let channelSerial = "sequence123:" + + // When + let cursor = try SyncCursor(channelSerial: channelSerial) + + // Then + #expect(cursor.sequenceID == "sequence123") + #expect(cursor.cursorValue == nil) + #expect(cursor.isEndOfSequence) + } + + @Test + func invalidChannelSerialWithoutColon() { + // 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 + } + } + } + + @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 + } + } + } + + // The spec isn't explicit here but doesn't rule this out + @Test + func validChannelSerialWithEmptySequenceID() throws { + // Given + let channelSerial = ":cursor456" + + // When + let cursor = try SyncCursor(channelSerial: channelSerial) + + // Then + // swiftlint:disable:next empty_string + #expect(cursor.sequenceID == "") + #expect(cursor.cursorValue == "cursor456") + #expect(!cursor.isEndOfSequence) + } + + // The spec isn't explicit here but doesn't rule this out + @Test + func validChannelSerialWithEmptySequenceIDAtEndOfSequence() throws { + // Given + let channelSerial = ":" + + // When + let cursor = try SyncCursor(channelSerial: channelSerial) + + // Then + // swiftlint:disable:next empty_string + #expect(cursor.sequenceID == "") + #expect(cursor.cursorValue == nil) + #expect(cursor.isEndOfSequence) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift new file mode 100644 index 000000000..5aa56b3be --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift @@ -0,0 +1,203 @@ +@testable import AblyLiveObjects +import Foundation +import Testing + +struct SyncObjectsPoolTests { + @Test + func initCreatesEmptyPool() { + let pool = SyncObjectsPool() + + #expect(pool.isEmpty) + } + + // MARK: - accumulate: skip / reject + + // @specOneOf (1/2) RTO5f3 + @Test + func accumulateSkipsMessageWithNoObjectState() { + var pool = SyncObjectsPool() + let message = TestFactories.inboundObjectMessage(object: nil) + + pool.accumulate([message], logger: TestLogger()) + + #expect(pool.isEmpty) + } + + // @specOneOf(2/2) RTO5f3 + @Test + func accumulateSkipsUnsupportedObjectType() { + var pool = SyncObjectsPool() + // An ObjectState with neither map nor counter set. + let message = TestFactories.inboundObjectMessage( + object: TestFactories.objectState(objectId: "unknown:abc@1"), + ) + + pool.accumulate([message], logger: TestLogger()) + + #expect(pool.isEmpty) + } + + // MARK: - accumulate: store new (RTO5f1) + + // @specOneOf(1/2) RTO5f1 + @Test + func accumulateStoresNewMapMessage() { + var pool = SyncObjectsPool() + let message = TestFactories.inboundObjectMessage( + object: TestFactories.mapObjectState(objectId: "map:a@1"), + serialTimestamp: Date(timeIntervalSince1970: 1_000_000), + ) + + pool.accumulate([message], logger: TestLogger()) + + #expect(pool.count == 1) + #expect(Array(pool) == [message]) + } + + // @specOneOf(2/2) RTO5f1 + @Test + func accumulateStoresNewCounterMessage() { + var pool = SyncObjectsPool() + let message = TestFactories.inboundObjectMessage( + object: TestFactories.counterObjectState(objectId: "counter:b@1", count: 42), + serialTimestamp: Date(timeIntervalSince1970: 2_000_000), + ) + + pool.accumulate([message], logger: TestLogger()) + + #expect(pool.count == 1) + #expect(Array(pool) == [message]) + } + + // MARK: - accumulate: partial map merge (RTO5f2a) + + // @spec RTO5f2a1 + @Test + func accumulateReplacesMapEntryWhenTombstoneTrue() { + var pool = SyncObjectsPool() + let logger = TestLogger() + + let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "value1") + let firstMessage = TestFactories.inboundObjectMessage( + object: TestFactories.mapObjectState( + objectId: "map:a@1", + entries: [key1: entry1], + ), + ) + pool.accumulate([firstMessage], logger: logger) + + // Second message with tombstone=true should replace entirely. (Note this is a somewhat contrived scenario because in reality a tombstoned map will have no entries — but then we wouldn't be able to test this spec point.) + let (key2, entry2) = TestFactories.stringMapEntry(key: "key2", value: "value2") + let tombstoneMessage = TestFactories.inboundObjectMessage( + object: TestFactories.mapObjectState( + objectId: "map:a@1", + tombstone: true, + entries: [key2: entry2], + ), + ) + pool.accumulate([tombstoneMessage], logger: logger) + + #expect(pool.count == 1) + let entry = Array(pool).first + #expect(entry?.object?.tombstone == true) + // Only the replacement entries should be present. + #expect(entry?.object?.map?.entries?["key2"] != nil) + #expect(entry?.object?.map?.entries?["key1"] == nil) + } + + // @spec RTO5f2a2 + @Test + func accumulateMergesMapEntries() { + var pool = SyncObjectsPool() + let logger = TestLogger() + + var expectedEntries: [String: ObjectsMapEntry] = [:] + for i in 1 ... 3 { + let (key, entry) = TestFactories.stringMapEntry(key: "key\(i)", value: "value\(i)") + expectedEntries[key] = entry + let message = TestFactories.inboundObjectMessage( + object: TestFactories.mapObjectState( + objectId: "map:a@1", + entries: [key: entry], + ), + ) + pool.accumulate([message], logger: logger) + } + + #expect(pool.count == 1) + let entry = Array(pool).first + #expect(entry?.object?.map?.entries == expectedEntries) + } + + // Note this is a gap in the spec (because the server should never send two different object types for a given object ID), and arguably not one worth specifying, and there's no _correct_ behaviour here — our handling is arbitrary — but we have a test just because the code still needs to do _something_ and we want code coverage for that branch. + @Test + func accumulateReplacesExistingNonMapEntryWhenMergingMap() { + var pool = SyncObjectsPool() + let logger = TestLogger() + + // Store a counter first. + let counterMessage = TestFactories.inboundObjectMessage( + object: TestFactories.counterObjectState(objectId: "counter:a@1", count: 10), + ) + pool.accumulate([counterMessage], logger: logger) + + // Now accumulate a map message for the same objectId — the existing entry is not a map, + // so the pool should replace it with the incoming map message. + let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "value1") + let mapMessage = TestFactories.inboundObjectMessage( + object: TestFactories.mapObjectState( + objectId: "counter:a@1", + entries: [key: entry], + ), + ) + pool.accumulate([mapMessage], logger: logger) + + #expect(pool.count == 1) + let result = Array(pool).first + #expect(result?.object?.map?.entries?["key1"] != nil) + #expect(result?.object?.counter == nil) + } + + // MARK: - accumulate: partial counter (RTO5f2b) + + // @spec RTO5f2b + @Test + func accumulateSkipsPartialCounter() { + var pool = SyncObjectsPool() + let logger = TestLogger() + + let firstMessage = TestFactories.inboundObjectMessage( + object: TestFactories.counterObjectState(objectId: "counter:a@1", count: 10), + ) + pool.accumulate([firstMessage], logger: logger) + + // A second counter message for the same objectId should be skipped. + let secondMessage = TestFactories.inboundObjectMessage( + object: TestFactories.counterObjectState(objectId: "counter:a@1", count: 20), + ) + pool.accumulate([secondMessage], logger: logger) + + #expect(pool.count == 1) + // The original entry should be preserved. + let entry = Array(pool).first + #expect(entry?.object?.counter?.count == NSNumber(value: 10)) + } + + // MARK: - Iteration + + @Test + func iterationYieldsAllEntries() { + var pool = SyncObjectsPool() + let messages = [ + TestFactories.inboundObjectMessage(object: TestFactories.mapObjectState(objectId: "map:a@1")), + TestFactories.inboundObjectMessage(object: TestFactories.counterObjectState(objectId: "counter:b@2")), + TestFactories.inboundObjectMessage(object: TestFactories.mapObjectState(objectId: "map:c@3")), + ] + + pool.accumulate(messages, logger: TestLogger()) + + let yielded = Array(pool).sorted { $0.object!.objectId < $1.object!.objectId } + let expected = messages.sorted { $0.object!.objectId < $1.object!.objectId } + #expect(yielded == expected) + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift new file mode 100644 index 000000000..a3cfcea65 --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -0,0 +1,764 @@ +import _AblyPluginSupportPrivate +@testable import AblyLiveObjects +import Foundation +import Testing + +enum WireObjectMessageTests { + // Helper: Fake decoding context + final class FakeDecodingContext: _AblyPluginSupportPrivate.DecodingContextProtocol, @unchecked Sendable { + let parentID: String? + let parentConnectionID: String? + let parentTimestamp: Date? + let indexInParent: Int + init(parentID: String?, parentConnectionID: String?, parentTimestamp: Date?, indexInParent: Int) { + self.parentID = parentID + self.parentConnectionID = parentConnectionID + self.parentTimestamp = parentTimestamp + self.indexInParent = indexInParent + } + } + + struct InboundWireObjectMessageDecodingTests { + @Test + func decodesAllFields() throws { + let timestamp = Date(timeIntervalSince1970: 1_234_567_890) + let wire: [String: WireValue] = [ + "id": "id1", + "clientId": "client1", + "connectionId": "conn1", + "extras": ["foo": "bar"], + "timestamp": .number(NSNumber(value: Int(timestamp.timeIntervalSince1970 * 1000))), + "operation": ["action": 0, "objectId": "obj1"], + "object": ["objectId": "obj2", "map": ["semantics": 0], "siteTimeserials": [:], "tombstone": false], + "serial": "s1", + "siteCode": "siteA", + ] + let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: nil, indexInParent: 0) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) + #expect(msg.id == "id1") + #expect(msg.clientId == "client1") + #expect(msg.connectionId == "conn1") + #expect(msg.extras == ["foo": "bar"]) + #expect(msg.timestamp == timestamp) + #expect(msg.operation?.objectId == "obj1") + #expect(msg.object?.objectId == "obj2") + #expect(msg.serial == "s1") + #expect(msg.siteCode == "siteA") + } + + @Test + func optionalFieldsAbsent() throws { + let wire: [String: WireValue] = [:] + let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: nil, indexInParent: 0) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) + #expect(msg.id == nil) + #expect(msg.clientId == nil) + #expect(msg.connectionId == nil) + #expect(msg.extras == nil) + #expect(msg.timestamp == nil) + #expect(msg.operation == nil) + #expect(msg.object == nil) + #expect(msg.serial == nil) + #expect(msg.siteCode == nil) + } + + // @specOneOf(1/2) OM2a + @Test + func idFromParent_whenPresentInParent() throws { + let wire: [String: WireValue] = [:] + let ctx = FakeDecodingContext(parentID: "parent1", parentConnectionID: nil, parentTimestamp: nil, indexInParent: 2) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) + #expect(msg.id == "parent1:2") + } + + // @specOneOf(2/2) OM2a + @Test + func idFromParent_whenAbsentInParent() throws { + let wire: [String: WireValue] = [:] + let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: nil, indexInParent: 2) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) + #expect(msg.id == nil) + } + + // @spec OM2c + @Test(arguments: [nil, "parentConn1"]) + func connectionIdFromParent(parentValue: String?) throws { + let wire: [String: WireValue] = [:] + let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: parentValue, parentTimestamp: nil, indexInParent: 0) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) + #expect(msg.connectionId == parentValue) + } + + // @spec OM2e + @Test(arguments: [nil, Date(timeIntervalSince1970: 1_234_567_890)]) + func timestampFromParent(parentValue: Date?) throws { + let wire: [String: WireValue] = [:] + let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: parentValue, indexInParent: 0) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) + #expect(msg.timestamp == parentValue) + } + } + + struct OutboundWireObjectMessageEncodingTests { + @Test + func encodesAllFields() { + let timestamp = Date(timeIntervalSince1970: 1_234_567_890) + let msg = OutboundWireObjectMessage( + id: "id1", + clientId: "client1", + connectionId: "conn1", + extras: ["foo": "bar"], + timestamp: timestamp, + operation: WireObjectOperation( + action: .known(.mapCreate), + objectId: "obj1", + ), + object: nil, + serial: "s1", + siteCode: "siteA", + ) + let wire = msg.toWireObject + #expect(wire == [ + "id": "id1", + "clientId": "client1", + "connectionId": "conn1", + "extras": ["foo": "bar"], + "timestamp": .number(NSNumber(value: Int(timestamp.timeIntervalSince1970 * 1000))), + "operation": ["action": 0, "objectId": "obj1"], + "serial": "s1", + "siteCode": "siteA", + ]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let timestamp = Date(timeIntervalSince1970: 1_234_567_890) + let msg = OutboundWireObjectMessage( + id: "id1", + clientId: nil, + connectionId: nil, + extras: nil, + timestamp: timestamp, + operation: nil, + object: nil, + serial: nil, + siteCode: nil, + ) + let wire = msg.toWireObject + #expect(wire == [ + "id": "id1", + "timestamp": .number(NSNumber(value: Int(timestamp.timeIntervalSince1970 * 1000))), + ]) + } + } + + struct WireObjectOperationTests { + @Test + func decodesAllFields() throws { + let wire: [String: WireValue] = [ + "action": 0, // mapCreate + "objectId": "obj1", + "mapCreate": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], + "mapSet": ["key": "key1", "value": ["string": "value1"]], + "mapRemove": ["key": "key2"], + "counterCreate": ["count": 42], + "counterInc": ["number": 10], + "objectDelete": [:], + "mapClear": [:], + ] + let op = try WireObjectOperation(wireObject: wire) + #expect(op.action == .known(.mapCreate)) + #expect(op.objectId == "obj1") + #expect(op.mapCreate?.semantics == .known(.lww)) + #expect(op.mapCreate?.entries?["key1"]?.data?.string == "value1") + #expect(op.mapCreate?.entries?["key1"]?.tombstone == false) + #expect(op.mapSet?.key == "key1") + #expect(op.mapSet?.value?.string == "value1") + #expect(op.mapRemove?.key == "key2") + #expect(op.counterCreate?.count == 42) + #expect(op.counterInc?.number == 10) + #expect(op.objectDelete != nil) + #expect(op.mapClear != nil) + // Outbound-only — do not access on inbound data + #expect(op.mapCreateWithObjectId == nil) + #expect(op.counterCreateWithObjectId == nil) + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let wire: [String: WireValue] = [ + "action": 0, + "objectId": "obj1", + ] + let op = try WireObjectOperation(wireObject: wire) + #expect(op.action == .known(.mapCreate)) + #expect(op.objectId == "obj1") + #expect(op.mapCreate == nil) + #expect(op.mapSet == nil) + #expect(op.mapRemove == nil) + #expect(op.counterCreate == nil) + #expect(op.counterInc == nil) + #expect(op.objectDelete == nil) + #expect(op.mapClear == nil) + #expect(op.mapCreateWithObjectId == nil) + #expect(op.counterCreateWithObjectId == nil) + } + + @Test + func decodesWithUnknownAction() throws { + let wire: [String: WireValue] = [ + "action": 999, // Unknown WireObjectOperation + "objectId": "obj1", + ] + let op = try WireObjectOperation(wireObject: wire) + #expect(op.action == .unknown(999)) + } + + @Test + func encodesAllFields() { + let op = WireObjectOperation( + action: .known(.mapCreate), + objectId: "obj1", + mapCreate: WireMapCreate( + semantics: .known(.lww), + entries: ["key1": WireObjectsMapEntry(tombstone: false, timeserial: nil, data: WireObjectData(string: "value1"))], + ), + mapSet: WireMapSet(key: "key1", value: WireObjectData(string: "value1")), + counterCreate: WireCounterCreate(count: 42), + counterInc: WireCounterInc(number: 10), + ) + let wire = op.toWireObject + #expect(wire == [ + "action": 0, + "objectId": "obj1", + "mapCreate": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], + "mapSet": ["key": "key1", "value": ["string": "value1"]], + "counterCreate": ["count": 42], + "counterInc": ["number": 10], + ]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let op = WireObjectOperation( + action: .known(.mapCreate), + objectId: "obj1", + ) + let wire = op.toWireObject + #expect(wire == [ + "action": 0, + "objectId": "obj1", + ]) + } + } + + struct WireObjectStateTests { + @Test + func decodesAllFields() throws { + let wire: [String: WireValue] = [ + "objectId": "obj1", + "siteTimeserials": ["site1": "ts1"], + "tombstone": true, + "createOp": ["action": 0, "objectId": "obj1"], + "map": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], + "counter": ["count": 42], + ] + let state = try WireObjectState(wireObject: wire) + #expect(state.objectId == "obj1") + #expect(state.siteTimeserials["site1"] == "ts1") + #expect(state.tombstone == true) + #expect(state.createOp?.action == .known(.mapCreate)) + #expect(state.createOp?.objectId == "obj1") + #expect(state.map?.semantics == .known(.lww)) + #expect(state.map?.entries?["key1"]?.data?.string == "value1") + #expect(state.map?.entries?["key1"]?.tombstone == false) + #expect(state.counter?.count == 42) + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let wire: [String: WireValue] = [ + "objectId": "obj1", + "siteTimeserials": [:], + "tombstone": false, + ] + let state = try WireObjectState(wireObject: wire) + #expect(state.objectId == "obj1") + #expect(state.siteTimeserials.isEmpty) + #expect(state.tombstone == false) + #expect(state.createOp == nil) + #expect(state.map == nil) + #expect(state.counter == nil) + } + + @Test + func encodesAllFields() { + let state = WireObjectState( + objectId: "obj1", + siteTimeserials: ["site1": "ts1"], + tombstone: true, + createOp: WireObjectOperation( + action: .known(.mapCreate), + objectId: "obj1", + ), + map: WireObjectsMap( + semantics: .known(.lww), + entries: ["key1": WireObjectsMapEntry(tombstone: false, timeserial: nil, data: WireObjectData(string: "value1"))], + ), + counter: WireObjectsCounter(count: 42), + ) + let wire = state.toWireObject + #expect(wire == [ + "objectId": "obj1", + "siteTimeserials": ["site1": "ts1"], + "tombstone": true, + "createOp": ["action": 0, "objectId": "obj1"], + "map": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], + "counter": ["count": 42], + ]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let state = WireObjectState( + objectId: "obj1", + siteTimeserials: [:], + tombstone: false, + createOp: nil, + map: nil, + counter: nil, + ) + let wire = state.toWireObject + #expect(wire == [ + "objectId": "obj1", + "siteTimeserials": [:], + "tombstone": false, + ]) + } + } + + struct WireObjectDataTests { + @Test + func decodesAllFields() throws { + let json: [String: WireValue] = [ + "objectId": "obj1", + "boolean": true, + "number": 42, + "string": "value1", + ] + let data = try WireObjectData(wireObject: json) + #expect(data.objectId == "obj1") + #expect(data.boolean == true) + #expect(data.number == 42) + #expect(data.string == "value1") + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let json: [String: WireValue] = [:] + let data = try WireObjectData(wireObject: json) + #expect(data.objectId == nil) + #expect(data.boolean == nil) + #expect(data.bytes == nil) + #expect(data.number == nil) + #expect(data.string == nil) + } + + @Test + func encodesAllFields() { + let data = WireObjectData( + objectId: "obj1", + boolean: true, + bytes: nil, + number: 42, + string: "value1", + ) + let wire = data.toWireObject + #expect(wire == [ + "objectId": "obj1", + "boolean": true, + "number": 42, + "string": "value1", + ]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let data = WireObjectData( + objectId: nil, + boolean: nil, + bytes: nil, + number: nil, + string: nil, + ) + let wire = data.toWireObject + #expect(wire.isEmpty) + } + } + + struct WireMapSetTests { + @Test + func decodesAllFields() throws { + let json: [String: WireValue] = [ + "key": "key1", + "value": ["string": "value1"], + ] + let op = try WireMapSet(wireObject: json) + #expect(op.key == "key1") + #expect(op.value?.string == "value1") + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let json: [String: WireValue] = ["key": "key1"] + let op = try WireMapSet(wireObject: json) + #expect(op.key == "key1") + #expect(op.value == nil) + } + + @Test + func encodesAllFields() { + let op = WireMapSet( + key: "key1", + value: WireObjectData(string: "value1"), + ) + let wire = op.toWireObject + #expect(wire == [ + "key": "key1", + "value": ["string": "value1"], + ]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let op = WireMapSet( + key: "key1", + value: nil, + ) + let wire = op.toWireObject + #expect(wire == [ + "key": "key1", + ]) + } + } + + struct WireMapRemoveTests { + @Test + func decodesAllFields() throws { + let json: [String: WireValue] = ["key": "key1"] + let op = try WireMapRemove(wireObject: json) + #expect(op.key == "key1") + } + + @Test + func encodesAllFields() { + let op = WireMapRemove(key: "key1") + let wire = op.toWireObject + #expect(wire == ["key": "key1"]) + } + } + + struct WireMapCreateTests { + @Test + func decodesAllFields() throws { + let json: [String: WireValue] = [ + "semantics": 0, + "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]], + ] + let op = try WireMapCreate(wireObject: json) + #expect(op.semantics == .known(.lww)) + #expect(op.entries?["key1"]?.data?.string == "value1") + #expect(op.entries?["key1"]?.tombstone == false) + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let json: [String: WireValue] = ["semantics": 0] + let op = try WireMapCreate(wireObject: json) + #expect(op.semantics == .known(.lww)) + #expect(op.entries == nil) + } + + @Test + func encodesAllFields() { + let op = WireMapCreate( + semantics: .known(.lww), + entries: ["key1": WireObjectsMapEntry(tombstone: false, timeserial: nil, data: WireObjectData(string: "value1"))], + ) + let wire = op.toWireObject + #expect(wire == [ + "semantics": 0, + "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]], + ]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let op = WireMapCreate( + semantics: .known(.lww), + entries: nil, + ) + let wire = op.toWireObject + #expect(wire == ["semantics": 0]) + } + } + + struct WireCounterCreateTests { + @Test + func decodesAllFields() throws { + let json: [String: WireValue] = ["count": 42] + let op = try WireCounterCreate(wireObject: json) + #expect(op.count == 42) + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let json: [String: WireValue] = [:] + let op = try WireCounterCreate(wireObject: json) + #expect(op.count == nil) + } + + @Test + func encodesAllFields() { + let op = WireCounterCreate(count: 42) + let wire = op.toWireObject + #expect(wire == ["count": 42]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let op = WireCounterCreate(count: nil) + let wire = op.toWireObject + #expect(wire.isEmpty) + } + } + + struct WireCounterIncTests { + @Test + func decodesAllFields() throws { + let json: [String: WireValue] = ["number": 42] + let op = try WireCounterInc(wireObject: json) + #expect(op.number == 42) + } + + @Test + func encodesAllFields() { + let op = WireCounterInc(number: 42) + let wire = op.toWireObject + #expect(wire == ["number": 42]) + } + } + + struct WireObjectDeleteTests { + @Test + func decodesEmptyObject() throws { + let json: [String: WireValue] = [:] + let op = try WireObjectDelete(wireObject: json) + _ = op // just verify it decodes without error + } + + @Test + func encodesEmptyObject() { + let op = WireObjectDelete() + let wire = op.toWireObject + #expect(wire.isEmpty) + } + } + + struct WireMapCreateWithObjectIdTests { + @Test + func decodesAllFields() throws { + let json: [String: WireValue] = [ + "nonce": "abc123", + "initialValue": "someJSON", + ] + let op = try WireMapCreateWithObjectId(wireObject: json) + #expect(op.nonce == "abc123") + #expect(op.initialValue == "someJSON") + } + + @Test + func encodesAllFields() { + let op = WireMapCreateWithObjectId(initialValue: "someJSON", nonce: "abc123") + let wire = op.toWireObject + #expect(wire == [ + "nonce": "abc123", + "initialValue": "someJSON", + ]) + } + } + + struct WireCounterCreateWithObjectIdTests { + @Test + func decodesAllFields() throws { + let json: [String: WireValue] = [ + "nonce": "abc123", + "initialValue": "someJSON", + ] + let op = try WireCounterCreateWithObjectId(wireObject: json) + #expect(op.nonce == "abc123") + #expect(op.initialValue == "someJSON") + } + + @Test + func encodesAllFields() { + let op = WireCounterCreateWithObjectId(initialValue: "someJSON", nonce: "abc123") + let wire = op.toWireObject + #expect(wire == [ + "nonce": "abc123", + "initialValue": "someJSON", + ]) + } + } + + struct WireMapTests { + @Test + func decodesAllFields() throws { + let json: [String: WireValue] = [ + "semantics": 0, + "entries": [ + "key1": ["data": ["string": "value1"], "tombstone": false, "timeserial": "ts1"], + "key2": ["data": ["string": "value2"], "tombstone": true], + ], + ] + let map = try WireObjectsMap(wireObject: json) + #expect(map.semantics == .known(.lww)) + #expect(map.entries?["key1"]?.data?.string == "value1") + #expect(map.entries?["key1"]?.tombstone == false) + #expect(map.entries?["key1"]?.timeserial == "ts1") + #expect(map.entries?["key2"]?.data?.string == "value2") + #expect(map.entries?["key2"]?.tombstone == true) + #expect(map.entries?["key2"]?.timeserial == nil) + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let json: [String: WireValue] = ["semantics": 0] + let map = try WireObjectsMap(wireObject: json) + #expect(map.semantics == .known(.lww)) + #expect(map.entries == nil) + } + + @Test + func decodesWithUnknownSemantics() throws { + let json: [String: WireValue] = [ + "semantics": 999, // Unknown MapSemantics + ] + let map = try WireObjectsMap(wireObject: json) + #expect(map.semantics == .unknown(999)) + } + + @Test + func encodesAllFields() { + let map = WireObjectsMap( + semantics: .known(.lww), + entries: [ + "key1": WireObjectsMapEntry(tombstone: false, timeserial: "ts1", data: WireObjectData(string: "value1")), + "key2": WireObjectsMapEntry(tombstone: true, timeserial: nil, data: WireObjectData(string: "value2")), + ], + ) + let wire = map.toWireObject + #expect(wire == [ + "semantics": 0, + "entries": [ + "key1": ["data": ["string": "value1"], "tombstone": false, "timeserial": "ts1"], + "key2": ["data": ["string": "value2"], "tombstone": true], + ], + ]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let map = WireObjectsMap( + semantics: .known(.lww), + entries: nil, + ) + let wire = map.toWireObject + #expect(wire == [ + "semantics": 0, + ]) + } + } + + struct WireCounterTests { + @Test + func decodesAllFields() throws { + let json: [String: WireValue] = ["count": 42] + let counter = try WireObjectsCounter(wireObject: json) + #expect(counter.count == 42) + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let json: [String: WireValue] = [:] + let counter = try WireObjectsCounter(wireObject: json) + #expect(counter.count == nil) + } + + @Test + func encodesAllFields() { + let counter = WireObjectsCounter(count: 42) + let wire = counter.toWireObject + #expect(wire == ["count": 42]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let counter = WireObjectsCounter(count: nil) + let wire = counter.toWireObject + #expect(wire.isEmpty) + } + } + + struct WireMapEntryTests { + @Test + func decodesAllFields() throws { + let json: [String: WireValue] = [ + "data": ["string": "value1"], + "tombstone": true, + "timeserial": "ts1", + ] + let entry = try WireObjectsMapEntry(wireObject: json) + #expect(entry.data?.string == "value1") + #expect(entry.tombstone == true) + #expect(entry.timeserial == "ts1") + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let json: [String: WireValue] = ["data": ["string": "value1"]] + let entry = try WireObjectsMapEntry(wireObject: json) + #expect(entry.data?.string == "value1") + #expect(entry.tombstone == nil) + #expect(entry.timeserial == nil) + } + + @Test + func encodesAllFields() { + let entry = WireObjectsMapEntry( + tombstone: true, + timeserial: "ts1", + data: WireObjectData(string: "value1"), + ) + let wire = entry.toWireObject + #expect(wire == [ + "data": ["string": "value1"], + "tombstone": true, + "timeserial": "ts1", + ]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let entry = WireObjectsMapEntry( + tombstone: nil, + timeserial: nil, + data: WireObjectData(string: "value1"), + ) + let wire = entry.toWireObject + #expect(wire == [ + "data": ["string": "value1"], + ]) + } + } +} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/WireValueTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/WireValueTests.swift new file mode 100644 index 000000000..7f29db0ee --- /dev/null +++ b/LiveObjects/Tests/AblyLiveObjectsTests/WireValueTests.swift @@ -0,0 +1,379 @@ +import Ably.Private +@testable import AblyLiveObjects +import Foundation +import Testing + +struct WireValueTests { + // MARK: Conversion from _AblyPluginSupportPrivate data + + @Test(arguments: [ + // object + (pluginSupportData: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), + // array + (pluginSupportData: ["someElement"], expectedResult: ["someElement"]), + // string + (pluginSupportData: "someString", expectedResult: "someString"), + // number + (pluginSupportData: NSNumber(value: 0), expectedResult: 0), + (pluginSupportData: NSNumber(value: 1), expectedResult: 1), + (pluginSupportData: NSNumber(value: 123), expectedResult: 123), + (pluginSupportData: NSNumber(value: 123.456), expectedResult: 123.456), + // bool + (pluginSupportData: NSNumber(value: true), expectedResult: true), + (pluginSupportData: NSNumber(value: false), expectedResult: false), + // null + (pluginSupportData: NSNull(), expectedResult: .null), + // data + (pluginSupportData: Data([0x01, 0x02, 0x03]), expectedResult: .data(Data([0x01, 0x02, 0x03]))), + ] as[(pluginSupportData: Sendable, expectedResult: WireValue?)]) + func initWithPluginSupportData(pluginSupportData: Sendable, expectedResult: WireValue?) { + #expect(WireValue(pluginSupportData: pluginSupportData) == expectedResult) + } + + // Tests that it correctly handles an object deserialized by `JSONSerialization` (which is what ably-cocoa uses for JSON deserialization). + @Test + func initWithPluginSupportData_endToEnd_json() throws { + let jsonString = """ + { + "someArray": [ + { + "someStringKey": "someString", + "zero": 0, + "one": 1, + "someIntegerKey": 123, + "someFloatKey": 123.456, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": null + }, + "someOtherArrayElement" + ], + "someNestedObject": { + "someOtherKey": "someOtherValue" + } + } + """ + + let pluginSupportData = try JSONSerialization.jsonObject(with: #require(jsonString.data(using: .utf8))) + + let expected: WireValue = [ + "someArray": [ + [ + "someStringKey": "someString", + "zero": 0, + "one": 1, + "someIntegerKey": 123, + "someFloatKey": 123.456, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": .null, + ], + "someOtherArrayElement", + ], + "someNestedObject": [ + "someOtherKey": "someOtherValue", + ], + ] + + #expect(WireValue(pluginSupportData: pluginSupportData) == expected) + } + + // Tests that it correctly handles an object deserialized by `ARTMsgPackEncoder` (which is what ably-cocoa uses for MessagePack deserialization). + @Test + func initWithPluginSupportData_endToEnd_msgpack() throws { + // MessagePack representation of the same data structure as in the JSON test above, plus binary data + // This represents: + // { + // "someArray": [ + // { + // "someStringKey": "someString", + // "someIntegerKey": 123, + // "zero": 0, + // "someFloatKey": 123.456, + // "someTrueKey": true, + // "someFalseKey": false, + // "someNullKey": null, + // "one": 1, + // "someBinaryKey": + // }, + // "someOtherArrayElement" + // ], + // "someNestedObject": { + // "someOtherKey": "someOtherValue" + // } + // } + let msgpackData = Data([ + // Root object - 2 elements map (fixmap format: 0x80 | count) + 0x82, + + // Key 1: "someArray" (fixstr format: 0xa0 | length = 9) + 0xA9, 0x73, 0x6F, 0x6D, 0x65, 0x41, 0x72, 0x72, 0x61, 0x79, // "someArray" + + // Value 1: Array with 2 elements (fixarray format: 0x90 | count) + 0x92, + + // Array element 1: Object with 9 elements (fixmap format: 0x80 | count) + 0x89, + + // Key-value pairs in map (order determined by MessagePack encoder): + + // "someStringKey": "someString" + 0xAD, 0x73, 0x6F, 0x6D, 0x65, 0x53, 0x74, 0x72, 0x69, 0x6E, 0x67, 0x4B, 0x65, 0x79, // key (13 chars) + 0xAA, 0x73, 0x6F, 0x6D, 0x65, 0x53, 0x74, 0x72, 0x69, 0x6E, 0x67, // value (10 chars) + + // "someIntegerKey": 123 + 0xAE, 0x73, 0x6F, 0x6D, 0x65, 0x49, 0x6E, 0x74, 0x65, 0x67, 0x65, 0x72, 0x4B, 0x65, 0x79, // key (14 chars) + 0x7B, // value 123 (positive fixint) + + // "zero": 0 + 0xA4, 0x7A, 0x65, 0x72, 0x6F, // key "zero" (4 chars) + 0x00, // value 0 (positive fixint) + + // "someFloatKey": 123.456 + 0xAC, 0x73, 0x6F, 0x6D, 0x65, 0x46, 0x6C, 0x6F, 0x61, 0x74, 0x4B, 0x65, 0x79, // key (12 chars) + 0xCB, 0x40, 0x5E, 0xDD, 0x2F, 0x1A, 0x9F, 0xBE, 0x77, // value 123.456 (float64) + + // "someTrueKey": true + 0xAB, 0x73, 0x6F, 0x6D, 0x65, 0x54, 0x72, 0x75, 0x65, 0x4B, 0x65, 0x79, // key (11 chars) + 0xC3, // value true + + // "someFalseKey": false + 0xAC, 0x73, 0x6F, 0x6D, 0x65, 0x46, 0x61, 0x6C, 0x73, 0x65, 0x4B, 0x65, 0x79, // key (12 chars) + 0xC2, // value false + + // "someNullKey": null + 0xAB, 0x73, 0x6F, 0x6D, 0x65, 0x4E, 0x75, 0x6C, 0x6C, 0x4B, 0x65, 0x79, // key (11 chars) + 0xC0, // value null + + // "one": 1 + 0xA3, 0x6F, 0x6E, 0x65, // key "one" (3 chars) + 0x01, // value 1 (positive fixint) + + // "someBinaryKey": binary data + 0xAD, 0x73, 0x6F, 0x6D, 0x65, 0x42, 0x69, 0x6E, 0x61, 0x72, 0x79, 0x4B, 0x65, 0x79, // key "someBinaryKey" (13 chars) + 0xC4, 0x04, 0x01, 0x02, 0x03, 0x04, // value: bin 8 format (0xc4 + 1 byte length + 4 bytes data) + + // Array element 2: "someOtherArrayElement" + 0xB5, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x41, 0x72, 0x72, 0x61, 0x79, 0x45, 0x6C, 0x65, 0x6D, 0x65, 0x6E, 0x74, // "someOtherArrayElement" (21 chars) + + // Key 2: "someNestedObject" + 0xB0, 0x73, 0x6F, 0x6D, 0x65, 0x4E, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4F, 0x62, 0x6A, 0x65, 0x63, 0x74, // "someNestedObject" (16 chars) + + // Value 2: Object with 1 element (fixmap format: 0x80 | count) + 0x81, + + // "someOtherKey": "someOtherValue" + 0xAC, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x4B, 0x65, 0x79, // key (12 chars) + 0xAE, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x56, 0x61, 0x6C, 0x75, 0x65, // value (14 chars) + ]) + + let pluginSupportData = try ARTMsgPackEncoder().decode(msgpackData) + + let expected: WireValue = [ + "someArray": [ + [ + "someStringKey": "someString", + "zero": 0, + "one": 1, + "someIntegerKey": 123, + "someFloatKey": 123.456, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": .null, + "someBinaryKey": .data(Data([0x01, 0x02, 0x03, 0x04])), + ], + "someOtherArrayElement", + ], + "someNestedObject": [ + "someOtherKey": "someOtherValue", + ], + ] + + #expect(WireValue(pluginSupportData: pluginSupportData) == expected) + } + + // MARK: Conversion to _AblyPluginSupportPrivate data + + @Test(arguments: [ + // object + (value: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), + // array + (value: ["someElement"], expectedResult: ["someElement"]), + // string + (value: "someString", expectedResult: "someString"), + // number + (value: 0, expectedResult: NSNumber(value: 0)), + (value: 1, expectedResult: NSNumber(value: 1)), + (value: 123, expectedResult: NSNumber(value: 123)), + (value: 123.456, expectedResult: NSNumber(value: 123.456)), + // bool + (value: true, expectedResult: NSNumber(value: true)), + (value: false, expectedResult: NSNumber(value: false)), + // null + (value: .null, expectedResult: NSNull()), + // data + (value: .data(Data([0x01, 0x02, 0x03])), expectedResult: Data([0x01, 0x02, 0x03])), + ] as[(value: WireValue, expectedResult: Sendable)]) + func toPluginSupportData(value: WireValue, expectedResult: Sendable) throws { + let resultAsNSObject = try #require(value.toPluginSupportData as? NSObject) + let expectedResultAsNSObject = try #require(expectedResult as? NSObject) + #expect(resultAsNSObject == expectedResultAsNSObject) + } + + // Tests that it creates an object that can be serialized by `JSONSerialization` (which is what ably-cocoa uses for JSON serialization), and that the result of this serialization is what we'd expect. + @Test + func toPluginSupportData_endToEnd_json() throws { + let value: WireValue = [ + "someArray": [ + [ + "someStringKey": "someString", + "zero": 0, + "one": 1, + "someIntegerKey": 123, + "someFloatKey": 123.456, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": .null, + ], + "someOtherArrayElement", + ], + "someNestedObject": [ + "someOtherKey": "someOtherValue", + ], + ] + + let expectedJSONString = """ + { + "someArray": [ + { + "someStringKey": "someString", + "someIntegerKey": 123, + "someFloatKey": 123.456, + "zero": 0, + "one": 1, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": null + }, + "someOtherArrayElement" + ], + "someNestedObject": { + "someOtherKey": "someOtherValue" + } + } + """ + + let jsonSerializationOptions: JSONSerialization.WritingOptions = [.sortedKeys] + + let valueData = try JSONSerialization.data(withJSONObject: value.toPluginSupportData, options: jsonSerializationOptions) + let expectedData = try { + let serialized = try JSONSerialization.jsonObject(with: #require(expectedJSONString.data(using: .utf8))) + return try JSONSerialization.data(withJSONObject: serialized, options: jsonSerializationOptions) + }() + + #expect(valueData == expectedData) + } + + // Tests that it creates an object that can be serialized by `ARTMsgPackEncoder` (which is what ably-cocoa uses for MessagePack serialization), and that the result of this serialization is what we'd expect. + @Test + func toPluginSupportData_endToEnd_msgpack() throws { + let value: WireValue = [ + "someArray": [ + [ + "someStringKey": "someString", + "zero": 0, + "one": 1, + "someIntegerKey": 123, + "someFloatKey": 123.456, + "someTrueKey": true, + "someFalseKey": false, + "someNullKey": .null, + "someBinaryKey": .data(Data([0x01, 0x02, 0x03, 0x04])), + ], + "someOtherArrayElement", + ], + "someNestedObject": [ + "someOtherKey": "someOtherValue", + ], + ] + + // Expected MessagePack data - manually crafted representation of the WireValue structure including binary data + // Note: The exact byte order may vary depending on how the encoder orders map keys, + // so we'll verify by decoding both and comparing the results + let expectedMsgPackData = Data([ + // Root object - 2 elements map (fixmap format: 0x80 | count) + 0x82, + + // Key 1: "someArray" (fixstr format: 0xa0 | length = 9) + 0xA9, 0x73, 0x6F, 0x6D, 0x65, 0x41, 0x72, 0x72, 0x61, 0x79, // "someArray" + + // Value 1: Array with 2 elements (fixarray format: 0x90 | count) + 0x92, + + // Array element 1: Object with 9 elements (fixmap format: 0x80 | count) + 0x89, + + // Key-value pairs in map (order determined by MessagePack encoder): + + // "someStringKey": "someString" + 0xAD, 0x73, 0x6F, 0x6D, 0x65, 0x53, 0x74, 0x72, 0x69, 0x6E, 0x67, 0x4B, 0x65, 0x79, // key (13 chars) + 0xAA, 0x73, 0x6F, 0x6D, 0x65, 0x53, 0x74, 0x72, 0x69, 0x6E, 0x67, // value (10 chars) + + // "someIntegerKey": 123 + 0xAE, 0x73, 0x6F, 0x6D, 0x65, 0x49, 0x6E, 0x74, 0x65, 0x67, 0x65, 0x72, 0x4B, 0x65, 0x79, // key (14 chars) + 0x7B, // value 123 (positive fixint) + + // "zero": 0 + 0xA4, 0x7A, 0x65, 0x72, 0x6F, // key "zero" (4 chars) + 0x00, // value 0 (positive fixint) + + // "someFloatKey": 123.456 + 0xAC, 0x73, 0x6F, 0x6D, 0x65, 0x46, 0x6C, 0x6F, 0x61, 0x74, 0x4B, 0x65, 0x79, // key (12 chars) + 0xCB, 0x40, 0x5E, 0xDD, 0x2F, 0x1A, 0x9F, 0xBE, 0x77, // value 123.456 (float64) + + // "someTrueKey": true + 0xAB, 0x73, 0x6F, 0x6D, 0x65, 0x54, 0x72, 0x75, 0x65, 0x4B, 0x65, 0x79, // key (11 chars) + 0xC3, // value true + + // "someFalseKey": false + 0xAC, 0x73, 0x6F, 0x6D, 0x65, 0x46, 0x61, 0x6C, 0x73, 0x65, 0x4B, 0x65, 0x79, // key (12 chars) + 0xC2, // value false + + // "someNullKey": null + 0xAB, 0x73, 0x6F, 0x6D, 0x65, 0x4E, 0x75, 0x6C, 0x6C, 0x4B, 0x65, 0x79, // key (11 chars) + 0xC0, // value null + + // "one": 1 + 0xA3, 0x6F, 0x6E, 0x65, // key "one" (3 chars) + 0x01, // value 1 (positive fixint) + + // "someBinaryKey": binary data + 0xAD, 0x73, 0x6F, 0x6D, 0x65, 0x42, 0x69, 0x6E, 0x61, 0x72, 0x79, 0x4B, 0x65, 0x79, // key "someBinaryKey" (13 chars) + 0xC4, 0x04, 0x01, 0x02, 0x03, 0x04, // value: bin 8 format (0xc4 + 1 byte length + 4 bytes data) + + // Array element 2: "someOtherArrayElement" + 0xB5, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x41, 0x72, 0x72, 0x61, 0x79, 0x45, 0x6C, 0x65, 0x6D, 0x65, 0x6E, 0x74, // "someOtherArrayElement" (21 chars) + + // Key 2: "someNestedObject" + 0xB0, 0x73, 0x6F, 0x6D, 0x65, 0x4E, 0x65, 0x73, 0x74, 0x65, 0x64, 0x4F, 0x62, 0x6A, 0x65, 0x63, 0x74, // "someNestedObject" (16 chars) + + // Value 2: Object with 1 element (fixmap format: 0x80 | count) + 0x81, + + // "someOtherKey": "someOtherValue" + 0xAC, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x4B, 0x65, 0x79, // key (12 chars) + 0xAE, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x56, 0x61, 0x6C, 0x75, 0x65, // value (14 chars) + ]) + + let actualMsgPackData = try ARTMsgPackEncoder().encode(value.toPluginSupportData) + + // Verify that both decode to the same Foundation object structure + let expectedDecoded = try ARTMsgPackEncoder().decode(expectedMsgPackData) + let actualDecoded = try ARTMsgPackEncoder().decode(actualMsgPackData) + + let expectedDecodedAsNSObject = try #require(expectedDecoded as? NSObject) + let actualDecodedAsNSObject = try #require(actualDecoded as? NSObject) + + #expect(actualDecodedAsNSObject == expectedDecodedAsNSObject) + } +} diff --git a/LiveObjects/images/SwiftSDK-LiveObjects-github.png b/LiveObjects/images/SwiftSDK-LiveObjects-github.png new file mode 100644 index 0000000000000000000000000000000000000000..ed504e0d3983e4024d00eb197f4d4b6f4690d5be GIT binary patch literal 935881 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

Tu&{*rjc3w!`{6d+gn&CI~$5d?H+JB(6JY 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

XK%2Zr!*%Jlta^Fi21e$2n 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

uph$3h|jzXmd~ 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%0A)hp424LO<6OOSHM`}WYCsMp> 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%0DbG?pa6FZOdBGz|KB1O; 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

2!c;!x==wrp&<&fzjQ{ZwPrw-Cq6YXBVI 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 a& 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 VQa`B{nZp18002ovPDHLkV1nHwNBRH& literal 0 HcmV?d00001 diff --git a/LiveObjects/images/unit-tests-test-plan-screenshot.png b/LiveObjects/images/unit-tests-test-plan-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..5934ce80a4119283d562e60f09f55226b2535101 GIT binary patch literal 118545 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 VJe%>| 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

9yK$!MTP#F?7`G-54RL}@_| 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%0D7D!Mm6T2 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 literal 0 HcmV?d00001 diff --git a/LiveObjects/package-lock.json b/LiveObjects/package-lock.json new file mode 100644 index 000000000..7ac4a172f --- /dev/null +++ b/LiveObjects/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "ably-liveobjects-swift-plugin-dev-tooling", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ably-liveobjects-swift-plugin-dev-tooling", + "version": "0.1.0", + "devDependencies": { + "prettier": "^3.3.3" + } + }, + "node_modules/prettier": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.3.3.tgz", + "integrity": "sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==", + "dev": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + } + } +} diff --git a/LiveObjects/package.json b/LiveObjects/package.json new file mode 100644 index 000000000..94eb34922 --- /dev/null +++ b/LiveObjects/package.json @@ -0,0 +1,12 @@ +{ + "name": "ably-liveobjects-swift-plugin-dev-tooling", + "version": "0.1.0", + "description": "Development tooling for the ably-liveobjects-swift-plugin repo", + "devDependencies": { + "prettier": "^3.3.3" + }, + "scripts": { + "prettier:check": "prettier --check .", + "prettier:fix": "prettier --write ." + } +} From 234cbac06bcc1def80df8513fd60f93a76cfb6c0 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 10 Jul 2026 18:05:35 -0300 Subject: [PATCH 08/21] Remove the repo-import script The two preceding import commits are the only reason Scripts/import-repo-contents.sh existed, so remove it now that they are done; this repo has nothing left to import. The script remains available in the history at the import commits (whose messages reference it and describe how to rerun it), so the imports remain reproducible. Co-Authored-By: Claude Fable 5 --- Scripts/import-repo-contents.sh | 38 --------------------------------- 1 file changed, 38 deletions(-) delete mode 100755 Scripts/import-repo-contents.sh diff --git a/Scripts/import-repo-contents.sh b/Scripts/import-repo-contents.sh deleted file mode 100755 index 43cf1aff3..000000000 --- a/Scripts/import-repo-contents.sh +++ /dev/null @@ -1,38 +0,0 @@ -#!/usr/bin/env bash - -# Imports the contents of another git repository, at a given commit, into a -# directory of this repository. -# -# Usage: Scripts/import-repo-contents.sh -# -# Only files tracked by git at the given commit are imported (via -# `git archive`); submodule paths come across as empty directories, not -# content. The commit SHA must be a full SHA (fetching an abbreviated SHA is -# not supported by all servers). -# -# This exists so that "import the contents of repo X at commit Y" commits are -# reproducible: to swap in a newer version of the imported repo, drop the -# import commit, rerun this script at the new SHA, and recommit. - -set -euo pipefail - -if [ "$#" -ne 3 ]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -url=$1 -sha=$2 -dest=$3 - -tmp=$(mktemp -d) -trap 'rm -rf "$tmp"' EXIT - -git init --quiet "$tmp" -git -C "$tmp" remote add origin "$url" -git -C "$tmp" fetch --quiet --depth 1 origin "$sha" - -mkdir -p "$dest" -git -C "$tmp" archive "$sha" | tar -x -C "$dest" - -echo "Imported $url @ $sha into $dest" From 52f9f8a2c35b2eea881de228fe2439f7f057d03e Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 9 Jul 2026 23:13:21 -0300 Subject: [PATCH 09/21] Remove imported LiveObjects files superseded in ably-cocoa Removes files imported from the ably-liveobjects-swift-plugin repo that are superseded by their ably-cocoa equivalents: - LiveObjects/Package.swift, LiveObjects/Package.resolved: the AblyLiveObjects library becomes a product of this package (a later commit adds its targets to the root manifest); the BuildTool target gets its own nested package in a later commit - LiveObjects/.github/: the plugin's CI workflow; a later commit adds an equivalent workflow to this repo's .github/workflows - LiveObjects/.gitmodules: git only honours the repository-root .gitmodules, so this imported copy is inert; the submodule it described is not imported (see previous commit) - LiveObjects/LICENSE, LiveObjects/COPYRIGHT, LiveObjects/ MAINTAINERS.md: this repo's equivalents (identical Apache 2.0 licence) now cover the code Co-Authored-By: Claude Fable 5 --- LiveObjects/.github/workflows/check.yaml | 343 ----------------------- LiveObjects/.gitmodules | 3 - LiveObjects/COPYRIGHT | 1 - LiveObjects/LICENSE | 176 ------------ LiveObjects/MAINTAINERS.md | 1 - LiveObjects/Package.resolved | 96 ------- LiveObjects/Package.swift | 98 ------- 7 files changed, 718 deletions(-) delete mode 100644 LiveObjects/.github/workflows/check.yaml delete mode 100644 LiveObjects/.gitmodules delete mode 100644 LiveObjects/COPYRIGHT delete mode 100644 LiveObjects/LICENSE delete mode 100644 LiveObjects/MAINTAINERS.md delete mode 100644 LiveObjects/Package.resolved delete mode 100644 LiveObjects/Package.swift diff --git a/LiveObjects/.github/workflows/check.yaml b/LiveObjects/.github/workflows/check.yaml deleted file mode 100644 index b50e21fed..000000000 --- a/LiveObjects/.github/workflows/check.yaml +++ /dev/null @@ -1,343 +0,0 @@ -name: Check - -on: - workflow_dispatch: - pull_request: - push: - branches: - - main - -permissions: {} - -jobs: - lint: - runs-on: macos-15 - - permissions: - contents: read - - # From actions/cache documentation linked to below - env: - MINT_PATH: .mint/lib - MINT_LINK_PATH: .mint/bin - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - submodules: true - - # This step can be removed once the runners' default version of Xcode is 16.4 or above - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - with: - xcode-version: 16.4 - - # We use caching for Mint because at the time of writing SwiftLint took about 5 minutes to build in CI, which is unacceptably slow. - # https://github.com/actions/cache/blob/40c3b67b2955d93d83b27ed164edd0756bc24049/examples.md#swift---mint - - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 - with: - path: .mint - key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }} - restore-keys: | - ${{ runner.os }}-mint- - - - run: npm ci - - run: brew install mint - - run: mint bootstrap - - - run: swift run BuildTool lint - - # TODO: Restore in https://github.com/ably/ably-liveobjects-swift-plugin/issues/2 once we've seen what form the LiveObjects spec takes - # - # spec-coverage: - # runs-on: macos-15 - # steps: - # - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - # with: - # persist-credentials: false - # submodules: true - # - # # This step can be removed once the runners' default version of Xcode is 16.4 or above - # - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - # with: - # xcode-version: 16.4 - # - # - name: Spec coverage - # run: swift run BuildTool spec-coverage --spec-commit-sha 2f88b1b - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - generate-matrices: - runs-on: macos-15 - - permissions: - contents: read - - outputs: - matrix: ${{ steps.generation-step.outputs.matrix }} - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - submodules: true - - # This step can be removed once the runners' default version of Xcode is 16.4 or above - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - with: - xcode-version: 16.4 - - - id: generation-step - run: swift run BuildTool generate-matrices >> $GITHUB_OUTPUT - - build-and-test-spm: - name: SPM (Xcode ${{ matrix.tooling.xcodeVersion }}) - runs-on: macos-15 - needs: generate-matrices - - permissions: - contents: read - - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withoutPlatform }} - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - submodules: true - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - with: - xcode-version: ${{ matrix.tooling.xcodeVersion }} - - # https://forums.swift.org/t/warnings-as-errors-for-libraries-frameworks/58393/2 - - run: swift build -Xswiftc -warnings-as-errors - - run: swift test -Xswiftc -warnings-as-errors - - build-release-configuration-spm: - name: SPM, `release` configuration (Xcode ${{ matrix.tooling.xcodeVersion }}) - runs-on: macos-15 - needs: generate-matrices - - permissions: - contents: read - - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withoutPlatform }} - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - submodules: true - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - with: - xcode-version: ${{ matrix.tooling.xcodeVersion }} - - # https://forums.swift.org/t/warnings-as-errors-for-libraries-frameworks/58393/2 - - run: swift build -Xswiftc -warnings-as-errors --configuration release - - build-and-test-xcode: - name: Xcode, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) - runs-on: macos-15 - needs: generate-matrices - - permissions: - contents: read - - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - submodules: true - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - with: - xcode-version: ${{ matrix.tooling.xcodeVersion }} - - # We run these as two separate steps so that we can easily see the execution time of each step. - - - name: Build for testing - env: - PLATFORM: ${{ matrix.platform }} - run: swift run BuildTool build-library-for-testing --platform "$PLATFORM" - - - name: Run tests - env: - PLATFORM: ${{ matrix.platform }} - run: swift run BuildTool test-library --platform "$PLATFORM" --without-building - - code-coverage: - name: Generate code coverage - runs-on: macos-15 - - permissions: - contents: read - checks: write - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - submodules: true - - # This step can be removed once the runners' default version of Xcode is 16.4 or above - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - with: - xcode-version: 16.4 - - - run: swift run BuildTool generate-code-coverage --result-bundle-path CodeCoverage.xcresult - - # Generate a Markdown report of the code coverage information and add it to the workflow run. - # - # This tool is the best option that I could find after a brief look at the options. There are a few things that I wish it could do: - # - # - post a message on the pull request, like they do on Kotlin - # - offer more fine-grained configuration about which data to include in the report (I only care about code coverage, not test results, and I don't care about code coverage of the AblyLiveObjectsTests target) - # - # but it'll do for now (we can always fork or have another look for tooling later). - - uses: slidoapp/xcresulttool@9c51e32ae0320e0ca9b63240e4da8e974ac573e3 # v3.1.0 - with: - path: CodeCoverage.xcresult - # This title will be used for the sidebar item that this job adds to GitHub results page for this workflow - title: Code coverage results - # Turning off as much non-code-coverage information as it lets me - show-passed-tests: false - if: success() || failure() - - build-release-configuration-xcode: - name: Xcode, `release` configuration, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) - runs-on: macos-15 - needs: generate-matrices - - permissions: - contents: read - - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - submodules: true - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - with: - xcode-version: ${{ matrix.tooling.xcodeVersion }} - - - name: Build library - env: - PLATFORM: ${{ matrix.platform }} - run: swift run BuildTool build-library --platform "$PLATFORM" --configuration release - - check-example-app: - name: Example app, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) - runs-on: macos-15 - needs: generate-matrices - - permissions: - contents: read - - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - submodules: true - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - with: - xcode-version: ${{ matrix.tooling.xcodeVersion }} - - - name: Build example app - env: - PLATFORM: ${{ matrix.platform }} - run: swift run BuildTool build-example-app --platform "$PLATFORM" - - check-documentation: - runs-on: macos-15 - - permissions: - deployments: write - id-token: write - contents: read - - steps: - - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - with: - persist-credentials: false - submodules: true - - # This step can be removed once the runners' default version of Xcode is 16.4 or above - - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 - with: - xcode-version: 16.4 - - # Dry run upload-action to get base-path url - - name: Dry-Run Upload (to get url) - id: preupload - uses: ably/sdk-upload-action@4e694297f208b72b5a9f6b1248a1556f19f821d6 # v2 - with: - mode: preempt - sourcePath: .build/plugins/Swift-DocC/outputs/AblyLiveObjects.doccarchive # Path to the Swift DocC output folder - githubToken: ${{ secrets.GITHUB_TOKEN }} - artifactName: AblyLiveObjects - - # Build the documentation using Swift DocC - - name: Build documentation - env: - HOSTING_BASE_PATH: ${{ steps.preupload.outputs.base-path }} - run: | - swift package generate-documentation --target AblyLiveObjects --disable-indexing \ - --hosting-base-path "$HOSTING_BASE_PATH" \ - --transform-for-static-hosting - working-directory: ${{ github.workspace }} - - # Configure AWS credentials for uploading documentation - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4 - with: - aws-region: eu-west-2 - role-to-assume: arn:aws:iam::${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}:role/ably-sdk-builds-ably-liveobjects-swift-plugin - role-session-name: "${{ github.run_id }}-${{ github.run_number }}" - - # Upload the generated documentation - - name: Upload Documentation - uses: ably/sdk-upload-action@4e694297f208b72b5a9f6b1248a1556f19f821d6 # v2 - with: - sourcePath: .build/plugins/Swift-DocC/outputs/AblyLiveObjects.doccarchive # Path to the Swift DocC output folder - githubToken: ${{ secrets.GITHUB_TOKEN }} - artifactName: AblyLiveObjects # Optional root-level directory name - landingPagePath: documentation/ablyliveobjects - - # We use this job as a marker that all of the required checks have completed. - # This allows us to configure a single required status check in our branch - # protection rules instead of having to type loads of different check names - # into the branch protection web UI (and keep this list up to date as we - # tweak the matrices). - all-checks-completed: - runs-on: ubuntu-latest - - permissions: {} - - needs: - - lint - # - spec-coverage - - build-and-test-spm - - build-release-configuration-spm - - build-and-test-xcode - - build-release-configuration-xcode - - check-example-app - - check-documentation - - code-coverage - - steps: - - name: No-op - run: "true" diff --git a/LiveObjects/.gitmodules b/LiveObjects/.gitmodules deleted file mode 100644 index aecc76733..000000000 --- a/LiveObjects/.gitmodules +++ /dev/null @@ -1,3 +0,0 @@ -[submodule "Tests/AblyLiveObjectsTests/ably-common"] - path = Tests/AblyLiveObjectsTests/ably-common - url = https://github.com/ably/ably-common diff --git a/LiveObjects/COPYRIGHT b/LiveObjects/COPYRIGHT deleted file mode 100644 index 625d86cf7..000000000 --- a/LiveObjects/COPYRIGHT +++ /dev/null @@ -1 +0,0 @@ -Copyright 2024 Ably Real-time Ltd (ably.com) diff --git a/LiveObjects/LICENSE b/LiveObjects/LICENSE deleted file mode 100644 index d9a10c0d8..000000000 --- a/LiveObjects/LICENSE +++ /dev/null @@ -1,176 +0,0 @@ - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS diff --git a/LiveObjects/MAINTAINERS.md b/LiveObjects/MAINTAINERS.md deleted file mode 100644 index e5fda9517..000000000 --- a/LiveObjects/MAINTAINERS.md +++ /dev/null @@ -1 +0,0 @@ -This repository is owned by the Ably Ecosystems team. diff --git a/LiveObjects/Package.resolved b/LiveObjects/Package.resolved deleted file mode 100644 index 95a949028..000000000 --- a/LiveObjects/Package.resolved +++ /dev/null @@ -1,96 +0,0 @@ -{ - "originHash" : "263b1ea0fd786ff1456ebaef26e6b6baa0f018c0154cf2929eaa91abb0b54061", - "pins" : [ - { - "identity" : "ably-cocoa", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ably/ably-cocoa.git", - "state" : { - "revision" : "83bb5366b09408723446d8ba8a146938eeba829e", - "version" : "1.2.59" - } - }, - { - "identity" : "ably-cocoa-plugin-support", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ably/ably-cocoa-plugin-support", - "state" : { - "revision" : "a290b8942086ffb6e21e4805d9319143669d9414", - "version" : "2.0.0" - } - }, - { - "identity" : "delta-codec-cocoa", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ably/delta-codec-cocoa", - "state" : { - "revision" : "d53eec08f9443c6160d941327a6f9d8bbb93cea2", - "version" : "1.3.5" - } - }, - { - "identity" : "msgpack-objective-c", - "kind" : "remoteSourceControl", - "location" : "https://github.com/rvi/msgpack-objective-C", - "state" : { - "revision" : "3e36b48e04ecd756cb927bd5f5b9bf6d45e475f9", - "version" : "0.4.0" - } - }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", - "state" : { - "revision" : "41982a3656a71c768319979febd796c6fd111d5c", - "version" : "1.5.0" - } - }, - { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms", - "state" : { - "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", - "version" : "1.0.1" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", - "version" : "1.1.2" - } - }, - { - "identity" : "swift-docc-plugin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-plugin", - "state" : { - "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", - "version" : "1.4.3" - } - }, - { - "identity" : "swift-docc-symbolkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-docc-symbolkit", - "state" : { - "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", - "version" : "1.0.0" - } - }, - { - "identity" : "table", - "kind" : "remoteSourceControl", - "location" : "https://github.com/JanGorman/Table.git", - "state" : { - "revision" : "7b8521c3b1078ba0b347964aec7c503cd6f1ff40", - "version" : "1.1.1" - } - } - ], - "version" : 3 -} diff --git a/LiveObjects/Package.swift b/LiveObjects/Package.swift deleted file mode 100644 index 982af18db..000000000 --- a/LiveObjects/Package.swift +++ /dev/null @@ -1,98 +0,0 @@ -// swift-tools-version: 6.1 - -import PackageDescription - -let package = Package( - name: "AblyLiveObjects", - platforms: [ - .macOS(.v11), - .iOS(.v14), - .tvOS(.v14), - ], - products: [ - .library( - name: "AblyLiveObjects", - targets: [ - "AblyLiveObjects", - ], - ), - ], - dependencies: [ - .package( - url: "https://github.com/ably/ably-cocoa.git", - from: "1.2.59", - ), - .package( - url: "https://github.com/ably/ably-cocoa-plugin-support", - from: "2.0.0", - ), - .package( - url: "https://github.com/apple/swift-argument-parser", - from: "1.5.0", - ), - .package( - url: "https://github.com/apple/swift-async-algorithms", - from: "1.0.1", - ), - .package( - url: "https://github.com/JanGorman/Table.git", - from: "1.1.1", - ), - .package( - url: "https://github.com/apple/swift-docc-plugin", - from: "1.0.0", - ), - ], - targets: [ - .target( - name: "AblyLiveObjects", - dependencies: [ - .product( - name: "Ably", - package: "ably-cocoa", - ), - .product( - name: "_AblyPluginSupportPrivate", - package: "ably-cocoa-plugin-support", - ), - ], - ), - .testTarget( - name: "AblyLiveObjectsTests", - dependencies: [ - "AblyLiveObjects", - .product( - name: "Ably", - package: "ably-cocoa", - ), - .product( - name: "_AblyPluginSupportPrivate", - package: "ably-cocoa-plugin-support", - ), - ], - exclude: [ - "CLAUDE.md", - ], - resources: [ - .copy("ably-common"), - ], - ), - .executableTarget( - name: "BuildTool", - dependencies: [ - .product( - name: "ArgumentParser", - package: "swift-argument-parser", - ), - .product( - name: "AsyncAlgorithms", - package: "swift-async-algorithms", - ), - .product( - name: "Table", - package: "Table", - ), - ], - ), - ], -) From b4dc51eaf05cd5ce4748f4900b008d19dae933eb Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 9 Jul 2026 23:14:22 -0300 Subject: [PATCH 10/21] Move BuildTool into its own package Moves the plugin's BuildTool executable (imported two commits ago as LiveObjects/Sources/BuildTool) into a standalone package at LiveObjects/BuildTool, with a new manifest carrying over its dependencies (swift-argument-parser, swift-async-algorithms, Table) from the deleted plugin manifest. Its Xcode scheme moves alongside it. Making it a separate package, rather than a target of the root ably-cocoa package, keeps its dependencies out of the manifest that ably-cocoa's consumers resolve. It is run from the LiveObjects directory: swift run --package-path BuildTool BuildTool The move of the sources is a pure git rename; a later commit fixes the path assumptions that no longer hold in its new home. Co-Authored-By: Claude Fable 5 --- .../xcshareddata/xcschemes/BuildTool.xcscheme | 0 LiveObjects/BuildTool/Package.resolved | 42 +++++++++++++++ LiveObjects/BuildTool/Package.swift | 54 +++++++++++++++++++ .../Sources}/BuildTool.swift | 0 .../Sources}/Configuration.swift | 0 .../Sources}/DestinationFetcher.swift | 0 .../Sources}/DestinationPredicate.swift | 0 .../Sources}/DestinationSpecifier.swift | 0 .../Sources}/DestinationStrategy.swift | 0 .../Sources}/Error.swift | 0 .../Sources}/Platform.swift | 0 .../Sources}/ProcessRunner.swift | 0 .../Sources}/String+Decoding.swift | 0 .../Sources}/XcodeRunner.swift | 0 14 files changed, 96 insertions(+) rename LiveObjects/{ => BuildTool}/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme (100%) create mode 100644 LiveObjects/BuildTool/Package.resolved create mode 100644 LiveObjects/BuildTool/Package.swift rename LiveObjects/{Sources/BuildTool => BuildTool/Sources}/BuildTool.swift (100%) rename LiveObjects/{Sources/BuildTool => BuildTool/Sources}/Configuration.swift (100%) rename LiveObjects/{Sources/BuildTool => BuildTool/Sources}/DestinationFetcher.swift (100%) rename LiveObjects/{Sources/BuildTool => BuildTool/Sources}/DestinationPredicate.swift (100%) rename LiveObjects/{Sources/BuildTool => BuildTool/Sources}/DestinationSpecifier.swift (100%) rename LiveObjects/{Sources/BuildTool => BuildTool/Sources}/DestinationStrategy.swift (100%) rename LiveObjects/{Sources/BuildTool => BuildTool/Sources}/Error.swift (100%) rename LiveObjects/{Sources/BuildTool => BuildTool/Sources}/Platform.swift (100%) rename LiveObjects/{Sources/BuildTool => BuildTool/Sources}/ProcessRunner.swift (100%) rename LiveObjects/{Sources/BuildTool => BuildTool/Sources}/String+Decoding.swift (100%) rename LiveObjects/{Sources/BuildTool => BuildTool/Sources}/XcodeRunner.swift (100%) diff --git a/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme b/LiveObjects/BuildTool/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme similarity index 100% rename from LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme rename to LiveObjects/BuildTool/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme diff --git a/LiveObjects/BuildTool/Package.resolved b/LiveObjects/BuildTool/Package.resolved new file mode 100644 index 000000000..f65a3b4da --- /dev/null +++ b/LiveObjects/BuildTool/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "5edc51588d2fa738b8a95824129b6ce3a78cd64d34e8e10fd7b3fc175555d837", + "pins" : [ + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "6a52f3251125d74daf04fcbd5e6f08a75d074382", + "version" : "1.8.2" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "3da39bbc4e687d4192af7c9cf4eab805745a0b9c", + "version" : "1.1.5" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "a0cb0954ecb21e4e31b0070e6ed5674e8556685a", + "version" : "1.6.0" + } + }, + { + "identity" : "table", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JanGorman/Table.git", + "state" : { + "revision" : "7b8521c3b1078ba0b347964aec7c503cd6f1ff40", + "version" : "1.1.1" + } + } + ], + "version" : 3 +} diff --git a/LiveObjects/BuildTool/Package.swift b/LiveObjects/BuildTool/Package.swift new file mode 100644 index 000000000..c76b5ac74 --- /dev/null +++ b/LiveObjects/BuildTool/Package.swift @@ -0,0 +1,54 @@ +// swift-tools-version: 6.1 + +// A command-line tool used by LiveObjects development and CI (linting, +// generating CI matrices, and driving xcodebuild). Run it from the +// LiveObjects directory: +// +// swift run --package-path BuildTool BuildTool +// +// This is a separate package, rather than a target of the root ably-cocoa +// package, so that its dependencies are not resolved by consumers of +// ably-cocoa. + +import PackageDescription + +let package = Package( + name: "BuildTool", + platforms: [ + .macOS(.v14), + ], + dependencies: [ + .package( + url: "https://github.com/apple/swift-argument-parser", + from: "1.5.0", + ), + .package( + url: "https://github.com/apple/swift-async-algorithms", + from: "1.0.1", + ), + .package( + url: "https://github.com/JanGorman/Table.git", + from: "1.1.1", + ), + ], + targets: [ + .executableTarget( + name: "BuildTool", + dependencies: [ + .product( + name: "ArgumentParser", + package: "swift-argument-parser", + ), + .product( + name: "AsyncAlgorithms", + package: "swift-async-algorithms", + ), + .product( + name: "Table", + package: "Table", + ), + ], + path: "Sources", + ), + ], +) diff --git a/LiveObjects/Sources/BuildTool/BuildTool.swift b/LiveObjects/BuildTool/Sources/BuildTool.swift similarity index 100% rename from LiveObjects/Sources/BuildTool/BuildTool.swift rename to LiveObjects/BuildTool/Sources/BuildTool.swift diff --git a/LiveObjects/Sources/BuildTool/Configuration.swift b/LiveObjects/BuildTool/Sources/Configuration.swift similarity index 100% rename from LiveObjects/Sources/BuildTool/Configuration.swift rename to LiveObjects/BuildTool/Sources/Configuration.swift diff --git a/LiveObjects/Sources/BuildTool/DestinationFetcher.swift b/LiveObjects/BuildTool/Sources/DestinationFetcher.swift similarity index 100% rename from LiveObjects/Sources/BuildTool/DestinationFetcher.swift rename to LiveObjects/BuildTool/Sources/DestinationFetcher.swift diff --git a/LiveObjects/Sources/BuildTool/DestinationPredicate.swift b/LiveObjects/BuildTool/Sources/DestinationPredicate.swift similarity index 100% rename from LiveObjects/Sources/BuildTool/DestinationPredicate.swift rename to LiveObjects/BuildTool/Sources/DestinationPredicate.swift diff --git a/LiveObjects/Sources/BuildTool/DestinationSpecifier.swift b/LiveObjects/BuildTool/Sources/DestinationSpecifier.swift similarity index 100% rename from LiveObjects/Sources/BuildTool/DestinationSpecifier.swift rename to LiveObjects/BuildTool/Sources/DestinationSpecifier.swift diff --git a/LiveObjects/Sources/BuildTool/DestinationStrategy.swift b/LiveObjects/BuildTool/Sources/DestinationStrategy.swift similarity index 100% rename from LiveObjects/Sources/BuildTool/DestinationStrategy.swift rename to LiveObjects/BuildTool/Sources/DestinationStrategy.swift diff --git a/LiveObjects/Sources/BuildTool/Error.swift b/LiveObjects/BuildTool/Sources/Error.swift similarity index 100% rename from LiveObjects/Sources/BuildTool/Error.swift rename to LiveObjects/BuildTool/Sources/Error.swift diff --git a/LiveObjects/Sources/BuildTool/Platform.swift b/LiveObjects/BuildTool/Sources/Platform.swift similarity index 100% rename from LiveObjects/Sources/BuildTool/Platform.swift rename to LiveObjects/BuildTool/Sources/Platform.swift diff --git a/LiveObjects/Sources/BuildTool/ProcessRunner.swift b/LiveObjects/BuildTool/Sources/ProcessRunner.swift similarity index 100% rename from LiveObjects/Sources/BuildTool/ProcessRunner.swift rename to LiveObjects/BuildTool/Sources/ProcessRunner.swift diff --git a/LiveObjects/Sources/BuildTool/String+Decoding.swift b/LiveObjects/BuildTool/Sources/String+Decoding.swift similarity index 100% rename from LiveObjects/Sources/BuildTool/String+Decoding.swift rename to LiveObjects/BuildTool/Sources/String+Decoding.swift diff --git a/LiveObjects/Sources/BuildTool/XcodeRunner.swift b/LiveObjects/BuildTool/Sources/XcodeRunner.swift similarity index 100% rename from LiveObjects/Sources/BuildTool/XcodeRunner.swift rename to LiveObjects/BuildTool/Sources/XcodeRunner.swift From 3eb6707b1b8ae33999e112eea8dd5bf2729671b6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 9 Jul 2026 23:14:59 -0300 Subject: [PATCH 11/21] Annotate LiveObjects declarations with @available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds Scripts/annotate-liveobjects-availability.py and applies it, inserting @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) above all 222 top-level declarations in LiveObjects/Sources/AblyLiveObjects. These are the platform versions that the standalone plugin declared in its Package.swift. This change is entirely script-generated, with no manual edits, so it can be regenerated if the imported plugin sources are ever replaced with a newer version (see the import commit). Why this is needed: SwiftPM platform requirements are package-wide, and ably-cocoa's (iOS 9 / macOS 10.11 / tvOS 10) are far below what LiveObjects requires — its Swift concurrency usage alone needs iOS 13 / macOS 10.15. Per-declaration availability is how a single package hosts both: the compiler builds AblyLiveObjects at the package's old deployment target and these annotations both satisfy its availability checking and make consumers on older OS versions guard their use of the LiveObjects API with #available. The script is idempotent; CI will run it and fail on a non-empty diff so that new declarations cannot be added without an annotation. Co-Authored-By: Claude Fable 5 --- .../Internal/ARTClientOptions+Objects.swift | 1 + .../AblyLiveObjects/Internal/CoreSDK.swift | 3 + .../Internal/DefaultInternalPlugin.swift | 1 + .../Internal/DefaultLiveCounterUpdate.swift | 1 + .../Internal/DefaultLiveMapUpdate.swift | 1 + .../Internal/InternalDefaultLiveCounter.swift | 1 + .../Internal/InternalDefaultLiveMap.swift | 2 + .../InternalDefaultRealtimeObjects.swift | 2 + .../Internal/InternalLiveMapValue.swift | 1 + .../Internal/InternalLiveObject.swift | 2 + .../Internal/InternalObjectsMapEntry.swift | 2 + .../Internal/LiveObjectMutableState.swift | 1 + .../Internal/LiveObjectUpdate.swift | 2 + .../Internal/ObjectCreationHelpers.swift | 1 + .../Internal/ObjectDiffHelpers.swift | 1 + .../Internal/ObjectsOperationSource.swift | 1 + .../Internal/ObjectsPool.swift | 1 + .../Internal/ObjectsSyncState.swift | 1 + .../Internal/PublishResult.swift | 2 + .../Internal/SimpleClock.swift | 2 + .../Internal/SubscriptionStorage.swift | 2 + .../Internal/SyncObjectsPool.swift | 1 + .../InboundObjectMessage+Synthetic.swift | 1 + .../Protocol/ObjectMessage.swift | 33 +++++++++ .../AblyLiveObjects/Protocol/SyncCursor.swift | 1 + .../AblyLiveObjects/Protocol/WireEnum.swift | 3 + .../Protocol/WireObjectMessage.swift | 49 +++++++++++++ .../Public/ARTRealtimeChannel+Objects.swift | 1 + .../AblyLiveObjects/Public/Plugin.swift | 1 + .../InternalLiveMapValue+ToPublic.swift | 1 + .../PublicDefaultLiveCounter.swift | 1 + .../PublicDefaultLiveMap.swift | 1 + .../PublicDefaultRealtimeObjects.swift | 1 + .../PublicObjectsStore.swift | 1 + .../AblyLiveObjects/Public/PublicTypes.swift | 23 ++++++ .../AblyLiveObjects/Utility/Assertions.swift | 1 + .../Utility/Data+Extensions.swift | 2 + .../Utility/Dictionary+Extensions.swift | 1 + .../Utility/DispatchQueue+Extensions.swift | 1 + .../Utility/DispatchQueueMutex.swift | 1 + .../AblyLiveObjects/Utility/Errors.swift | 12 +++ .../Utility/ExtendedJSONValue.swift | 3 + .../AblyLiveObjects/Utility/JSONValue.swift | 15 ++++ .../AblyLiveObjects/Utility/Logger.swift | 4 + .../Utility/LoggingUtilities.swift | 1 + .../Utility/MarkerProtocolHelpers.swift | 4 + .../Utility/NSLock+Extensions.swift | 1 + .../AblyLiveObjects/Utility/WeakRef.swift | 2 + .../AblyLiveObjects/Utility/WireCodable.swift | 14 ++++ .../AblyLiveObjects/Utility/WireValue.swift | 11 +++ Scripts/annotate-liveobjects-availability.py | 73 +++++++++++++++++++ 51 files changed, 295 insertions(+) create mode 100755 Scripts/annotate-liveobjects-availability.py diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift index 685543452..e036885f6 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift @@ -1,6 +1,7 @@ internal import _AblyPluginSupportPrivate import Ably +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension ARTClientOptions { private class Box { internal let boxed: T diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 8c4e9c4e9..5aa75629f 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -4,6 +4,7 @@ import Ably /// The API that the internal components of the SDK (that is, `DefaultLiveObjects` and down) use to interact with our core SDK (i.e. ably-cocoa). /// /// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to `_AblyPluginSupportPrivate`'s Objective-C API (i.e. boxing). +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal protocol CoreSDK: AnyObject, Sendable { /// Implements the internal `#publish` method of RTO15. func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) @@ -20,6 +21,7 @@ internal protocol CoreSDK: AnyObject, Sendable { var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { get } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class DefaultCoreSDK: CoreSDK { /// Used to synchronize access to internal mutable state. private let mutex = NSLock() @@ -114,6 +116,7 @@ internal final class DefaultCoreSDK: CoreSDK { // MARK: - Channel State Validation /// Extension on CoreSDK to provide channel state validation utilities. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension CoreSDK { /// Validates that the channel is not in any of the specified invalid states. /// diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index b15c7ab74..5f0ee4803 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -5,6 +5,7 @@ import Ably import ObjectiveC.NSObject /// The default implementation of `_AblyPluginSupportPrivate`'s `LiveObjectsInternalPluginProtocol`. Implements the interface that ably-cocoa uses to access the functionality provided by the LiveObjects plugin. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) @objc internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate.LiveObjectsInternalPluginProtocol { private let pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift index b45a130d8..32f25b28e 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift @@ -1,3 +1,4 @@ +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct DefaultLiveCounterUpdate: LiveCounterUpdate, Equatable { internal var amount: Double } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift index 3c1453950..28ece0b52 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift @@ -1,3 +1,4 @@ +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct DefaultLiveMapUpdate: LiveMapUpdate, Equatable { internal var update: [String: LiveMapUpdateAction] } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index a94b77516..59a661f81 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -3,6 +3,7 @@ import Ably import Foundation /// This provides the implementation behind ``PublicDefaultLiveCounter``, via internal versions of the ``LiveCounter`` API. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class InternalDefaultLiveCounter: Sendable { private let mutableStateMutex: DispatchQueueMutex diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index e13ab40de..99cd8e6b8 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -2,12 +2,14 @@ internal import _AblyPluginSupportPrivate import Ably /// Protocol for accessing objects from the ObjectsPool. This is used by a LiveMap when it needs to return an object given an object ID. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal protocol LiveMapObjectsPoolDelegate: AnyObject, Sendable { /// A snapshot of the objects pool. var nosync_objectsPool: ObjectsPool { get } } /// This provides the implementation behind ``PublicDefaultLiveMap``, via internal versions of the ``LiveMap`` API. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class InternalDefaultLiveMap: Sendable { private let mutableStateMutex: DispatchQueueMutex diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index d50ca339e..370114446 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -2,6 +2,7 @@ internal import _AblyPluginSupportPrivate import Ably /// Protocol that abstracts `InternalDefaultRealtimeObjects`, for testability. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal protocol InternalRealtimeObjectsProtocol: LiveMapObjectsPoolDelegate { /// Per RTO20. /// @@ -16,6 +17,7 @@ internal protocol InternalRealtimeObjectsProtocol: LiveMapObjectsPoolDelegate { } /// This provides the implementation behind ``PublicDefaultRealtimeObjects``, via internal versions of the ``RealtimeObjects`` API. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeObjectsProtocol { private let mutableStateMutex: DispatchQueueMutex diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift index d8264ed89..ecdad9a86 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -1,6 +1,7 @@ import Foundation /// Same as the public ``LiveMapValue`` type but with associated values of internal type. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum InternalLiveMapValue: Sendable, Equatable { case string(String) case number(Double) diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift index 11679d152..abfe9d7d8 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift @@ -3,6 +3,7 @@ internal import _AblyPluginSupportPrivate /// Provides RTLO spec point functionality common to all LiveObjects. /// /// This exists in addition to ``LiveObjectMutableState`` to enable polymorphism. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal protocol InternalLiveObject { associatedtype Update: Sendable @@ -12,6 +13,7 @@ internal protocol InternalLiveObject { mutating func resetDataToZeroValued() } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension InternalLiveObject { /// Convenience method for tombstoning a `LiveObject`, as specified in RTLO4e. mutating func tombstone( diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift index 4922ddfc3..a6a54bc3d 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift @@ -1,6 +1,7 @@ import Foundation /// The entries stored in a `LiveMap`'s data. Same as an `ObjectsMapEntry` but with an additional `tombstonedAt` property, per RTLM3a. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct InternalObjectsMapEntry: Equatable { internal var tombstonedAt: Date? // RTLM3a internal var tombstone: Bool { @@ -12,6 +13,7 @@ internal struct InternalObjectsMapEntry: Equatable { internal var data: ObjectData? // OME2c } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension InternalObjectsMapEntry { init(objectsMapEntry: ObjectsMapEntry, tombstonedAt: Date?) { self.tombstonedAt = tombstonedAt diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index 8978b9ea2..20639a4d0 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -4,6 +4,7 @@ import Ably /// This is the equivalent of the `LiveObject` abstract class described in RTLO. /// /// ``InternalDefaultLiveCounter`` and ``InternalDefaultLiveMap`` include it by composition. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct LiveObjectMutableState { // RTLO3a internal var objectID: String diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift index e31a06393..c9f90f8cc 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift @@ -1,3 +1,4 @@ +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum LiveObjectUpdate: Sendable { case noop // RTLO4b4 case update(Update) // RTLO4b4a @@ -23,4 +24,5 @@ internal enum LiveObjectUpdate: Sendable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension LiveObjectUpdate: Equatable where Update: Equatable {} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift index 66b17485d..a741a4f3a 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift @@ -5,6 +5,7 @@ import Foundation /// Helpers for creating a new LiveObject. /// /// These generate an object ID and the `ObjectMessage` needed to create the LiveObject. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum ObjectCreationHelpers { /// The metadata that `createCounter` needs in order to request that Realtime create a LiveCounter and to populate the local objects pool. internal struct CounterCreationOperation { diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift index dbd930918..9895c6ede 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift @@ -1,6 +1,7 @@ import Foundation /// Helper methods for calculating diffs between LiveObject data values. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum ObjectDiffHelpers { /// Calculates the diff between two LiveCounter data values, per RTLC14. /// diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift index eb8672a77..748004afe 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift @@ -1,4 +1,5 @@ /// RTO22: Describes the source of an operation being applied. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum ObjectsOperationSource { /// RTO22a: An operation that originated locally, being applied upon receipt of the ACK from Realtime. case local diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 5d36f674d..163f9156f 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -3,6 +3,7 @@ internal import _AblyPluginSupportPrivate /// Maintains the list of objects present on a channel, as described by RTO3. /// /// Note that this is a value type. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct ObjectsPool { /// The possible `ObjectsPool` entries, as described by RTO3a. internal enum Entry { diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift index 0a95d9520..9c844e413 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift @@ -1,4 +1,5 @@ /// The type that the spec uses to represent the client's state of syncing its local Objects data with the server, per RTO17a. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum ObjectsSyncState { case initialized case syncing diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/PublishResult.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/PublishResult.swift index 3c4a9202b..6795238c4 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/PublishResult.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/PublishResult.swift @@ -3,10 +3,12 @@ internal import _AblyPluginSupportPrivate /// As described by `PBR*` spec points. /// /// We use this internally instead of `_AblyCocoaPluginSupportPrivate.PublishResultProtocol` because Swift allows us to directly store `nil` values in an array. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct PublishResult { internal var serials: [String?] } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension PublishResult { init(pluginPublishResult: _AblyPluginSupportPrivate.PublishResultProtocol) { serials = pluginPublishResult.serials.map(\.value) diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/SimpleClock.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/SimpleClock.swift index bc6c0faa0..5dda4b5f0 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/SimpleClock.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/SimpleClock.swift @@ -4,12 +4,14 @@ import Foundation /// /// This protocol allows for dependency injection of time-related functionality, /// making it easier to test time-dependent code. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal protocol SimpleClock: Sendable { /// Returns the current time as a Date. var now: Date { get } } /// The default implementation of SimpleClock that uses the system clock. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class DefaultSimpleClock: SimpleClock { internal init() {} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift index e03ac2a21..9a6665a34 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift @@ -1,6 +1,7 @@ import Foundation /// Handles subscription bookkeeping, providing methods for subscribing and emitting events. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct SubscriptionStorage { /// Internal bookkeeping for subscriptions, organized by event name. /// Each event name maps to a dictionary of subscriptions keyed by their ID for O(1) operations. @@ -83,6 +84,7 @@ internal struct SubscriptionStorage // OOP3a internal var objectId: String // OOP3b @@ -48,6 +51,7 @@ internal struct ObjectOperation: Equatable { internal var mapClear: WireMapClear? // OOP3r } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct ObjectData: Equatable { internal var objectId: String? // OD2a internal var boolean: Bool? // OD2c @@ -57,16 +61,19 @@ internal struct ObjectData: Equatable { internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct MapSet: Equatable { internal var key: String // MST2a internal var value: ObjectData? // MST2b } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct MapCreate: Equatable { internal var semantics: WireEnum // MCR2a internal var entries: [String: ObjectsMapEntry]? // MCR2b } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct MapCreateWithObjectId: Equatable { internal var initialValue: String // MCRO2a internal var nonce: String // MCRO2b @@ -77,6 +84,7 @@ internal struct MapCreateWithObjectId: Equatable { internal var derivedFrom: MapCreate? } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct CounterCreateWithObjectId: Equatable { internal var initialValue: String // CCRO2a internal var nonce: String // CCRO2b @@ -87,6 +95,7 @@ internal struct CounterCreateWithObjectId: Equatable { internal var derivedFrom: WireCounterCreate? } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct ObjectsMapEntry: Equatable { internal var tombstone: Bool? // OME2a internal var timeserial: String? // OME2b @@ -94,12 +103,14 @@ internal struct ObjectsMapEntry: Equatable { internal var serialTimestamp: Date? // OME2d } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct ObjectsMap: Equatable { internal var semantics: WireEnum // OMP3a internal var entries: [String: ObjectsMapEntry]? // OMP3b internal var clearTimeserial: String? // OMP3c } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct ObjectState: Equatable { internal var objectId: String // OST2a internal var siteTimeserials: [String: String] // OST2b @@ -109,6 +120,7 @@ internal struct ObjectState: Equatable { internal var counter: WireObjectsCounter? // OST2f } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension InboundObjectMessage { /// Initializes an `InboundObjectMessage` from an `InboundWireObjectMessage`, applying the data decoding rules of OD5. /// @@ -136,6 +148,7 @@ internal extension InboundObjectMessage { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension OutboundObjectMessage { /// Converts this `OutboundObjectMessage` to an `OutboundWireObjectMessage`, applying the data encoding rules of OD4. /// @@ -157,6 +170,7 @@ internal extension OutboundObjectMessage { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension ObjectOperation { /// Initializes an `ObjectOperation` from a `WireObjectOperation`, applying the data decoding rules of OD5. /// @@ -207,6 +221,7 @@ internal extension ObjectOperation { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension ObjectData { /// Initializes an `ObjectData` from a `WireObjectData`, applying the data decoding rules of OD5. /// @@ -311,6 +326,7 @@ internal extension ObjectData { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension MapSet { init( wireMapSet: WireMapSet, @@ -330,6 +346,7 @@ internal extension MapSet { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension MapCreate { init( wireMapCreate: WireMapCreate, @@ -349,6 +366,7 @@ internal extension MapCreate { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension MapCreateWithObjectId { init(wireMapCreateWithObjectId: WireMapCreateWithObjectId) { nonce = wireMapCreateWithObjectId.nonce @@ -360,6 +378,7 @@ internal extension MapCreateWithObjectId { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension CounterCreateWithObjectId { init(wireCounterCreateWithObjectId: WireCounterCreateWithObjectId) { nonce = wireCounterCreateWithObjectId.nonce @@ -371,6 +390,7 @@ internal extension CounterCreateWithObjectId { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension ObjectsMapEntry { /// Initializes an `ObjectsMapEntry` from a `WireObjectsMapEntry`, applying the data decoding rules of OD5. /// @@ -404,6 +424,7 @@ internal extension ObjectsMapEntry { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension ObjectsMap { /// Initializes an `ObjectsMap` from a `WireObjectsMap`, applying the data decoding rules of OD5. /// @@ -434,6 +455,7 @@ internal extension ObjectsMap { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension ObjectState { /// Initializes an `ObjectState` from a `WireObjectState`, applying the data decoding rules of OD5. /// @@ -474,6 +496,7 @@ internal extension ObjectState { // MARK: - CustomDebugStringConvertible +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension InboundObjectMessage: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -493,6 +516,7 @@ extension InboundObjectMessage: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension OutboundObjectMessage: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -512,6 +536,7 @@ extension OutboundObjectMessage: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension ObjectOperation: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -532,6 +557,7 @@ extension ObjectOperation: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension ObjectState: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -547,6 +573,7 @@ extension ObjectState: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension ObjectsMap: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -566,6 +593,7 @@ extension ObjectsMap: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension ObjectsMapEntry: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -579,6 +607,7 @@ extension ObjectsMapEntry: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension ObjectData: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -594,6 +623,7 @@ extension ObjectData: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension MapSet: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -605,6 +635,7 @@ extension MapSet: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension MapCreate: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -623,6 +654,7 @@ extension MapCreate: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension MapCreateWithObjectId: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -635,6 +667,7 @@ extension MapCreateWithObjectId: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension CounterCreateWithObjectId: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] diff --git a/LiveObjects/Sources/AblyLiveObjects/Protocol/SyncCursor.swift b/LiveObjects/Sources/AblyLiveObjects/Protocol/SyncCursor.swift index 311e8f98e..8bd0eadba 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Protocol/SyncCursor.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Protocol/SyncCursor.swift @@ -2,6 +2,7 @@ import Ably import Foundation /// The `OBJECT_SYNC` sync cursor, as extracted from a `channelSerial` per RTO5a1 and RTO5a4. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct SyncCursor { internal var sequenceID: String /// `nil` in the case where the objects sync sequence is complete (RTO5a4). diff --git a/LiveObjects/Sources/AblyLiveObjects/Protocol/WireEnum.swift b/LiveObjects/Sources/AblyLiveObjects/Protocol/WireEnum.swift index 9df4f47e9..1e52fd685 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Protocol/WireEnum.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Protocol/WireEnum.swift @@ -1,4 +1,5 @@ /// An enum extracted from a wire representation that either belongs to one of a set of known values or is a new, unknown value. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum WireEnum where Known: RawRepresentable { case known(Known) case unknown(Known.RawValue) @@ -21,5 +22,7 @@ internal enum WireEnum where Known: RawRepresentable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireEnum: Sendable where Known: Sendable, Known.RawValue: Sendable {} +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireEnum: Equatable where Known: Equatable, Known.RawValue: Equatable {} diff --git a/LiveObjects/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/LiveObjects/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 8647e733f..4f40e0d72 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -5,6 +5,7 @@ import Foundation // This file contains the ObjectMessage types that we send and receive over the wire. We convert them to and from the corresponding non-wire types (e.g. `InboundObjectMessage`) for use within the codebase. /// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct InboundWireObjectMessage { // TODO: Spec has `id`, `connectionId`, `timestamp`, `clientId`, `serial`, `sideCode` as non-nullable but I don't think this is right; raised https://github.com/ably/specification/issues/334 internal var id: String? // OM2a @@ -20,6 +21,7 @@ internal struct InboundWireObjectMessage { } /// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct OutboundWireObjectMessage { internal var id: String? // OM2a internal var clientId: String? // OM2b @@ -34,6 +36,7 @@ internal struct OutboundWireObjectMessage { } /// The keys for decoding an `InboundWireObjectMessage` or encoding an `OutboundWireObjectMessage`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum WireObjectMessageWireKey: String { case id case clientId @@ -47,6 +50,7 @@ internal enum WireObjectMessageWireKey: String { case serialTimestamp } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension InboundWireObjectMessage { /// An error that can occur when decoding an ``InboundWireObjectMessage``. enum DecodingError: Error { @@ -104,6 +108,7 @@ internal extension InboundWireObjectMessage { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension OutboundWireObjectMessage: WireObjectEncodable { internal var toWireObject: [String: WireValue] { var result: [String: WireValue] = [:] @@ -144,6 +149,7 @@ extension OutboundWireObjectMessage: WireObjectEncodable { } // OOP2 +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum ObjectOperationAction: Int { case mapCreate = 0 case mapSet = 1 @@ -155,10 +161,12 @@ internal enum ObjectOperationAction: Int { } // OMP2 +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum ObjectsMapSemantics: Int { case lww = 0 } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireObjectOperation { internal var action: WireEnum // OOP3a internal var objectId: String // OOP3b @@ -173,6 +181,7 @@ internal struct WireObjectOperation { internal var mapClear: WireMapClear? // OOP3r } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireObjectOperation: WireObjectCodable { internal enum WireKey: String { case action @@ -242,6 +251,7 @@ extension WireObjectOperation: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireObjectState { internal var objectId: String // OST2a internal var siteTimeserials: [String: String] // OST2b @@ -251,6 +261,7 @@ internal struct WireObjectState { internal var counter: WireObjectsCounter? // OST2f } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireObjectState: WireObjectCodable { internal enum WireKey: String { case objectId @@ -296,12 +307,14 @@ extension WireObjectState: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireObjectsMap { internal var semantics: WireEnum // OMP3a internal var entries: [String: WireObjectsMapEntry]? // OMP3b internal var clearTimeserial: String? // OMP3c } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireObjectsMap: WireObjectCodable { internal enum WireKey: String { case semantics @@ -336,10 +349,12 @@ extension WireObjectsMap: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireObjectsCounter: Equatable { internal var count: NSNumber? // OCN2a } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireObjectsCounter: WireObjectCodable { internal enum WireKey: String { case count @@ -358,11 +373,13 @@ extension WireObjectsCounter: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireMapSet { internal var key: String // MST2a internal var value: WireObjectData? // MST2b } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireMapSet: WireObjectCodable { internal enum WireKey: String { case key @@ -387,10 +404,12 @@ extension WireMapSet: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireMapRemove: Equatable { internal var key: String // MRM2a } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireMapRemove: WireObjectCodable { internal enum WireKey: String { case key @@ -407,11 +426,13 @@ extension WireMapRemove: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireMapCreate { internal var semantics: WireEnum // MCR2a internal var entries: [String: WireObjectsMapEntry]? // MCR2b } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireMapCreate: WireObjectCodable { internal enum WireKey: String { case semantics @@ -441,10 +462,12 @@ extension WireMapCreate: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireCounterCreate: Equatable { internal var count: NSNumber? // CCR2a } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireCounterCreate: WireObjectCodable { internal enum WireKey: String { case count @@ -463,10 +486,12 @@ extension WireCounterCreate: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireCounterInc: Equatable { internal var number: NSNumber // CIN2a } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireCounterInc: WireObjectCodable { internal enum WireKey: String { case number @@ -483,10 +508,12 @@ extension WireCounterInc: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireObjectDelete: Equatable { // Empty struct } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireObjectDelete: WireObjectCodable { internal init(wireObject _: [String: WireValue]) throws(ARTErrorInfo) { // No fields to decode @@ -497,10 +524,12 @@ extension WireObjectDelete: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireMapClear: Equatable { // Empty struct } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireMapClear: WireObjectCodable { internal init(wireObject _: [String: WireValue]) throws(ARTErrorInfo) { // No fields to decode @@ -511,11 +540,13 @@ extension WireMapClear: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireMapCreateWithObjectId: Equatable { internal var initialValue: String // MCRO2a internal var nonce: String // MCRO2b } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireMapCreateWithObjectId: WireObjectCodable { internal enum WireKey: String { case nonce @@ -535,11 +566,13 @@ extension WireMapCreateWithObjectId: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireCounterCreateWithObjectId: Equatable { internal var initialValue: String // CCRO2a internal var nonce: String // CCRO2b } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireCounterCreateWithObjectId: WireObjectCodable { internal enum WireKey: String { case nonce @@ -559,6 +592,7 @@ extension WireCounterCreateWithObjectId: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireObjectsMapEntry { internal var tombstone: Bool? // OME2a internal var timeserial: String? // OME2b @@ -566,6 +600,7 @@ internal struct WireObjectsMapEntry { internal var serialTimestamp: Date? // OME2d } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireObjectsMapEntry: WireObjectCodable { internal enum WireKey: String { case tombstone @@ -601,6 +636,7 @@ extension WireObjectsMapEntry: WireObjectCodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireObjectData { internal var objectId: String? // OD2a internal var boolean: Bool? // OD2c @@ -610,6 +646,7 @@ internal struct WireObjectData { internal var json: String? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireObjectData: WireObjectCodable { internal enum WireKey: String { case objectId @@ -658,6 +695,7 @@ extension WireObjectData: WireObjectCodable { /// A type that can be either a string or binary data. /// /// Used to represent the values that `WireObjectData.bytes` might hold, after being encoded per OD4 or before being decoded per OD5. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum StringOrData: Equatable, WireCodable { case string(String) case data(Data) @@ -690,6 +728,7 @@ internal enum StringOrData: Equatable, WireCodable { // MARK: - CustomDebugStringConvertible +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireObjectsCounter: CustomDebugStringConvertible { internal var debugDescription: String { if let count { @@ -700,6 +739,7 @@ extension WireObjectsCounter: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireObjectsMapEntry: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -713,6 +753,7 @@ extension WireObjectsMapEntry: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireObjectData: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -728,6 +769,7 @@ extension WireObjectData: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireMapSet: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -739,12 +781,14 @@ extension WireMapSet: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireMapRemove: CustomDebugStringConvertible { internal var debugDescription: String { "{ key: \(key) }" } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireMapCreate: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -763,6 +807,7 @@ extension WireMapCreate: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireCounterCreate: CustomDebugStringConvertible { internal var debugDescription: String { if let count { @@ -773,24 +818,28 @@ extension WireCounterCreate: CustomDebugStringConvertible { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireCounterInc: CustomDebugStringConvertible { internal var debugDescription: String { "{ number: \(number) }" } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireObjectDelete: CustomDebugStringConvertible { internal var debugDescription: String { "{ }" } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireMapCreateWithObjectId: CustomDebugStringConvertible { internal var debugDescription: String { "{ initialValue: \(initialValue), nonce: \(nonce) }" } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireCounterCreateWithObjectId: CustomDebugStringConvertible { internal var debugDescription: String { "{ initialValue: \(initialValue), nonce: \(nonce) }" diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/LiveObjects/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index 0beb04b02..e40b8ce6a 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -1,6 +1,7 @@ internal import _AblyPluginSupportPrivate import Ably +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public extension ARTRealtimeChannel { /// A ``RealtimeObjects`` object. var objects: RealtimeObjects { diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Plugin.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Plugin.swift index a39b7a74f..db5e5c2ca 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Public/Plugin.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Plugin.swift @@ -35,6 +35,7 @@ import ObjectiveC.NSObject /// let rootObject = try await channel.objects.getRoot() /// // …and so on /// ``` +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) @objc public class Plugin: NSObject { /// The `_AblyPluginSupportPrivate.PluginAPIProtocol` that the LiveObjects plugin should use by default (i.e. when one hasn't been injected for test purposes). diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift index 6e0419a75..50a0554b4 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift @@ -1,5 +1,6 @@ internal import _AblyPluginSupportPrivate +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension InternalLiveMapValue { // MARK: - Mapping to public types diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 53995a79a..7778f3a60 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -4,6 +4,7 @@ import Ably /// Our default implementation of ``LiveCounter``. /// /// This is largely a wrapper around ``InternalDefaultLiveCounter``. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class PublicDefaultLiveCounter: LiveCounter { internal let proxied: InternalDefaultLiveCounter diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index d3b003cf6..e5374078a 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -4,6 +4,7 @@ import Ably /// Our default implementation of ``LiveMap``. /// /// This is largely a wrapper around ``InternalDefaultLiveMap``. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class PublicDefaultLiveMap: LiveMap { internal let proxied: InternalDefaultLiveMap diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index 1dd1fb3a1..d07567f38 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -4,6 +4,7 @@ import Ably /// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. /// /// This is largely a wrapper around ``InternalDefaultRealtimeObjects``. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class PublicDefaultRealtimeObjects: RealtimeObjects { private let proxied: InternalDefaultRealtimeObjects internal var testsOnly_proxied: InternalDefaultRealtimeObjects { diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift index 865210856..35a73c66b 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift @@ -12,6 +12,7 @@ import Foundation /// This differs from the approach that we take in ably-cocoa, in which we create a new public object each time we need to return one. Given that the LiveObjects SDK revolves around the concept of various live-updating objects, it seemed like it might be quite a confusing user experience if the pointer identity of, say, a `LiveMap` changed each time it was fetched. /// /// - Note: We can only make a best-effort attempt to maintain the pointer identity of the public objects. Since the SDK cannot maintain a strong reference to the public objects (given that the whole reason that these objects exist is for us to know whether the user holds a strong reference to them), if the user releases all of their strong references to a public object then the next time they fetch the public object they will receive a new object. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class PublicObjectsStore: Sendable { // Used to synchronize access to mutable state private let mutex = NSLock() diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/PublicTypes.swift b/LiveObjects/Sources/AblyLiveObjects/Public/PublicTypes.swift index 086ee9863..659184a1d 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -5,18 +5,22 @@ import Ably /// - Parameters: /// - update: The update object describing the changes made to the object. /// - subscription: A ``SubscribeResponse`` object that allows the provided listener to deregister itself from future updates. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public typealias LiveObjectUpdateCallback = @Sendable (_ update: sending T, _ subscription: SubscribeResponse) -> Void /// The callback used for the events emitted by ``RealtimeObjects``. /// /// - Parameter subscription: An ``OnObjectsEventResponse`` object that allows the provided listener to deregister itself from future updates. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public typealias ObjectsEventCallback = @Sendable (_ subscription: OnObjectsEventResponse) -> Void /// The callback used for the lifecycle events emitted by ``LiveObject``. /// - Parameter subscription: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to deregister itself from future updates. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public typealias LiveObjectLifecycleEventCallback = @Sendable (_ subscription: OnLiveObjectLifecycleEventResponse) -> Void /// Describes the events emitted by an ``RealtimeObjects`` object. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public enum ObjectsEvent: Sendable { /// The local copy of Objects on a channel is currently being synchronized with the Ably service. case syncing @@ -25,12 +29,14 @@ public enum ObjectsEvent: Sendable { } /// Describes the events emitted by a ``LiveObject`` object. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public enum LiveObjectLifecycleEvent: Sendable { /// Indicates that the object has been deleted from the Objects pool and should no longer be interacted with. case deleted } /// Enables the Objects to be read, modified and subscribed to for a channel. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public protocol RealtimeObjects: Sendable { /// Retrieves the root ``LiveMap`` object for Objects on a channel. func getRoot() async throws(ARTErrorInfo) -> any LiveMap @@ -87,6 +93,7 @@ public protocol RealtimeObjects: Sendable { /// ], /// ]) /// ``` +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public enum LiveMapValue: Sendable, Equatable { case string(String) case number(Double) @@ -191,36 +198,42 @@ public enum LiveMapValue: Sendable, Equatable { // MARK: - ExpressibleBy*Literal conformances +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension LiveMapValue: ExpressibleByDictionaryLiteral { public init(dictionaryLiteral elements: (String, JSONValue)...) { self = .jsonObject(.init(uniqueKeysWithValues: elements)) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension LiveMapValue: ExpressibleByArrayLiteral { public init(arrayLiteral elements: JSONValue...) { self = .jsonArray(elements) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension LiveMapValue: ExpressibleByStringLiteral { public init(stringLiteral value: String) { self = .string(value) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension LiveMapValue: ExpressibleByIntegerLiteral { public init(integerLiteral value: Int) { self = .number(Double(value)) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension LiveMapValue: ExpressibleByFloatLiteral { public init(floatLiteral value: Double) { self = .number(value) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension LiveMapValue: ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = .bool(value) @@ -228,6 +241,7 @@ extension LiveMapValue: ExpressibleByBooleanLiteral { } /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public protocol OnObjectsEventResponse: Sendable { /// Deregisters the listener passed to the `on` call. func off() @@ -238,6 +252,7 @@ public protocol OnObjectsEventResponse: Sendable { /// meaning that if two clients update the same key in the map, the update with the most recent timestamp wins. /// /// Keys must be strings. Values can be another ``LiveObject``, or a primitive type, such as a string, number, boolean, JSON-serializable object or array, or binary data. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public protocol LiveMap: LiveObject where Update == LiveMapUpdate { /// Returns the value associated with a given key. Returns `nil` if the key doesn't exist in a map or if the associated ``LiveObject`` has been deleted. /// @@ -281,6 +296,7 @@ public protocol LiveMap: LiveObject where Update == LiveMapUpdate { } /// Describes whether an entry in ``LiveMapUpdate/update`` represents an update or a removal. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public enum LiveMapUpdateAction: Sendable { /// The value of a key in the map was updated. case updated @@ -289,6 +305,7 @@ public enum LiveMapUpdateAction: Sendable { } /// Represents an update to a ``LiveMap`` object, describing the keys that were updated or removed. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public protocol LiveMapUpdate: Sendable { /// An object containing keys from a `LiveMap` that have changed, along with their change status: /// - ``LiveMapUpdateAction/updated`` - the value of a key in the map was updated. @@ -297,6 +314,7 @@ public protocol LiveMapUpdate: Sendable { } /// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { /// Returns the current value of the counter. var value: Double { get throws(ARTErrorInfo) } @@ -317,12 +335,14 @@ public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { } /// Represents an update to a ``LiveCounter`` object. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public protocol LiveCounterUpdate: Sendable { /// Holds the numerical change to the counter value. var amount: Double { get } } /// Describes the common interface for all conflict-free data structures supported by the Objects. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public protocol LiveObject: AnyObject, Sendable { /// The type of update event that this object emits. associatedtype Update @@ -351,12 +371,14 @@ public protocol LiveObject: AnyObject, Sendable { } /// Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public protocol SubscribeResponse: Sendable { /// Deregisters the listener passed to the `subscribe` call. func unsubscribe() } /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public protocol OnLiveObjectLifecycleEventResponse: Sendable { /// Deregisters the listener passed to the `on` call. func off() @@ -365,6 +387,7 @@ public protocol OnLiveObjectLifecycleEventResponse: Sendable { // MARK: - AsyncSequence Extensions /// Extension to provide AsyncSequence-based subscription for `LiveObject` updates. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public extension LiveObject { /// Returns an `AsyncSequence` that emits updates to this `LiveObject`. /// diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/Assertions.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/Assertions.swift index bc1045e27..cd6c8f98c 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/Assertions.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/Assertions.swift @@ -1,4 +1,5 @@ /// Stops execution because we tried to use a feature that is not yet implemented. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal func notYetImplemented(_ message: @autoclosure () -> String = String(), file _: StaticString = #file, line _: UInt = #line) -> Never { fatalError({ let returnedMessage = message() diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/Data+Extensions.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/Data+Extensions.swift index 286e0bb67..d85fa4958 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/Data+Extensions.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/Data+Extensions.swift @@ -2,10 +2,12 @@ import Ably import Foundation /// Errors that can occur during decoding operations. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum DecodingError: Error, Equatable { case invalidBase64String(String) } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension Data { /// Initialize Data from a Base64-encoded string, throwing an error if decoding fails. /// - Parameter base64String: The Base64-encoded string to decode diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift index 9635b9ebd..95e9e443e 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift @@ -1,3 +1,4 @@ +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension Dictionary { /// Behaves like `Dictionary.mapValues`, but the thrown error has the same type as that thrown by the transform. (`mapValues` uses `rethrows`, which is always an untyped throw.) func ablyLiveObjects_mapValuesWithTypedThrow(_ transform: (Value) throws(E) -> T) throws(E) -> [Key: T] where E: Error { diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift index fc188589d..bdeb83982 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift @@ -1,5 +1,6 @@ import Foundation +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension DispatchQueue { /// Same as `sync(execute:)` but with a runtime precondition that we are not already on this queue. func ably_syncNoDeadlock(execute block: () -> Void) { diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift index 5be97eb46..7b64697fc 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift @@ -5,6 +5,7 @@ import Foundation /// In order to access or mutate the mutex's value, it is expected that you know whether or not you are already executing on the mutex's queue. If you are, then use ``withoutSync(_:)``, which simply performs a runtime check that the current queue is correct. If not, then use ``withSync(_:)``, which synchronously dispatches to the queue. ``withSync(_:)`` must not be called from the queue, as doing so would cause a deadlock (there is a runtime check which terminates execution in this case). /// /// This class is styled on Swift's built-in `Mutex` type. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class DispatchQueueMutex: Sendable { /// The queue that this mutex uses to synchronise access to the wrapped value. internal let dispatchQueue: DispatchQueue diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift index e434d6094..59886e95f 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/Errors.swift @@ -4,6 +4,7 @@ import Ably /** Describes the errors that can be thrown by the LiveObjects SDK. Use ``toARTErrorInfo()`` to convert to an `ARTErrorInfo` that you can throw. */ +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum LiveObjectsError { // operationDescription should be a description of a method like "LiveCounter.value"; it will be interpolated into an error message case objectsOperationFailedInvalidChannelState(operationDescription: String, channelState: _AblyPluginSupportPrivate.RealtimeChannelState) @@ -104,10 +105,12 @@ internal enum LiveObjectsError { /// We deliberately do not conform `ARTErrorInfo` (or its parent types `NSError` or `Error`) to this protocol, so that we do not accidentally end up flattening an `ARTErrorInfo` into the `.other` `LiveObjectsError` case; if we have an `ARTErrorInfo` then it should just be thrown directly. /// /// If you need to convert a non-specific `NSError` or `Error` to a `LiveObjects` error, then do so explicitly using `LiveObjectsError.other`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal protocol ConvertibleToLiveObjectsError { func toLiveObjectsError() -> LiveObjectsError } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension ConvertibleToLiveObjectsError { /// Convenience method to convert directly to an `ARTErrorInfo`. func toARTErrorInfo() -> ARTErrorInfo { @@ -117,42 +120,49 @@ internal extension ConvertibleToLiveObjectsError { // MARK: - Conversion Extensions +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension DecodingError: ConvertibleToLiveObjectsError { internal func toLiveObjectsError() -> LiveObjectsError { .other(self) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireValueDecodingError: ConvertibleToLiveObjectsError { internal func toLiveObjectsError() -> LiveObjectsError { .other(self) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireValue.ConversionError: ConvertibleToLiveObjectsError { internal func toLiveObjectsError() -> LiveObjectsError { .other(self) } } +@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 { .other(self) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension StringOrData.DecodingError: ConvertibleToLiveObjectsError { internal func toLiveObjectsError() -> LiveObjectsError { .other(self) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension JSONObjectOrArray.ConversionError: ConvertibleToLiveObjectsError { internal func toLiveObjectsError() -> LiveObjectsError { .other(self) @@ -162,8 +172,10 @@ extension JSONObjectOrArray.ConversionError: ConvertibleToLiveObjectsError { // MARK: - ARTErrorInfo Extension /// The `ARTErrorInfo.userInfo` key under which we store the underlying `LiveObjectsError`. Used by `testsOnly_underlyingLiveObjectsError`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) private let liveObjectsErrorUserInfoKey = "LiveObjectsError" +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension ARTErrorInfo { /// Retrieves the underlying `LiveObjectsError` from this `ARTErrorInfo` if it was generated from a `LiveObjectsError`. /// diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift index 1f1d133d9..a02f02256 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift @@ -1,6 +1,7 @@ import Foundation /// Like ``JSONValue``, but provides a flexible `number` case and an additional case named `extra`, which allows you to support additional types of data. It's used as a common base for the implementations of ``JSONValue`` and ``WireValue``, and for converting between them. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal indirect enum ExtendedJSONValue { case object([String: Self]) case array([Self]) @@ -13,6 +14,7 @@ internal indirect enum ExtendedJSONValue { // MARK: - Bridging with Foundation +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension ExtendedJSONValue { /// Creates an `ExtendedJSONValue` from an object. /// @@ -66,6 +68,7 @@ internal extension ExtendedJSONValue { // MARK: - Transforming the extra data +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension ExtendedJSONValue { /// Converts this `ExtendedJSONValue` to an `ExtendedJSONValue` using given transformations. func map(number transformNumber: @escaping (Number) throws(Failure) -> NewNumber, extra transformExtra: @escaping (Extra) throws(Failure) -> NewExtra) throws(Failure) -> ExtendedJSONValue { diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/JSONValue.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/JSONValue.swift index 02a71c907..e6b85b260 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -25,6 +25,7 @@ import Foundation /// ``` /// /// > Note: To write a `JSONValue` that corresponds to the `null` JSON value, you must explicitly write `.null`. `JSONValue` deliberately does not implement the `ExpressibleByNilLiteral` protocol in order to avoid confusion between a value of type `JSONValue?` and a `JSONValue` with case `.null`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public indirect enum JSONValue: Sendable, Equatable { case object([String: JSONValue]) case array([JSONValue]) @@ -90,36 +91,42 @@ public indirect enum JSONValue: Sendable, Equatable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension JSONValue: ExpressibleByDictionaryLiteral { public init(dictionaryLiteral elements: (String, JSONValue)...) { self = .object(.init(uniqueKeysWithValues: elements)) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension JSONValue: ExpressibleByArrayLiteral { public init(arrayLiteral elements: JSONValue...) { self = .array(elements) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension JSONValue: ExpressibleByStringLiteral { public init(stringLiteral value: String) { self = .string(value) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension JSONValue: ExpressibleByIntegerLiteral { public init(integerLiteral value: Int) { self = .number(Double(value)) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension JSONValue: ExpressibleByFloatLiteral { public init(floatLiteral value: Double) { self = .number(value) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension JSONValue: ExpressibleByBooleanLiteral { public init(booleanLiteral value: Bool) { self = .bool(value) @@ -128,6 +135,7 @@ extension JSONValue: ExpressibleByBooleanLiteral { // MARK: - Bridging with JSONSerialization +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension JSONValue { /// Creates a `JSONValue` from the output of Foundation's `JSONSerialization`. /// @@ -158,6 +166,7 @@ internal extension JSONValue { // MARK: - JSON objects and arrays /// A subset of ``JSONValue`` that has only `object` or `array` cases. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum JSONObjectOrArray: Equatable { case object([String: JSONValue]) case array([JSONValue]) @@ -196,18 +205,21 @@ internal enum JSONObjectOrArray: Equatable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension JSONObjectOrArray: ExpressibleByDictionaryLiteral { internal init(dictionaryLiteral elements: (String, JSONValue)...) { self = .object(.init(uniqueKeysWithValues: elements)) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension JSONObjectOrArray: ExpressibleByArrayLiteral { internal init(arrayLiteral elements: JSONValue...) { self = .array(elements) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension [String: JSONValue] { /// Converts a dictionary that has string keys and `JSONValue` values into an input for Foundation's `JSONSerialization`. var toJSONSerializationInput: [String: Any] { @@ -215,6 +227,7 @@ internal extension [String: JSONValue] { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension [JSONValue] { /// Converts an array that has `JSONValue` values into an input for Foundation's `JSONSerialization`. var toJSONSerializationInput: [Any] { @@ -224,6 +237,7 @@ internal extension [JSONValue] { // MARK: - Conversion to/from ExtendedJSONValue +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension JSONValue { init(extendedJSONValue: ExtendedJSONValue) { switch extendedJSONValue { @@ -262,6 +276,7 @@ internal extension JSONValue { // MARK: Serializing to and deserializing from a JSON string +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension JSONObjectOrArray { enum DecodingError: Swift.Error { case incompatibleJSONValue(JSONValue) diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/Logger.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/Logger.swift index 90cb7d49a..64ba0e210 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/Logger.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/Logger.swift @@ -1,6 +1,7 @@ internal import _AblyPluginSupportPrivate /// A reference to a line within a source code file. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct CodeLocation: Equatable { /// A file identifier in the format used by Swift's `#fileID` macro. For example, `"AblyChat/Room.swift"`. internal var fileID: String @@ -8,10 +9,12 @@ internal struct CodeLocation: Equatable { internal var line: Int } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal protocol Logger: Sendable { func log(_ message: String, level: _AblyPluginSupportPrivate.LogLevel, codeLocation: CodeLocation) } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension AblyLiveObjects.Logger { /// A convenience method that provides default values for `file` and `line`. func log(_ message: String, level: _AblyPluginSupportPrivate.LogLevel, fileID: String = #fileID, line: Int = #line) { @@ -20,6 +23,7 @@ internal extension AblyLiveObjects.Logger { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class DefaultLogger: Logger { private let pluginLogger: _AblyPluginSupportPrivate.Logger private let pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift index c1939abdc..6e9f62276 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift @@ -1,5 +1,6 @@ import Foundation +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum LoggingUtilities { /// Formats an array of object messages for logging with one message per line. /// - Parameter objectMessages: The array of object messages to format diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift index 28bf619c3..b429d331c 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift @@ -2,6 +2,7 @@ internal import _AblyPluginSupportPrivate import Ably /// Upcasts an instance of an `_AblyPluginSupportPrivate` marker protocol to the concrete type that this marker protocol represents. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal func castPluginPublicMarkerProtocolValue(_ pluginMarkerProtocolValue: Any, to _: T.Type) -> T { guard let actualPublicValue = pluginMarkerProtocolValue as? T else { preconditionFailure("Expected \(T.self), got \(type(of: pluginMarkerProtocolValue))") @@ -10,6 +11,7 @@ internal func castPluginPublicMarkerProtocolValue(_ pluginMarkerProtocolValue return actualPublicValue } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension ARTRealtimeChannel { /// Downcasts this `ARTRealtimeChannel` to its `_AblyPluginSupportPrivate` equivalent type `PublicRealtimeChannel`. /// @@ -21,6 +23,7 @@ internal extension ARTRealtimeChannel { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension ARTClientOptions { /// Downcasts this `ARTClientOptions` to its `_AblyPluginSupportPrivate` marker protocol type `PublicClientOptions`. /// @@ -37,6 +40,7 @@ internal extension ARTClientOptions { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension ARTErrorInfo { /// Downcasts this `ARTErrorInfo` to its `_AblyPluginSupportPrivate` marker protocol type `PublicErrorInfo`. /// diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift index 0a9540aa1..1d05472b3 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift @@ -1,5 +1,6 @@ import Foundation +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension NSLock { /// Behaves like `NSLock.withLock`, but the thrown error has the same type as that thrown by the body. (`withLock` uses `rethrows`, which is always an untyped throw.) func ablyLiveObjects_withLockWithTypedThrow(_ body: () throws(E) -> R) throws(E) -> R { diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/WeakRef.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/WeakRef.swift index c7175a7ee..edfa842f5 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/WeakRef.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/WeakRef.swift @@ -1,8 +1,10 @@ /// A struct that holds a weak reference to an object. /// /// This allows us to store a weak reference inside a Sendable object. The pattern comes from the [`weak let` proposal](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0481-weak-let.md). (We can get rid of this type and use `weak let` once Swift 6.2 is out.) +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WeakRef { internal weak var referenced: Referenced? } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WeakRef: Sendable where Referenced: Sendable {} diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/WireCodable.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/WireCodable.swift index b68485d18..6138fea51 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/WireCodable.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/WireCodable.swift @@ -1,31 +1,38 @@ import Ably import Foundation +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal protocol WireEncodable { var toWireValue: WireValue { get } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal protocol WireDecodable { init(wireValue: WireValue) throws(ARTErrorInfo) } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal typealias WireCodable = WireDecodable & WireEncodable +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal protocol WireObjectEncodable: WireEncodable { var toWireObject: [String: WireValue] { get } } // Default implementation of `WireEncodable` conformance for `WireObjectEncodable` +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension WireObjectEncodable { var toWireValue: WireValue { .object(toWireObject) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal protocol WireObjectDecodable: WireDecodable { init(wireObject: [String: WireValue]) throws(ARTErrorInfo) } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum WireValueDecodingError: Error { case valueIsNotObject case noValueForKey(String) @@ -34,6 +41,7 @@ internal enum WireValueDecodingError: Error { } // Default implementation of `WireDecodable` conformance for `WireObjectDecodable` +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension WireObjectDecodable { init(wireValue: WireValue) throws(ARTErrorInfo) { guard case let .object(wireObject) = wireValue else { @@ -44,11 +52,13 @@ internal extension WireObjectDecodable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal typealias WireObjectCodable = WireObjectDecodable & WireObjectEncodable // MARK: - Extracting primitive values from a dictionary /// This extension adds some helper methods for extracting values from a dictionary of `WireValue` values; you may find them helpful when implementing `WireCodable`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension [String: WireValue] { /// If this dictionary contains a value for `key`, and this value has case `object`, this returns the associated value. /// @@ -269,6 +279,7 @@ internal extension [String: WireValue] { // MARK: - Extracting dates from a dictionary +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension [String: WireValue] { /// If this dictionary contains a value for `key`, and this value has case `number`, this returns a date created by interpreting this value as the number of milliseconds since the Unix epoch (which is the format used by Ably). /// @@ -298,6 +309,7 @@ internal extension [String: WireValue] { // MARK: - Extracting RawRepresentable values from a dictionary +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension [String: WireValue] { /// If this dictionary contains a value for `key`, and this value has case `string`, this creates an instance of `T` using its `init(rawValue:)` initializer. /// @@ -335,6 +347,7 @@ internal extension [String: WireValue] { // MARK: - Extracting WireEnum values from a dictionary +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension [String: WireValue] { /// If this dictionary contains a value for `key`, and this value has case `number`, this creates a `WireEnum` instance using its `init(rawValue:)` initializer. /// @@ -359,6 +372,7 @@ internal extension [String: WireValue] { // MARK: - Extracting WireDecodable values from a dictionary +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension [String: WireValue] { /// If this dictionary contains a value for `key`, this attempts to decode it into an instance of `T` using its `init(wireValue:)` initializer. /// diff --git a/LiveObjects/Sources/AblyLiveObjects/Utility/WireValue.swift b/LiveObjects/Sources/AblyLiveObjects/Utility/WireValue.swift index c8b75f28b..c5d6b9e34 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Utility/WireValue.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Utility/WireValue.swift @@ -4,6 +4,7 @@ import Foundation /// A wire value that can be represents the kinds of data that we expect to find inside a deserialized wire object received from `_AblyPluginSupportPrivate`, or which we may put inside a serialized wire object that we send to `_AblyPluginSupportPrivate`. /// /// Its cases are a superset of those of ``JSONValue``, adding a further `data` case for binary data (we expect to be able to send and receive binary data in the case where ably-cocoa is using the MessagePack format). Also, its `number` case is `NSNumber` instead of `Double`, to allow us to communicate to ably-cocoa's MessagePack encoder that it should encode certain values (e.g. enums) as integers, not doubles. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal indirect enum WireValue: Sendable, Equatable { case object([String: WireValue]) case array([WireValue]) @@ -79,36 +80,42 @@ internal indirect enum WireValue: Sendable, Equatable { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireValue: ExpressibleByDictionaryLiteral { internal init(dictionaryLiteral elements: (String, WireValue)...) { self = .object(.init(uniqueKeysWithValues: elements)) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireValue: ExpressibleByArrayLiteral { internal init(arrayLiteral elements: WireValue...) { self = .array(elements) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireValue: ExpressibleByStringLiteral { internal init(stringLiteral value: String) { self = .string(value) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireValue: ExpressibleByIntegerLiteral { internal init(integerLiteral value: Int) { self = .number(value as NSNumber) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireValue: ExpressibleByFloatLiteral { internal init(floatLiteral value: Double) { self = .number(value as NSNumber) } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) extension WireValue: ExpressibleByBooleanLiteral { internal init(booleanLiteral value: Bool) { self = .bool(value) @@ -117,6 +124,7 @@ extension WireValue: ExpressibleByBooleanLiteral { // MARK: - Bridging with ably-cocoa +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension WireValue { /// Creates a `WireValue` from an `_AblyPluginSupportPrivate` deserialized wire object. /// @@ -158,6 +166,7 @@ internal extension WireValue { } } +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension [String: WireValue] { /// Creates an `_AblyPluginSupportPrivate` deserialized wire object from a dictionary that has string keys and `WireValue` values. /// @@ -169,6 +178,7 @@ internal extension [String: WireValue] { // MARK: - Conversion to/from ExtendedJSONValue +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension WireValue { enum ExtraValue { case data(Data) @@ -218,6 +228,7 @@ internal extension WireValue { // MARK: - Conversion to/from JSONValue +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension WireValue { /// Converts a `JSONValue` to its corresponding `WireValue`. init(jsonValue: JSONValue) { diff --git a/Scripts/annotate-liveobjects-availability.py b/Scripts/annotate-liveobjects-availability.py new file mode 100755 index 000000000..6e91297f4 --- /dev/null +++ b/Scripts/annotate-liveobjects-availability.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +"""Inserts an @available annotation above every top-level declaration in the +LiveObjects sources that does not already have one. + +The ably-cocoa package's declared platform versions are far lower than those +that LiveObjects supports, and SwiftPM has no per-target platform settings, so +availability annotations are how the AblyLiveObjects target declares its true +platform requirements within this package. + +The script is idempotent: running it on an already-annotated tree makes no +changes. CI runs it and fails if it produces a diff, which ensures that new +top-level declarations carry the annotation (the compiler independently +enforces this for public declarations via -require-explicit-availability, and +for any declaration that uses newer-than-baseline API). + +Usage: Scripts/annotate-liveobjects-availability.py [dir] +(dir defaults to LiveObjects/Sources/AblyLiveObjects, relative to the repo +root, which is assumed to be the working directory) +""" + +import re +import sys +from pathlib import Path + +ANNOTATION = "@available(macOS 11.0, iOS 14.0, tvOS 14.0, *)" + +# A top-level declaration: optional modifiers/attributes on the same line, +# then a declaration-introducing keyword, starting at column 0. +DECL_RE = re.compile( + r"^(?:(?:public|internal|private|fileprivate|package|open|final|indirect|" + r"nonisolated(?:\(unsafe\))?|prefix|postfix|infix|" + r"@\w+(?:\([^)]*\))?)\s+)*" + r"(?:class|struct|enum|protocol|extension|actor|typealias|func|var|let)\b" +) +# An attribute occupying its own line at column 0 (e.g. @MainActor), which +# belongs to the declaration that follows it. +ATTR_RE = re.compile(r"^@\w+(?:\([^)]*\))?\s*$") + + +def process(path: Path) -> int: + lines = path.read_text().splitlines(keepends=True) + out = [] + insertions = 0 + for line in lines: + if DECL_RE.match(line): + # Walk back over any attribute lines already emitted, so the + # annotation goes above the declaration's whole attribute block. + attr_start = len(out) + while attr_start > 0 and ATTR_RE.match(out[attr_start - 1]): + attr_start -= 1 + block = "".join(out[attr_start:]) + if "@available" not in block: + out.insert(attr_start, ANNOTATION + "\n") + insertions += 1 + out.append(line) + if insertions: + path.write_text("".join(out)) + return insertions + + +def main() -> None: + root = Path(sys.argv[1] if len(sys.argv) > 1 else "LiveObjects/Sources/AblyLiveObjects") + total = 0 + for path in sorted(root.rglob("*.swift")): + count = process(path) + total += count + if count: + print(f"{path}: {count}") + print(f"TOTAL: {total}") + + +if __name__ == "__main__": + main() From 468796e27c78382d8790fbf6044b0f7d96c94beb Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 9 Jul 2026 23:15:16 -0300 Subject: [PATCH 12/21] Load the sandbox fixture from the root ably-common submodule The plugin repo had its own ably-common submodule, used for exactly one thing: the sandbox test helper reads test-app-setup.json from it when creating an Ably sandbox app for the integration tests. This repo already has an ably-common submodule (at Test/AblyTests/ably-common, used by AblyTests), and we do not want two. The helper now resolves the file via #filePath relative to the repo layout, instead of a bundle resource, because: - SwiftPM requires a target's resources to live inside the target's directory, and the submodule lives in another target's tree. - Symlinking it into this target's tree does not work: SwiftPM copies the symlink itself into the resource bundle, where it dangles. Note that the two repos' submodules pointed at different ably-common commits; this repo's pin is a descendant of the plugin's, so this is a fast-forward for the fixture. Co-Authored-By: Claude Fable 5 --- .../AblyLiveObjectsTests/Helpers/Sandbox.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift index d88edf51e..477941085 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift @@ -15,11 +15,15 @@ enum Sandbox { } private static func loadAppCreationRequestBody() async throws -> Data { - let testAppSetupFileURL = Bundle.module.url( - forResource: "test-app-setup", - withExtension: "json", - subdirectory: "ably-common/test-resources", - )! + // Resolved relative to this source file, which lives at + // LiveObjects/Tests/AblyLiveObjectsTests/Helpers/ in the ably-cocoa repo. + let testAppSetupFileURL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Helpers + .deletingLastPathComponent() // AblyLiveObjectsTests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // LiveObjects + .deletingLastPathComponent() // repo root + .appendingPathComponent("Test/AblyTests/ably-common/test-resources/test-app-setup.json") let (data, _) = try await URLSession.shared.data(for: .init(url: testAppSetupFileURL)) // swiftlint:disable:next force_cast From aff0b19edec8327a03ca27c1efaebf29c835fe0f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 9 Jul 2026 23:16:16 -0300 Subject: [PATCH 13/21] Add the AblyLiveObjects product to the package Adds the AblyLiveObjects target (the sources imported from the ably-liveobjects-swift-plugin repo), its test target, and a library product, to the root manifest. The target paths point into LiveObjects/. Users of the standalone plugin migrate by replacing their ably-liveobjects-swift-plugin package dependency with this package's AblyLiveObjects product; the module name and API are unchanged. The test target has no resources entry: the fixture that was a bundle resource in the plugin repo is loaded from the repo tree instead (see previous commit). Package.resolved is unchanged, since the LiveObjects library brings no new dependencies (its BuildTool development tool, which does have dependencies, is deliberately a separate nested package). Co-Authored-By: Claude Fable 5 --- Package.swift | 29 +++++++++++++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/Package.swift b/Package.swift index f83942dbf..0cfeae98e 100644 --- a/Package.swift +++ b/Package.swift @@ -14,6 +14,10 @@ let package = Package( name: "Ably", targets: ["Ably"] ), + .library( + name: "AblyLiveObjects", + targets: ["AblyLiveObjects"] + ), ], dependencies: [ .package(url: "https://github.com/rvi/msgpack-objective-C", from: "0.4.0"), @@ -21,6 +25,31 @@ let package = Package( .package(url: "https://github.com/quick/nimble", from: "11.2.2") ], targets: [ + // The LiveObjects plugin. Formerly the separate + // ably-liveobjects-swift-plugin repository. Unlike the rest of the + // package it requires macOS 11 / iOS 14 / tvOS 14, which it declares + // through @available annotations on all of its top-level declarations + // (see Scripts/annotate-liveobjects-availability.py). + .target( + name: "AblyLiveObjects", + dependencies: [ + .target(name: "Ably"), + .target(name: "_AblyPluginSupportPrivate"), + ], + path: "LiveObjects/Sources/AblyLiveObjects" + ), + .testTarget( + name: "AblyLiveObjectsTests", + dependencies: [ + .target(name: "AblyLiveObjects"), + .target(name: "Ably"), + .target(name: "_AblyPluginSupportPrivate"), + ], + path: "LiveObjects/Tests/AblyLiveObjectsTests", + exclude: [ + "CLAUDE.md" + ] + ), // Private API of the core SDK, exposed to Ably-authored plugins. Formerly // the separate ably-cocoa-plugin-support repository; deliberately not // vended as a product. From 11c38025434d02fbd0f45028abb4d13871a7c5c7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 9 Jul 2026 23:16:55 -0300 Subject: [PATCH 14/21] Repoint the LiveObjects workspace and scheme at this package MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin's Xcode workspace and shared scheme assumed the package manifest lived at the plugin repo root; the AblyLiveObjects targets are now part of the ably-cocoa package at this repo's root. So: - AblyLiveObjects.xcworkspace's package reference changes from "group:" to "group:..". - The shared AblyLiveObjects scheme (which carries the test action and its test-plan associations, used by BuildTool's xcodebuild invocations) moves to this repo's .swiftpm scheme directory — Xcode only discovers package schemes at the package root — with its test-plan references updated to container:LiveObjects/TestPlans/. - The AblyLiveObjects-Package scheme and the standalone package's workspace settings are removed as obsolete (the equivalent whole-package scheme now belongs to ably-cocoa). Co-Authored-By: Claude Fable 5 --- .../xcschemes/AblyLiveObjects.xcscheme | 4 +- .../xcshareddata/WorkspaceSettings.xcsettings | 8 -- .../AblyLiveObjects-Package.xcscheme | 110 ------------------ .../contents.xcworkspacedata | 2 +- 4 files changed, 3 insertions(+), 121 deletions(-) rename {LiveObjects/.swiftpm => .swiftpm}/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme (94%) delete mode 100644 LiveObjects/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings delete mode 100644 LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme diff --git a/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme similarity index 94% rename from LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme rename to .swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme index 007419881..b80319399 100644 --- a/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme +++ b/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme @@ -30,11 +30,11 @@ shouldUseLaunchSchemeArgsEnv = "YES"> + reference = "container:LiveObjects/TestPlans/UnitTests.xctestplan"> diff --git a/LiveObjects/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/LiveObjects/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings deleted file mode 100644 index 54782e32f..000000000 --- a/LiveObjects/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded - - - diff --git a/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme b/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme deleted file mode 100644 index 5c088be7a..000000000 --- a/LiveObjects/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme +++ /dev/null @@ -1,110 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/LiveObjects/AblyLiveObjects.xcworkspace/contents.xcworkspacedata b/LiveObjects/AblyLiveObjects.xcworkspace/contents.xcworkspacedata index 82a77443a..787fe8f7f 100644 --- a/LiveObjects/AblyLiveObjects.xcworkspace/contents.xcworkspacedata +++ b/LiveObjects/AblyLiveObjects.xcworkspace/contents.xcworkspacedata @@ -5,7 +5,7 @@ location = "group:TestPlans/UnitTests.xctestplan"> + location = "group:.."> From 41d0efcc924572072cb47527c0fdddee3caab852 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 10 Jul 2026 13:09:50 -0300 Subject: [PATCH 15/21] Remove the example app's unused AsyncAlgorithms dependency MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The example app project declared a remote package dependency on swift-async-algorithms, but nothing in its code imports it. In the plugin repo this was harmless: BuildTool (a target of the plugin package) also depended on swift-async-algorithms, so it was part of the package's dependency graph anyway. In this repo, where BuildTool is a separate nested package, the example app's reference would be the only thing pulling swift-async-algorithms into the LiveObjects workspace's resolution, making that resolution a superset of the root package's dependency graph — which both defeats the lockfile consistency check that a later commit preserves, and causes Xcode to transiently rewrite the root Package.resolved with the extra pins. Also removes the example project's own workspace-scoped Package.resolved, whose only pins were this removed dependency and its transitive dependency. Verified by resolving the LiveObjects workspace (its resolution now matches the root package's dependency graph exactly) and building the example app for macOS via BuildTool. Co-Authored-By: Claude Fable 5 --- .../project.pbxproj | 20 ---------------- .../xcshareddata/swiftpm/Package.resolved | 24 ------------------- 2 files changed, 44 deletions(-) delete mode 100644 LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj b/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj index 097bba4c5..c2ca08b9f 100644 --- a/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj +++ b/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj @@ -8,7 +8,6 @@ /* Begin PBXBuildFile section */ 21971DFF2C60D89C0074B8AE /* AblyLiveObjects in Frameworks */ = {isa = PBXBuildFile; productRef = 21971DFE2C60D89C0074B8AE /* AblyLiveObjects */; }; - 8436FC692CA0723C0013EDE5 /* AsyncAlgorithms in Frameworks */ = {isa = PBXBuildFile; productRef = 8436FC682CA0723C0013EDE5 /* AsyncAlgorithms */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -25,7 +24,6 @@ buildActionMask = 2147483647; files = ( 21971DFF2C60D89C0074B8AE /* AblyLiveObjects in Frameworks */, - 8436FC692CA0723C0013EDE5 /* AsyncAlgorithms in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -77,7 +75,6 @@ name = AblyLiveObjectsExample; packageProductDependencies = ( 21971DFE2C60D89C0074B8AE /* AblyLiveObjects */, - 8436FC682CA0723C0013EDE5 /* AsyncAlgorithms */, ); productName = AblyLiveObjectsExample; productReference = 21F09A9C2C60CAF00025AF73 /* AblyLiveObjectsExample.app */; @@ -108,7 +105,6 @@ ); mainGroup = 21F09A932C60CAF00025AF73; packageReferences = ( - 8436FC672CA0723C0013EDE5 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */, ); productRefGroup = 21F09A9D2C60CAF00025AF73 /* Products */; projectDirPath = ""; @@ -367,27 +363,11 @@ }; /* End XCConfigurationList section */ -/* Begin XCRemoteSwiftPackageReference section */ - 8436FC672CA0723C0013EDE5 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */ = { - isa = XCRemoteSwiftPackageReference; - repositoryURL = "https://github.com/apple/swift-async-algorithms.git"; - requirement = { - kind = upToNextMajorVersion; - minimumVersion = 1.0.1; - }; - }; -/* End XCRemoteSwiftPackageReference section */ - /* Begin XCSwiftPackageProductDependency section */ 21971DFE2C60D89C0074B8AE /* AblyLiveObjects */ = { isa = XCSwiftPackageProductDependency; productName = AblyLiveObjects; }; - 8436FC682CA0723C0013EDE5 /* AsyncAlgorithms */ = { - isa = XCSwiftPackageProductDependency; - package = 8436FC672CA0723C0013EDE5 /* XCRemoteSwiftPackageReference "swift-async-algorithms" */; - productName = AsyncAlgorithms; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 21F09A942C60CAF00025AF73 /* Project object */; diff --git a/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index 05de7ab26..000000000 --- a/LiveObjects/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,24 +0,0 @@ -{ - "originHash" : "13678df8bf756b5a26255707c7a7a2f70f4fa09e8466f4b4d0ca97436222d7d0", - "pins" : [ - { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms.git", - "state" : { - "revision" : "042e1c4d9d19748c9c228f8d4ebc97bb1e339b0b", - "version" : "1.0.4" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "c1805596154bb3a265fd91b8ac0c4433b4348fb0", - "version" : "1.2.0" - } - } - ], - "version" : 3 -} From dae2fa447bef8680a2ea19c60b55a12b3e0da5dd Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 10 Jul 2026 13:13:19 -0300 Subject: [PATCH 16/21] Fix BuildTool and lint path assumptions for the new layout BuildTool and the lint configuration assumed the LiveObjects directory was a repo/package root. It is still the working directory that BuildTool must be run from, but some of the things it deals with now live elsewhere: - Lint's swift-tools-version consistency check now reads the root ../Package.swift (which is what governs compilation of the LiveObjects sources); .swift-version stays in LiveObjects/ because SwiftFormat reads it from there. - Lint's lockfile comparison now compares the Xcode-managed workspace lockfile against the root ../Package.resolved. To make the two comparable (and to stop SwiftPM and Xcode disagreeing about the file's representation), the root Package.resolved is rewritten in the current lockfile format (version 3) with unchanged pins, and the workspace copy imported from the plugin repo is replaced with the Xcode-written equivalent for this package's dependency graph. - SwiftLint and Prettier now exclude BuildTool/.build, the nested BuildTool package's build directory, which contains dependency checkouts (previously the build directory was .build, which the configs already exclude). - The Secrets.swift file that BuildTool generates for example-app CI builds now ends with a newline, without which SwiftFormat fails a subsequent local lint run. Verified by running every lint step and BuildTool's generate-matrices, test-library and build-example-app subcommands against this layout. Co-Authored-By: Claude Fable 5 --- LiveObjects/.prettierignore | 1 + LiveObjects/.swiftlint.yml | 1 + .../xcshareddata/swiftpm/Package.resolved | 71 +++----------- LiveObjects/BuildTool/Sources/BuildTool.swift | 10 +- Package.resolved | 97 +++++++++---------- 5 files changed, 71 insertions(+), 109 deletions(-) diff --git a/LiveObjects/.prettierignore b/LiveObjects/.prettierignore index e2e73d508..7608a9f7b 100644 --- a/LiveObjects/.prettierignore +++ b/LiveObjects/.prettierignore @@ -4,3 +4,4 @@ # Submodules Tests/AblyLiveObjectsTests/ably-common ably-cocoa +BuildTool/.build diff --git a/LiveObjects/.swiftlint.yml b/LiveObjects/.swiftlint.yml index f6d10a1e2..5ad767540 100644 --- a/LiveObjects/.swiftlint.yml +++ b/LiveObjects/.swiftlint.yml @@ -1,5 +1,6 @@ excluded: - .build + - BuildTool/.build # Submodules - ably-cocoa diff --git a/LiveObjects/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/LiveObjects/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index a45cc6840..7a4d58ec4 100644 --- a/LiveObjects/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/LiveObjects/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,22 +1,22 @@ { - "originHash" : "882ea3086372221060f5fc4b93d0254adddc2a879699f35a91adee80d3ba15ce", + "originHash" : "3fe06eedbf682bdc826aca06f1db51f421aac8857952f5aec4c2ff021c6f8c44", "pins" : [ { - "identity" : "ably-cocoa", + "identity" : "cwlcatchexception", "kind" : "remoteSourceControl", - "location" : "https://github.com/ably/ably-cocoa.git", + "location" : "https://github.com/mattgallagher/CwlCatchException.git", "state" : { - "revision" : "83bb5366b09408723446d8ba8a146938eeba829e", - "version" : "1.2.59" + "revision" : "07b2ba21d361c223e25e3c1e924288742923f08c", + "version" : "2.2.1" } }, { - "identity" : "ably-cocoa-plugin-support", + "identity" : "cwlpreconditiontesting", "kind" : "remoteSourceControl", - "location" : "https://github.com/ably/ably-cocoa-plugin-support", + "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", "state" : { - "revision" : "a290b8942086ffb6e21e4805d9319143669d9414", - "version" : "2.0.0" + "revision" : "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071", + "version" : "2.2.2" } }, { @@ -38,57 +38,12 @@ } }, { - "identity" : "swift-argument-parser", + "identity" : "nimble", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", + "location" : "https://github.com/quick/nimble", "state" : { - "revision" : "41982a3656a71c768319979febd796c6fd111d5c", - "version" : "1.5.0" - } - }, - { - "identity" : "swift-async-algorithms", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-async-algorithms", - "state" : { - "revision" : "6ae9a051f76b81cc668305ceed5b0e0a7fd93d20", - "version" : "1.0.1" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "3d2dc41a01f9e49d84f0a3925fb858bed64f702d", - "version" : "1.1.2" - } - }, - { - "identity" : "swift-docc-plugin", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-plugin", - "state" : { - "revision" : "85e4bb4e1cd62cec64a4b8e769dcefdf0c5b9d64", - "version" : "1.4.3" - } - }, - { - "identity" : "swift-docc-symbolkit", - "kind" : "remoteSourceControl", - "location" : "https://github.com/swiftlang/swift-docc-symbolkit", - "state" : { - "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", - "version" : "1.0.0" - } - }, - { - "identity" : "table", - "kind" : "remoteSourceControl", - "location" : "https://github.com/JanGorman/Table.git", - "state" : { - "revision" : "7b8521c3b1078ba0b347964aec7c503cd6f1ff40", - "version" : "1.1.1" + "revision" : "eb5e3d717224fa0d1f6aff3fc2c5e8e81fa1f728", + "version" : "11.2.2" } } ], diff --git a/LiveObjects/BuildTool/Sources/BuildTool.swift b/LiveObjects/BuildTool/Sources/BuildTool.swift index 8e9fbb858..94ce11d51 100644 --- a/LiveObjects/BuildTool/Sources/BuildTool.swift +++ b/LiveObjects/BuildTool/Sources/BuildTool.swift @@ -119,6 +119,7 @@ struct BuildExampleApp: AsyncParsableCommand { // Insert your Ably API key inside the double quotes below. static let ablyAPIKey = "" } + """ let data = secretsFileContents.data(using: .utf8)! @@ -216,8 +217,11 @@ struct Lint: AsyncParsableCommand { /// Checks that the Swift version specified by the `Package.swift`'s `"swift-tools-version"` matches that in the `.swift-version` file (which is used to tell SwiftFormat the minimum version of Swift supported by our code). Per [SwiftFormat#1496](https://github.com/nicklockwood/SwiftFormat/issues/1496) it's currently our responsibility to make sure they're kept in sync./// func checkSwiftVersionFile() async throws { + // The manifest that governs the compilation of the LiveObjects sources + // is the root ably-cocoa one (BuildTool is run with the LiveObjects + // directory as the working directory). async let swiftVersionFileContents = loadUTF8StringFromFile(at: ".swift-version") - async let packageManifestFileContents = loadUTF8StringFromFile(at: "Package.swift") + async let packageManifestFileContents = loadUTF8StringFromFile(at: "../Package.swift") guard let swiftVersionFileMatch = try await /^(\d+\.\d+)\n$/.firstMatch(in: swiftVersionFileContents) else { throw Error.malformedSwiftVersionFile @@ -243,7 +247,9 @@ struct Lint: AsyncParsableCommand { /// /// Ignores the `originHash` property of the Package.resolved file, because this property seems to frequently be different between the SPM version and the Xcode version, and I don't know enough about SPM to know what this property means or whether there's a reproducible way to get them to match. func comparePackageLockfiles() async throws { - let lockfilePaths = ["Package.resolved", "AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved"] + // The SPM-managed lockfile is the root ably-cocoa one (BuildTool is run + // with the LiveObjects directory as the working directory). + let lockfilePaths = ["../Package.resolved", "AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved"] let lockfileContents = try await withThrowingTaskGroup(of: Data.self) { group in for lockfilePath in lockfilePaths { group.addTask { diff --git a/Package.resolved b/Package.resolved index fec99d1ef..7a4d58ec4 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,52 +1,51 @@ { - "object": { - "pins": [ - { - "package": "CwlCatchException", - "repositoryURL": "https://github.com/mattgallagher/CwlCatchException.git", - "state": { - "branch": null, - "revision": "07b2ba21d361c223e25e3c1e924288742923f08c", - "version": "2.2.1" - } - }, - { - "package": "CwlPreconditionTesting", - "repositoryURL": "https://github.com/mattgallagher/CwlPreconditionTesting.git", - "state": { - "branch": null, - "revision": "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071", - "version": "2.2.2" - } - }, - { - "package": "AblyDeltaCodec", - "repositoryURL": "https://github.com/ably/delta-codec-cocoa", - "state": { - "branch": null, - "revision": "d53eec08f9443c6160d941327a6f9d8bbb93cea2", - "version": "1.3.5" - } - }, - { - "package": "msgpack", - "repositoryURL": "https://github.com/rvi/msgpack-objective-C", - "state": { - "branch": null, - "revision": "3e36b48e04ecd756cb927bd5f5b9bf6d45e475f9", - "version": "0.4.0" - } - }, - { - "package": "Nimble", - "repositoryURL": "https://github.com/quick/nimble", - "state": { - "branch": null, - "revision": "eb5e3d717224fa0d1f6aff3fc2c5e8e81fa1f728", - "version": "11.2.2" - } + "originHash" : "3fe06eedbf682bdc826aca06f1db51f421aac8857952f5aec4c2ff021c6f8c44", + "pins" : [ + { + "identity" : "cwlcatchexception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattgallagher/CwlCatchException.git", + "state" : { + "revision" : "07b2ba21d361c223e25e3c1e924288742923f08c", + "version" : "2.2.1" } - ] - }, - "version": 1 + }, + { + "identity" : "cwlpreconditiontesting", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", + "state" : { + "revision" : "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071", + "version" : "2.2.2" + } + }, + { + "identity" : "delta-codec-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/delta-codec-cocoa", + "state" : { + "revision" : "d53eec08f9443c6160d941327a6f9d8bbb93cea2", + "version" : "1.3.5" + } + }, + { + "identity" : "msgpack-objective-c", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rvi/msgpack-objective-C", + "state" : { + "revision" : "3e36b48e04ecd756cb927bd5f5b9bf6d45e475f9", + "version" : "0.4.0" + } + }, + { + "identity" : "nimble", + "kind" : "remoteSourceControl", + "location" : "https://github.com/quick/nimble", + "state" : { + "revision" : "eb5e3d717224fa0d1f6aff3fc2c5e8e81fa1f728", + "version" : "11.2.2" + } + } + ], + "version" : 3 } From 1e143077c2c24b095a4c01422438c8eefcaa9f88 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 10 Jul 2026 13:13:42 -0300 Subject: [PATCH 17/21] Raise deployment target of xcodebuild test builds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit xcodebuild compiles the AblyLiveObjectsTests target at (nearly) this package's declared platform floor: for iOS and tvOS destinations it raises the floor of 12 only as far as 13. The test code was written against the standalone plugin repo's floor of iOS/tvOS 14 — its xcodebuild test builds compiled at exactly that — and fails availability checking below it, since it freely uses the LiveObjects API, whose declarations are @available(iOS 14, tvOS 14, ...). The plugin repo never hit this, and `swift test` doesn't either (SwiftPM raises test targets' deployment target on macOS of its own accord), which is why it only surfaced in this repo's Xcode-based CI jobs. The test sources cannot simply be annotated the way the library sources are: the swift-testing @Suite macro rejects types marked @available ("Attribute 'Suite' cannot be applied to this structure because it has been marked '@available(...)'"). So instead, BuildTool now passes deployment-target build-setting overrides of iOS/tvOS 14 — restoring the regime the test code was written for — to the xcodebuild invocations that compile or run the tests. Command-line build settings do apply to Swift package targets. Test code that needs newer OS versions than this continues to declare it with @available on the affected declarations (e.g. Subscriber.swift and the tests that use it, whose parameter packs in generic types need iOS/tvOS 17); the library's declared availability is still checked at the package floor by the SPM-based CI jobs. There is deliberately no macOS override: xcodebuild raises macOS test-bundle builds well above the package floor of its own accord (the test host has to run on the build machine), and the test code relies on this — Subscriber.swift's @available annotation names only iOS and tvOS, so its use of parameter packs (which need macOS 14) is unguarded on macOS. Verified by building and running the unit tests via BuildTool for tvOS simulator and macOS destinations. Co-Authored-By: Claude Fable 5 --- LiveObjects/BuildTool/Sources/BuildTool.swift | 35 +++++++++++++++++-- .../BuildTool/Sources/XcodeRunner.swift | 6 +++- 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/LiveObjects/BuildTool/Sources/BuildTool.swift b/LiveObjects/BuildTool/Sources/BuildTool.swift index 94ce11d51..ff0bf9a23 100644 --- a/LiveObjects/BuildTool/Sources/BuildTool.swift +++ b/LiveObjects/BuildTool/Sources/BuildTool.swift @@ -3,6 +3,36 @@ import AsyncAlgorithms import Foundation import Table +/// Build settings passed to `xcodebuild` invocations that compile the test target. +/// +/// These raise the deployment target of the build to the OS versions that the +/// test code actually requires. Without this, Xcode builds the test target at +/// (nearly) the old deployment target declared by the ably-cocoa package's +/// platforms, and the test code fails availability checking. Unlike the +/// library sources — which declare their platform requirements with +/// `@available` annotations — the test sources cannot carry such annotations, +/// because the swift-testing macros do not support being applied to +/// declarations with availability. +/// +/// The values are the standalone plugin repo's package platform floor for +/// iOS and tvOS — the deployment target that the test code was written +/// against (the plugin's xcodebuild test builds compiled at exactly that +/// floor). Test code that needs newer OS versions than this declares it with +/// an @available annotation on the affected declarations, e.g. +/// `Subscriber.swift` and the tests that use it (parameter packs in generic +/// types need iOS 17 / tvOS 17). +/// +/// There is deliberately no macOS override: xcodebuild raises macOS +/// test-bundle builds well above this package's platform floor of its own +/// accord (the test host has to run on the build machine), and the plugin +/// repo's test code relied on this too (its macOS floor was 11, and e.g. +/// Subscriber.swift's use of parameter packs — which need macOS 14 — is +/// unguarded on macOS). +let testDeploymentTargetOverrides = [ + "IPHONEOS_DEPLOYMENT_TARGET": "14.0", + "TVOS_DEPLOYMENT_TARGET": "14.0", +] + @main @available(macOS 14, *) struct BuildTool: AsyncParsableCommand { @@ -51,7 +81,7 @@ struct BuildLibraryForTesting: AsyncParsableCommand { let destinationSpecifier = try await platform.resolve() let scheme = "AblyLiveObjects" - try await XcodeRunner.runXcodebuild(action: "build-for-testing", scheme: scheme, destination: destinationSpecifier) + try await XcodeRunner.runXcodebuild(action: "build-for-testing", scheme: scheme, destination: destinationSpecifier, buildSettingOverrides: testDeploymentTargetOverrides) } } @@ -73,7 +103,7 @@ struct TestLibrary: AsyncParsableCommand { let scheme = "AblyLiveObjects" let action = withoutBuilding ? "test-without-building" : "test" - try await XcodeRunner.runXcodebuild(action: action, scheme: scheme, destination: destinationSpecifier, testPlan: onlyUnitTests ? "UnitTests" : nil) + try await XcodeRunner.runXcodebuild(action: action, scheme: scheme, destination: destinationSpecifier, testPlan: onlyUnitTests ? "UnitTests" : nil, buildSettingOverrides: testDeploymentTargetOverrides) } } @@ -98,6 +128,7 @@ struct GenerateCodeCoverage: AsyncParsableCommand { destination: destinationSpecifier, testPlan: "UnitTests", resultBundlePath: resultBundlePath, + buildSettingOverrides: testDeploymentTargetOverrides, ) } } diff --git a/LiveObjects/BuildTool/Sources/XcodeRunner.swift b/LiveObjects/BuildTool/Sources/XcodeRunner.swift index 379fcb237..87e4b0e89 100644 --- a/LiveObjects/BuildTool/Sources/XcodeRunner.swift +++ b/LiveObjects/BuildTool/Sources/XcodeRunner.swift @@ -2,13 +2,17 @@ import Foundation @available(macOS 14, *) enum XcodeRunner { - static func runXcodebuild(action: String?, configuration: Configuration? = nil, scheme: String, destination: DestinationSpecifier, testPlan: String? = nil, resultBundlePath: String? = nil) async throws { + static func runXcodebuild(action: String?, configuration: Configuration? = nil, scheme: String, destination: DestinationSpecifier, testPlan: String? = nil, resultBundlePath: String? = nil, buildSettingOverrides: [String: String] = [:]) async throws { var arguments: [String] = [] if let action { arguments.append(action) } + for (key, value) in buildSettingOverrides.sorted(by: { $0.key < $1.key }) { + arguments.append("\(key)=\(value)") + } + if let configuration { arguments.append(contentsOf: ["-configuration", configuration.rawValue]) } From c24f0e77e3384a20b307bb65548349d8a6265ac7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 10 Jul 2026 13:13:59 -0300 Subject: [PATCH 18/21] Add CI workflow for LiveObjects MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ports the ably-liveobjects-swift-plugin repo's Check workflow as .github/workflows/liveobjects.yaml, with the following adaptations for its new home (the core SDK's existing workflows are untouched; the check-spm workflow now inherently also builds the AblyLiveObjects product): - Steps run with LiveObjects/ as the working directory, and BuildTool is invoked via `swift run --package-path BuildTool BuildTool`. (The all-checks-completed marker job, which runs on ubuntu-latest without a checkout, overrides this default.) - The SPM job's `swift test` is filtered to the AblyLiveObjectsTests module; the rest of the package's tests belong to the core SDK's own workflows (though `swift test` builds all of the package's test targets, so -warnings-as-errors covers their compilation here too — note that the warning set is a property of this job's Xcode version, and newer toolchains may warn about things this one does not). - The docs job uses `xcodebuild docbuild` plus `docc process-archive transform-for-static-hosting` instead of the swift-docc-plugin package plugin, because the plugin would have to be a dependency of the root manifest, where ably-cocoa's consumers would resolve it. Uploads use this repo's AWS role. - New check-availability-annotations job enforcing the @available annotations on the LiveObjects sources (see Scripts/annotate-liveobjects-availability.py): the compiler checks public declarations via -require-explicit-availability, and the annotation script (which is idempotent) checks all top-level declarations by requiring a clean diff after running it. (-require-explicit-availability must be spelled as a frontend flag; the driver flag of the same name is a no-op.) The plugin repo's commented-out spec-coverage job has not been ported; it can be revisited when spec coverage tooling is picked up again. Note for branch protection: the all-checks-completed marker job of this workflow should be added to the required status checks. Co-Authored-By: Claude Fable 5 --- .github/workflows/liveobjects.yaml | 394 +++++++++++++++++++++++++++++ 1 file changed, 394 insertions(+) create mode 100644 .github/workflows/liveobjects.yaml diff --git a/.github/workflows/liveobjects.yaml b/.github/workflows/liveobjects.yaml new file mode 100644 index 000000000..c72c7317d --- /dev/null +++ b/.github/workflows/liveobjects.yaml @@ -0,0 +1,394 @@ +# Checks for the LiveObjects plugin (the AblyLiveObjects product and the +# LiveObjects/ directory). Ported from the ably-liveobjects-swift-plugin +# repo's Check workflow when that repo was merged into this one; the core +# SDK's checks live in the other workflows in this directory. + +name: LiveObjects + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +permissions: {} + +defaults: + run: + working-directory: LiveObjects + +jobs: + lint: + runs-on: macos-15 + + permissions: + contents: read + + # From actions/cache documentation linked to below + env: + MINT_PATH: .mint/lib + MINT_LINK_PATH: .mint/bin + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + + # This step can be removed once the runners' default version of Xcode is 16.4 or above + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: 16.4 + + # We use caching for Mint because at the time of writing SwiftLint took about 5 minutes to build in CI, which is unacceptably slow. + # https://github.com/actions/cache/blob/40c3b67b2955d93d83b27ed164edd0756bc24049/examples.md#swift---mint + - uses: actions/cache@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: LiveObjects/.mint + key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }} + restore-keys: | + ${{ runner.os }}-mint- + + - run: npm ci + - run: brew install mint + - run: mint bootstrap + + - run: swift run --package-path BuildTool BuildTool lint + + # Checks that every top-level declaration in the LiveObjects sources + # carries an @available annotation. The LiveObjects code requires newer OS + # versions than the rest of this package declares in its platforms, and + # per-declaration availability is how it declares this (see + # Scripts/annotate-liveobjects-availability.py). + check-availability-annotations: + runs-on: macos-15 + + permissions: + contents: read + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + + # This step can be removed once the runners' default version of Xcode is 16.4 or above + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: 16.4 + + # The compiler check: public declarations must carry availability. + # (-require-explicit-availability must be spelled as a frontend flag; + # the driver flag of the same name is a no-op.) + - name: Check availability of public declarations (compiler) + working-directory: . + run: swift build --target AblyLiveObjects -Xswiftc -Xfrontend -Xswiftc -require-explicit-availability=error + + # The script check: all top-level declarations (including internal + # ones) must carry availability. The annotation script is idempotent, + # so a non-empty diff means somebody added a declaration without an + # annotation. + - name: Check availability of all top-level declarations (script) + working-directory: . + run: | + python3 Scripts/annotate-liveobjects-availability.py + git diff --exit-code LiveObjects/Sources + + generate-matrices: + runs-on: macos-15 + + permissions: + contents: read + + outputs: + matrix: ${{ steps.generation-step.outputs.matrix }} + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + + # This step can be removed once the runners' default version of Xcode is 16.4 or above + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: 16.4 + + - id: generation-step + run: swift run --package-path BuildTool BuildTool generate-matrices >> $GITHUB_OUTPUT + + build-and-test-spm: + name: SPM (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withoutPlatform }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + # https://forums.swift.org/t/warnings-as-errors-for-libraries-frameworks/58393/2 + # + # These run at the repo root: the package that contains the + # AblyLiveObjects targets is the root ably-cocoa package. The test run + # is filtered to the LiveObjects test module; the rest of the package's + # tests are run by the core SDK's own workflows (though `swift test` + # builds all of the package's test targets, so -warnings-as-errors + # covers their compilation here too — note that the warning set is a + # property of this job's Xcode version, and newer toolchains may warn + # about things this one does not). + - name: Build (warnings as errors) + working-directory: . + run: swift build -Xswiftc -warnings-as-errors + - name: Run LiveObjects tests + working-directory: . + run: swift test -Xswiftc -warnings-as-errors --filter 'AblyLiveObjectsTests\.' + + build-release-configuration-spm: + name: SPM, `release` configuration (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withoutPlatform }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + # https://forums.swift.org/t/warnings-as-errors-for-libraries-frameworks/58393/2 + - name: Build (release, warnings as errors) + working-directory: . + run: swift build -Xswiftc -warnings-as-errors --configuration release + + build-and-test-xcode: + name: Xcode, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + # We run these as two separate steps so that we can easily see the execution time of each step. + + - name: Build for testing + env: + PLATFORM: ${{ matrix.platform }} + run: swift run --package-path BuildTool BuildTool build-library-for-testing --platform "$PLATFORM" + + - name: Run tests + env: + PLATFORM: ${{ matrix.platform }} + run: swift run --package-path BuildTool BuildTool test-library --platform "$PLATFORM" --without-building + + code-coverage: + name: Generate code coverage + runs-on: macos-15 + + permissions: + contents: read + checks: write + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + + # This step can be removed once the runners' default version of Xcode is 16.4 or above + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: 16.4 + + - run: swift run --package-path BuildTool BuildTool generate-code-coverage --result-bundle-path CodeCoverage.xcresult + + # Generate a Markdown report of the code coverage information and add it to the workflow run. + # + # This tool is the best option that I could find after a brief look at the options. There are a few things that I wish it could do: + # + # - post a message on the pull request, like they do on Kotlin + # - offer more fine-grained configuration about which data to include in the report (I only care about code coverage, not test results, and I don't care about code coverage of the AblyLiveObjectsTests target) + # + # but it'll do for now (we can always fork or have another look for tooling later). + - uses: slidoapp/xcresulttool@9c51e32ae0320e0ca9b63240e4da8e974ac573e3 # v3.1.0 + with: + path: LiveObjects/CodeCoverage.xcresult + # This title will be used for the sidebar item that this job adds to GitHub results page for this workflow + title: LiveObjects code coverage results + # Turning off as much non-code-coverage information as it lets me + show-passed-tests: false + if: success() || failure() + + build-release-configuration-xcode: + name: Xcode, `release` configuration, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + - name: Build library + env: + PLATFORM: ${{ matrix.platform }} + run: swift run --package-path BuildTool BuildTool build-library --platform "$PLATFORM" --configuration release + + check-example-app: + name: Example app, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + + permissions: + contents: read + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + - name: Build example app + env: + PLATFORM: ${{ matrix.platform }} + run: swift run --package-path BuildTool BuildTool build-example-app --platform "$PLATFORM" + + check-documentation: + runs-on: macos-15 + + permissions: + deployments: write + id-token: write + contents: read + + steps: + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 + with: + persist-credentials: false + submodules: true + + # This step can be removed once the runners' default version of Xcode is 16.4 or above + - uses: maxim-lobanov/setup-xcode@ed7a3b1fda3918c0306d1b724322adc0b8cc0a90 # v1 + with: + xcode-version: 16.4 + + # Dry run upload-action to get base-path url + - name: Dry-Run Upload (to get url) + id: preupload + uses: ably/sdk-upload-action@4e694297f208b72b5a9f6b1248a1556f19f821d6 # v2 + with: + mode: preempt + sourcePath: LiveObjects/AblyLiveObjects.doccarchive + githubToken: ${{ secrets.GITHUB_TOKEN }} + artifactName: AblyLiveObjects + + # Build the documentation using xcodebuild's built-in DocC support and + # transform it for static hosting. (The standalone plugin repo used the + # swift-docc-plugin package plugin instead; we deliberately avoid that + # here because it would have to be a dependency of the root manifest, + # where ably-cocoa's consumers would resolve it.) + - name: Build documentation + env: + HOSTING_BASE_PATH: ${{ steps.preupload.outputs.base-path }} + run: | + xcodebuild docbuild -scheme AblyLiveObjects -destination generic/platform=macOS -derivedDataPath DerivedData + $(xcrun --find docc) process-archive transform-for-static-hosting \ + "$(find DerivedData -name AblyLiveObjects.doccarchive -print -quit)" \ + --output-path AblyLiveObjects.doccarchive \ + --hosting-base-path "$HOSTING_BASE_PATH" + + # Configure AWS credentials for uploading documentation + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@7474bc4690e29a8392af63c5b98e7449536d5c3a # v4 + with: + aws-region: eu-west-2 + role-to-assume: arn:aws:iam::${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}:role/ably-sdk-builds-ably-cocoa + role-session-name: "${{ github.run_id }}-${{ github.run_number }}" + + # Upload the generated documentation + - name: Upload Documentation + uses: ably/sdk-upload-action@4e694297f208b72b5a9f6b1248a1556f19f821d6 # v2 + with: + sourcePath: LiveObjects/AblyLiveObjects.doccarchive + githubToken: ${{ secrets.GITHUB_TOKEN }} + artifactName: AblyLiveObjects + landingPagePath: documentation/ablyliveobjects + + # We use this job as a marker that all of the required checks have completed. + # This allows us to configure a single required status check in our branch + # protection rules instead of having to type loads of different check names + # into the branch protection web UI (and keep this list up to date as we + # tweak the matrices). + all-checks-completed: + runs-on: ubuntu-latest + + permissions: {} + + needs: + - lint + - check-availability-annotations + - build-and-test-spm + - build-release-configuration-spm + - build-and-test-xcode + - build-release-configuration-xcode + - check-example-app + - check-documentation + - code-coverage + + steps: + - name: No-op + # Override the workflow-wide default working directory: this job runs + # without a checkout, so the LiveObjects directory does not exist. + working-directory: . + run: "true" From b99c0af9a64ab49e89535085a824186482ab7fec Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 10 Jul 2026 13:14:15 -0300 Subject: [PATCH 19/21] Update documentation for the LiveObjects merge - Root README: new LiveObjects section, including the experimental status note: breaking changes to the AblyLiveObjects API may occur in minor or patch releases without a major version bump, and ably-cocoa's semantic versioning guarantees apply only to the Ably product. - CHANGELOG: unreleased entry describing the migration from the standalone plugin package and the new Xcode 16.3+ requirement for SPM consumers, restating the semver exemption. - LiveObjects/README: the experimental warning gains the same semver wording; installation and releases now point at ably-cocoa; a note records the move from the ably-liveobjects-swift-plugin repo. - LiveObjects/CONTRIBUTING: commands updated for the new layout; the "developing ably-cocoa alongside this plugin" section is removed (the code now lives in the same repo and package, so there is nothing for it to say); the standalone release process is replaced by a pointer to ably-cocoa's. - LiveObjects/CLAUDE.md and root CLAUDE.md: build/test/lint commands updated for the new layout, and cross-references added. - Docs/plugins.md: _AblyPluginSupportPrivate and the LiveObjects plugin are now described at their in-repo locations, and ADR-128 is described as the original design, noting that its central premise for a separate plugin repository (package-wide OS requirements) turned out to be wrong. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 7 +++++++ CLAUDE.md | 4 ++++ Docs/plugins.md | 8 ++++---- LiveObjects/CLAUDE.md | 8 +++++--- LiveObjects/CONTRIBUTING.md | 29 +++++++---------------------- LiveObjects/README.md | 7 +++++-- README.md | 7 +++++++ 7 files changed, 39 insertions(+), 31 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 885647964..c8b5c3109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,12 @@ # Change Log +## Unreleased + +### What's Changed + +* The Ably LiveObjects plugin has moved into this repository and is now available as the `AblyLiveObjects` product of this package. Users of the standalone [ably-liveobjects-swift-plugin](https://github.com/ably/ably-liveobjects-swift-plugin) package should replace that dependency with this package's `AblyLiveObjects` product; the module name and API are unchanged. Note that LiveObjects remains experimental: breaking changes to the `AblyLiveObjects` API may be made in minor or patch releases, and ably-cocoa's semantic versioning guarantees apply only to the `Ably` product. +* Swift Package Manager consumers of this package now require Xcode 16.3 or later (the package manifest's swift-tools-version is now 6.1). CocoaPods and Carthage consumers are unaffected. + ## [1.2.62](https://github.com/ably/ably-cocoa/tree/1.2.62) ### What's Changed diff --git a/CLAUDE.md b/CLAUDE.md index ce1d77076..02837f174 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -58,6 +58,10 @@ Key classes follow the `ART` prefix convention: `ARTRealtime`, `ARTRest`, `ARTAu Plugins are passed via `ARTClientOptions.plugins`. Plugin support is gated behind `#ifdef ABLY_SUPPORTS_PLUGINS` (enabled only in SPM builds). See `Docs/plugins.md`. +## LiveObjects + +The LiveObjects plugin (the `AblyLiveObjects` product, in Swift) lives in `LiveObjects/`, which has its own CLAUDE.md with build/test/lint instructions; consult it when working on LiveObjects code. Its targets are declared in this repo's root `Package.swift`, and every top-level declaration in its sources must carry an `@available` annotation (enforced by `Scripts/annotate-liveobjects-availability.py`; see that script for details). + ## Test Structure - `Test/AblyTests/Tests/` — Swift test files. diff --git a/Docs/plugins.md b/Docs/plugins.md index 8a477a8a5..26fe40339 100644 --- a/Docs/plugins.md +++ b/Docs/plugins.md @@ -2,18 +2,18 @@ ably-cocoa allows users to pass in Ably-authored plugins via the `ARTClientOptions.plugins` property. These plugins extend the functionality of the SDK. -ably-cocoa and its plugins depend on a separate library called `_AblyPluginSupportPrivate`, which can be found in the [ably-cocoa-plugin-support repository](https://github.com/ably/ably-cocoa-plugin-support). This provides an API that plugins can use to access ably-cocoa's internals, and which ably-cocoa can use to communicate with its plugins. +ably-cocoa and its plugins depend on a library called `_AblyPluginSupportPrivate`, which is a target of this package (found in `Sources/_AblyPluginSupportPrivate`; it was previously in the separate [ably-cocoa-plugin-support repository](https://github.com/ably/ably-cocoa-plugin-support)). This provides an API that plugins can use to access ably-cocoa's internals, and which ably-cocoa can use to communicate with its plugins. -I will expand on this documentation once this mechanism is more mature; currently [ADR-128: Plugins for ably-cocoa SDK](https://ably.atlassian.net/wiki/spaces/ENG/pages/3838574593/ADR-128+Plugins+for+ably-cocoa+SDK) provides a decent description of our approach. The key divergence between ADR-128 and our current approach is that `_AblyPluginSupportPrivate` (called `AblyPlugin` in the ADR) is no longer part of ably-cocoa and instead lives in a separate repository. I will update that ADR or create a new one later on as part of clearing technical debt caused by this last-minute change of approach. +I will expand on this documentation once this mechanism is more mature. [ADR-128: Plugins for ably-cocoa SDK](https://ably.atlassian.net/wiki/spaces/ENG/pages/3838574593/ADR-128+Plugins+for+ably-cocoa+SDK) describes the original design, but is now outdated in some important ways: it is centred on the plugin living in a separate repository so as not to be bound by ably-cocoa's OS version requirements, a constraint that turned out not to exist (per-declaration `@available` annotations let a single package host components with different OS requirements, and the plugin now lives in this repository), and `_AblyPluginSupportPrivate` (called `AblyPlugin` in the ADR) is a standalone library rather than part of the Ably product. -Currently, our only plugin is for adding LiveObjects functionality. This plugin can be found at https://github.com/ably/ably-liveobjects-swift-plugin. +Currently, our only plugin is for adding LiveObjects functionality. This plugin can be found in this repository's `LiveObjects/` directory, and is vended as this package's `AblyLiveObjects` product. ## Notes on `_AblyPluginSupportPrivate` - Everything in ably-cocoa that depends on `_AblyPluginSupportPrivate` is gated behind `#ifdef ABLY_SUPPORTS_PLUGINS`, which is only defined in SPM builds. This is so as not to affect the non-SPM builds (i.e. Xcode, CocoaPods), which do not have access to `_AblyPluginSupportPrivate`. - ably-cocoa provides an implementation of `_AblyPluginSupportPrivate`'s `APPluginAPI` protocol, which is the interface that plugins use to access ably-cocoa's internals. On library initialization, it registers this implementation with `APDependencyStore`, from where plugins can subsequetly fetch it. - There are some tests for the `APPluginAPI` implementation in `PluginAPITests.swift` (with only partial coverage at the moment). -- Currently, the plan is to test all the LiveObjects functionality within the LiveObjects plugin repository, so the ably-cocoa tests do not import the LiveObjects plugin. +- Currently, the plan is to test all the LiveObjects functionality within the plugin's own test suite (`LiveObjects/Tests`), so the ably-cocoa tests do not import the LiveObjects plugin. - The underscore and `Private` are to reduce the chances of the user accidentally importing it into their project. - `_AblyPluginSupportPrivate` can not refer to any of ably-cocoa's types (since SPM forbids circular dependencies) and so it contains various types that duplicate ably-cocoa's public types. These fall into two categories: - enums e.g. `APRealtimeChannelState`, which are an exact copy of the corresponding ably-cocoa enum (in this example, `ARTRealtimeChannelState`) diff --git a/LiveObjects/CLAUDE.md b/LiveObjects/CLAUDE.md index b1b9c7324..0985ab773 100644 --- a/LiveObjects/CLAUDE.md +++ b/LiveObjects/CLAUDE.md @@ -1,9 +1,11 @@ # CLAUDE.md +This directory contains the LiveObjects plugin, which is part of the ably-cocoa package (its targets are declared in the repo-root `Package.swift`). Unless stated otherwise, run the commands below with this directory (`LiveObjects/`) as the working directory. + ## Build ```sh -swift build +swift build --package-path .. --target AblyLiveObjects ``` ## Test @@ -11,7 +13,7 @@ swift build When verifying changes, always run the unit tests first: ```sh -swift run BuildTool test-library --platform macOS --only-unit-tests +swift run --package-path BuildTool BuildTool test-library --platform macOS --only-unit-tests ``` This is fast (a few seconds) and excludes integration tests. Only run the full test suite if explicitly asked. @@ -19,7 +21,7 @@ This is fast (a few seconds) and excludes integration tests. Only run the full t ## Lint ```sh -swift run BuildTool lint +swift run --package-path BuildTool BuildTool lint ``` Use `--fix` to auto-fix where possible. diff --git a/LiveObjects/CONTRIBUTING.md b/LiveObjects/CONTRIBUTING.md index b99ee9f7b..28050332b 100644 --- a/LiveObjects/CONTRIBUTING.md +++ b/LiveObjects/CONTRIBUTING.md @@ -8,7 +8,9 @@ ## Setup -1. `git submodule update --init` +Run the following, with this directory (`LiveObjects/`) as the working directory (as for all commands in this document, unless stated otherwise): + +1. `git submodule update --init` (from the repo root) 2. `mint bootstrap` — this will take quite a long time (~5 minutes on my machine) the first time you run it 3. `npm install` @@ -16,7 +18,7 @@ Either: -- `swift test`, or +- `swift test --filter 'AblyLiveObjectsTests\.'` (from the repo root), or - open `AblyLiveObjects.xcworkspace` in Xcode and test the `AblyLiveObjects` scheme ### Running only the unit tests @@ -26,7 +28,7 @@ There is a test plan called `UnitTests` which excludes tests tagged with `.integ From the command line: ```sh -swift run BuildTool test-library --platform macOS --only-unit-tests +swift run --package-path BuildTool BuildTool test-library --platform macOS --only-unit-tests ``` Or in Xcode, set the `UnitTests` test plan as the _active test plan_ (the test plan which ⌘U will run): @@ -35,7 +37,7 @@ Or in Xcode, set the `UnitTests` test plan as the _active test plan_ (the test p ## Linting -To check formatting and code quality, run `swift run BuildTool lint`. Run with `--fix` to first automatically fix things where possible. +To check formatting and code quality, run `swift run --package-path BuildTool BuildTool lint`. Run with `--fix` to first automatically fix things where possible. ## Development guidelines @@ -145,23 +147,6 @@ Example: Some of our integration tests require access to ably-cocoa internals that are not exposed via `_AblyPluginSupportPrivate`, for example to inject protocol messages. Since, unlike the plugin implementation, the test suite does not require access to a stable private API (as it will never be compiled by end users and we're in control of which version of ably-cocoa gets used for testing the plugin), we just directly import ably-cocoa's internal APIs in the test suite using `import Ably.Private`. -## Developing ably-cocoa alongside this plugin - -The quickest way to edit ably-cocoa is to use `swift package edit ably-cocoa --path ably-cocoa`, which will give you a Git checkout of ably-cocoa at `ably-cocoa`. To edit ably-cocoa using Xcode, you will then need to open `ably-cocoa/Package.swift` _separately_ (making sure to close any other LiveObjects Xcode windows, else Xcode will not let you edit it). - -If you use edit mode, Xcode will not let you edit ably-cocoa from _within_ `./Package.swift` or `AblyLiveObjects.xcworkspace` (it does not let you edit SPM dependencies even if they're in edit mode). If you wish to edit ably-cocoa in one of these environments, consider temporarily instead pulling ably-cocoa in as a submodule. See commit [`29c6aa8`](https://github.com/ably/ably-liveobjects-swift-plugin/commit/29c6aa8) as an example. - ## Release process -For each release, the following needs to be done: - -- Create a new branch `release/x.x.x` (where `x.x.x` is the new version number) from the `main` branch -- Go to [Github releases](https://github.com/ably/ably-liveobjects-swift-plugin/releases) and press the `Draft a new release` button. Choose your new branch as a target -- Press the `Choose a tag` dropdown and start typing a new tag, Github will suggest the `Create new tag x.x.x on publish` option. After you select it Github will unveil the `Generate release notes` button -- From the newly generated changes remove everything that don't make much sense to the library user -- Copy the final list of changes to the top of the `CHANGELOG.md` file. Modify as necessary to fit the existing format of this file -- Commit these changes and push to the origin `git add CHANGELOG.md && git commit -m "Update change log." && git push -u origin release/x.x.x` -- Make a pull request against `main` and await approval of reviewer(s) -- Once approved and/or any additional commits have been added, merge the PR -- After merging the PR, wait for all CI jobs for `main` to pass. -- Publish your drafted release (refer to previous releases for release notes format) +The plugin is released as part of ably-cocoa; see ably-cocoa's [CONTRIBUTING.md](../CONTRIBUTING.md) for the release process. (This plugin was previously released independently from the ably-liveobjects-swift-plugin repository; its historical releases remain on that repository's releases page.) diff --git a/LiveObjects/README.md b/LiveObjects/README.md index 6ed7a235d..533641713 100644 --- a/LiveObjects/README.md +++ b/LiveObjects/README.md @@ -9,7 +9,10 @@ The Ably LiveObjects plugin enables real-time collaborative data synchronization for the [ably-cocoa](https://github.com/ably/ably-cocoa/) SDK. LiveObjects provides a simple way to build collaborative applications with synchronized state across multiple clients in real-time. Built on [Ably's](https://ably.com/) core service, it abstracts complex details to enable efficient collaborative architectures. > [!WARNING] -> This plugin is currently experimental and the public API may change. +> This plugin is currently experimental. Breaking changes to its API may be made in minor or patch releases of ably-cocoa, without a major version bump; ably-cocoa's semantic versioning guarantees apply only to the `Ably` product. + +> [!NOTE] +> The plugin lives in the [ably-cocoa](https://github.com/ably/ably-cocoa/) repository and is versioned and released as part of ably-cocoa: add the ably-cocoa package to your project and select its `AblyLiveObjects` product. It was previously developed in the [ably-liveobjects-swift-plugin](https://github.com/ably/ably-liveobjects-swift-plugin) repository. --- @@ -60,7 +63,7 @@ To run the app: ## Releases -The [CHANGELOG.md](./CHANGELOG.md) contains details of the latest releases for this plugin. You can also view all Ably releases on [changelog.ably.com](https://changelog.ably.com). +The plugin is released as part of ably-cocoa; see the [ably-cocoa CHANGELOG](../CHANGELOG.md) for details of releases (and [CHANGELOG.md](./CHANGELOG.md) for the historical releases of the standalone plugin package). You can also view all Ably releases on [changelog.ably.com](https://changelog.ably.com). --- diff --git a/README.md b/README.md index 96a7a6728..e3ae6f56d 100644 --- a/README.md +++ b/README.md @@ -183,6 +183,13 @@ realtime.connection.on { stateChange in --- +## LiveObjects + +This repository also contains the Ably LiveObjects plugin, which enables real-time, collaborative data synchronisation on top of the core SDK. It is available via Swift Package Manager only: add this package as above and additionally select the `AblyLiveObjects` product for your target. See the [LiveObjects README](LiveObjects/README.md) and the [LiveObjects documentation](https://ably.com/docs/liveobjects) to get started. + +> [!WARNING] +> LiveObjects is currently experimental. Breaking changes to the `AblyLiveObjects` API may be made in minor or patch releases of ably-cocoa, without a major version bump; ably-cocoa's semantic versioning guarantees apply only to the `Ably` product. + ## Contribute Read the [CONTRIBUTING.md](./CONTRIBUTING.md) guidelines to contribute to Ably. From c75c44d62d83b5cc73bf483e13edb2750a8b17ca Mon Sep 17 00:00:00 2001 From: sacOO7 Date: Thu, 23 Jul 2026 17:54:43 +0530 Subject: [PATCH 20/21] liveobjects: add path-based public API LiveObjects-only extraction of the path-based public API work (alternative to #2225, with the unrelated UTS test-infra and tooling changes dropped): - Namespace the internal ObjectMessage wire types under ProtocolTypes, freeing their plain names for the public API - Add the new path-based public surface (PathObject, Instance, RealtimeObject, value types, subscriptions) with Default* skeletons - Remove the old channel.objects proxy objects, PublicTypes, and the ported JS integration tests - Adapt the example app view models to the new API --- .../ViewModels/LiveCounterViewModel.swift | 121 +- .../ViewModels/TaskBoardViewModel.swift | 123 +- .../AblyLiveObjects/Internal/CoreSDK.swift | 10 +- .../Internal/DefaultInternalPlugin.swift | 12 +- .../Internal/InternalDefaultLiveCounter.swift | 18 +- .../Internal/InternalDefaultLiveMap.swift | 34 +- .../InternalDefaultRealtimeObjects.swift | 40 +- .../Internal/InternalLiveMapValue.swift | 38 +- .../Internal/InternalObjectsMapEntry.swift | 4 +- .../Internal/InternalTypes.swift | 75 + .../Internal/ObjectCreationHelpers.swift | 22 +- .../Internal/ObjectsPool.swift | 8 +- .../Internal/SyncObjectsPool.swift | 10 +- .../Default/DefaultLiveCounterInstance.swift | 31 + .../DefaultLiveCounterPathObject.swift | 61 + .../Default/DefaultLiveMapInstance.swift | 47 + .../Default/DefaultLiveMapPathObject.swift | 81 + .../Default/DefaultPrimitiveInstance.swift | 18 + .../Default/DefaultPrimitivePathObject.swift | 53 + .../Default/DefaultStatusSubscription.swift | 8 + .../Default/DefaultSubscription.swift | 8 + .../Path Based API/NotImplemented.swift | 11 + .../Public/Channel+Object.swift} | 24 +- .../Path Based API/Public/Instance.swift | 210 + .../Path Based API/Public/PathObject.swift | 184 + .../Public/PublicObjectMessage.swift | 276 + .../Public/RealtimeObject.swift | 34 + .../Path Based API/Public/Subscriptions.swift | 74 + .../Path Based API/Public/ValueTypes.swift | 279 + .../InboundObjectMessage+Synthetic.swift | 6 +- .../Protocol/ObjectMessage.swift | 255 +- .../Protocol/WireObjectMessage.swift | 35 +- .../InternalLiveMapValue+ToPublic.swift | 52 - .../PublicDefaultLiveCounter.swift | 55 - .../PublicDefaultLiveMap.swift | 104 - .../PublicDefaultRealtimeObject.swift | 43 + .../PublicDefaultRealtimeObjects.swift | 132 - .../PublicObjectsStore.swift | 104 +- .../AblyLiveObjects/Public/PublicTypes.swift | 412 -- .../AblyLiveObjectsTests.swift | 89 +- .../Helpers/TestFactories.swift | 134 +- .../InternalDefaultLiveCounterTests.swift | 8 +- .../InternalDefaultLiveMapTests.swift | 136 +- .../InternalDefaultRealtimeObjectsTests.swift | 18 +- .../JS Integration Tests/ObjectsHelper.swift | 595 -- .../ObjectsIntegrationTests.swift | 5684 ----------------- .../Mocks/MockCoreSDK.swift | 16 +- .../Mocks/MockRealtimeObjects.swift | 8 +- .../ObjectCreationHelpersTests.swift | 4 +- .../ObjectDiffHelpersTests.swift | 46 +- .../ObjectLifetimesTests.swift | 83 +- .../ObjectMessageTests.swift | 68 +- .../ObjectsPoolTests.swift | 6 +- .../SyncObjectsPoolTests.swift | 2 +- 54 files changed, 2140 insertions(+), 7869 deletions(-) create mode 100644 LiveObjects/Sources/AblyLiveObjects/Internal/InternalTypes.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterInstance.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterPathObject.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapInstance.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapPathObject.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitiveInstance.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitivePathObject.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultStatusSubscription.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultSubscription.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/NotImplemented.swift rename LiveObjects/Sources/AblyLiveObjects/{Public/ARTRealtimeChannel+Objects.swift => Path Based API/Public/Channel+Object.swift} (63%) create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/Instance.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/PathObject.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/PublicObjectMessage.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/RealtimeObject.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/Subscriptions.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/ValueTypes.swift delete mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift delete mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift delete mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift create mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift delete mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift delete mode 100644 LiveObjects/Sources/AblyLiveObjects/Public/PublicTypes.swift delete mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift delete mode 100644 LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift diff --git a/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift b/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift index 6009eb514..75f5fc6d9 100644 --- a/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift +++ b/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift @@ -23,6 +23,9 @@ enum VoteColor: String, CaseIterable { } } +// NOTE: This view model uses the path-based LiveObjects API, whose implementation is currently a +// skeleton: every operation traps via `notImplemented()` at runtime. The example compiles against +// the final API shape but is not runnable end-to-end until the path-based API is implemented. @MainActor final class LiveCounterViewModel: ObservableObject { @Published var redCount: Double = 0 @@ -33,14 +36,10 @@ final class LiveCounterViewModel: ObservableObject { private var realtime: ARTRealtime private var channel: ARTRealtimeChannel - private var objects: any RealtimeObjects - private var root: (any LiveMap)? + private var object: any RealtimeObject + private var root: (any LiveMapPathObject)? - private var redCounter: (any LiveCounter)? - private var greenCounter: (any LiveCounter)? - private var blueCounter: (any LiveCounter)? - - private var subscribeResponses: [String: any SubscribeResponse] = [:] + private var subscriptions: [String: any Subscription] = [:] init(realtime: ARTRealtime) { self.realtime = realtime @@ -50,7 +49,7 @@ final class LiveCounterViewModel: ObservableObject { let channelOptions = ARTRealtimeChannelOptions() channelOptions.modes = [.objectPublish, .objectSubscribe] channel = realtime.channels.get(channelName, options: channelOptions) - objects = channel.objects + object = channel.object Task { await initializeCounters() @@ -59,8 +58,15 @@ final class LiveCounterViewModel: ObservableObject { deinit { // Clean up subscriptions - subscribeResponses.values.forEach { $0.unsubscribe() } - subscribeResponses.removeAll() + subscriptions.values.forEach { $0.unsubscribe() } + subscriptions.removeAll() + } + + /// A path object for the counter of a given color. Purely navigational (does not resolve the + /// path), and it survives the counter being replaced by `resetCounter` — unlike the previous + /// proxy-object API, there is no need to re-fetch anything when the object at the path changes. + private func counterPath(for color: VoteColor) -> (any LiveCounterPathObject)? { + root?.get(key: color.rawValue).asLiveCounter() } private func initializeCounters() async { @@ -71,23 +77,10 @@ final class LiveCounterViewModel: ObservableObject { // Attach channel first try await channel.attachAsync() - // Get root object - let root = try await objects.getRoot() + // Get the root map path object, once objects are synchronized + let root = try await object.get() self.root = root - // Subscribe to root changes - let rootSubscription = try root.subscribe { [weak self] update, _ in - MainActor.assumeIsolated { - // Handle root updates - this will fire when counters are reset - for (keyName, change) in update.update { - if change == .updated, let color = VoteColor(rawValue: keyName) { - self?.subscribeToCounter(color: color) - } - } - } - } - subscribeResponses["root"] = rootSubscription - // Initialize all color counters for color in VoteColor.allCases { await initializeCounter(for: color) @@ -102,71 +95,51 @@ final class LiveCounterViewModel: ObservableObject { private func initializeCounter(for color: VoteColor) async { do { - guard let root else { + guard let counterPath = counterPath(for: color) else { return } - // Check if counter already exists - if let existingValue = try root.get(key: color.rawValue), let existingCounter = existingValue.liveCounterValue { - // Counter exists, store it - setCounter(existingCounter, for: color) - } else { - // Counter doesn't exist, create it - let newCounter = try await objects.createCounter(count: 0) - try await root.set(key: color.rawValue, value: .liveCounter(newCounter)) - setCounter(newCounter, for: color) + // Create the counter if nothing exists at its path yet. (Creation is declarative in the + // path-based API: setting a `LiveCounter` value type creates the counter.) + if try !counterPath.exists() { + try await root?.set(key: color.rawValue, value: .liveCounter(.create())) } - // Subscribe to it + + // Read its current value and subscribe to updates + updateCounterValue(for: color) subscribeToCounter(color: color) } catch { errorMessage = "Failed to initialize \(color.rawValue) counter: \(error.localizedDescription)" } } - private func setCounter(_ counter: any LiveCounter, for color: VoteColor) { - do { - let value = try counter.value - switch color { - case .red: - redCounter = counter - redCount = value - case .green: - greenCounter = counter - greenCount = value - case .blue: - blueCounter = counter - blueCount = value - } - } catch { - errorMessage = "Error getting \(color.rawValue) counter value: \(error)" - } - } - private func subscribeToCounter(color: VoteColor) { do { - guard let root, - let value = try root.get(key: color.rawValue), - let counter = value.liveCounterValue else { return } + guard let counterPath = counterPath(for: color) else { + return + } - subscribeResponses[color.rawValue]?.unsubscribe() + subscriptions[color.rawValue]?.unsubscribe() - subscribeResponses[color.rawValue] = try counter.subscribe { [weak self] _, _ in + // Because the subscription is path-based, it also survives the counter being replaced + // by `resetCounter`; there is no need to re-subscribe. + subscriptions[color.rawValue] = try counterPath.subscribe { [weak self] _ in MainActor.assumeIsolated { - // Update current value - self?.updateCounterValue(for: color, counter: counter) + self?.updateCounterValue(for: color) } } - // Set counter with value - setCounter(counter, for: color) + updateCounterValue(for: color) } catch { errorMessage = "Failed to subscribe to \(color.rawValue) counter: \(error)" } } - private func updateCounterValue(for color: VoteColor, counter: any LiveCounter) { + private func updateCounterValue(for color: VoteColor) { do { - let value = try counter.value + guard let value = try counterPath(for: color)?.value() else { + return + } switch color { case .red: redCount = value @@ -183,16 +156,7 @@ final class LiveCounterViewModel: ObservableObject { func vote(for color: VoteColor) { Task { do { - let counter: (any LiveCounter)? = switch color { - case .red: - redCounter - case .green: - greenCounter - case .blue: - blueCounter - } - - try await counter?.increment(amount: 1) + try await counterPath(for: color)?.increment() } catch { errorMessage = "Failed to vote for \(color.rawValue): \(error.localizedDescription)" } @@ -202,8 +166,9 @@ final class LiveCounterViewModel: ObservableObject { func resetCounter(color: VoteColor) { Task { do { - let newCounter = try await objects.createCounter(count: 0) - try await self.root?.set(key: color.rawValue, value: .liveCounter(newCounter)) + // Replace the counter with a fresh zero-count one. The stored path objects and the + // path-based subscription automatically refer to the new counter. + try await root?.set(key: color.rawValue, value: .liveCounter(.create())) } catch { errorMessage = "Failed to reset counters: \(error.localizedDescription)" } diff --git a/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift b/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift index dfd1b4da1..78b244ec4 100644 --- a/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift +++ b/LiveObjects/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift @@ -2,6 +2,9 @@ import Ably import AblyLiveObjects import SwiftUI +// NOTE: This view model uses the path-based LiveObjects API, whose implementation is currently a +// skeleton: every operation traps via `notImplemented()` at runtime. The example compiles against +// the final API shape but is not runnable end-to-end until the path-based API is implemented. @MainActor final class TaskBoardViewModel: ObservableObject { @Published var tasks: [String: String] = [:] @@ -10,11 +13,10 @@ final class TaskBoardViewModel: ObservableObject { private var realtime: ARTRealtime private var channel: ARTRealtimeChannel - private var objects: any RealtimeObjects - private var root: (any LiveMap)? - private var tasksMap: (any LiveMap)? + private var object: any RealtimeObject + private var root: (any LiveMapPathObject)? - private var subscribeResponses: [String: any SubscribeResponse] = [:] + private var subscriptions: [String: any Subscription] = [:] init(realtime: ARTRealtime, channelName: String = "objects-live-map") { self.realtime = realtime @@ -22,7 +24,7 @@ final class TaskBoardViewModel: ObservableObject { let channelOptions = ARTRealtimeChannelOptions() channelOptions.modes = [.objectPublish, .objectSubscribe] channel = realtime.channels.get(channelName, options: channelOptions) - objects = channel.objects + object = channel.object Task { await initializeTasks() @@ -31,8 +33,15 @@ final class TaskBoardViewModel: ObservableObject { deinit { // Clean up subscriptions - subscribeResponses.values.forEach { $0.unsubscribe() } - subscribeResponses.removeAll() + subscriptions.values.forEach { $0.unsubscribe() } + subscriptions.removeAll() + } + + /// A path object for the tasks map. Purely navigational (does not resolve the path), and it + /// survives the map being replaced by `removeAllTasks` — unlike the previous proxy-object API, + /// there is no need to re-fetch anything when the object at the path changes. + private var tasksMapPath: (any LiveMapPathObject)? { + root?.get(key: "tasks").asLiveMap() } private func initializeTasks() async { @@ -43,35 +52,19 @@ final class TaskBoardViewModel: ObservableObject { // Attach channel first try await channel.attachAsync() - // Get root object - let root = try await objects.getRoot() + // Get the root map path object, once objects are synchronized + let root = try await object.get() self.root = root - // Subscribe to root changes - let rootSubscription = try root.subscribe { [weak self] update, _ in - MainActor.assumeIsolated { - // Handle root updates - this will fire when tasks map is reset - if update.update["tasks"] == .updated { - if let newTasksMap = try? root.get(key: "tasks")?.liveMapValue { - self?.tasksMap = newTasksMap - self?.subscribeToTasksUpdates(tasksMap: newTasksMap) - } - } - } - } - subscribeResponses["root"] = rootSubscription - - // Initialize or get existing tasks map - if let existingTasksMap = try root.get(key: "tasks")?.liveMapValue { - tasksMap = existingTasksMap - subscribeToTasksUpdates(tasksMap: existingTasksMap) - } else { - let newTasksMap = try await objects.createMap() - try await root.set(key: "tasks", value: .liveMap(newTasksMap)) - tasksMap = newTasksMap - subscribeToTasksUpdates(tasksMap: newTasksMap) + // Create the tasks map if nothing exists at its path yet. (Creation is declarative in + // the path-based API: setting a `LiveMap` value type creates the map.) + if let tasksMapPath, try !tasksMapPath.exists() { + try await root.set(key: "tasks", value: .liveMap(.create())) } + loadTasks() + subscribeToTasksUpdates() + isLoading = false } catch { errorMessage = "Failed to initialize: \(error.localizedDescription)" @@ -79,36 +72,39 @@ final class TaskBoardViewModel: ObservableObject { } } - private func subscribeToTasksUpdates(tasksMap: any LiveMap) { + private func loadTasks() { do { - // Load existing tasks - let entries = try tasksMap.entries - var currentTasks: [String: String] = [:] + guard let tasksMapPath else { + return + } - for (key, value) in entries { - if let stringValue = value.stringValue { + var currentTasks: [String: String] = [:] + for (key, value) in try tasksMapPath.entries() { + if let primitive = try value.asPrimitive().value(), let stringValue = primitive.stringValue { currentTasks[key] = stringValue } } - tasks = currentTasks + } catch { + errorMessage = "Failed to load tasks: \(error.localizedDescription)" + } + } + + private func subscribeToTasksUpdates() { + do { + guard let tasksMapPath else { + return + } // Clean up existing subscription - subscribeResponses["tasks"]?.unsubscribe() + subscriptions["tasks"]?.unsubscribe() - // Subscribe to updates - subscribeResponses["tasks"] = try tasksMap.subscribe { [weak self] update, _ in + // Subscribe to updates. Because the subscription is path-based, it also survives the + // tasks map being replaced by `removeAllTasks`; there is no need to re-subscribe. The + // event does not carry a per-key change set, so reload the tasks on each update. + subscriptions["tasks"] = try tasksMapPath.subscribe { [weak self] _ in MainActor.assumeIsolated { - for (taskId, action) in update.update { - switch action { - case .updated: - if let updatedValue = try? tasksMap.get(key: taskId)?.stringValue { - self?.tasks[taskId] = updatedValue - } - case .removed: - self?.tasks.removeValue(forKey: taskId) - } - } + self?.loadTasks() } } } catch { @@ -124,10 +120,8 @@ final class TaskBoardViewModel: ObservableObject { Task { do { - if let tasksMap { - let taskId = UUID().uuidString - try await tasksMap.set(key: taskId, value: .string(taskTitle)) - } + let taskId = UUID().uuidString + try await tasksMapPath?.set(key: taskId, value: .primitive(.string(taskTitle))) } catch { errorMessage = "Failed to add task: \(error.localizedDescription)" } @@ -142,9 +136,7 @@ final class TaskBoardViewModel: ObservableObject { Task { do { - if let tasksMap { - try await tasksMap.set(key: id, value: .string(taskTitle)) - } + try await tasksMapPath?.set(key: id, value: .primitive(.string(taskTitle))) } catch { errorMessage = "Failed to edit task: \(error.localizedDescription)" } @@ -154,9 +146,7 @@ final class TaskBoardViewModel: ObservableObject { func removeTask(id: String) { Task { do { - if let tasksMap { - try await tasksMap.remove(key: id) - } + try await tasksMapPath?.remove(key: id) } catch { errorMessage = "Failed to remove task: \(error.localizedDescription)" } @@ -166,12 +156,9 @@ final class TaskBoardViewModel: ObservableObject { func removeAllTasks() { Task { do { - guard let root = self.root else { - return - } - - let newTasksMap = try await objects.createMap() - try await root.set(key: "tasks", value: .liveMap(newTasksMap)) + // Replace the tasks map with a fresh empty one. The stored path objects and the + // path-based subscription automatically refer to the new map. + try await root?.set(key: "tasks", value: .liveMap(.create())) } catch { errorMessage = "Failed to remove all tasks: \(error.localizedDescription)" } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 5aa75629f..cb0933b90 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -7,7 +7,7 @@ import Ably @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal protocol CoreSDK: AnyObject, Sendable { /// Implements the internal `#publish` method of RTO15. - func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) + func nosync_publish(objectMessages: [ProtocolTypes.OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) /// Implements the server time fetch of RTO16, including the storing and usage of the local clock offset. func nosync_fetchServerTime(callback: @escaping @Sendable (Result) -> Void) @@ -15,7 +15,7 @@ internal protocol CoreSDK: AnyObject, Sendable { /// Replaces the implementation of ``nosync_publish(objectMessages:callback:)``. /// /// Used by integration tests, for example to disable `ObjectMessage` publishing so that a test can verify that a behaviour is not a side effect of an `ObjectMessage` sent by the SDK. - func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) + func testsOnly_overridePublish(with newImplementation: @escaping ([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) /// Returns the current state of the Realtime channel that this wraps. var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { get } @@ -36,7 +36,7 @@ internal final class DefaultCoreSDK: CoreSDK { /// This enables the `testsOnly_overridePublish(with:)` test hook. /// /// - Note: This should be `throws(ARTErrorInfo)` but that causes a compilation error of "Runtime support for typed throws function types is only available in macOS 15.0.0 or newer". - private nonisolated(unsafe) var overriddenPublishImplementation: (([OutboundObjectMessage]) async throws -> PublishResult)? + private nonisolated(unsafe) var overriddenPublishImplementation: (([ProtocolTypes.OutboundObjectMessage]) async throws -> PublishResult)? internal init( channel: _AblyPluginSupportPrivate.RealtimeChannel, @@ -52,7 +52,7 @@ internal final class DefaultCoreSDK: CoreSDK { // MARK: - CoreSDK conformance - internal func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { + internal func nosync_publish(objectMessages: [ProtocolTypes.OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { logger.log("nosync_publish(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) // Use the overridden implementation if supplied @@ -85,7 +85,7 @@ internal final class DefaultCoreSDK: CoreSDK { ) } - internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { + internal func testsOnly_overridePublish(with newImplementation: @escaping ([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { mutex.withLock { overriddenPublishImplementation = newImplementation } diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 5f0ee4803..01c3c39be 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -98,7 +98,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. wireObject: wireObject, decodingContext: context, ) - let objectMessage = try InboundObjectMessage( + let objectMessage = try ProtocolTypes.InboundObjectMessage( wireObjectMessage: wireObjectMessage, format: format, ) @@ -113,7 +113,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. _ publicObjectMessage: any _AblyPluginSupportPrivate.ObjectMessageProtocol, format: EncodingFormat, ) -> [String: Any] { - guard let outboundObjectMessageBox = publicObjectMessage as? ObjectMessageBox else { + guard let outboundObjectMessageBox = publicObjectMessage as? ObjectMessageBox else { preconditionFailure("Expected to receive the same OutboundObjectMessage type as we emit") } @@ -126,7 +126,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. } internal func nosync_handleObjectProtocolMessage(withObjectMessages publicObjectMessages: [any _AblyPluginSupportPrivate.ObjectMessageProtocol], channel: _AblyPluginSupportPrivate.RealtimeChannel) { - guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { + guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") } @@ -138,7 +138,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. } internal func nosync_handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any _AblyPluginSupportPrivate.ObjectMessageProtocol], protocolMessageChannelSerial: String?, channel: _AblyPluginSupportPrivate.RealtimeChannel) { - guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { + guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") } @@ -168,13 +168,13 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. // MARK: - Sending `OBJECT` ProtocolMessage internal static func nosync_sendObject( - objectMessages: [OutboundObjectMessage], + objectMessages: [ProtocolTypes.OutboundObjectMessage], channel: _AblyPluginSupportPrivate.RealtimeChannel, client: _AblyPluginSupportPrivate.RealtimeClient, pluginAPI: PluginAPIProtocol, callback: @escaping @Sendable (Result) -> Void, ) { - let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } + let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } let internalQueue = pluginAPI.internalQueue(for: client) pluginAPI.nosync_sendObject( diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 59a661f81..13de2fc93 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -119,7 +119,7 @@ internal final class InternalDefaultLiveCounter: Sendable { operationDescription: "LiveCounter.increment", ) - let objectMessage = OutboundObjectMessage( + let objectMessage = ProtocolTypes.OutboundObjectMessage( operation: .init( // RTLC12e2 action: .known(.counterInc), @@ -210,7 +210,7 @@ internal final class InternalDefaultLiveCounter: Sendable { /// - Parameters: /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this counter. internal func nosync_replaceData( - using state: ObjectState, + using state: ProtocolTypes.ObjectState, objectMessageSerialTimestamp: Date?, ) -> LiveObjectUpdate { mutableStateMutex.withoutSync { mutableState in @@ -225,14 +225,14 @@ internal final class InternalDefaultLiveCounter: Sendable { } /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC16. - internal func nosync_mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { + internal func nosync_mergeInitialValue(from operation: ProtocolTypes.ObjectOperation) -> LiveObjectUpdate { mutableStateMutex.withoutSync { mutableState in mutableState.mergeInitialValue(from: operation) } } /// Test-only method to apply a COUNTER_CREATE operation, per RTLC8. - internal func testsOnly_applyCounterCreateOperation(_ operation: ObjectOperation) -> LiveObjectUpdate { + internal func testsOnly_applyCounterCreateOperation(_ operation: ProtocolTypes.ObjectOperation) -> LiveObjectUpdate { mutableStateMutex.withSync { mutableState in mutableState.applyCounterCreateOperation(operation, logger: logger) } @@ -249,7 +249,7 @@ internal final class InternalDefaultLiveCounter: Sendable { /// /// - Returns: `true` if the operation was applied, `false` if it was skipped (RTLC7g). internal func nosync_apply( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, @@ -315,7 +315,7 @@ internal final class InternalDefaultLiveCounter: Sendable { /// - Parameters: /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this counter. internal mutating func replaceData( - using state: ObjectState, + using state: ProtocolTypes.ObjectState, objectMessageSerialTimestamp: Date?, logger: Logger, clock: SimpleClock, @@ -364,7 +364,7 @@ internal final class InternalDefaultLiveCounter: Sendable { } /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC16. - internal mutating func mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { + internal mutating func mergeInitialValue(from operation: ProtocolTypes.ObjectOperation) -> LiveObjectUpdate { let update: LiveObjectUpdate // RTLC16: Resolve counterCreate from either the direct property or the one @@ -391,7 +391,7 @@ internal final class InternalDefaultLiveCounter: Sendable { /// /// - Returns: `true` if the operation was applied, `false` if skipped (RTLC7g). internal mutating func apply( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, @@ -460,7 +460,7 @@ internal final class InternalDefaultLiveCounter: Sendable { /// Applies a `COUNTER_CREATE` operation, per RTLC8. internal mutating func applyCounterCreateOperation( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, logger: Logger, ) -> LiveObjectUpdate { if liveObjectMutableState.createOperationIsMerged { diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 99cd8e6b8..8fdd59d82 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -19,7 +19,7 @@ internal final class InternalDefaultLiveMap: Sendable { } } - internal var testsOnly_semantics: WireEnum? { + internal var testsOnly_semantics: WireEnum? { mutableStateMutex.withSync { mutableState in mutableState.semantics } @@ -52,7 +52,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal convenience init( testsOnly_data data: [String: InternalObjectsMapEntry], objectID: String, - testsOnly_semantics semantics: WireEnum? = nil, + testsOnly_semantics semantics: WireEnum? = nil, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, @@ -72,7 +72,7 @@ internal final class InternalDefaultLiveMap: Sendable { private init( data: [String: InternalObjectsMapEntry], objectID: String, - semantics: WireEnum?, + semantics: WireEnum?, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, @@ -94,7 +94,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// - semantics: The value to use for the "private `semantics` field" of RTO5c1b1b. internal static func createZeroValued( objectID: String, - semantics: WireEnum? = nil, + semantics: WireEnum? = nil, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, @@ -174,7 +174,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM20c try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.set") - let objectMessage = OutboundObjectMessage( + let objectMessage = ProtocolTypes.OutboundObjectMessage( operation: .init( // RTLM20e2 action: .known(.mapSet), @@ -207,7 +207,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM21c try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.remove") - let objectMessage = OutboundObjectMessage( + let objectMessage = ProtocolTypes.OutboundObjectMessage( operation: .init( // RTLM21e2 action: .known(.mapRemove), @@ -294,7 +294,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this map. internal func nosync_replaceData( - using state: ObjectState, + using state: ProtocolTypes.ObjectState, objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, ) -> LiveObjectUpdate { @@ -312,7 +312,7 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM23. - internal func nosync_mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { + internal func nosync_mergeInitialValue(from operation: ProtocolTypes.ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { mutableStateMutex.withoutSync { mutableState in mutableState.mergeInitialValue( from: operation, @@ -326,7 +326,7 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Test-only method to apply a MAP_CREATE operation, per RTLM16. - internal func testsOnly_applyMapCreateOperation(_ operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { + internal func testsOnly_applyMapCreateOperation(_ operation: ProtocolTypes.ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { mutableStateMutex.withSync { mutableState in mutableState.applyMapCreateOperation( operation, @@ -343,7 +343,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// /// - Returns: `true` if the operation was applied, `false` if it was skipped (RTLM15g). internal func nosync_apply( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, @@ -372,7 +372,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal func testsOnly_applyMapSetOperation( key: String, operationTimeserial: String?, - operationData: ObjectData, + operationData: ProtocolTypes.ObjectData, objectsPool: inout ObjectsPool, ) -> LiveObjectUpdate { mutableStateMutex.withSync { mutableState in @@ -467,7 +467,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal var data: [String: InternalObjectsMapEntry] /// The "private `semantics` field" of RTO5c1b1b. - internal var semantics: WireEnum? + internal var semantics: WireEnum? /// RTLM25 internal var clearTimeserial: String? @@ -478,7 +478,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this map. internal mutating func replaceData( - using state: ObjectState, + using state: ProtocolTypes.ObjectState, objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, logger: Logger, @@ -558,7 +558,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM23. internal mutating func mergeInitialValue( - from operation: ObjectOperation, + from operation: ProtocolTypes.ObjectOperation, objectsPool: inout ObjectsPool, logger: Logger, internalQueue: DispatchQueue, @@ -625,7 +625,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// /// - Returns: `true` if the operation was applied, `false` if skipped (RTLM15g). internal mutating func apply( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, @@ -745,7 +745,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal mutating func applyMapSetOperation( key: String, operationTimeserial: String?, - operationData: ObjectData?, + operationData: ProtocolTypes.ObjectData?, objectsPool: inout ObjectsPool, logger: Logger, internalQueue: DispatchQueue, @@ -885,7 +885,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// Applies a `MAP_CREATE` operation, per RTLM16. internal mutating func applyMapCreateOperation( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, objectsPool: inout ObjectsPool, logger: Logger, internalQueue: DispatchQueue, diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 370114446..f0a9c4337 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -10,7 +10,7 @@ internal protocol InternalRealtimeObjectsProtocol: LiveMapObjectsPoolDelegate { /// call other methods on this object that read or mutate its state. /// https://github.com/ably/ably-liveobjects-swift-plugin/issues/120 tracks removing this restriction. func nosync_publishAndApply( - objectMessages: [OutboundObjectMessage], + objectMessages: [ProtocolTypes.OutboundObjectMessage], coreSDK: CoreSDK, callback: @escaping @Sendable (Result) -> Void, ) @@ -26,10 +26,10 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO private let clock: SimpleClock // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. - private let receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> - private let receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation - private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> - private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation + private let receivedObjectProtocolMessages: AsyncStream<[ProtocolTypes.InboundObjectMessage]> + private let receivedObjectProtocolMessagesContinuation: AsyncStream<[ProtocolTypes.InboundObjectMessage]>.Continuation + private let receivedObjectSyncProtocolMessages: AsyncStream<[ProtocolTypes.InboundObjectMessage]> + private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[ProtocolTypes.InboundObjectMessage]>.Continuation /// The RTO10a interval at which we will perform garbage collection. private let garbageCollectionInterval: TimeInterval @@ -389,12 +389,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } } - internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { + internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[ProtocolTypes.InboundObjectMessage]> { receivedObjectProtocolMessages } /// Implements the `OBJECT` handling of RTO8. - internal func nosync_handleObjectProtocolMessage(objectMessages: [InboundObjectMessage]) { + internal func nosync_handleObjectProtocolMessage(objectMessages: [ProtocolTypes.InboundObjectMessage]) { mutableStateMutex.withoutSync { mutableState in mutableState.nosync_handleObjectProtocolMessage( objectMessages: objectMessages, @@ -407,12 +407,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } } - internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { + internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[ProtocolTypes.InboundObjectMessage]> { receivedObjectSyncProtocolMessages } /// Implements the `OBJECT_SYNC` handling of RTO5. - internal func nosync_handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial: String?) { + internal func nosync_handleObjectSyncProtocolMessage(objectMessages: [ProtocolTypes.InboundObjectMessage], protocolMessageChannelSerial: String?) { mutableStateMutex.withoutSync { mutableState in mutableState.nosync_handleObjectSyncProtocolMessage( objectMessages: objectMessages, @@ -444,7 +444,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO // MARK: - Sending `OBJECT` ProtocolMessage // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. - internal func testsOnly_publish(objectMessages: [OutboundObjectMessage], coreSDK: CoreSDK) async throws(ARTErrorInfo) { + internal func testsOnly_publish(objectMessages: [ProtocolTypes.OutboundObjectMessage], coreSDK: CoreSDK) async throws(ARTErrorInfo) { try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in mutableStateMutex.withSync { _ in coreSDK.nosync_publish(objectMessages: objectMessages) { result in @@ -458,7 +458,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO /// /// Must be called from within `mutableStateMutex.withSync` (i.e. on the internal queue). internal func nosync_publishAndApply( - objectMessages: [OutboundObjectMessage], + objectMessages: [ProtocolTypes.OutboundObjectMessage], coreSDK: CoreSDK, callback: @escaping @Sendable (Result) -> Void, ) { @@ -477,7 +477,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO /// /// Must be called from within `mutableStateMutex.withSync` (i.e. on the internal queue). private func nosync_publishAndApply( - objectMessages: [OutboundObjectMessage], + objectMessages: [ProtocolTypes.OutboundObjectMessage], coreSDK: CoreSDK, mutableStateCallback: @escaping @Sendable (inout MutableState, Result) -> Void, ) { @@ -512,7 +512,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } // RTO20d: Create synthetic inbound ObjectMessages - let syntheticMessages = objectMessages.enumerated().compactMap { index, outboundMessage -> InboundObjectMessage? in + let syntheticMessages = objectMessages.enumerated().compactMap { index, outboundMessage -> ProtocolTypes.InboundObjectMessage? in // RTO20d1: Skip null serials (conflated) guard let serial = publishResult.serials[index] else { logger.log("nosync_publishAndApply: operation at index \(index) will not be applied locally: serial is null in PublishResult", level: .debug) @@ -690,14 +690,14 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO enum AssociatedData { class Syncing { /// `OBJECT` ProtocolMessages that were received whilst SYNCING, to be applied once the sync sequence is complete, per RTO7a. - var bufferedObjectOperations: [InboundObjectMessage] + var bufferedObjectOperations: [ProtocolTypes.InboundObjectMessage] /// Note that we only ever populate this during a multi-`ProtocolMessage` sync sequence. It is not used in the RTO4b or RTO5a5 cases where the sync data is entirely contained within a single ProtocolMessage, because an individual ProtocolMessage is processed atomically and so no other operations that might wish to query this property can occur concurrently with the handling of these cases. /// /// It is optional because there are times that we transition to SYNCING even when the sync data is contained in a single ProtocolMessage. var syncSequence: SyncSequence? - init(bufferedObjectOperations: [InboundObjectMessage], syncSequence: SyncSequence?) { + init(bufferedObjectOperations: [ProtocolTypes.InboundObjectMessage], syncSequence: SyncSequence?) { self.bufferedObjectOperations = bufferedObjectOperations self.syncSequence = syncSequence } @@ -772,13 +772,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO /// Implements the `OBJECT_SYNC` handling of RTO5. internal mutating func nosync_handleObjectSyncProtocolMessage( - objectMessages: [InboundObjectMessage], + objectMessages: [ProtocolTypes.InboundObjectMessage], protocolMessageChannelSerial: String?, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, - receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, + receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[ProtocolTypes.InboundObjectMessage]>.Continuation, ) { logger.log("handleObjectSyncProtocolMessage(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) @@ -884,12 +884,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO /// Implements the `OBJECT` handling of RTO8. internal mutating func nosync_handleObjectProtocolMessage( - objectMessages: [InboundObjectMessage], + objectMessages: [ProtocolTypes.InboundObjectMessage], logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, - receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, + receivedObjectProtocolMessagesContinuation: AsyncStream<[ProtocolTypes.InboundObjectMessage]>.Continuation, ) { receivedObjectProtocolMessagesContinuation.yield(objectMessages) @@ -917,7 +917,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO /// Implements the `OBJECT` application of RTO9. internal mutating func nosync_applyObjectProtocolMessageObjectMessage( - _ objectMessage: InboundObjectMessage, + _ objectMessage: ProtocolTypes.InboundObjectMessage, source: ObjectsOperationSource, logger: Logger, internalQueue: DispatchQueue, diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift index ecdad9a86..8910f4902 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -1,6 +1,6 @@ import Foundation -/// Same as the public ``LiveMapValue`` type but with associated values of internal type. +/// The internal representation of a LiveMap value, with associated values of internal type. @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal enum InternalLiveMapValue: Sendable, Equatable { case string(String) @@ -12,44 +12,10 @@ internal enum InternalLiveMapValue: Sendable, Equatable { case liveMap(InternalDefaultLiveMap) case liveCounter(InternalDefaultLiveCounter) - // MARK: - Creating from a public LiveMapValue - - /// Converts a public ``LiveMapValue`` into an ``InternalLiveMapValue``. - /// - /// Needed in order to access the internals of user-provided LiveObject-valued LiveMap entries to extract their object ID. - internal init(liveMapValue: LiveMapValue) { - switch liveMapValue { - case let .string(value): - self = .string(value) - case let .number(value): - self = .number(value) - case let .bool(value): - self = .bool(value) - case let .data(value): - self = .data(value) - case let .jsonArray(value): - self = .jsonArray(value) - case let .jsonObject(value): - self = .jsonObject(value) - case let .liveMap(publicLiveMap): - guard let publicDefaultLiveMap = publicLiveMap as? PublicDefaultLiveMap else { - // TODO: Try and remove this runtime check and know this type statically, see https://github.com/ably/ably-liveobjects-swift-plugin/issues/37 - preconditionFailure("Expected PublicDefaultLiveMap, got \(publicLiveMap)") - } - self = .liveMap(publicDefaultLiveMap.proxied) - case let .liveCounter(publicLiveCounter): - guard let publicDefaultLiveCounter = publicLiveCounter as? PublicDefaultLiveCounter else { - // TODO: Try and remove this runtime check and know this type statically, see https://github.com/ably/ably-liveobjects-swift-plugin/issues/37 - preconditionFailure("Expected PublicDefaultLiveCounter, got \(publicLiveCounter)") - } - self = .liveCounter(publicDefaultLiveCounter.proxied) - } - } - // MARK: - Representation in the Realtime protocol /// Converts an `InternalLiveMapValue` to the value that should be used when creating or updating a map entry in the Realtime protocol, per the rules of RTO11f14 and RTLM20e7. - internal var nosync_toObjectData: ObjectData { + internal var nosync_toObjectData: ProtocolTypes.ObjectData { // RTO11f14c1: Create an ObjectsMapEntry for the current value switch self { case let .bool(value): diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift index a6a54bc3d..f42cfa590 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift @@ -10,12 +10,12 @@ internal struct InternalObjectsMapEntry: Equatable { } internal var timeserial: String? // OME2b - internal var data: ObjectData? // OME2c + internal var data: ProtocolTypes.ObjectData? // OME2c } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal extension InternalObjectsMapEntry { - init(objectsMapEntry: ObjectsMapEntry, tombstonedAt: Date?) { + init(objectsMapEntry: ProtocolTypes.ObjectsMapEntry, tombstonedAt: Date?) { self.tombstonedAt = tombstonedAt timeserial = objectsMapEntry.timeserial data = objectsMapEntry.data diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/InternalTypes.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalTypes.swift new file mode 100644 index 000000000..58b313abe --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/InternalTypes.swift @@ -0,0 +1,75 @@ +import Ably + +// This file contains supporting types for the internal live-object engine (the callbacks, update +// descriptors and subscription-handle protocols used by `InternalDefaultLiveMap` / +// `InternalDefaultLiveCounter` and `InternalDefaultRealtimeObjects`). None of these are exposed to +// users; the public surface is the path-object / instance API (see the `Path Based API` directory). + +/// A callback used by an internal live object to listen for updates to the object. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal typealias LiveObjectUpdateCallback = @Sendable (_ update: sending T, _ subscription: SubscribeResponse) -> Void + +/// The callback used for the lifecycle events emitted by an internal live object. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal typealias LiveObjectLifecycleEventCallback = @Sendable (_ subscription: OnLiveObjectLifecycleEventResponse) -> Void + +/// Describes the lifecycle events emitted by an internal live object. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal enum LiveObjectLifecycleEvent: Sendable { + /// Indicates that the object has been deleted from the Objects pool and should no longer be interacted with. + case deleted +} + +// The `ObjectsEvent` enum that these types refer to now lives in `RealtimeObject.swift` as part of +// the new public API; its cases (`.syncing` / `.synced`) are unchanged, so the internal engine +// continues to use it. The following two supporting types were part of the old public API surface +// but are still needed internally by `InternalDefaultRealtimeObjects` (e.g. `getRoot()` waits for a +// sync via `onInternal(event:callback:)`), so they are retained as `internal`. + +/// The callback used for the events emitted by ``InternalDefaultRealtimeObjects``. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal typealias ObjectsEventCallback = @Sendable (_ subscription: OnObjectsEventResponse) -> Void + +/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal protocol OnObjectsEventResponse: Sendable { + /// Deregisters the listener passed to the `on` call. + func off() +} + +/// Describes whether an entry in ``LiveMapUpdate/update`` represents an update or a removal. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal enum LiveMapUpdateAction: Sendable { + /// The value of a key in the map was updated. + case updated + /// The value of a key in the map was removed. + case removed +} + +/// Represents an update to an internal live map (``InternalDefaultLiveMap``). +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal protocol LiveMapUpdate: Sendable { + /// The keys that have changed, along with their change status. + var update: [String: LiveMapUpdateAction] { get } +} + +/// Represents an update to an internal live counter (``InternalDefaultLiveCounter``). +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal protocol LiveCounterUpdate: Sendable { + /// Holds the numerical change to the counter value. + var amount: Double { get } +} + +/// Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal protocol SubscribeResponse: Sendable { + /// Deregisters the listener passed to the `subscribe` call. + func unsubscribe() +} + +/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal protocol OnLiveObjectLifecycleEventResponse: Sendable { + /// Deregisters the listener passed to the `on` call. + func off() +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift index a741a4f3a..08c091dee 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift @@ -15,7 +15,7 @@ internal enum ObjectCreationHelpers { internal var objectID: String /// The ObjectMessage that must be sent in order for Realtime to create the object. - internal var objectMessage: OutboundObjectMessage + internal var objectMessage: ProtocolTypes.OutboundObjectMessage } /// The metadata that `createMap` needs in order to request that Realtime create a LiveMap and to populate the local objects pool. @@ -26,12 +26,12 @@ internal enum ObjectCreationHelpers { internal var objectID: String /// The ObjectMessage that must be sent in order for Realtime to create the object. - internal var objectMessage: OutboundObjectMessage + internal var objectMessage: ProtocolTypes.OutboundObjectMessage /// The semantics that should be used for the created LiveMap. /// /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. - internal var semantics: ObjectsMapSemantics + internal var semantics: ProtocolTypes.ObjectsMapSemantics } /// Creates a `COUNTER_CREATE` `ObjectMessage` for the `RealtimeObjects.createCounter` method per RTO12f. @@ -64,7 +64,7 @@ internal enum ObjectCreationHelpers { ) // RTO12f7-12: Set ObjectMessage.operation fields - let operation = ObjectOperation( + let operation = ProtocolTypes.ObjectOperation( action: .known(.counterCreate), objectId: objectId, counterCreateWithObjectId: .init( @@ -75,7 +75,7 @@ internal enum ObjectCreationHelpers { ) // Create the OutboundObjectMessage - let objectMessage = OutboundObjectMessage( + let objectMessage = ProtocolTypes.OutboundObjectMessage( operation: operation, ) @@ -95,12 +95,12 @@ internal enum ObjectCreationHelpers { timestamp: Date, ) -> MapCreationOperation { // RTO11f14: Create initial value for the new LiveMap - let mapEntries = entries.mapValues { liveMapValue -> ObjectsMapEntry in - ObjectsMapEntry(data: liveMapValue.nosync_toObjectData) + let mapEntries = entries.mapValues { liveMapValue -> ProtocolTypes.ObjectsMapEntry in + ProtocolTypes.ObjectsMapEntry(data: liveMapValue.nosync_toObjectData) } - let semantics = ObjectsMapSemantics.lww - let mapCreate = MapCreate( + let semantics = ProtocolTypes.ObjectsMapSemantics.lww + let mapCreate = ProtocolTypes.MapCreate( semantics: .known(semantics), entries: mapEntries, ) @@ -123,7 +123,7 @@ internal enum ObjectCreationHelpers { ) // RTO11f9-13: Set ObjectMessage.operation fields - let operation = ObjectOperation( + let operation = ProtocolTypes.ObjectOperation( action: .known(.mapCreate), objectId: objectId, mapCreateWithObjectId: .init( @@ -134,7 +134,7 @@ internal enum ObjectCreationHelpers { ) // Create the OutboundObjectMessage - let objectMessage = OutboundObjectMessage( + let objectMessage = ProtocolTypes.OutboundObjectMessage( operation: operation, ) diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 163f9156f..90b89b0dc 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -34,7 +34,7 @@ internal struct ObjectsPool { /// /// - Returns: `true` if the operation was applied, `false` if it was skipped. internal func nosync_apply( - _ operation: ObjectOperation, + _ operation: ProtocolTypes.ObjectOperation, source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, @@ -86,7 +86,7 @@ internal struct ObjectsPool { /// - Parameters: /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone the object. fileprivate func nosync_replaceData( - using state: ObjectState, + using state: ProtocolTypes.ObjectState, objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, userCallbackQueue: DispatchQueue, @@ -351,8 +351,8 @@ internal struct ObjectsPool { /// - Precondition: `state.objectId` must not be the root object ID, in order to preserve the RTO3b invariant that the root is always a map. /// - Precondition: `state` must have either `.counter` or `.map` populated. private mutating func nosync_createObjectFromSync( - state: ObjectState, - objectMessage: InboundObjectMessage, + state: ProtocolTypes.ObjectState, + objectMessage: ProtocolTypes.InboundObjectMessage, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, diff --git a/LiveObjects/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift b/LiveObjects/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift index b49b6349d..ee26e0353 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift @@ -7,7 +7,7 @@ import Foundation internal struct SyncObjectsPool: Collection { /// Keyed by `objectId`. Every value has a non-nil `.object` with either `.map` or `.counter` populated; the /// `accumulate` method enforces this invariant. - private var objectMessages: [String: InboundObjectMessage] + private var objectMessages: [String: ProtocolTypes.InboundObjectMessage] /// Creates an empty pool. internal init() { @@ -16,7 +16,7 @@ internal struct SyncObjectsPool: Collection { /// Accumulates object messages into the pool per RTO5f. internal mutating func accumulate( - _ objectMessages: [InboundObjectMessage], + _ objectMessages: [ProtocolTypes.InboundObjectMessage], logger: Logger, ) { for objectMessage in objectMessages { @@ -26,7 +26,7 @@ internal struct SyncObjectsPool: Collection { /// Accumulates a single `ObjectMessage` into the pool per RTO5f. private mutating func accumulate( - _ objectMessage: InboundObjectMessage, + _ objectMessage: ProtocolTypes.InboundObjectMessage, logger: Logger, ) { // RTO5f3: Reject unsupported object types before pool lookup. Only messages whose `.object` has `.map` or `.counter` @@ -77,8 +77,8 @@ internal struct SyncObjectsPool: Collection { // MARK: - Collection conformance - internal typealias Index = Dictionary.Values.Index - internal typealias Element = InboundObjectMessage + internal typealias Index = Dictionary.Values.Index + internal typealias Element = ProtocolTypes.InboundObjectMessage internal var startIndex: Index { objectMessages.values.startIndex } internal var endIndex: Index { objectMessages.values.endIndex } diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterInstance.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterInstance.swift new file mode 100644 index 000000000..eb78fc32e --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterInstance.swift @@ -0,0 +1,31 @@ +import Ably + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal final class DefaultLiveCounterInstance: LiveCounterInstance { + internal var id: String { + notImplemented() + } + + internal var value: Double { + get throws(ARTErrorInfo) { + notImplemented() + } + } + + internal func increment(amount _: Double) async throws(ARTErrorInfo) { + notImplemented() + } + + internal func decrement(amount _: Double) async throws(ARTErrorInfo) { + notImplemented() + } + + @discardableResult + internal func subscribe(listener _: @escaping InstanceSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { + notImplemented() + } + + internal func compactJson() throws(ARTErrorInfo) -> JSONValue { + notImplemented() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterPathObject.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterPathObject.swift new file mode 100644 index 000000000..c9d8faa14 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveCounterPathObject.swift @@ -0,0 +1,61 @@ +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. +@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 func value() throws(ARTErrorInfo) -> Double? { + notImplemented() + } + + internal func increment(amount _: Double) async throws(ARTErrorInfo) { + notImplemented() + } + + internal func decrement(amount _: Double) async throws(ARTErrorInfo) { + notImplemented() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapInstance.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapInstance.swift new file mode 100644 index 000000000..d08d6f569 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapInstance.swift @@ -0,0 +1,47 @@ +import Ably + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal final class DefaultLiveMapInstance: LiveMapInstance { + internal var id: String { + notImplemented() + } + + internal func get(key _: String) throws(ARTErrorInfo) -> Instance? { + notImplemented() + } + + internal func entries() throws(ARTErrorInfo) -> [(key: String, value: Instance)] { + notImplemented() + } + + internal func keys() throws(ARTErrorInfo) -> [String] { + notImplemented() + } + + internal func values() throws(ARTErrorInfo) -> [Instance] { + notImplemented() + } + + internal var size: Int { + get throws(ARTErrorInfo) { + notImplemented() + } + } + + internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { + notImplemented() + } + + internal func remove(key _: String) async throws(ARTErrorInfo) { + notImplemented() + } + + @discardableResult + internal func subscribe(listener _: @escaping InstanceSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { + notImplemented() + } + + internal func compactJson() throws(ARTErrorInfo) -> JSONValue { + notImplemented() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapPathObject.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapPathObject.swift new file mode 100644 index 000000000..1b3eb8796 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultLiveMapPathObject.swift @@ -0,0 +1,81 @@ +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. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal final class DefaultLiveMapPathObject: LiveMapPathObject, Sendable { + // MARK: - PathObject + + internal var path: String { + notImplemented() + } + + internal func instance() throws(ARTErrorInfo) -> Instance? { + notImplemented() + } + + internal func compactJson() throws(ARTErrorInfo) -> JSONValue? { + notImplemented() + } + + @discardableResult + internal func subscribe(options _: PathObjectSubscriptionOptions?, listener _: @escaping PathObjectSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { + notImplemented() + } + + internal func exists() throws(ARTErrorInfo) -> Bool { + notImplemented() + } + + internal func type() throws(ARTErrorInfo) -> ValueType? { + notImplemented() + } + + internal func asLiveMap() -> any LiveMapPathObject { + notImplemented() + } + + internal func asLiveCounter() -> any LiveCounterPathObject { + notImplemented() + } + + internal func asPrimitive() -> any PrimitivePathObject { + notImplemented() + } + + // MARK: - LiveMapPathObject + + internal func get(key _: String) -> any PathObject { + notImplemented() + } + + internal func at(path _: String) -> any PathObject { + notImplemented() + } + + internal func entries() throws(ARTErrorInfo) -> [(key: String, value: any PathObject)] { + notImplemented() + } + + internal func keys() throws(ARTErrorInfo) -> [String] { + notImplemented() + } + + internal func values() throws(ARTErrorInfo) -> [any PathObject] { + notImplemented() + } + + internal func size() throws(ARTErrorInfo) -> Int? { + notImplemented() + } + + internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { + notImplemented() + } + + internal func remove(key _: String) async throws(ARTErrorInfo) { + notImplemented() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitiveInstance.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitiveInstance.swift new file mode 100644 index 000000000..be677102e --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitiveInstance.swift @@ -0,0 +1,18 @@ +import Ably + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal final class DefaultPrimitiveInstance: PrimitiveInstance { + internal var value: Primitive { + get throws(ARTErrorInfo) { + notImplemented() + } + } + + internal var type: ValueType { + notImplemented() + } + + internal func compactJson() throws(ARTErrorInfo) -> JSONValue { + notImplemented() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitivePathObject.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitivePathObject.swift new file mode 100644 index 000000000..8bbcce804 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultPrimitivePathObject.swift @@ -0,0 +1,53 @@ +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. +@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 func value() throws(ARTErrorInfo) -> Primitive? { + notImplemented() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultStatusSubscription.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultStatusSubscription.swift new file mode 100644 index 000000000..27cdff5cd --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultStatusSubscription.swift @@ -0,0 +1,8 @@ +import Ably + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal final class DefaultStatusSubscription: StatusSubscription, Sendable { + internal func off() { + notImplemented() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultSubscription.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultSubscription.swift new file mode 100644 index 000000000..0bc992e9f --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Default/DefaultSubscription.swift @@ -0,0 +1,8 @@ +import Ably + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal final class DefaultSubscription: Subscription, Sendable { + internal func unsubscribe() { + notImplemented() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/NotImplemented.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/NotImplemented.swift new file mode 100644 index 000000000..e39f007b0 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/NotImplemented.swift @@ -0,0 +1,11 @@ +/// Marks an API surface point that has not yet been implemented in this experimental target. +/// +/// Every public type in this target is currently a skeleton: the API shape is defined, but the +/// behaviour is not. Calling into any of it traps. This mirrors the requested `fail("Not implemented")` +/// behaviour; we use `fatalError` (rather than Nimble's `fail`, which is test-only and returns `Void`) +/// because it returns `Never` and therefore satisfies any return type, including `throws`/`async` +/// contexts. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal func notImplemented(_ function: StaticString = #function) -> Never { + fatalError("Not implemented: \(function)") +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/Channel+Object.swift similarity index 63% rename from LiveObjects/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift rename to LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/Channel+Object.swift index e40b8ce6a..884b93059 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/Channel+Object.swift @@ -3,12 +3,20 @@ import Ably @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) public extension ARTRealtimeChannel { - /// A ``RealtimeObjects`` object. - var objects: RealtimeObjects { - nonTypeErasedObjects + /// The ``RealtimeObject`` for this channel — the entry point into the LiveObjects API. + /// + /// From here, ``RealtimeObject/get()`` returns a ``LiveMapPathObject`` rooted at the channel's + /// root map, from which the rest of the object graph is navigated. + /// + /// > Note: It is a programmer error to access this property without first providing the + /// > `LiveObjects` plugin in the client options. + /// + /// Spec: `RTL27`. + var object: any RealtimeObject { + nonTypeErasedObject } - private var nonTypeErasedObjects: PublicDefaultRealtimeObjects { + private var nonTypeErasedObject: PublicDefaultRealtimeObject { let pluginAPI = Plugin.defaultPluginAPI let underlyingObjects = pluginAPI.underlyingObjects(for: asPluginPublicRealtimeChannel) let internalQueue = pluginAPI.internalQueue(for: underlyingObjects.client) @@ -26,7 +34,7 @@ public extension ARTRealtimeChannel { logger: logger, ) - return PublicObjectsStore.shared.getOrCreateRealtimeObjects( + return PublicObjectsStore.shared.getOrCreateRealtimeObject( proxying: internalObjects, creationArgs: .init( coreSDK: coreSDK, @@ -35,8 +43,8 @@ public extension ARTRealtimeChannel { ) } - /// For tests to access the non-public API of `PublicDefaultRealtimeObjects`. - internal var testsOnly_nonTypeErasedObjects: PublicDefaultRealtimeObjects { - nonTypeErasedObjects + /// For tests to access the non-public API of `PublicDefaultRealtimeObject`. + internal var testsOnly_nonTypeErasedObject: PublicDefaultRealtimeObject { + nonTypeErasedObject } } diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/Instance.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/Instance.swift new file mode 100644 index 000000000..8b096fddd --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/Instance.swift @@ -0,0 +1,210 @@ +import Ably + +// MARK: - Instance (RTINS / RTTS9) + +/// A direct-reference view of a `LiveObject` or primitive value. +/// +/// Unlike ``PathObject``, which is path-addressed and re-resolves on each call, an `Instance` is +/// identity-addressed: it follows the specific object it was created with, regardless of where that +/// object sits in the graph. +/// +/// An `Instance` is obtained from ``PathObject/instance()``. It is modelled as an **enum** so that +/// callers can exhaustively `switch` over the three instance kinds and obtain the correctly-typed +/// payload directly: +/// +/// ```swift +/// switch instance { +/// case let .liveMap(map): … +/// case let .liveCounter(counter): … +/// case let .primitive(primitive): … +/// } +/// ``` +/// +/// > Note: This enum shape is the Swift-specific decision recorded on **AIT-1023** (chosen over the +/// > language-agnostic base-type + `as*`-cast model of spec `RTTS9`, so that discrimination is +/// > compile-time-exhaustive and there is no undefined mismatch path). Spec: `RTINS1`, `RTTS9`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public enum Instance: Sendable { + case liveMap(any LiveMapInstance) + case liveCounter(any LiveCounterInstance) + case primitive(any PrimitiveInstance) + + /// The type of the wrapped value. Spec: `RTTS8`. O(1). + public var type: ValueType { + switch self { + case .liveMap: + .liveMap + case .liveCounter: + .liveCounter + case let .primitive(instance): + instance.type + } + } + + /// Returns a JSON-serializable, recursively-compacted representation of the wrapped value. + /// Spec: `RTINS11`, `RTINS11c` (never `nil`), `RTTS7a`. + /// + /// - Complexity: O(n) in the size of the wrapped value's subtree. + public func compactJson() throws(ARTErrorInfo) -> JSONValue { + switch self { + case let .liveMap(instance): + try instance.compactJson() + case let .liveCounter(instance): + try instance.compactJson() + case let .primitive(instance): + try instance.compactJson() + } + } +} + +// MARK: - LiveMapInstance (RTINS / RTTS10, map subset) + +/// An ``Instance`` payload exposing the members applicable when the wrapped value is a map. +/// Spec: `RTTS10`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public protocol LiveMapInstance: Sendable { + /// The `objectId` of the wrapped map. Spec: `RTINS3`. + var id: String { get } + + /// Looks up `key` and returns an ``Instance`` wrapping the result, or `nil` if absent. + /// Spec: `RTINS5`, `RTINS5c`. + func get(key: String) throws(ARTErrorInfo) -> Instance? + + /// Returns an array of `[key, Instance]` pairs for the wrapped map. Spec: `RTINS6`. + /// + /// - Complexity: O(n) in the number of entries. + func entries() throws(ARTErrorInfo) -> [(key: String, value: Instance)] + + /// Returns the keys of the wrapped map. Spec: `RTINS7`. + /// + /// - Complexity: O(n) in the number of entries. + func keys() throws(ARTErrorInfo) -> [String] + + /// Returns an ``Instance`` for each value of the wrapped map. Spec: `RTINS8`. + /// + /// - Complexity: O(n) in the number of entries. + func values() throws(ARTErrorInfo) -> [Instance] + + /// Returns the number of entries in the wrapped map. Spec: `RTTS10a`, `RTINS9`. + /// + /// Non-optional: an `Instance` is bound to an already-resolved map, so this always yields a value + /// (`throws` only for the `RTO25` access-precondition check). + var size: Int { get throws(ARTErrorInfo) } + + /// Sends an operation to set `key` to `value` on the wrapped map. Spec: `RTINS12`. + func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) + + /// Sends an operation to remove `key` from the wrapped map. Spec: `RTINS13`. + func remove(key: String) async throws(ARTErrorInfo) + + /// Registers a listener that is called each time the wrapped map is updated. Spec: `RTINS16`. + @discardableResult + func subscribe(listener: @escaping InstanceSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription + + /// A JSON-serializable, recursively-compacted representation of the wrapped map. + /// Spec: `RTINS11`, `RTINS11c` (never `nil`). + /// + /// - Complexity: O(n) in the size of the map's subtree. + func compactJson() throws(ARTErrorInfo) -> JSONValue +} + +// MARK: - LiveCounterInstance (RTINS / RTTS10, counter subset) + +/// An ``Instance`` payload exposing the members applicable when the wrapped value is a counter. +/// Spec: `RTTS10`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public protocol LiveCounterInstance: Sendable { + /// The `objectId` of the wrapped counter. Spec: `RTINS3`. + var id: String { get } + + /// The current value of the wrapped counter. Spec: `RTTS10b`, `RTINS4`. + /// + /// Non-optional: an `Instance` is bound to an already-resolved counter, so this always yields a + /// value (`throws` only for the `RTO25` access-precondition check). + var value: Double { get throws(ARTErrorInfo) } + + /// Sends an operation to increment the wrapped counter. Spec: `RTINS14`. + func increment(amount: Double) async throws(ARTErrorInfo) + + /// Sends an operation to decrement the wrapped counter. Spec: `RTINS15`. + func decrement(amount: Double) async throws(ARTErrorInfo) + + /// Registers a listener that is called each time the wrapped counter is updated. Spec: `RTINS16`. + @discardableResult + func subscribe(listener: @escaping InstanceSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription + + /// A JSON-serializable, recursively-compacted representation of the wrapped counter. + /// Spec: `RTINS11`, `RTINS11c` (never `nil`). + func compactJson() throws(ARTErrorInfo) -> JSONValue +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public extension LiveCounterInstance { + /// Sends an operation to increment the wrapped counter by 1. Spec: `RTINS14`. + func increment() async throws(ARTErrorInfo) { + try await increment(amount: 1) + } + + /// Sends an operation to decrement the wrapped counter by 1. Spec: `RTINS15`. + func decrement() async throws(ARTErrorInfo) { + try await decrement(amount: 1) + } +} + +// MARK: - PrimitiveInstance (RTINS / RTTS10, primitive subset) + +/// An ``Instance`` payload exposing the members applicable when the wrapped value is a primitive. +/// Spec: `RTTS10`. (See ``Primitive`` for the note on collapsing the six spec primitive sub-types.) +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public protocol PrimitiveInstance: Sendable { + /// The wrapped primitive value. Spec: `RTTS10c`, `RTINS4`. + /// + /// Non-optional: an `Instance` is bound to an already-resolved primitive, so this always yields a + /// value (`throws` only for the `RTO25` access-precondition check). + var value: Primitive { get throws(ARTErrorInfo) } + + /// The specific primitive type of the wrapped value (e.g. ``ValueType/string``, ``ValueType/number``). + /// Spec: `RTTS8`. O(1). + var type: ValueType { get } + + /// A JSON-serializable representation of the wrapped primitive. Spec: `RTINS11`, `RTINS11c` (never `nil`). + func compactJson() throws(ARTErrorInfo) -> JSONValue +} + +// MARK: - AsyncSequence subscription variants + +/// `AsyncStream`-based subscription for map instances. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public extension LiveMapInstance { + /// Returns an `AsyncSequence` that emits an ``InstanceSubscriptionEvent`` each time the wrapped + /// map is updated. The underlying subscription is removed when the stream is terminated. + /// Spec: `RTINS16`. + func events() throws(ARTErrorInfo) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: InstanceSubscriptionEvent.self) + let subscription = try subscribe { event in + continuation.yield(event) + } + continuation.onTermination = { _ in + subscription.unsubscribe() + } + return stream + } +} + +/// `AsyncStream`-based subscription for counter instances. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public extension LiveCounterInstance { + /// Returns an `AsyncSequence` that emits an ``InstanceSubscriptionEvent`` each time the wrapped + /// counter is updated. The underlying subscription is removed when the stream is terminated. + /// Spec: `RTINS16`. + func events() throws(ARTErrorInfo) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: InstanceSubscriptionEvent.self) + let subscription = try subscribe { event in + continuation.yield(event) + } + continuation.onTermination = { _ in + subscription.unsubscribe() + } + return stream + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/PathObject.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/PathObject.swift new file mode 100644 index 000000000..f540334b2 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/PathObject.swift @@ -0,0 +1,184 @@ +import Ably + +// MARK: - PathObject (RTPO) + +/// A lazy, path-based reference into the LiveObjects graph. +/// +/// A `PathObject` stores a path (an ordered list of string segments) from the root map and resolves +/// it at the time each method is called. This means a `PathObject` survives object replacements: if +/// the object at a given path changes, the same `PathObject` will resolve to the new object on +/// subsequent calls. +/// +/// A `PathObject` is obtained from ``RealtimeObject/get()``, which returns a ``LiveMapPathObject`` +/// rooted at the channel's root map with an empty path. Further path objects are obtained by +/// navigating with ``LiveMapPathObject/get(key:)`` or ``LiveMapPathObject/at(path:)``. +/// +/// `PathObject` is loosely typed. To obtain a view with the methods applicable to a particular +/// expected type, use ``asLiveMap()``, ``asLiveCounter()`` or ``asPrimitive()``. These do not +/// guarantee that the value actually at the path has that type; that can only be determined when the +/// value is evaluated (e.g. via `value()`), at which point `nil` is returned if the actual type +/// differs from the one requested. To discriminate the type before casting, use ``type()`` or +/// ``exists()``. +/// +/// > Note: `PathObject`'s path-resolving accessors (``instance()``, ``compactJson()``, ``exists()`` +/// > etc.) are exposed as **methods** (rather than properties) because each resolves the path at call +/// > time and is therefore O(path length) — see the Swift API Design Guidelines note on documenting +/// > non-O(1) computed properties. ``path`` is a property, since it is constant for a given +/// > `PathObject` and can be trivially cached. +/// +/// Spec: `RTPO1`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public protocol PathObject: Sendable { + /// A dot-delimited string representation of the stored path segments. Dot characters + /// occurring within individual segments are escaped with a backslash. An empty path (the root) + /// is the empty string. Spec: `RTPO4`. + var path: String { get } + + /// Resolves the path and, if it resolves to a `LiveObject`, returns an ``Instance`` wrapping it. + /// Returns `nil` if the resolved value is a primitive or if resolution fails. Spec: `RTPO8`. + func instance() throws(ARTErrorInfo) -> Instance? + + /// Resolves the path and returns a JSON-serializable, recursively-compacted representation of the + /// resolved value, or `nil` if resolution fails. Spec: `RTPO14`. + func compactJson() throws(ARTErrorInfo) -> JSONValue? + + /// Registers a listener that is called when the object at this path is updated. + /// + /// - Parameters: + /// - options: Subscription options, such as the nesting depth to observe. + /// - listener: The listener to call with a ``PathObjectSubscriptionEvent``. + /// - Returns: A ``Subscription`` that allows the listener to be deregistered. + /// Spec: `RTPO19`. + @discardableResult + func subscribe(options: PathObjectSubscriptionOptions?, listener: @escaping PathObjectSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription + + /// Resolves the path and reports whether a value exists there. Spec: `RTTS4a`. + func exists() throws(ARTErrorInfo) -> Bool + + /// Resolves the path and returns the ``ValueType`` of the value there, or `nil` if nothing + /// resolves at the path. Spec: `RTTS4b`. + func type() throws(ARTErrorInfo) -> ValueType? + + /// Returns a view of this path object typed as a ``LiveMapPathObject``. Purely a type refinement; + /// it does not resolve the path and never throws on a type mismatch. Spec: `RTTS5a`. + func asLiveMap() -> any LiveMapPathObject + + /// Returns a view of this path object typed as a ``LiveCounterPathObject``. Purely a type + /// refinement; it does not resolve the path and never throws on a type mismatch. Spec: `RTTS5b`. + func asLiveCounter() -> any LiveCounterPathObject + + /// Returns a view of this path object typed as a ``PrimitivePathObject``. Purely a type + /// refinement; it does not resolve the path and never throws on a type mismatch. Spec: `RTTS5c`. + func asPrimitive() -> any PrimitivePathObject +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public extension PathObject { + /// Registers a listener that is called when the object at this path is updated, using default + /// options. Spec: `RTPO19`. + @discardableResult + func subscribe(listener: @escaping PathObjectSubscriptionCallback) throws(ARTErrorInfo) -> any Subscription { + try subscribe(options: nil, listener: listener) + } +} + +/// `AsyncStream`-based subscription. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public extension PathObject { + /// Returns an `AsyncSequence` that emits a ``PathObjectSubscriptionEvent`` each time the object at + /// this path is updated. The underlying subscription is removed when the stream is terminated. + /// Spec: `RTPO19`. + func events(options: PathObjectSubscriptionOptions? = nil) throws(ARTErrorInfo) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: PathObjectSubscriptionEvent.self) + let subscription = try subscribe(options: options) { event in + continuation.yield(event) + } + continuation.onTermination = { _ in + subscription.unsubscribe() + } + return stream + } +} + +// MARK: - LiveMapPathObject (RTPO / RTTS6, map subset) + +/// A ``PathObject`` view exposing the methods applicable when the value at the path is expected to be +/// a map. Spec: `RTTS6`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public protocol LiveMapPathObject: PathObject { + /// Returns a new ``PathObject`` with `key` appended to this object's path. Purely navigational; + /// does not resolve the path. Spec: `RTPO5`. + func get(key: String) -> any PathObject + + /// Returns a new ``PathObject`` with the parsed segments of the dot-delimited `path` appended to + /// this object's path. Purely navigational; does not resolve the path. Spec: `RTPO6`. + func at(path: String) -> any PathObject + + /// Resolves the path and, if it resolves to a map, returns an array of `[key, PathObject]` pairs. + /// Returns an empty array if the resolved value is not a map or resolution fails. Spec: `RTPO9`. + func entries() throws(ARTErrorInfo) -> [(key: String, value: any PathObject)] + + /// Resolves the path and, if it resolves to a map, returns its keys. Returns an empty array if + /// the resolved value is not a map or resolution fails. Spec: `RTPO10`. + func keys() throws(ARTErrorInfo) -> [String] + + /// Resolves the path and, if it resolves to a map, returns a ``PathObject`` for each value. + /// Returns an empty array if the resolved value is not a map or resolution fails. Spec: `RTPO11`. + func values() throws(ARTErrorInfo) -> [any PathObject] + + /// Resolves the path and, if it resolves to a map, returns the number of entries. Returns `nil` + /// if the resolved value is not a map or resolution fails. Spec: `RTPO12`. + func size() throws(ARTErrorInfo) -> Int? + + /// Sends an operation to set `key` to `value` on the map at this path. Spec: `RTPO15`. + func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) + + /// Sends an operation to remove `key` from the map at this path. Spec: `RTPO16`. + func remove(key: String) async throws(ARTErrorInfo) +} + +// MARK: - LiveCounterPathObject (RTPO / RTTS6, counter subset) + +/// A ``PathObject`` view exposing the methods applicable when the value at the path is expected to be +/// a counter. Spec: `RTTS6`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public protocol LiveCounterPathObject: PathObject { + /// Resolves the path and, if it resolves to a counter, returns its current value. Returns `nil` + /// if the resolved value is not a counter or resolution fails. Spec: `RTTS6b`. + func value() throws(ARTErrorInfo) -> Double? + + /// Sends an operation to increment the counter at this path. Spec: `RTPO17`. + /// + /// - Parameter amount: The amount by which to increment. + func increment(amount: Double) async throws(ARTErrorInfo) + + /// Sends an operation to decrement the counter at this path. Spec: `RTPO18`. + /// + /// - Parameter amount: The amount by which to decrement. + func decrement(amount: Double) async throws(ARTErrorInfo) +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public extension LiveCounterPathObject { + /// Sends an operation to increment the counter at this path by 1. Spec: `RTPO17`. + func increment() async throws(ARTErrorInfo) { + try await increment(amount: 1) + } + + /// Sends an operation to decrement the counter at this path by 1. Spec: `RTPO18`. + func decrement() async throws(ARTErrorInfo) { + try await decrement(amount: 1) + } +} + +// MARK: - PrimitivePathObject (RTPO / RTTS6, primitive subset) + +/// A ``PathObject`` view exposing the methods applicable when the value at the path is expected to be +/// a primitive. Spec: `RTTS6`. (See ``Primitive`` for the note on collapsing the six spec primitive +/// sub-types.) +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public protocol PrimitivePathObject: PathObject { + /// Resolves the path and, if it resolves to a primitive, returns it. Returns `nil` if the + /// resolved value is not a primitive or resolution fails. Spec: `RTTS6b`. + func value() throws(ARTErrorInfo) -> Primitive? +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/PublicObjectMessage.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/PublicObjectMessage.swift new file mode 100644 index 000000000..6ea54dc19 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/PublicObjectMessage.swift @@ -0,0 +1,276 @@ +import Ably +import Foundation + +// The user-facing value types representing an inbound object message that carried an +// operation. These correspond to the spec's `PublicAPI::ObjectMessage` (PAOM), +// `PublicAPI::ObjectOperation` (PAOOP) and friends. +// +// They are delivered to subscription listeners (see ``PathObjectSubscriptionEvent`` / +// ``InstanceSubscriptionEvent``) so user code can inspect the metadata of the message that +// triggered an object change. They are modelled as plain `Sendable` value types rather than +// protocols: they carry no behaviour, only data. Their internal wire counterparts of the same +// name live under the ``ProtocolTypes`` namespace. + +// MARK: - ObjectMessage (PAOM) + +/// The user-facing representation of an inbound object message that carried an operation. +/// Spec: `PAOM`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct ObjectMessage: Sendable, Equatable { + /// The `id` of the source object message. Spec: `PAOM2a`. + public var id: String? + /// The `clientId` of the source object message. Spec: `PAOM2b`. + public var clientId: String? + /// The `connectionId` of the source object message. Spec: `PAOM2c`. + public var connectionId: String? + /// The `timestamp` of the source object message. Spec: `PAOM2d`. + public var timestamp: Date? + /// The name of the channel on which the source object message was received. Spec: `PAOM2e`. + public var channel: String + /// The operation carried by the source object message. Spec: `PAOM2f`. + public var operation: ObjectOperation + /// The `serial` of the source object message. Spec: `PAOM2g`. + public var serial: String? + /// The `serialTimestamp` of the source object message. Spec: `PAOM2h`. + public var serialTimestamp: Date? + /// The `siteCode` of the source object message. Spec: `PAOM2i`. + public var siteCode: String? + /// The `extras` of the source object message. Spec: `PAOM2j`. + public var extras: [String: JSONValue]? + + public init( + id: String? = nil, + clientId: String? = nil, + connectionId: String? = nil, + timestamp: Date? = nil, + channel: String, + operation: ObjectOperation, + serial: String? = nil, + serialTimestamp: Date? = nil, + siteCode: String? = nil, + extras: [String: JSONValue]? = nil + ) { + self.id = id + self.clientId = clientId + self.connectionId = connectionId + self.timestamp = timestamp + self.channel = channel + self.operation = operation + self.serial = serial + self.serialTimestamp = serialTimestamp + self.siteCode = siteCode + self.extras = extras + } +} + +// MARK: - ObjectOperation (PAOOP) + +/// The user-facing representation of an object operation. It is the type of +/// ``ObjectMessage/operation``. +/// +/// Unlike the wire `ObjectOperation`, it does not carry the `mapCreateWithObjectId` / +/// `counterCreateWithObjectId` variants; those outbound-only forms are resolved back to their +/// derived ``MapCreate`` / ``CounterCreate`` forms. Spec: `PAOOP`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct ObjectOperation: Sendable, Equatable { + /// The action of the operation. Spec: `PAOOP2a`. + public var action: ObjectOperationAction + /// The object ID the operation applies to. Spec: `PAOOP2b`. + public var objectId: String + /// The map-create payload, if applicable. Spec: `PAOOP2c`. + public var mapCreate: MapCreate? + /// The map-set payload, if applicable. Spec: `PAOOP2d`. + public var mapSet: MapSet? + /// The map-remove payload, if applicable. Spec: `PAOOP2e`. + public var mapRemove: MapRemove? + /// The counter-create payload, if applicable. Spec: `PAOOP2f`. + public var counterCreate: CounterCreate? + /// The counter-increment payload, if applicable. Spec: `PAOOP2g`. + public var counterInc: CounterInc? + /// The object-delete payload, if applicable. Spec: `PAOOP2h`. + public var objectDelete: ObjectDelete? + /// The map-clear payload, if applicable. Spec: `PAOOP2i`. + public var mapClear: MapClear? + + public init( + action: ObjectOperationAction, + objectId: String, + mapCreate: MapCreate? = nil, + mapSet: MapSet? = nil, + mapRemove: MapRemove? = nil, + counterCreate: CounterCreate? = nil, + counterInc: CounterInc? = nil, + objectDelete: ObjectDelete? = nil, + mapClear: MapClear? = nil + ) { + self.action = action + self.objectId = objectId + self.mapCreate = mapCreate + self.mapSet = mapSet + self.mapRemove = mapRemove + self.counterCreate = counterCreate + self.counterInc = counterInc + self.objectDelete = objectDelete + self.mapClear = mapClear + } +} + +// MARK: - ObjectOperationAction (OOP2) + +/// The set of actions that an ``ObjectOperation`` can represent. Spec: `OOP2`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public enum ObjectOperationAction: Sendable, Equatable { + case mapCreate + case mapSet + case mapRemove + case counterCreate + case counterInc + case objectDelete + case mapClear +} + +// MARK: - Operation payloads + +/// The map-create operation payload. Spec: `MCR`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct MapCreate: Sendable, Equatable { + /// The conflict-resolution semantics for the map. Spec: `MCR2a`. + public var semantics: ObjectsMapSemantics + /// The initial entries for the map. Spec: `MCR2b`. + public var entries: [String: ObjectsMapEntry] + + public init(semantics: ObjectsMapSemantics, entries: [String: ObjectsMapEntry]) { + self.semantics = semantics + self.entries = entries + } +} + +/// The map-set operation payload. Spec: `MST`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct MapSet: Sendable, Equatable { + /// The key being set. Spec: `MST2a`. + public var key: String + /// The value being set. Spec: `MST2b`. + public var value: ObjectData + + public init(key: String, value: ObjectData) { + self.key = key + self.value = value + } +} + +/// The map-remove operation payload. Spec: `MRM`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct MapRemove: Sendable, Equatable { + /// The key being removed. Spec: `MRM2a`. + public var key: String + + public init(key: String) { + self.key = key + } +} + +/// The counter-create operation payload. Spec: `CCR`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct CounterCreate: Sendable, Equatable { + /// The initial count. Spec: `CCR2a`. + public var count: Double + + public init(count: Double) { + self.count = count + } +} + +/// The counter-increment operation payload. Spec: `CIN`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct CounterInc: Sendable, Equatable { + /// The amount to increment by. Spec: `CIN2a`. + public var number: Double + + public init(number: Double) { + self.number = number + } +} + +/// The object-delete operation payload. Spec: `ODE`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct ObjectDelete: Sendable, Equatable { + public init() {} +} + +/// The map-clear operation payload. Spec: `MCL`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct MapClear: Sendable, Equatable { + public init() {} +} + +// MARK: - Supporting wire types + +/// The conflict-resolution semantics for a map. Spec: `OMP2`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public enum ObjectsMapSemantics: Sendable, Equatable { + /// Last-write-wins. Spec: `OMP2`. + case lww +} + +/// A single entry within a ``MapCreate`` payload. Spec: `OME`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct ObjectsMapEntry: Sendable, Equatable { + /// Whether this entry is tombstoned (removed). Spec: `OME2a`. + public var tombstone: Bool? + /// The timeserial at which this entry was last updated. Spec: `OME2b`. + public var timeserial: String? + /// The serial timestamp at which this entry was last updated. Spec: `OME2d`. + public var serialTimestamp: Date? + /// The entry's data. Spec: `OME2c`. + public var data: ObjectData? + + public init( + tombstone: Bool? = nil, + timeserial: String? = nil, + serialTimestamp: Date? = nil, + data: ObjectData? = nil + ) { + self.tombstone = tombstone + self.timeserial = timeserial + self.serialTimestamp = serialTimestamp + self.data = data + } +} + +/// The data value carried by a map entry or map-set operation. Spec: `OD`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct ObjectData: Sendable, Equatable { + /// The object ID, if this data references a `LiveObject`. Spec: `OD2a`. + public var objectId: String? + /// The encoding applied to the data. Spec: `OD2b`. + public var encoding: String? + /// A boolean value. Spec: `OD2c`. + public var boolean: Bool? + /// A binary value. Spec: `OD2d`. + public var bytes: Data? + /// A numeric value. Spec: `OD2e`. + public var number: Double? + /// A string value. Spec: `OD2f`. + public var string: String? + /// A JSON-encoded value. Spec: `OD2g`. + public var json: String? + + public init( + objectId: String? = nil, + encoding: String? = nil, + boolean: Bool? = nil, + bytes: Data? = nil, + number: Double? = nil, + string: String? = nil, + json: String? = nil + ) { + self.objectId = objectId + self.encoding = encoding + self.boolean = boolean + self.bytes = bytes + self.number = number + self.string = string + self.json = json + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/RealtimeObject.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/RealtimeObject.swift new file mode 100644 index 000000000..5920efb3a --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/RealtimeObject.swift @@ -0,0 +1,34 @@ +import Ably + +/// Describes the events emitted by a ``RealtimeObject``. Spec: `RTO18b`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public enum ObjectsEvent: Sendable { + /// The local copy of Objects on a channel is currently being synchronized with the Ably service. + case syncing + /// The local copy of Objects on a channel has been synchronized with the Ably service. + case synced +} + +/// Enables the Objects on a channel to be read, modified and subscribed to, via path objects. +/// +/// This is the entry point into the public LiveObjects API. ``get()`` returns a +/// ``LiveMapPathObject`` rooted at the channel's root map, from which the rest of the graph is +/// navigated. Spec: `RTO`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public protocol RealtimeObject: Sendable { + /// Returns a ``LiveMapPathObject`` rooted at the channel's root map with an empty path, once the + /// objects are synchronized with the Ably service. Spec: `RTO23`. + func get() async throws(ARTErrorInfo) -> any LiveMapPathObject + + /// Registers the provided listener for the specified event. + /// + /// To deregister the listener, call ``StatusSubscription/off()`` on the returned subscription. + /// + /// - Parameters: + /// - event: The event to listen for. + /// - callback: The listener to call when the event is emitted. + /// - Returns: A ``StatusSubscription`` that allows the listener to be deregistered. + /// Spec: `RTO18`. + @discardableResult + func on(event: ObjectsEvent, callback: @escaping @Sendable () -> Void) -> any StatusSubscription +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/Subscriptions.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/Subscriptions.swift new file mode 100644 index 000000000..bfdc6267b --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/Subscriptions.swift @@ -0,0 +1,74 @@ +import Ably + +// MARK: - Subscription (SUB) + +/// A registration for receiving events from a subscribe operation. Spec: `SUB`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public protocol Subscription: Sendable { + /// Deregisters the listener registered by the corresponding `subscribe` call. Once called, the + /// listener must not be called for any subsequent events. Calling more than once is a no-op. + /// Spec: `SUB2a`, `SUB2b`. + func unsubscribe() +} + +// MARK: - StatusSubscription (RTO18f) + +/// Object returned from ``RealtimeObject/on(event:callback:)``, allowing the listener provided in +/// that call to be deregistered. Spec: `RTO18f`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public protocol StatusSubscription: Sendable { + /// Deregisters the listener passed to the `on` call. Spec: `RTO18f1`. + func off() +} + +// MARK: - PathObject subscription + +/// The event delivered to a ``PathObject/subscribe(options:listener:)`` listener. Spec: `RTPO19e`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct PathObjectSubscriptionEvent: Sendable { + /// A ``PathObject`` pointing to the path where the change occurred. Spec: `RTPO19e1`. + public let object: any PathObject + /// The object message that triggered this event, if available. Spec: `RTPO19e2`. + public let message: ObjectMessage? + + public init(object: any PathObject, message: ObjectMessage? = nil) { + self.object = object + self.message = message + } +} + +/// Options for ``PathObject/subscribe(options:listener:)``. Spec: `RTPO19c`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct PathObjectSubscriptionOptions: Sendable { + /// Controls how many levels of path nesting below the subscription path trigger the listener. + /// Defaults to `nil`. If provided, must be a positive integer. Spec: `RTPO19c1`. + public let depth: Int? + + public init(depth: Int? = nil) { + self.depth = depth + } +} + +/// The callback used by ``PathObject/subscribe(options:listener:)``. Spec: `RTPO19a1`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public typealias PathObjectSubscriptionCallback = @Sendable (_ event: PathObjectSubscriptionEvent) -> Void + +// MARK: - Instance subscription + +/// The event delivered to an ``Instance`` subscribe listener. Spec: `RTINS16e`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct InstanceSubscriptionEvent: Sendable { + /// An ``Instance`` wrapping the underlying object. Spec: `RTINS16e1`. + public let object: Instance + /// The object message that triggered this event, if available. Spec: `RTINS16e2`. + public let message: ObjectMessage? + + public init(object: Instance, message: ObjectMessage? = nil) { + self.object = object + self.message = message + } +} + +/// The callback used by an ``Instance`` subscribe. Spec: `RTINS16a1`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public typealias InstanceSubscriptionCallback = @Sendable (_ event: InstanceSubscriptionEvent) -> Void diff --git a/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/ValueTypes.swift b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/ValueTypes.swift new file mode 100644 index 000000000..42f004aa2 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Path Based API/Public/ValueTypes.swift @@ -0,0 +1,279 @@ +import Ably +import Foundation + +// MARK: - ValueType + +/// Identifies the type of the value at a path or wrapped by an ``Instance``. Spec: `RTTS2`. +/// +/// Used by ``PathObject/type()`` to discriminate a path's resolved type before casting, and by +/// ``PrimitiveInstance/type`` / ``Instance/type`` to identify the wrapped value's type. +/// +/// > Note: the spec name for this type is `ValueType` (`RTTS2`). A rename to `TypeOfValue` has been +/// > suggested on specification #491 but is not yet decided; this SDK follows the current spec name. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public enum ValueType: Sendable, Equatable { + case string + case number + case boolean + case binary + case jsonObject + case jsonArray + case liveMap + case liveCounter + /// The resolved value has an unrecognised type. Spec: `RTTS2`. + case unknown +} + +// MARK: - Primitive + +/// Represents a primitive value that can be stored at a path in the LiveObjects graph. +/// +/// A ``PrimitivePathObject`` or ``PrimitiveInstance`` resolves to a `Primitive` when its +/// ``PrimitivePathObject/value()`` (resp. ``PrimitiveInstance/value``) is read and the underlying +/// value is in fact a primitive. +/// +/// > Note: **Deliberate divergence from `RTTS6c`/`RTTS10c`.** The spec defines six separate primitive +/// > path-object and instance sub-types (`StringPathObject`, `NumberPathObject`, … / `StringInstance`, +/// > `NumberInstance`, …), each with a type-filtered `value`. This SDK instead collapses them into a +/// > single ``PrimitivePathObject``/``PrimitiveInstance`` that resolves to this `Primitive` enum, +/// > which callers pattern-match. This is a Swift-idiomatic consolidation agreed for this SDK; it +/// > means `value` returns whatever primitive resolved rather than being pre-filtered to one type. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public enum Primitive: Sendable, Equatable { + case string(String) + case number(Double) + case bool(Bool) + case data(Data) + case jsonArray([JSONValue]) + case jsonObject([String: JSONValue]) + + // MARK: - Convenience getters for associated values + + /// If this `Primitive` has case `string`, this returns the associated value. Else, it returns `nil`. + public var stringValue: String? { + if case let .string(value) = self { + return value + } + return nil + } + + /// If this `Primitive` has case `number`, this returns the associated value. Else, it returns `nil`. + public var numberValue: Double? { + if case let .number(value) = self { + return value + } + return nil + } + + /// If this `Primitive` has case `bool`, this returns the associated value. Else, it returns `nil`. + public var boolValue: Bool? { + if case let .bool(value) = self { + return value + } + return nil + } + + /// If this `Primitive` has case `data`, this returns the associated value. Else, it returns `nil`. + public var dataValue: Data? { + if case let .data(value) = self { + return value + } + return nil + } + + /// If this `Primitive` has case `jsonArray`, this returns the associated value. Else, it returns `nil`. + public var jsonArrayValue: [JSONValue]? { + if case let .jsonArray(value) = self { + return value + } + return nil + } + + /// If this `Primitive` has case `jsonObject`, this returns the associated value. Else, it returns `nil`. + public var jsonObjectValue: [String: JSONValue]? { + if case let .jsonObject(value) = self { + return value + } + return nil + } +} + +// MARK: - LiveCounter (value type, RTLCV) + +/// A value type describing a new `LiveCounter` to be created, for use as a value passed to +/// ``LiveMapPathObject/set(key:value:)`` (or the equivalent on ``LiveMapInstance``). +/// +/// This is **not** a live, synchronized counter; it is a lightweight, local description of the +/// counter to create. The live counter is created on the Ably system when this value is set into the +/// graph. Spec: `RTLCV`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct LiveCounter: Sendable, Equatable { + /// The initial count for the counter. Spec: `RTLCV2a`. + internal let count: Double + + private init(count: Double) { + self.count = count + } + + /// Creates a new `LiveCounter` value type with the provided initial count. + /// + /// - Parameter initialCount: The initial value for the new counter. + /// Spec: `RTLCV3`. + public static func create(initialCount: Double) -> LiveCounter { + .init(count: initialCount) + } + + /// Creates a new `LiveCounter` value type with an initial count of zero. + /// Spec: `RTLCV3`. + public static func create() -> LiveCounter { + .init(count: 0) + } +} + +// MARK: - LiveMap (value type, RTLMV) + +/// A value type describing a new `LiveMap` to be created, for use as a value passed to +/// ``LiveMapPathObject/set(key:value:)`` (or the equivalent on ``LiveMapInstance``). +/// +/// This is **not** a live, synchronized map; it is a lightweight, local description of the map to +/// create. The live map is created on the Ably system when this value is set into the graph. +/// Spec: `RTLMV`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public struct LiveMap: Sendable, Equatable { + /// The initial entries for the map. Spec: `RTLMV2a`. + internal let entries: [String: LiveMapValue]? + + private init(entries: [String: LiveMapValue]?) { + self.entries = entries + } + + /// Creates a new `LiveMap` value type with the provided initial entries. + /// + /// - Parameter entries: The initial entries for the new map. + /// Spec: `RTLMV3`. + public static func create(entries: [String: LiveMapValue]) -> LiveMap { + .init(entries: entries) + } + + /// Creates a new empty `LiveMap` value type. + /// Spec: `RTLMV3`. + public static func create() -> LiveMap { + .init(entries: nil) + } +} + +// MARK: - LiveMapValue + +/// Represents the type of data that can be stored for a given key in a map, when *writing* to the +/// graph via ``LiveMapPathObject/set(key:value:)`` or ``LiveMapInstance/set(key:value:)``. +/// +/// It may be a primitive value (string, number, boolean, binary data, JSON array, or JSON object), +/// or a new ``LiveMap``/``LiveCounter`` value type to be created. +/// +/// `LiveMapValue` implements Swift's `ExpressibleBy*Literal` protocols. This, in combination with +/// `JSONValue`'s conformance to these protocols, allows you to write type-safe map values using +/// familiar syntax. For example: +/// +/// ```swift +/// try await root.asLiveMap().set(key: "someStringKey", value: "someString") +/// try await root.asLiveMap().set(key: "someJSONObjectKey", value: [ +/// "someNestedJSONObjectKey": [ +/// "someOtherKey": "someOtherValue", +/// ], +/// ]) +/// ``` +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +public enum LiveMapValue: Sendable, Equatable { + /// A primitive value (string, number, boolean, binary data, JSON array, or JSON object). + case primitive(Primitive) + case liveMap(LiveMap) + case liveCounter(LiveCounter) + + // MARK: - Convenience getters for associated values + + /// If this `LiveMapValue` has case `primitive`, this returns the associated value. Else, it returns `nil`. + public var primitiveValue: Primitive? { + if case let .primitive(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `liveMap`, this returns the associated value. Else, it returns `nil`. + public var liveMapValue: LiveMap? { + if case let .liveMap(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `liveCounter`, this returns the associated value. Else, it returns `nil`. + public var liveCounterValue: LiveCounter? { + if case let .liveCounter(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` wraps a `string` primitive, this returns the associated value. Else, it returns `nil`. + public var stringValue: String? { primitiveValue?.stringValue } + + /// If this `LiveMapValue` wraps a `number` primitive, this returns the associated value. Else, it returns `nil`. + public var numberValue: Double? { primitiveValue?.numberValue } + + /// If this `LiveMapValue` wraps a `bool` primitive, this returns the associated value. Else, it returns `nil`. + public var boolValue: Bool? { primitiveValue?.boolValue } + + /// If this `LiveMapValue` wraps a `data` primitive, this returns the associated value. Else, it returns `nil`. + public var dataValue: Data? { primitiveValue?.dataValue } + + /// If this `LiveMapValue` wraps a `jsonArray` primitive, this returns the associated value. Else, it returns `nil`. + public var jsonArrayValue: [JSONValue]? { primitiveValue?.jsonArrayValue } + + /// If this `LiveMapValue` wraps a `jsonObject` primitive, this returns the associated value. Else, it returns `nil`. + public var jsonObjectValue: [String: JSONValue]? { primitiveValue?.jsonObjectValue } +} + +// MARK: - ExpressibleBy*Literal conformances + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +extension LiveMapValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .primitive(.jsonObject(.init(uniqueKeysWithValues: elements))) + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +extension LiveMapValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .primitive(.jsonArray(elements)) + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +extension LiveMapValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .primitive(.string(value)) + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +extension LiveMapValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .primitive(.number(Double(value))) + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +extension LiveMapValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .primitive(.number(value)) + } +} + +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +extension LiveMapValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .primitive(.bool(value)) + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift b/LiveObjects/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift index 85d7d1350..df3b8ed15 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift @@ -1,10 +1,10 @@ @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal extension InboundObjectMessage { +internal extension ProtocolTypes.InboundObjectMessage { /// Creates a synthetic inbound message from an outbound message, per RTO20d2 and RTO20d3. /// /// Used to apply a locally-published operation upon receipt of the ACK from Realtime. - static func createSynthetic(from outboundMessage: OutboundObjectMessage, serial: String, siteCode: String) -> InboundObjectMessage { - InboundObjectMessage( + static func createSynthetic(from outboundMessage: ProtocolTypes.OutboundObjectMessage, serial: String, siteCode: String) -> ProtocolTypes.InboundObjectMessage { + ProtocolTypes.InboundObjectMessage( id: outboundMessage.id, clientId: outboundMessage.clientId, connectionId: outboundMessage.connectionId, diff --git a/LiveObjects/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/LiveObjects/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 84a6289cf..437292311 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -4,124 +4,127 @@ import Foundation // This file contains the ObjectMessage types that we use within the codebase. We convert them to and from the corresponding wire types (e.g. `InboundWireObjectMessage`) for sending and receiving over the wire. -/// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal struct InboundObjectMessage: Equatable { - internal var id: String? // OM2a - internal var clientId: String? // OM2b - internal var connectionId: String? // OM2c - internal var extras: [String: JSONValue]? // OM2d - internal var timestamp: Date? // OM2e - internal var operation: ObjectOperation? // OM2f - internal var object: ObjectState? // OM2g - internal var serial: String? // OM2h - internal var siteCode: String? // OM2i - internal var serialTimestamp: Date? // OM2j -} - -/// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. +/// Namespace for the internal "protocol" representations of an object message and its constituent +/// operations, states and data. /// -/// - Important: When adding new fields, also update ``InboundObjectMessage/createSynthetic(from:serial:siteCode:)``. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal struct OutboundObjectMessage: Equatable { - internal var id: String? // OM2a - internal var clientId: String? // OM2b - internal var connectionId: String? - internal var extras: [String: JSONValue]? // OM2d - internal var timestamp: Date? // OM2e - internal var operation: ObjectOperation? // OM2f - internal var object: ObjectState? // OM2g - internal var serial: String? // OM2h - internal var siteCode: String? // OM2i - internal var serialTimestamp: Date? // OM2j -} +/// These types are scoped under `ProtocolTypes` to disambiguate them from the identically-named +/// public value types (e.g. `ObjectOperation` / `ObjectData`) that the SDK exposes to users; see the +/// `Path Based API` directory. They mirror the wire types (e.g. ``InboundWireObjectMessage``) but +/// with decoded, strongly-typed payloads. +/// +/// > Note: The spec-suggested name for this namespace was `Protocol`, but that clashes with the +/// > Objective-C `Protocol` type imported via Foundation (ambiguous for importers such as the test +/// > target), so `ProtocolTypes` is used instead. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal enum ProtocolTypes { + /// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. + internal struct InboundObjectMessage: Equatable { + internal var id: String? // OM2a + internal var clientId: String? // OM2b + internal var connectionId: String? // OM2c + internal var extras: [String: JSONValue]? // OM2d + internal var timestamp: Date? // OM2e + internal var operation: ObjectOperation? // OM2f + internal var object: ObjectState? // OM2g + internal var serial: String? // OM2h + internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j + } -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal struct ObjectOperation: Equatable { - internal var action: WireEnum // OOP3a - internal var objectId: String // OOP3b - internal var mapCreate: MapCreate? // OOP3j - internal var mapSet: MapSet? // OOP3k - internal var mapRemove: WireMapRemove? // OOP3l - internal var counterCreate: WireCounterCreate? // OOP3m - internal var counterInc: WireCounterInc? // OOP3n - internal var objectDelete: WireObjectDelete? // OOP3o - internal var mapCreateWithObjectId: MapCreateWithObjectId? // OOP3p - internal var counterCreateWithObjectId: CounterCreateWithObjectId? // OOP3q - internal var mapClear: WireMapClear? // OOP3r -} + /// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. + /// + /// - Important: When adding new fields, also update ``InboundObjectMessage/createSynthetic(from:serial:siteCode:)``. + internal struct OutboundObjectMessage: Equatable { + internal var id: String? // OM2a + internal var clientId: String? // OM2b + internal var connectionId: String? + internal var extras: [String: JSONValue]? // OM2d + internal var timestamp: Date? // OM2e + internal var operation: ObjectOperation? // OM2f + internal var object: ObjectState? // OM2g + internal var serial: String? // OM2h + internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j + } -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal struct ObjectData: Equatable { - internal var objectId: String? // OD2a - internal var boolean: Bool? // OD2c - internal var bytes: Data? // OD2d - internal var number: NSNumber? // OD2e - internal var string: String? // OD2f - internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) -} + internal struct ObjectOperation: Equatable { + internal var action: WireEnum // OOP3a + internal var objectId: String // OOP3b + internal var mapCreate: MapCreate? // OOP3j + internal var mapSet: MapSet? // OOP3k + internal var mapRemove: WireMapRemove? // OOP3l + internal var counterCreate: WireCounterCreate? // OOP3m + internal var counterInc: WireCounterInc? // OOP3n + internal var objectDelete: WireObjectDelete? // OOP3o + internal var mapCreateWithObjectId: MapCreateWithObjectId? // OOP3p + internal var counterCreateWithObjectId: CounterCreateWithObjectId? // OOP3q + internal var mapClear: WireMapClear? // OOP3r + } -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal struct MapSet: Equatable { - internal var key: String // MST2a - internal var value: ObjectData? // MST2b -} + internal struct ObjectData: Equatable { + internal var objectId: String? // OD2a + internal var boolean: Bool? // OD2c + internal var bytes: Data? // OD2d + internal var number: NSNumber? // OD2e + internal var string: String? // OD2f + internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + } -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal struct MapCreate: Equatable { - internal var semantics: WireEnum // MCR2a - internal var entries: [String: ObjectsMapEntry]? // MCR2b -} + internal struct MapSet: Equatable { + internal var key: String // MST2a + internal var value: ObjectData? // MST2b + } -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal struct MapCreateWithObjectId: Equatable { - internal var initialValue: String // MCRO2a - internal var nonce: String // MCRO2b - - /// The source `MapCreate` from which this `MapCreateWithObjectId` was derived. - /// For local use only (apply-on-ACK per RTLM23); must not be sent over the wire. - /// - SeeAlso: RTO11f18 - internal var derivedFrom: MapCreate? -} + internal struct MapCreate: Equatable { + internal var semantics: WireEnum // MCR2a + internal var entries: [String: ObjectsMapEntry]? // MCR2b + } -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal struct CounterCreateWithObjectId: Equatable { - internal var initialValue: String // CCRO2a - internal var nonce: String // CCRO2b - - /// The source `WireCounterCreate` from which this `CounterCreateWithObjectId` was derived. - /// For local use only (apply-on-ACK per RTLC16); must not be sent over the wire. - /// - SeeAlso: RTO12f16 - internal var derivedFrom: WireCounterCreate? -} + internal struct MapCreateWithObjectId: Equatable { + internal var initialValue: String // MCRO2a + internal var nonce: String // MCRO2b -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal struct ObjectsMapEntry: Equatable { - internal var tombstone: Bool? // OME2a - internal var timeserial: String? // OME2b - internal var data: ObjectData? // OME2c - internal var serialTimestamp: Date? // OME2d -} + /// The source `MapCreate` from which this `MapCreateWithObjectId` was derived. + /// For local use only (apply-on-ACK per RTLM23); must not be sent over the wire. + /// - SeeAlso: RTO11f18 + internal var derivedFrom: MapCreate? + } -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal struct ObjectsMap: Equatable { - internal var semantics: WireEnum // OMP3a - internal var entries: [String: ObjectsMapEntry]? // OMP3b - internal var clearTimeserial: String? // OMP3c -} + internal struct CounterCreateWithObjectId: Equatable { + internal var initialValue: String // CCRO2a + internal var nonce: String // CCRO2b -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal struct ObjectState: Equatable { - internal var objectId: String // OST2a - internal var siteTimeserials: [String: String] // OST2b - internal var tombstone: Bool // OST2c - internal var createOp: ObjectOperation? // OST2d - internal var map: ObjectsMap? // OST2e - internal var counter: WireObjectsCounter? // OST2f + /// The source `WireCounterCreate` from which this `CounterCreateWithObjectId` was derived. + /// For local use only (apply-on-ACK per RTLC16); must not be sent over the wire. + /// - SeeAlso: RTO12f16 + internal var derivedFrom: WireCounterCreate? + } + + internal struct ObjectsMapEntry: Equatable { + internal var tombstone: Bool? // OME2a + internal var timeserial: String? // OME2b + internal var data: ObjectData? // OME2c + internal var serialTimestamp: Date? // OME2d + } + + internal struct ObjectsMap: Equatable { + internal var semantics: WireEnum // OMP3a + internal var entries: [String: ObjectsMapEntry]? // OMP3b + internal var clearTimeserial: String? // OMP3c + } + + internal struct ObjectState: Equatable { + internal var objectId: String // OST2a + internal var siteTimeserials: [String: String] // OST2b + internal var tombstone: Bool // OST2c + internal var createOp: ObjectOperation? // OST2d + internal var map: ObjectsMap? // OST2e + internal var counter: WireObjectsCounter? // OST2f + } } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal extension InboundObjectMessage { +internal extension ProtocolTypes.InboundObjectMessage { /// Initializes an `InboundObjectMessage` from an `InboundWireObjectMessage`, applying the data decoding rules of OD5. /// /// - Parameters: @@ -149,7 +152,7 @@ internal extension InboundObjectMessage { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal extension OutboundObjectMessage { +internal extension ProtocolTypes.OutboundObjectMessage { /// Converts this `OutboundObjectMessage` to an `OutboundWireObjectMessage`, applying the data encoding rules of OD4. /// /// - Parameters: @@ -171,7 +174,7 @@ internal extension OutboundObjectMessage { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal extension ObjectOperation { +internal extension ProtocolTypes.ObjectOperation { /// Initializes an `ObjectOperation` from a `WireObjectOperation`, applying the data decoding rules of OD5. /// /// - Parameters: @@ -222,7 +225,7 @@ internal extension ObjectOperation { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal extension ObjectData { +internal extension ProtocolTypes.ObjectData { /// Initializes an `ObjectData` from a `WireObjectData`, applying the data decoding rules of OD5. /// /// - Parameters: @@ -327,7 +330,7 @@ internal extension ObjectData { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal extension MapSet { +internal extension ProtocolTypes.MapSet { init( wireMapSet: WireMapSet, format: _AblyPluginSupportPrivate.EncodingFormat @@ -347,7 +350,7 @@ internal extension MapSet { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal extension MapCreate { +internal extension ProtocolTypes.MapCreate { init( wireMapCreate: WireMapCreate, format: _AblyPluginSupportPrivate.EncodingFormat @@ -367,7 +370,7 @@ internal extension MapCreate { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal extension MapCreateWithObjectId { +internal extension ProtocolTypes.MapCreateWithObjectId { init(wireMapCreateWithObjectId: WireMapCreateWithObjectId) { nonce = wireMapCreateWithObjectId.nonce initialValue = wireMapCreateWithObjectId.initialValue @@ -379,7 +382,7 @@ internal extension MapCreateWithObjectId { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal extension CounterCreateWithObjectId { +internal extension ProtocolTypes.CounterCreateWithObjectId { init(wireCounterCreateWithObjectId: WireCounterCreateWithObjectId) { nonce = wireCounterCreateWithObjectId.nonce initialValue = wireCounterCreateWithObjectId.initialValue @@ -391,7 +394,7 @@ internal extension CounterCreateWithObjectId { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal extension ObjectsMapEntry { +internal extension ProtocolTypes.ObjectsMapEntry { /// Initializes an `ObjectsMapEntry` from a `WireObjectsMapEntry`, applying the data decoding rules of OD5. /// /// - Parameters: @@ -425,7 +428,7 @@ internal extension ObjectsMapEntry { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal extension ObjectsMap { +internal extension ProtocolTypes.ObjectsMap { /// Initializes an `ObjectsMap` from a `WireObjectsMap`, applying the data decoding rules of OD5. /// /// - Parameters: @@ -456,7 +459,7 @@ internal extension ObjectsMap { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal extension ObjectState { +internal extension ProtocolTypes.ObjectState { /// Initializes an `ObjectState` from a `WireObjectState`, applying the data decoding rules of OD5. /// /// - Parameters: @@ -497,7 +500,7 @@ internal extension ObjectState { // MARK: - CustomDebugStringConvertible @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension InboundObjectMessage: CustomDebugStringConvertible { +extension ProtocolTypes.InboundObjectMessage: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -517,7 +520,7 @@ extension InboundObjectMessage: CustomDebugStringConvertible { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension OutboundObjectMessage: CustomDebugStringConvertible { +extension ProtocolTypes.OutboundObjectMessage: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -537,7 +540,7 @@ extension OutboundObjectMessage: CustomDebugStringConvertible { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension ObjectOperation: CustomDebugStringConvertible { +extension ProtocolTypes.ObjectOperation: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -558,7 +561,7 @@ extension ObjectOperation: CustomDebugStringConvertible { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension ObjectState: CustomDebugStringConvertible { +extension ProtocolTypes.ObjectState: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -574,7 +577,7 @@ extension ObjectState: CustomDebugStringConvertible { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension ObjectsMap: CustomDebugStringConvertible { +extension ProtocolTypes.ObjectsMap: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -594,7 +597,7 @@ extension ObjectsMap: CustomDebugStringConvertible { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension ObjectsMapEntry: CustomDebugStringConvertible { +extension ProtocolTypes.ObjectsMapEntry: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -608,7 +611,7 @@ extension ObjectsMapEntry: CustomDebugStringConvertible { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension ObjectData: CustomDebugStringConvertible { +extension ProtocolTypes.ObjectData: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -624,7 +627,7 @@ extension ObjectData: CustomDebugStringConvertible { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension MapSet: CustomDebugStringConvertible { +extension ProtocolTypes.MapSet: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -636,7 +639,7 @@ extension MapSet: CustomDebugStringConvertible { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension MapCreate: CustomDebugStringConvertible { +extension ProtocolTypes.MapCreate: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -655,7 +658,7 @@ extension MapCreate: CustomDebugStringConvertible { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension MapCreateWithObjectId: CustomDebugStringConvertible { +extension ProtocolTypes.MapCreateWithObjectId: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -668,7 +671,7 @@ extension MapCreateWithObjectId: CustomDebugStringConvertible { } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension CounterCreateWithObjectId: CustomDebugStringConvertible { +extension ProtocolTypes.CounterCreateWithObjectId: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] diff --git a/LiveObjects/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/LiveObjects/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 4f40e0d72..856506172 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -148,27 +148,28 @@ extension OutboundWireObjectMessage: WireObjectEncodable { } } -// OOP2 @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal enum ObjectOperationAction: Int { - case mapCreate = 0 - case mapSet = 1 - case mapRemove = 2 - case counterCreate = 3 - case counterInc = 4 - case objectDelete = 5 - case mapClear = 6 -} +internal extension ProtocolTypes { + // OOP2 + enum ObjectOperationAction: Int { + case mapCreate = 0 + case mapSet = 1 + case mapRemove = 2 + case counterCreate = 3 + case counterInc = 4 + case objectDelete = 5 + case mapClear = 6 + } -// OMP2 -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal enum ObjectsMapSemantics: Int { - case lww = 0 + // OMP2 + enum ObjectsMapSemantics: Int { + case lww = 0 + } } @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireObjectOperation { - internal var action: WireEnum // OOP3a + internal var action: WireEnum // OOP3a internal var objectId: String // OOP3b internal var mapCreate: WireMapCreate? // OOP3j internal var mapSet: WireMapSet? // OOP3k @@ -309,7 +310,7 @@ extension WireObjectState: WireObjectCodable { @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireObjectsMap { - internal var semantics: WireEnum // OMP3a + internal var semantics: WireEnum // OMP3a internal var entries: [String: WireObjectsMapEntry]? // OMP3b internal var clearTimeserial: String? // OMP3c } @@ -428,7 +429,7 @@ extension WireMapRemove: WireObjectCodable { @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal struct WireMapCreate { - internal var semantics: WireEnum // MCR2a + internal var semantics: WireEnum // MCR2a internal var entries: [String: WireObjectsMapEntry]? // MCR2b } diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift deleted file mode 100644 index 50a0554b4..000000000 --- a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift +++ /dev/null @@ -1,52 +0,0 @@ -internal import _AblyPluginSupportPrivate - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal extension InternalLiveMapValue { - // MARK: - Mapping to public types - - struct PublicValueCreationArgs { - internal var coreSDK: CoreSDK - internal var realtimeObjects: any InternalRealtimeObjectsProtocol - internal var logger: Logger - - internal var toCounterCreationArgs: PublicObjectsStore.CounterCreationArgs { - .init(coreSDK: coreSDK, realtimeObjects: realtimeObjects, logger: logger) - } - - internal var toMapCreationArgs: PublicObjectsStore.MapCreationArgs { - .init(coreSDK: coreSDK, realtimeObjects: realtimeObjects, logger: logger) - } - } - - /// Fetches the cached public object that wraps this `InternalLiveMapValue`'s associated value, creating a new public object if there isn't already one. - func toPublic(creationArgs: PublicValueCreationArgs) -> LiveMapValue { - switch self { - case let .string(value): - .string(value) - case let .number(value): - .number(value) - case let .bool(value): - .bool(value) - case let .data(value): - .data(value) - case let .jsonArray(value): - .jsonArray(value) - case let .jsonObject(value): - .jsonObject(value) - case let .liveMap(internalLiveMap): - .liveMap( - PublicObjectsStore.shared.getOrCreateMap( - proxying: internalLiveMap, - creationArgs: creationArgs.toMapCreationArgs, - ), - ) - case let .liveCounter(internalLiveCounter): - .liveCounter( - PublicObjectsStore.shared.getOrCreateCounter( - proxying: internalLiveCounter, - creationArgs: creationArgs.toCounterCreationArgs, - ), - ) - } - } -} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift deleted file mode 100644 index 7778f3a60..000000000 --- a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ /dev/null @@ -1,55 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// Our default implementation of ``LiveCounter``. -/// -/// This is largely a wrapper around ``InternalDefaultLiveCounter``. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal final class PublicDefaultLiveCounter: LiveCounter { - internal let proxied: InternalDefaultLiveCounter - - // MARK: - Dependencies that hold a strong reference to `proxied` - - private let coreSDK: CoreSDK - private let realtimeObjects: any InternalRealtimeObjectsProtocol - private let logger: Logger - - internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK, realtimeObjects: any InternalRealtimeObjectsProtocol, logger: Logger) { - self.proxied = proxied - self.coreSDK = coreSDK - self.realtimeObjects = realtimeObjects - self.logger = logger - } - - // MARK: - `LiveCounter` protocol - - internal var value: Double { - get throws(ARTErrorInfo) { - try proxied.value(coreSDK: coreSDK) - } - } - - internal func increment(amount: Double) async throws(ARTErrorInfo) { - try await proxied.increment(amount: amount, coreSDK: coreSDK, realtimeObjects: realtimeObjects) - } - - internal func decrement(amount: Double) async throws(ARTErrorInfo) { - try await proxied.decrement(amount: amount, coreSDK: coreSDK, realtimeObjects: realtimeObjects) - } - - internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { - try proxied.subscribe(listener: listener, coreSDK: coreSDK) - } - - internal func unsubscribeAll() { - proxied.unsubscribeAll() - } - - internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { - proxied.on(event: event, callback: callback) - } - - internal func offAll() { - proxied.offAll() - } -} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift deleted file mode 100644 index e5374078a..000000000 --- a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ /dev/null @@ -1,104 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// Our default implementation of ``LiveMap``. -/// -/// This is largely a wrapper around ``InternalDefaultLiveMap``. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal final class PublicDefaultLiveMap: LiveMap { - internal let proxied: InternalDefaultLiveMap - - // MARK: - Dependencies that hold a strong reference to `proxied` - - private let coreSDK: CoreSDK - private let realtimeObjects: any InternalRealtimeObjectsProtocol - private let logger: Logger - - internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, realtimeObjects: any InternalRealtimeObjectsProtocol, logger: Logger) { - self.proxied = proxied - self.coreSDK = coreSDK - self.realtimeObjects = realtimeObjects - self.logger = logger - } - - // MARK: - `LiveMap` protocol - - internal func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? { - try proxied.get(key: key, coreSDK: coreSDK, delegate: realtimeObjects)?.toPublic( - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: realtimeObjects, - logger: logger, - ), - ) - } - - internal var size: Int { - get throws(ARTErrorInfo) { - try proxied.size(coreSDK: coreSDK, delegate: realtimeObjects) - } - } - - internal var entries: [(key: String, value: LiveMapValue)] { - get throws(ARTErrorInfo) { - try proxied.entries(coreSDK: coreSDK, delegate: realtimeObjects).map { entry in - ( - entry.key, - entry.value.toPublic( - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: realtimeObjects, - logger: logger, - ), - ) - ) - } - } - } - - internal var keys: [String] { - get throws(ARTErrorInfo) { - try proxied.keys(coreSDK: coreSDK, delegate: realtimeObjects) - } - } - - internal var values: [LiveMapValue] { - get throws(ARTErrorInfo) { - try proxied.values(coreSDK: coreSDK, delegate: realtimeObjects).map { value in - value.toPublic( - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: realtimeObjects, - logger: logger, - ), - ) - } - } - } - - internal func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) { - let internalValue = InternalLiveMapValue(liveMapValue: value) - - try await proxied.set(key: key, value: internalValue, coreSDK: coreSDK, realtimeObjects: realtimeObjects) - } - - internal func remove(key: String) async throws(ARTErrorInfo) { - try await proxied.remove(key: key, coreSDK: coreSDK, realtimeObjects: realtimeObjects) - } - - internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { - try proxied.subscribe(listener: listener, coreSDK: coreSDK) - } - - internal func unsubscribeAll() { - proxied.unsubscribeAll() - } - - internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { - proxied.on(event: event, callback: callback) - } - - internal func offAll() { - proxied.offAll() - } -} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift new file mode 100644 index 000000000..1c2bbfc04 --- /dev/null +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObject.swift @@ -0,0 +1,43 @@ +import Ably + +/// The default implementation of the public ``RealtimeObject`` entry point, backing +/// ``ARTRealtimeChannel/object``. +/// +/// This is largely a wrapper around ``InternalDefaultRealtimeObjects``. The `Public` prefix +/// expresses the contrast with that internal type, per the documented memory-management policy (the +/// public proxy holds a strong reference to the internal object, not vice versa); hence it lives +/// alongside the other proxy objects in `Public/Public Proxy Objects`. +@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) +internal final class PublicDefaultRealtimeObject: RealtimeObject { + private let proxied: InternalDefaultRealtimeObjects + + // MARK: - Dependencies that hold a strong reference to `proxied` + + private let coreSDK: CoreSDK + private let logger: Logger + + internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK, logger: Logger) { + self.proxied = proxied + self.coreSDK = coreSDK + self.logger = logger + } + + internal var testsOnly_proxied: InternalDefaultRealtimeObjects { + proxied + } + + internal var testsOnly_coreSDK: CoreSDK { + coreSDK + } + + // MARK: - `RealtimeObject` protocol + + internal func get() async throws(ARTErrorInfo) -> any LiveMapPathObject { + notImplemented() + } + + @discardableResult + internal func on(event _: ObjectsEvent, callback _: @escaping @Sendable () -> Void) -> any StatusSubscription { + notImplemented() + } +} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift deleted file mode 100644 index d07567f38..000000000 --- a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ /dev/null @@ -1,132 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. -/// -/// This is largely a wrapper around ``InternalDefaultRealtimeObjects``. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -internal final class PublicDefaultRealtimeObjects: RealtimeObjects { - private let proxied: InternalDefaultRealtimeObjects - internal var testsOnly_proxied: InternalDefaultRealtimeObjects { - proxied - } - - // MARK: - Dependencies that hold a strong reference to `proxied` - - private let coreSDK: CoreSDK - private let logger: Logger - - internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK, logger: Logger) { - self.proxied = proxied - self.coreSDK = coreSDK - self.logger = logger - } - - // MARK: - `RealtimeObjects` protocol - - internal func getRoot() async throws(ARTErrorInfo) -> any LiveMap { - let internalMap = try await proxied.getRoot(coreSDK: coreSDK) - return PublicObjectsStore.shared.getOrCreateMap( - proxying: internalMap, - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: proxied, - logger: logger, - ), - ) - } - - internal func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { - let internalEntries: [String: InternalLiveMapValue] = entries.mapValues { .init(liveMapValue: $0) } - let internalMap = try await proxied.createMap(entries: internalEntries, coreSDK: coreSDK) - - return PublicObjectsStore.shared.getOrCreateMap( - proxying: internalMap, - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: proxied, - logger: logger, - ), - ) - } - - internal func createMap() async throws(ARTErrorInfo) -> any LiveMap { - let internalMap = try await proxied.createMap(coreSDK: coreSDK) - - return PublicObjectsStore.shared.getOrCreateMap( - proxying: internalMap, - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: proxied, - logger: logger, - ), - ) - } - - internal func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter { - let internalCounter = try await proxied.createCounter(count: count, coreSDK: coreSDK) - - return PublicObjectsStore.shared.getOrCreateCounter( - proxying: internalCounter, - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: proxied, - logger: logger, - ), - ) - } - - internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter { - let internalCounter = try await proxied.createCounter(coreSDK: coreSDK) - - return PublicObjectsStore.shared.getOrCreateCounter( - proxying: internalCounter, - creationArgs: .init( - coreSDK: coreSDK, - realtimeObjects: proxied, - logger: logger, - ), - ) - } - - internal func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { - proxied.on(event: event, callback: callback) - } - - internal func offAll() { - proxied.offAll() - } - - // MARK: - Test-only APIs - - // These are only used by our plumbingSmokeTest (the rest of our unit tests test the internal classes, not the public ones). - - internal var testsOnly_onChannelAttachedHasObjects: Bool? { - proxied.testsOnly_onChannelAttachedHasObjects - } - - internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { - proxied.testsOnly_receivedObjectProtocolMessages - } - - internal func testsOnly_publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) { - try await proxied.testsOnly_publish(objectMessages: objectMessages, coreSDK: coreSDK) - } - - internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { - proxied.testsOnly_receivedObjectSyncProtocolMessages - } - - // These are used by the integration tests. - - /// Replaces the method that this `RealtimeObjects` uses to send any outbound `ObjectMessage`s. - /// - /// Used by integration tests, for example to disable `ObjectMessage` publishing so that a test can verify that a behaviour is not a side effect of an `ObjectMessage` sent by the SDK. - internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { - coreSDK.testsOnly_overridePublish(with: newImplementation) - } - - internal var testsOnly_gcGracePeriod: TimeInterval { - proxied.testsOnly_gcGracePeriod - } -} diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift index 35a73c66b..898ed26f2 100644 --- a/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift +++ b/LiveObjects/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift @@ -1,17 +1,17 @@ -internal import _AblyPluginSupportPrivate import Foundation -/// Stores the public objects that wrap the SDK's internal components. +/// Stores the public path-based objects that wrap the SDK's internal components. /// -/// This allows us to provide stable object identity for our public `RealtimeObjects`, `LiveMap`, and `LiveCounter` objects. Concretely, this means that it allows us to, for example, consistently return: +/// This allows us to provide stable object identity for our public objects. Concretely, it allows us +/// to consistently return the same `PublicDefaultRealtimeObject` instance across multiple calls to +/// `ARTRealtimeChannel.object`. It mirrors the mechanism previously used for the (now-removed) +/// `objects` API. /// -/// - the same `PublicDefaultRealtimeObjects` instance across multiple calls to `ARTRealtimeChannel.objects` -/// - the same `PublicDefaultLiveMap` instance across multiple calls to `PublicDefaultRealtimeObjects.getRoot()` -/// - the same `PublicDefaultLiveMap` and `PublicDefaultLiveCounter` instance across multiple calls to `PublicDefaultLiveMap.get(…)` with the same key (similarly for other `LiveMap` getters) -/// -/// This differs from the approach that we take in ably-cocoa, in which we create a new public object each time we need to return one. Given that the LiveObjects SDK revolves around the concept of various live-updating objects, it seemed like it might be quite a confusing user experience if the pointer identity of, say, a `LiveMap` changed each time it was fetched. -/// -/// - Note: We can only make a best-effort attempt to maintain the pointer identity of the public objects. Since the SDK cannot maintain a strong reference to the public objects (given that the whole reason that these objects exist is for us to know whether the user holds a strong reference to them), if the user releases all of their strong references to a public object then the next time they fetch the public object they will receive a new object. +/// - Note: We can only make a best-effort attempt to maintain the pointer identity of the public +/// objects. Since the SDK cannot maintain a strong reference to the public objects (given that the +/// whole reason that these objects exist is for us to know whether the user holds a strong reference +/// to them), if the user releases all of their strong references to a public object then the next +/// time they fetch the public object they will receive a new object. @available(macOS 11.0, iOS 14.0, tvOS 14.0, *) internal final class PublicObjectsStore: Sendable { // Used to synchronize access to mutable state @@ -20,48 +20,20 @@ internal final class PublicObjectsStore: Sendable { internal static let shared = PublicObjectsStore() - internal struct RealtimeObjectsCreationArgs { - internal var coreSDK: CoreSDK - internal var logger: Logger - } - - /// Fetches the cached `PublicDefaultRealtimeObjects` that wraps a given `InternalDefaultRealtimeObjects`, creating a new public object if there isn't already one. - internal func getOrCreateRealtimeObjects(proxying proxied: InternalDefaultRealtimeObjects, creationArgs: RealtimeObjectsCreationArgs) -> PublicDefaultRealtimeObjects { - mutex.withLock { - mutableState.getOrCreateRealtimeObjects(proxying: proxied, creationArgs: creationArgs) - } - } - - internal struct CounterCreationArgs { + internal struct RealtimeObjectCreationArgs { internal var coreSDK: CoreSDK - internal var realtimeObjects: any InternalRealtimeObjectsProtocol internal var logger: Logger } - /// Fetches the cached `PublicDefaultLiveCounter` that wraps a given `InternalDefaultLiveCounter`, creating a new public object if there isn't already one. - internal func getOrCreateCounter(proxying proxied: InternalDefaultLiveCounter, creationArgs: CounterCreationArgs) -> PublicDefaultLiveCounter { + /// Fetches the cached `PublicDefaultRealtimeObject` that wraps a given `InternalDefaultRealtimeObjects`, creating a new public object if there isn't already one. + internal func getOrCreateRealtimeObject(proxying proxied: InternalDefaultRealtimeObjects, creationArgs: RealtimeObjectCreationArgs) -> PublicDefaultRealtimeObject { mutex.withLock { - mutableState.getOrCreateCounter(proxying: proxied, creationArgs: creationArgs) - } - } - - internal struct MapCreationArgs { - internal var coreSDK: CoreSDK - internal var realtimeObjects: any InternalRealtimeObjectsProtocol - internal var logger: Logger - } - - /// Fetches the cached `PublicDefaultLiveMap` that wraps a given `InternalDefaultLiveMap`, creating a new public object if there isn't already one. - internal func getOrCreateMap(proxying proxied: InternalDefaultLiveMap, creationArgs: MapCreationArgs) -> PublicDefaultLiveMap { - mutex.withLock { - mutableState.getOrCreateMap(proxying: proxied, creationArgs: creationArgs) + mutableState.getOrCreateRealtimeObject(proxying: proxied, creationArgs: creationArgs) } } private struct MutableState { - private var realtimeObjectsProxies = Proxies() - private var counterProxies = Proxies() - private var mapProxies = Proxies() + private var realtimeObjectProxies = Proxies() /// Stores weak references to proxy objects. private struct Proxies { @@ -105,54 +77,18 @@ internal final class PublicObjectsStore: Sendable { } } - internal mutating func getOrCreateRealtimeObjects( + internal mutating func getOrCreateRealtimeObject( proxying proxied: InternalDefaultRealtimeObjects, - creationArgs: RealtimeObjectsCreationArgs, - ) -> PublicDefaultRealtimeObjects { - realtimeObjectsProxies.getOrCreate( - proxying: proxied, - logger: creationArgs.logger, - logObjectType: "RealtimeObjects", - ) { - .init( - proxied: proxied, - coreSDK: creationArgs.coreSDK, - logger: creationArgs.logger, - ) - } - } - - internal mutating func getOrCreateCounter( - proxying proxied: InternalDefaultLiveCounter, - creationArgs: CounterCreationArgs, - ) -> PublicDefaultLiveCounter { - counterProxies.getOrCreate( - proxying: proxied, - logger: creationArgs.logger, - logObjectType: "LiveCounter", - ) { - .init( - proxied: proxied, - coreSDK: creationArgs.coreSDK, - realtimeObjects: creationArgs.realtimeObjects, - logger: creationArgs.logger, - ) - } - } - - internal mutating func getOrCreateMap( - proxying proxied: InternalDefaultLiveMap, - creationArgs: MapCreationArgs, - ) -> PublicDefaultLiveMap { - mapProxies.getOrCreate( + creationArgs: RealtimeObjectCreationArgs, + ) -> PublicDefaultRealtimeObject { + realtimeObjectProxies.getOrCreate( proxying: proxied, logger: creationArgs.logger, - logObjectType: "LiveMap", + logObjectType: "RealtimeObject", ) { .init( proxied: proxied, coreSDK: creationArgs.coreSDK, - realtimeObjects: creationArgs.realtimeObjects, logger: creationArgs.logger, ) } diff --git a/LiveObjects/Sources/AblyLiveObjects/Public/PublicTypes.swift b/LiveObjects/Sources/AblyLiveObjects/Public/PublicTypes.swift deleted file mode 100644 index 659184a1d..000000000 --- a/LiveObjects/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ /dev/null @@ -1,412 +0,0 @@ -import Ably - -/// A callback used in ``LiveObject`` to listen for updates to the object. -/// -/// - Parameters: -/// - update: The update object describing the changes made to the object. -/// - subscription: A ``SubscribeResponse`` object that allows the provided listener to deregister itself from future updates. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public typealias LiveObjectUpdateCallback = @Sendable (_ update: sending T, _ subscription: SubscribeResponse) -> Void - -/// The callback used for the events emitted by ``RealtimeObjects``. -/// -/// - Parameter subscription: An ``OnObjectsEventResponse`` object that allows the provided listener to deregister itself from future updates. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public typealias ObjectsEventCallback = @Sendable (_ subscription: OnObjectsEventResponse) -> Void - -/// The callback used for the lifecycle events emitted by ``LiveObject``. -/// - Parameter subscription: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to deregister itself from future updates. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public typealias LiveObjectLifecycleEventCallback = @Sendable (_ subscription: OnLiveObjectLifecycleEventResponse) -> Void - -/// Describes the events emitted by an ``RealtimeObjects`` object. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public enum ObjectsEvent: Sendable { - /// The local copy of Objects on a channel is currently being synchronized with the Ably service. - case syncing - /// The local copy of Objects on a channel has been synchronized with the Ably service. - case synced -} - -/// Describes the events emitted by a ``LiveObject`` object. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public enum LiveObjectLifecycleEvent: Sendable { - /// Indicates that the object has been deleted from the Objects pool and should no longer be interacted with. - case deleted -} - -/// Enables the Objects to be read, modified and subscribed to for a channel. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public protocol RealtimeObjects: Sendable { - /// Retrieves the root ``LiveMap`` object for Objects on a channel. - func getRoot() async throws(ARTErrorInfo) -> any LiveMap - - /// Creates a new ``LiveMap`` object instance with the provided entries. - /// - /// - Parameter entries: The initial entries for the new ``LiveMap`` object. - func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap - - /// Creates a new empty ``LiveMap`` object instance. - func createMap() async throws(ARTErrorInfo) -> any LiveMap - - /// Creates a new ``LiveCounter`` object instance with the provided `count` value. - /// - /// - Parameter count: The initial value for the new ``LiveCounter`` object. - func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter - - /// Creates a new ``LiveCounter`` object instance with a value of zero. - func createCounter() async throws(ARTErrorInfo) -> any LiveCounter - - /// Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. - /// - /// - Parameters: - /// - event: The named event to listen for. - /// - callback: The event listener. - /// - Returns: An ``OnObjectsEventResponse`` object that allows the provided listener to be deregistered from future updates. - @discardableResult - func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> OnObjectsEventResponse - - /// Deregisters all registrations, for all events and listeners. - func offAll() -} - -/// Represents the type of data stored for a given key in a ``LiveMap``. -/// It may be a primitive value (string, number, boolean, binary data, JSON array, or JSON object), or another ``LiveObject``. -/// -/// `LiveMapValue` implements Swift's `ExpressibleBy*Literal` protocols. This, in combination with `JSONValue`'s conformance to these protocols, allows you to write type-safe map values using familiar syntax. For example: -/// -/// ```swift -/// let map = try await channel.objects.createMap(entries: [ -/// "someStringKey": "someString", -/// "someIntegerKey": 123, -/// "someFloatKey": 123.456, -/// "someTrueKey": true, -/// "someFalseKey": false, -/// "someJSONObjectKey": [ -/// "someNestedJSONObjectKey": [ -/// "someOtherKey": "someOtherValue", -/// ], -/// ], -/// "someJSONArrayKey": [ -/// "foo", -/// 42, -/// ], -/// ]) -/// ``` -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public enum LiveMapValue: Sendable, Equatable { - case string(String) - case number(Double) - case bool(Bool) - case data(Data) - case jsonArray([JSONValue]) - case jsonObject([String: JSONValue]) - case liveMap(any LiveMap) - case liveCounter(any LiveCounter) - - // MARK: - Convenience getters for associated values - - /// If this `LiveMapValue` has case `liveMap`, this returns the associated value. Else, it returns `nil`. - public var liveMapValue: (any LiveMap)? { - if case let .liveMap(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `liveCounter`, this returns the associated value. Else, it returns `nil`. - public var liveCounterValue: (any LiveCounter)? { - if case let .liveCounter(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `string`, this returns the associated value. Else, it returns `nil`. - public var stringValue: String? { - if case let .string(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `number`, this returns the associated value. Else, it returns `nil`. - public var numberValue: Double? { - if case let .number(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `bool`, this returns the associated value. Else, it returns `nil`. - public var boolValue: Bool? { - if case let .bool(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `data`, this returns the associated value. Else, it returns `nil`. - public var dataValue: Data? { - if case let .data(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `jsonArray`, this returns the associated value. Else, it returns `nil`. - public var jsonArrayValue: [JSONValue]? { - if case let .jsonArray(value) = self { - return value - } - return nil - } - - /// If this `LiveMapValue` has case `jsonObject`, this returns the associated value. Else, it returns `nil`. - public var jsonObjectValue: [String: JSONValue]? { - if case let .jsonObject(value) = self { - return value - } - return nil - } - - // MARK: - Equatable Implementation - - public static func == (lhs: LiveMapValue, rhs: LiveMapValue) -> Bool { - switch (lhs, rhs) { - case let (.string(lhsValue), .string(rhsValue)): - lhsValue == rhsValue - case let (.number(lhsValue), .number(rhsValue)): - lhsValue == rhsValue - case let (.bool(lhsValue), .bool(rhsValue)): - lhsValue == rhsValue - case let (.data(lhsValue), .data(rhsValue)): - lhsValue == rhsValue - case let (.jsonArray(lhsValue), .jsonArray(rhsValue)): - lhsValue == rhsValue - case let (.jsonObject(lhsValue), .jsonObject(rhsValue)): - lhsValue == rhsValue - case let (.liveMap(lhsMap), .liveMap(rhsMap)): - lhsMap === rhsMap - case let (.liveCounter(lhsCounter), .liveCounter(rhsCounter)): - lhsCounter === rhsCounter - default: - false - } - } -} - -// MARK: - ExpressibleBy*Literal conformances - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension LiveMapValue: ExpressibleByDictionaryLiteral { - public init(dictionaryLiteral elements: (String, JSONValue)...) { - self = .jsonObject(.init(uniqueKeysWithValues: elements)) - } -} - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension LiveMapValue: ExpressibleByArrayLiteral { - public init(arrayLiteral elements: JSONValue...) { - self = .jsonArray(elements) - } -} - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension LiveMapValue: ExpressibleByStringLiteral { - public init(stringLiteral value: String) { - self = .string(value) - } -} - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension LiveMapValue: ExpressibleByIntegerLiteral { - public init(integerLiteral value: Int) { - self = .number(Double(value)) - } -} - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension LiveMapValue: ExpressibleByFloatLiteral { - public init(floatLiteral value: Double) { - self = .number(value) - } -} - -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -extension LiveMapValue: ExpressibleByBooleanLiteral { - public init(booleanLiteral value: Bool) { - self = .bool(value) - } -} - -/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public protocol OnObjectsEventResponse: Sendable { - /// Deregisters the listener passed to the `on` call. - func off() -} - -/// The `LiveMap` class represents a key-value map data structure, similar to a Swift `Dictionary`, where all changes are synchronized across clients in realtime. -/// Conflicts in a LiveMap are automatically resolved with last-write-wins (LWW) semantics, -/// meaning that if two clients update the same key in the map, the update with the most recent timestamp wins. -/// -/// Keys must be strings. Values can be another ``LiveObject``, or a primitive type, such as a string, number, boolean, JSON-serializable object or array, or binary data. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public protocol LiveMap: LiveObject where Update == LiveMapUpdate { - /// Returns the value associated with a given key. Returns `nil` if the key doesn't exist in a map or if the associated ``LiveObject`` has been deleted. - /// - /// Always returns `nil` if this map object is deleted. - /// - /// - Parameter key: The key to retrieve the value for. - /// - Returns: A ``LiveObject``, a primitive type (string, number, boolean, JSON-serializable object or array, or binary data) or `nil` if the key doesn't exist in a map or the associated ``LiveObject`` has been deleted. Always `nil` if this map object is deleted. - func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? - - /// Returns the number of key-value pairs in the map. - var size: Int { get throws(ARTErrorInfo) } - - /// Returns an array of key-value pairs for every entry in the map. - var entries: [(key: String, value: LiveMapValue)] { get throws(ARTErrorInfo) } - - /// Returns an array of keys in the map. - var keys: [String] { get throws(ARTErrorInfo) } - - /// Returns an iterable of values in the map. - var values: [LiveMapValue] { get throws(ARTErrorInfo) } - - /// Sends an operation to the Ably system to set a key on this `LiveMap` object to a specified value. - /// - /// This does not modify the underlying data of this object. Instead, the change is applied when - /// the published operation is echoed back to the client and applied to the object. - /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. - /// - /// - Parameters: - /// - key: The key to set the value for. - /// - value: The value to assign to the key. - func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) - - /// Sends an operation to the Ably system to remove a key from this `LiveMap` object. - /// - /// This does not modify the underlying data of this object. Instead, the change is applied when - /// the published operation is echoed back to the client and applied to the object. - /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. - /// - /// - Parameter key: The key to remove. - func remove(key: String) async throws(ARTErrorInfo) -} - -/// Describes whether an entry in ``LiveMapUpdate/update`` represents an update or a removal. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public enum LiveMapUpdateAction: Sendable { - /// The value of a key in the map was updated. - case updated - /// The value of a key in the map was removed. - case removed -} - -/// Represents an update to a ``LiveMap`` object, describing the keys that were updated or removed. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public protocol LiveMapUpdate: Sendable { - /// An object containing keys from a `LiveMap` that have changed, along with their change status: - /// - ``LiveMapUpdateAction/updated`` - the value of a key in the map was updated. - /// - ``LiveMapUpdateAction/removed`` - the key was removed from the map. - var update: [String: LiveMapUpdateAction] { get } -} - -/// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { - /// Returns the current value of the counter. - var value: Double { get throws(ARTErrorInfo) } - - /// Sends an operation to the Ably system to increment the value of this `LiveCounter` object. - /// - /// This does not modify the underlying data of this object. Instead, the change is applied when - /// the published operation is echoed back to the client and applied to the object. - /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. - /// - /// - Parameter amount: The amount by which to increase the counter value. - func increment(amount: Double) async throws(ARTErrorInfo) - - /// An alias for calling [`increment(-amount)`](doc:LiveCounter/increment(amount:)). - /// - /// - Parameter amount: The amount by which to decrease the counter value. - func decrement(amount: Double) async throws(ARTErrorInfo) -} - -/// Represents an update to a ``LiveCounter`` object. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public protocol LiveCounterUpdate: Sendable { - /// Holds the numerical change to the counter value. - var amount: Double { get } -} - -/// Describes the common interface for all conflict-free data structures supported by the Objects. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public protocol LiveObject: AnyObject, Sendable { - /// The type of update event that this object emits. - associatedtype Update - - /// Registers a listener that is called each time this LiveObject is updated. - /// - /// - Parameter listener: An event listener function that is called with an update object whenever this LiveObject is updated. - /// - Returns: A ``SubscribeResponse`` object that allows the provided listener to be deregistered from future updates. - @discardableResult - func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> SubscribeResponse - - /// Deregisters all listeners from updates for this LiveObject. - func unsubscribeAll() - - /// Registers the provided listener for the specified event. If `on()` is called more than once with the same listener and event, the listener is added multiple times to its listener registry. Therefore, as an example, assuming the same listener is registered twice using `on()`, and an event is emitted once, the listener would be invoked twice. - /// - /// - Parameters: - /// - event: The named event to listen for. - /// - callback: The event listener. - /// - Returns: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to be deregistered from future updates. - @discardableResult - func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse - - /// Deregisters all registrations, for all events and listeners. - func offAll() -} - -/// Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public protocol SubscribeResponse: Sendable { - /// Deregisters the listener passed to the `subscribe` call. - func unsubscribe() -} - -/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public protocol OnLiveObjectLifecycleEventResponse: Sendable { - /// Deregisters the listener passed to the `on` call. - func off() -} - -// MARK: - AsyncSequence Extensions - -/// Extension to provide AsyncSequence-based subscription for `LiveObject` updates. -@available(macOS 11.0, iOS 14.0, tvOS 14.0, *) -public extension LiveObject { - /// Returns an `AsyncSequence` that emits updates to this `LiveObject`. - /// - /// This provides an alternative to the callback-based ``subscribe(listener:)`` method, - /// allowing you to use Swift's structured concurrency features like `for await` loops. - /// - /// - Returns: An AsyncSequence that emits ``Update`` values when the object is updated. - /// - Throws: An ``ARTErrorInfo`` if the subscription fails. - func updates() throws(ARTErrorInfo) -> AsyncStream { - let (stream, continuation) = AsyncStream.makeStream(of: Update.self) - - let subscription = try subscribe { update, _ in - continuation.yield(update) - } - - continuation.onTermination = { _ in - subscription.unsubscribe() - } - - return stream - } -} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index fbb9ef899..f16d24328 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -20,8 +20,8 @@ struct AblyLiveObjectsTests { // Then - // Check that the `channel.objects` property works and gives the internal type we expect - #expect(channel.objects is PublicDefaultRealtimeObjects) + // Check that the `channel.object` property works and gives the internal type we expect + #expect(channel.object is PublicDefaultRealtimeObject) } /// A basic test of the core interactions between this plugin and ably-cocoa. @@ -79,11 +79,17 @@ struct AblyLiveObjectsTests { let channel = realtime.channels.get(channelName, options: channelOptions) try await channel.attachAsync() + // Go through the real public proxy plumbing (rather than a test-only reimplementation of it) + // to reach the internal objects and a `CoreSDK` for publishing. + let object = channel.testsOnly_nonTypeErasedObject + let objects = object.testsOnly_proxied + let coreSDK = object.testsOnly_coreSDK + // 3. Check that ably-cocoa called our onChannelAttached and passed the HAS_OBJECTS flag. - #expect(channel.testsOnly_nonTypeErasedObjects.testsOnly_onChannelAttachedHasObjects == true) + #expect(objects.testsOnly_onChannelAttachedHasObjects == true) // 4. Check that ably-cocoa used us to decode the ObjectMessages in the OBJECT_SYNC, and then called our handleObjectSyncProtocolMessage with these ObjectMessages; we expect the OBJECT_SYNC to contain the root object and the map that we created in the REST call above. - let objectSyncObjectMessages = try #require(await channel.testsOnly_nonTypeErasedObjects.testsOnly_receivedObjectSyncProtocolMessages.first { _ in true }) + let objectSyncObjectMessages = try #require(await objects.testsOnly_receivedObjectSyncProtocolMessages.first { _ in true }) #expect(Set(objectSyncObjectMessages.map(\.object?.objectId)) == ["root", restCreatedMapObjectID]) // 5. Now, send an OBJECT ProtocolMessage that creates a new Map. This confirms that Ably is using us to encode this ProtocolMessage's contained ObjectMessages. @@ -96,8 +102,8 @@ struct AblyLiveObjectsTests { timestamp: Date(timeIntervalSince1970: Double(currentAblyTimestamp) / 1000), ) - try await channel.testsOnly_nonTypeErasedObjects.testsOnly_publish(objectMessages: [ - OutboundObjectMessage( + try await objects.testsOnly_publish(objectMessages: [ + ProtocolTypes.OutboundObjectMessage( operation: .init( action: .known(.mapCreate), objectId: realtimeCreatedMapObjectID, @@ -105,10 +111,10 @@ struct AblyLiveObjectsTests { mapCreateWithObjectId: .init(initialValue: initialValueJSON, nonce: "1"), ), ), - ]) + ], coreSDK: coreSDK) // 6. Check that ably-cocoa used us to decode the ObjectMessages in the OBJECT triggered by this map creation, and then called our handleObjectProtocolMessage with these ObjectMessages; we expect the OBJECT to contain the map create operation that we just performed. - let objectObjectMessages = try #require(await channel.testsOnly_nonTypeErasedObjects.testsOnly_receivedObjectProtocolMessages.first { _ in true }) + let objectObjectMessages = try #require(await objects.testsOnly_receivedObjectProtocolMessages.first { _ in true }) try #require(objectObjectMessages.count == 1) let receivedMapCreateObjectMessage = objectObjectMessages[0] #expect(receivedMapCreateObjectMessage.operation?.objectId == realtimeCreatedMapObjectID) @@ -116,76 +122,13 @@ struct AblyLiveObjectsTests { // 7. Now, send an invalid OBJECT ProtocolMessage to check that ably-cocoa correctly reports on its NACK. let invalidObjectThrownError = try await #require(throws: ARTErrorInfo.self) { - try await channel.testsOnly_nonTypeErasedObjects.testsOnly_publish(objectMessages: [ + try await objects.testsOnly_publish(objectMessages: [ .init(), - ]) + ], coreSDK: coreSDK) } // (These are just based on what I observed in the NACK) #expect(invalidObjectThrownError.code == 92000) #expect(invalidObjectThrownError.message == "invalid object message: object operation required") } - - /// A basic test of the public API of the LiveObjects plugin. - @Test(arguments: [true, false]) - func smokeTest(useBinaryProtocol: Bool) async throws { - let client = try await ClientHelper.realtimeWithObjects(options: .init(useBinaryProtocol: useBinaryProtocol)) - let channel = client.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) - try await channel.attachAsync() - - let root = try await channel.objects.getRoot() - let rootSubscription = try root.updates() - - // Create a counter - let counter = try await channel.objects.createCounter(count: 52) - let counterSubscription = try counter.updates() - - // Create a map and check its initial entries - let map = try await channel.objects.createMap(entries: [ - "boolKey": true, - "numberKey": 10, - ]) - #expect( - try Dictionary(uniqueKeysWithValues: map.entries) == [ - "boolKey": true, - "numberKey": 10, - ], - ) - let mapSubscription = try map.updates() - - // Perform a `set` on the root and check it comes through on subscription - try await root.set(key: "mapKey", value: .liveMap(map)) - let rootUpdate = try #require(await rootSubscription.first { _ in true }) - #expect(rootUpdate.update == ["mapKey": .updated]) - #expect(try Dictionary(uniqueKeysWithValues: root.entries) == ["mapKey": .liveMap(map)]) - - // Perform a `set` on the map and check it comes through on subscription and that the map is updated - try await map.set(key: "counterKey", value: .liveCounter(counter)) - let mapUpdate = try #require(await mapSubscription.first { _ in true }) - #expect(mapUpdate.update == ["counterKey": .updated]) - #expect( - try Dictionary(uniqueKeysWithValues: map.entries) == [ - "boolKey": true, - "numberKey": 10, - "counterKey": .liveCounter(counter), - ], - ) - - // Perform an `increment` on the counter and check it comes through on subscription and that the counter is updated - try await counter.increment(amount: 30) - let counterUpdate = try #require(await counterSubscription.first { _ in true }) - #expect(counterUpdate.amount == 30) - #expect(try counter.value == 82) - - // Perform a `remove` on the map and check it comes through on subscription and that the map is updated - try await map.remove(key: "boolKey") - let mapRemoveUpdate = try #require(await mapSubscription.first { _ in true }) - #expect(mapRemoveUpdate.update == ["boolKey": .removed]) - #expect( - try Dictionary(uniqueKeysWithValues: map.entries) == [ - "numberKey": 10, - "counterKey": .liveCounter(counter), - ], - ) - } } diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index 55b63a008..49ff608f4 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -190,11 +190,11 @@ struct TestFactories { objectId: String = "test:object@123", siteTimeserials: [String: String] = ["site1": "ts1"], tombstone: Bool = false, - createOp: ObjectOperation? = nil, - map: ObjectsMap? = nil, + createOp: ProtocolTypes.ObjectOperation? = nil, + map: ProtocolTypes.ObjectsMap? = nil, counter: WireObjectsCounter? = nil, - ) -> ObjectState { - ObjectState( + ) -> ProtocolTypes.ObjectState { + ProtocolTypes.ObjectState( objectId: objectId, siteTimeserials: siteTimeserials, tombstone: tombstone, @@ -209,15 +209,15 @@ struct TestFactories { objectId: String = "map:test@123", siteTimeserials: [String: String] = ["site1": "ts1"], tombstone: Bool = false, - createOp: ObjectOperation? = nil, - entries: [String: ObjectsMapEntry]? = nil, - ) -> ObjectState { + createOp: ProtocolTypes.ObjectOperation? = nil, + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, + ) -> ProtocolTypes.ObjectState { objectState( objectId: objectId, siteTimeserials: siteTimeserials, tombstone: tombstone, createOp: createOp, - map: ObjectsMap( + map: ProtocolTypes.ObjectsMap( semantics: .known(.lww), entries: entries, ), @@ -230,9 +230,9 @@ struct TestFactories { objectId: String = "counter:test@123", siteTimeserials: [String: String] = ["site1": "ts1"], tombstone: Bool = false, - createOp: ObjectOperation? = nil, + createOp: ProtocolTypes.ObjectOperation? = nil, count: Int? = 42, - ) -> ObjectState { + ) -> ProtocolTypes.ObjectState { objectState( objectId: objectId, siteTimeserials: siteTimeserials, @@ -246,8 +246,8 @@ struct TestFactories { /// Creates an ObjectState for the root object static func rootObjectState( siteTimeserials: [String: String] = ["site1": "ts1"], - entries: [String: ObjectsMapEntry]? = nil, - ) -> ObjectState { + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, + ) -> ProtocolTypes.ObjectState { mapObjectState( objectId: "root", siteTimeserials: siteTimeserials, @@ -264,13 +264,13 @@ struct TestFactories { connectionId: String? = nil, extras: [String: JSONValue]? = nil, timestamp: Date? = nil, - operation: ObjectOperation? = nil, - object: ObjectState? = nil, + operation: ProtocolTypes.ObjectOperation? = nil, + object: ProtocolTypes.ObjectState? = nil, serial: String? = nil, siteCode: String? = nil, serialTimestamp: Date? = nil, - ) -> InboundObjectMessage { - InboundObjectMessage( + ) -> ProtocolTypes.InboundObjectMessage { + ProtocolTypes.InboundObjectMessage( id: id, clientId: clientId, connectionId: connectionId, @@ -288,8 +288,8 @@ struct TestFactories { static func mapObjectMessage( objectId: String = "map:test@123", siteTimeserials: [String: String] = ["site1": "ts1"], - entries: [String: ObjectsMapEntry]? = nil, - ) -> InboundObjectMessage { + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( object: mapObjectState( objectId: objectId, @@ -304,7 +304,7 @@ struct TestFactories { objectId: String = "counter:test@123", siteTimeserials: [String: String] = ["site1": "ts1"], count: Int? = 42, - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( object: counterObjectState( objectId: objectId, @@ -317,8 +317,8 @@ struct TestFactories { /// Creates an InboundObjectMessage with a root ObjectState static func rootObjectMessage( siteTimeserials: [String: String] = ["site1": "ts1"], - entries: [String: ObjectsMapEntry]? = nil, - ) -> InboundObjectMessage { + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( object: rootObjectState( siteTimeserials: siteTimeserials, @@ -328,7 +328,7 @@ struct TestFactories { } /// Creates an InboundObjectMessage without an ObjectState - static func objectMessageWithoutState() -> InboundObjectMessage { + static func objectMessageWithoutState() -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage(object: nil) } @@ -336,19 +336,19 @@ struct TestFactories { /// Creates an ObjectOperation with sensible defaults static func objectOperation( - action: WireEnum = .known(.mapCreate), + action: WireEnum = .known(.mapCreate), objectId: String = "test:object@123", - mapCreate: MapCreate? = nil, - mapSet: MapSet? = nil, + mapCreate: ProtocolTypes.MapCreate? = nil, + mapSet: ProtocolTypes.MapSet? = nil, mapRemove: WireMapRemove? = nil, counterCreate: WireCounterCreate? = nil, counterInc: WireCounterInc? = nil, objectDelete: WireObjectDelete? = nil, - mapCreateWithObjectId: MapCreateWithObjectId? = nil, - counterCreateWithObjectId: CounterCreateWithObjectId? = nil, + mapCreateWithObjectId: ProtocolTypes.MapCreateWithObjectId? = nil, + counterCreateWithObjectId: ProtocolTypes.CounterCreateWithObjectId? = nil, mapClear: WireMapClear? = nil, - ) -> ObjectOperation { - ObjectOperation( + ) -> ProtocolTypes.ObjectOperation { + ProtocolTypes.ObjectOperation( action: action, objectId: objectId, mapCreate: mapCreate, @@ -366,12 +366,12 @@ struct TestFactories { /// Creates a map create operation static func mapCreateOperation( objectId: String = "map:test@123", - entries: [String: ObjectsMapEntry]? = nil, - ) -> ObjectOperation { + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, + ) -> ProtocolTypes.ObjectOperation { objectOperation( action: .known(.mapCreate), objectId: objectId, - mapCreate: MapCreate( + mapCreate: ProtocolTypes.MapCreate( semantics: .known(.lww), entries: entries, ), @@ -382,7 +382,7 @@ struct TestFactories { static func counterCreateOperation( objectId: String = "counter:test@123", count: Int? = 42, - ) -> ObjectOperation { + ) -> ProtocolTypes.ObjectOperation { objectOperation( action: .known(.counterCreate), objectId: objectId, @@ -401,9 +401,9 @@ struct TestFactories { static func mapEntry( tombstone: Bool? = false, timeserial: String? = "ts1", - data: ObjectData?, - ) -> ObjectsMapEntry { - ObjectsMapEntry( + data: ProtocolTypes.ObjectData?, + ) -> ProtocolTypes.ObjectsMapEntry { + ProtocolTypes.ObjectsMapEntry( tombstone: tombstone, timeserial: timeserial, data: data, @@ -416,7 +416,7 @@ struct TestFactories { static func internalMapEntry( tombstonedAt: Date? = nil, timeserial: String? = "ts1", - data: ObjectData, + data: ProtocolTypes.ObjectData, ) -> InternalObjectsMapEntry { InternalObjectsMapEntry( tombstonedAt: tombstonedAt, @@ -431,13 +431,13 @@ struct TestFactories { value: String = "testValue", tombstone: Bool? = false, timeserial: String? = "ts1", - ) -> (key: String, entry: ObjectsMapEntry) { + ) -> (key: String, entry: ProtocolTypes.ObjectsMapEntry) { ( key: key, entry: mapEntry( tombstone: tombstone, timeserial: timeserial, - data: ObjectData(string: value), + data: ProtocolTypes.ObjectData(string: value), ), ) } @@ -456,7 +456,7 @@ struct TestFactories { entry: internalMapEntry( tombstonedAt: tombstonedAt, timeserial: timeserial, - data: ObjectData(string: value), + data: ProtocolTypes.ObjectData(string: value), ), ) } @@ -467,13 +467,13 @@ struct TestFactories { value: NSNumber = NSNumber(value: 42), tombstone: Bool? = false, timeserial: String? = "ts1", - ) -> (key: String, entry: ObjectsMapEntry) { + ) -> (key: String, entry: ProtocolTypes.ObjectsMapEntry) { ( key: key, entry: mapEntry( tombstone: tombstone, timeserial: timeserial, - data: ObjectData(number: value), + data: ProtocolTypes.ObjectData(number: value), ), ) } @@ -484,13 +484,13 @@ struct TestFactories { value: Bool = true, tombstone: Bool? = false, timeserial: String? = "ts1", - ) -> (key: String, entry: ObjectsMapEntry) { + ) -> (key: String, entry: ProtocolTypes.ObjectsMapEntry) { ( key: key, entry: mapEntry( tombstone: tombstone, timeserial: timeserial, - data: ObjectData(boolean: value), + data: ProtocolTypes.ObjectData(boolean: value), ), ) } @@ -501,13 +501,13 @@ struct TestFactories { value: Data = Data([0x01, 0x02, 0x03]), tombstone: Bool? = false, timeserial: String? = "ts1", - ) -> (key: String, entry: ObjectsMapEntry) { + ) -> (key: String, entry: ProtocolTypes.ObjectsMapEntry) { ( key: key, entry: mapEntry( tombstone: tombstone, timeserial: timeserial, - data: ObjectData(bytes: value), + data: ProtocolTypes.ObjectData(bytes: value), ), ) } @@ -518,13 +518,13 @@ struct TestFactories { objectId: String = "map:referenced@123", tombstone: Bool? = false, timeserial: String? = "ts1", - ) -> (key: String, entry: ObjectsMapEntry) { + ) -> (key: String, entry: ProtocolTypes.ObjectsMapEntry) { ( key: key, entry: mapEntry( tombstone: tombstone, timeserial: timeserial, - data: ObjectData(objectId: objectId), + data: ProtocolTypes.ObjectData(objectId: objectId), ), ) } @@ -533,11 +533,11 @@ struct TestFactories { /// Creates an ObjectsMap with sensible defaults static func objectsMap( - semantics: WireEnum = .known(.lww), - entries: [String: ObjectsMapEntry]? = nil, + semantics: WireEnum = .known(.lww), + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, clearTimeserial: String? = nil, - ) -> ObjectsMap { - ObjectsMap( + ) -> ProtocolTypes.ObjectsMap { + ProtocolTypes.ObjectsMap( semantics: semantics, entries: entries, clearTimeserial: clearTimeserial, @@ -547,9 +547,9 @@ struct TestFactories { /// Creates an ObjectsMap with string entries static func objectsMapWithStringEntries( entries: [String: String] = ["key1": "value1", "key2": "value2"], - ) -> ObjectsMap { + ) -> ProtocolTypes.ObjectsMap { let mapEntries = entries.mapValues { value in - mapEntry(data: ObjectData(string: value)) + mapEntry(data: ProtocolTypes.ObjectData(string: value)) } return objectsMap(entries: mapEntries) } @@ -570,14 +570,14 @@ struct TestFactories { value: String = "testValue", serial: String = "ts1", siteCode: String = "site1", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( operation: objectOperation( action: .known(.mapSet), objectId: objectId, - mapSet: MapSet( + mapSet: ProtocolTypes.MapSet( key: key, - value: ObjectData(string: value), + value: ProtocolTypes.ObjectData(string: value), ), ), serial: serial, @@ -591,7 +591,7 @@ struct TestFactories { key: String = "testKey", serial: String = "ts1", siteCode: String = "site1", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( operation: objectOperation( action: .known(.mapRemove), @@ -608,7 +608,7 @@ struct TestFactories { objectId: String = "map:test@123", serial: String = "ts1", siteCode: String = "site1", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( operation: objectOperation( action: .known(.mapClear), @@ -623,10 +623,10 @@ struct TestFactories { /// Creates an InboundObjectMessage with a MAP_CREATE operation static func mapCreateOperationMessage( objectId: String = "map:test@123", - entries: [String: ObjectsMapEntry]? = nil, + entries: [String: ProtocolTypes.ObjectsMapEntry]? = nil, serial: String = "ts1", siteCode: String = "site1", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( operation: mapCreateOperation( objectId: objectId, @@ -643,7 +643,7 @@ struct TestFactories { count: Int? = 42, serial: String = "ts1", siteCode: String = "site1", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( operation: counterCreateOperation( objectId: objectId, @@ -660,7 +660,7 @@ struct TestFactories { number: Int = 10, serial: String = "ts1", siteCode: String = "site1", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { inboundObjectMessage( operation: objectOperation( action: .known(.counterInc), @@ -679,7 +679,7 @@ struct TestFactories { objectId: String = "map:simple@123", key: String = "testKey", value: String = "testValue", - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { let (entryKey, entry) = stringMapEntry(key: key, value: value) return mapObjectMessage( objectId: objectId, @@ -691,7 +691,7 @@ struct TestFactories { static func simpleCounterMessage( objectId: String = "counter:simple@123", count: Int = 42, - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { counterObjectMessage( objectId: objectId, count: count, @@ -701,9 +701,9 @@ struct TestFactories { /// Creates a root object message with multiple entries static func rootMessageWithEntries( entries: [String: String] = ["key1": "value1", "key2": "value2"], - ) -> InboundObjectMessage { + ) -> ProtocolTypes.InboundObjectMessage { let mapEntries = entries.mapValues { value in - mapEntry(data: ObjectData(string: value)) + mapEntry(data: ProtocolTypes.ObjectData(string: value)) } return rootObjectMessage(entries: mapEntries) } diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index a81046281..3d23f2b72 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -677,7 +677,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let realtimeObjects = MockRealtimeObjects() - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] realtimeObjects.setPublishAndApplyHandler { messages in publishedMessages.append(contentsOf: messages) return .success(()) @@ -685,8 +685,8 @@ struct InternalDefaultLiveCounterTests { try await counter.increment(amount: 10.5, coreSDK: coreSDK, realtimeObjects: realtimeObjects) - let expectedMessage = OutboundObjectMessage( - operation: ObjectOperation( + let expectedMessage = ProtocolTypes.OutboundObjectMessage( + operation: ProtocolTypes.ObjectOperation( // RTLC12e2 action: .known(.counterInc), // RTLC12e3 @@ -734,7 +734,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let realtimeObjects = MockRealtimeObjects() - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] realtimeObjects.setPublishAndApplyHandler { messages in publishedMessages.append(contentsOf: messages) return .success(()) diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 6d5a89203..6fcdf9003 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -43,7 +43,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.internalMapEntry( tombstonedAt: Date(), - data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned + data: ProtocolTypes.ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) let internalQueue = TestFactories.createInternalQueue() let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) @@ -56,7 +56,7 @@ struct InternalDefaultLiveMapTests { func returnsBooleanValue() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let entry = TestFactories.internalMapEntry(data: ObjectData(boolean: true)) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(boolean: true)) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) @@ -69,7 +69,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let bytes = Data([0x01, 0x02, 0x03]) - let entry = TestFactories.internalMapEntry(data: ObjectData(bytes: bytes)) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(bytes: bytes)) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) @@ -81,7 +81,7 @@ struct InternalDefaultLiveMapTests { func returnsNumberValue() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let entry = TestFactories.internalMapEntry(data: ObjectData(number: NSNumber(value: 123.456))) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(number: NSNumber(value: 123.456))) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) @@ -93,7 +93,7 @@ struct InternalDefaultLiveMapTests { func returnsStringValue() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let entry = TestFactories.internalMapEntry(data: ObjectData(string: "test")) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "test")) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) @@ -106,7 +106,7 @@ struct InternalDefaultLiveMapTests { func returnsJSONArrayValue() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let entry = TestFactories.internalMapEntry(data: ObjectData(json: .array(["foo"]))) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(json: .array(["foo"]))) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) @@ -119,7 +119,7 @@ struct InternalDefaultLiveMapTests { func returnsJSONObjectValue() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let entry = TestFactories.internalMapEntry(data: ObjectData(json: .object(["foo": "bar"]))) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(json: .object(["foo": "bar"]))) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) @@ -130,7 +130,7 @@ struct InternalDefaultLiveMapTests { @Test func returnsNilWhenReferencedObjectDoesNotExist() throws { let logger = TestLogger() - let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: "missing")) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "missing")) let internalQueue = TestFactories.createInternalQueue() let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) @@ -144,7 +144,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let objectId = "map1" - let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -161,7 +161,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let objectId = "counter1" - let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -177,7 +177,7 @@ struct InternalDefaultLiveMapTests { func returnsNullOtherwise() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let entry = TestFactories.internalMapEntry(data: ObjectData()) + let entry = TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData()) let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -276,7 +276,7 @@ struct InternalDefaultLiveMapTests { "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, ], ), - map: ObjectsMap( + map: ProtocolTypes.ObjectsMap( semantics: .known(.lww), entries: [ "keyFromMapEntries": TestFactories.stringMapEntry(key: "keyFromMapEntries", value: "valueFromMapEntries").entry, @@ -418,7 +418,7 @@ struct InternalDefaultLiveMapTests { "fromCreateOp": TestFactories.stringMapEntry(key: "fromCreateOp", value: "value").entry, ], ), - map: ObjectsMap( + map: ProtocolTypes.ObjectsMap( semantics: .known(.lww), entries: [ "fromEntries": TestFactories.stringMapEntry(key: "fromEntries", value: "value").entry, @@ -492,10 +492,10 @@ struct InternalDefaultLiveMapTests { let map = InternalDefaultLiveMap( testsOnly_data: [ // tombstonedAt is nil, so not considered tombstoned - "active1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "active1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), // tombstonedAt is false, so not considered tombstoned - "tombstoned": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "tombstoned")), - "tombstoned2": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "tombstoned2")), + "tombstoned": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ProtocolTypes.ObjectData(string: "tombstoned")), + "tombstoned2": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ProtocolTypes.ObjectData(string: "tombstoned2")), ], objectID: "arbitrary", logger: logger, @@ -538,9 +538,9 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let map = InternalDefaultLiveMap( testsOnly_data: [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), - "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), - "key3": TestFactories.internalMapEntry(data: ObjectData(string: "value3")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value2")), + "key3": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value3")), ], objectID: "arbitrary", logger: logger, @@ -585,14 +585,14 @@ struct InternalDefaultLiveMapTests { let map = InternalDefaultLiveMap( testsOnly_data: [ - "boolean": TestFactories.internalMapEntry(data: ObjectData(boolean: true)), // RTLM5d2b - "bytes": TestFactories.internalMapEntry(data: ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c - "number": TestFactories.internalMapEntry(data: ObjectData(number: NSNumber(value: 42))), // RTLM5d2d - "string": TestFactories.internalMapEntry(data: ObjectData(string: "hello")), // RTLM5d2e - "jsonArray": TestFactories.internalMapEntry(data: ObjectData(json: .array(["foo"]))), // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) - "jsonObject": TestFactories.internalMapEntry(data: ObjectData(json: .object(["foo": "bar"]))), // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) - "mapRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 - "counterRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 + "boolean": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(boolean: true)), // RTLM5d2b + "bytes": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c + "number": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(number: NSNumber(value: 42))), // RTLM5d2d + "string": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "hello")), // RTLM5d2e + "jsonArray": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(json: .array(["foo"]))), // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + "jsonObject": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(json: .object(["foo": "bar"]))), // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) + "mapRef": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 + "counterRef": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 ], objectID: "arbitrary", logger: logger, @@ -677,7 +677,7 @@ struct InternalDefaultLiveMapTests { let update = map.testsOnly_applyMapSetOperation( key: "key1", operationTimeserial: operationSerial, - operationData: ObjectData(string: "new"), + operationData: ProtocolTypes.ObjectData(string: "new"), objectsPool: &pool, ) @@ -701,7 +701,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -714,7 +714,7 @@ struct InternalDefaultLiveMapTests { let update = map.testsOnly_applyMapSetOperation( key: "key1", operationTimeserial: "ts1", - operationData: ObjectData(objectId: "new"), + operationData: ProtocolTypes.ObjectData(objectId: "new"), objectsPool: &pool, ) @@ -731,19 +731,19 @@ struct InternalDefaultLiveMapTests { // @specOneOf(1/2) RTLM7f @Test(arguments: [ // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectsPool per RTLM7g) - (operationData: ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), + (operationData: ProtocolTypes.ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), // Case 2: ObjectData refers to an object value but the object ID is an empty string (shouldn't modify the ObjectsPool per RTLM7g) - (operationData: ObjectData(objectId: ""), expectedCreatedObjectID: nil), + (operationData: ProtocolTypes.ObjectData(objectId: ""), expectedCreatedObjectID: nil), // Case 3: ObjectData refers to an object value (should modify the ObjectsPool per RTLM7g and RTLM7g1) - (operationData: ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), - ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) - func appliesOperationWhenCanBeApplied(operationData: ObjectData, expectedCreatedObjectID: String?) throws { + (operationData: ProtocolTypes.ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), + ] as [(operationData: ProtocolTypes.ObjectData, expectedCreatedObjectID: String?)]) + func appliesOperationWhenCanBeApplied(operationData: ProtocolTypes.ObjectData, expectedCreatedObjectID: String?) throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstonedAt: Date(), timeserial: "ts1", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstonedAt: Date(), timeserial: "ts1", data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -806,13 +806,13 @@ struct InternalDefaultLiveMapTests { // @specOneOf(2/2) RTLM7f @Test(arguments: [ // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectsPool per RTLM7g) - (operationData: ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), + (operationData: ProtocolTypes.ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), // Case 2: ObjectData refers to an object value but the object ID is an empty string (shouldn't modify the ObjectsPool per RTLM7g) - (operationData: ObjectData(objectId: ""), expectedCreatedObjectID: nil), + (operationData: ProtocolTypes.ObjectData(objectId: ""), expectedCreatedObjectID: nil), // Case 3: ObjectData refers to an object value (should modify the ObjectsPool per RTLM7g and RTLM7g1) - (operationData: ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), - ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) - func createsNewEntryWhenNoExistingEntry(operationData: ObjectData, expectedCreatedObjectID: String?) throws { + (operationData: ProtocolTypes.ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), + ] as [(operationData: ProtocolTypes.ObjectData, expectedCreatedObjectID: String?)]) + func createsNewEntryWhenNoExistingEntry(operationData: ProtocolTypes.ObjectData, expectedCreatedObjectID: String?) throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) @@ -894,7 +894,7 @@ struct InternalDefaultLiveMapTests { _ = map.testsOnly_applyMapSetOperation( key: "referenceKey", operationTimeserial: "ts1", - operationData: ObjectData(objectId: existingObjectId), + operationData: ProtocolTypes.ObjectData(objectId: existingObjectId), objectsPool: &pool, ) @@ -931,7 +931,7 @@ struct InternalDefaultLiveMapTests { // Given: a map with an existing entry and the specified clearTimeserial let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts1", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts1", data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -980,7 +980,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -1008,7 +1008,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstonedAt: nil, timeserial: "ts1", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstonedAt: nil, timeserial: "ts1", data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -1128,7 +1128,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: entrySerial, data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: entrySerial, data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -1140,7 +1140,7 @@ struct InternalDefaultLiveMapTests { _ = map.testsOnly_applyMapSetOperation( key: "key1", operationTimeserial: operationSerial, - operationData: ObjectData(string: "new"), + operationData: ProtocolTypes.ObjectData(string: "new"), objectsPool: &pool, ) @@ -1201,7 +1201,7 @@ struct InternalDefaultLiveMapTests { mapCreateWithObjectId: .init( initialValue: "arbitrary", nonce: "arbitrary", - derivedFrom: MapCreate( + derivedFrom: ProtocolTypes.MapCreate( semantics: .known(.lww), entries: [ "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, @@ -1241,7 +1241,7 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.mapEntry( tombstone: true, timeserial: "ts2", // Must be greater than existing entry's timeserial "ts1" - data: ObjectData(), + data: ProtocolTypes.ObjectData(), ) let operation = TestFactories.mapCreateOperation( objectId: "arbitrary-id", @@ -1280,12 +1280,12 @@ struct InternalDefaultLiveMapTests { "keyThatWillBeRemoved": TestFactories.mapEntry( tombstone: true, timeserial: "ts2", // Must be greater than existing entry's timeserial "ts1" - data: ObjectData(), + data: ProtocolTypes.ObjectData(), ), "keyThatWillNotBeRemoved": TestFactories.mapEntry( tombstone: true, timeserial: "ts0", // Less than existing entry's timeserial "ts1" so MAP_REMOVE will be a no-op (this lets us test that no-ops are excluded from return value per RTLM23c) - data: ObjectData(), + data: ProtocolTypes.ObjectData(), ), "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, ], @@ -1404,7 +1404,7 @@ struct InternalDefaultLiveMapTests { // Given: a map with an existing entry and the specified clearTimeserial let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts1", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts1", data: ProtocolTypes.ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, internalQueue: internalQueue, @@ -1455,11 +1455,11 @@ struct InternalDefaultLiveMapTests { // Given: a map with multiple entries at different timeserials, including one with nil timeserial let map = InternalDefaultLiveMap( testsOnly_data: [ - "olderThanClear": TestFactories.internalMapEntry(timeserial: "ts1", data: ObjectData(string: "value1")), + "olderThanClear": TestFactories.internalMapEntry(timeserial: "ts1", data: ProtocolTypes.ObjectData(string: "value1")), // Note that this shouldn't happen in real life — timeserials are unique - "equalToClear": TestFactories.internalMapEntry(timeserial: "ts3", data: ObjectData(string: "value2")), - "newerThanClear": TestFactories.internalMapEntry(timeserial: "ts5", data: ObjectData(string: "value3")), - "nilTimeserial": TestFactories.internalMapEntry(timeserial: nil, data: ObjectData(string: "value4")), + "equalToClear": TestFactories.internalMapEntry(timeserial: "ts3", data: ProtocolTypes.ObjectData(string: "value2")), + "newerThanClear": TestFactories.internalMapEntry(timeserial: "ts5", data: ProtocolTypes.ObjectData(string: "value3")), + "nilTimeserial": TestFactories.internalMapEntry(timeserial: nil, data: ProtocolTypes.ObjectData(string: "value4")), ], objectID: "arbitrary", logger: logger, @@ -1513,7 +1513,7 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.objectOperation( action: .known(.mapSet), - mapSet: MapSet(key: "key1", value: ObjectData(string: "new")), + mapSet: ProtocolTypes.MapSet(key: "key1", value: ProtocolTypes.ObjectData(string: "new")), ) // Apply operation with serial "ts1" which is lexicographically less than existing "ts2" and thus will be applied per RTLO4a (this is a non-pathological case of RTOL4a, that spec point being fully tested elsewhere) @@ -1614,7 +1614,7 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.objectOperation( action: .known(.mapSet), - mapSet: MapSet(key: "key1", value: ObjectData(string: "new")), + mapSet: ProtocolTypes.MapSet(key: "key1", value: ProtocolTypes.ObjectData(string: "new")), ) // Apply MAP_SET operation @@ -1770,7 +1770,7 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.objectOperation( action: .known(.mapSet), - mapSet: MapSet(key: "key1", value: ObjectData(string: "new")), + mapSet: ProtocolTypes.MapSet(key: "key1", value: ProtocolTypes.ObjectData(string: "new")), ) var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -1874,15 +1874,15 @@ struct InternalDefaultLiveMapTests { (value: { @Sendable _ in .bool(true) }, expectedData: .init(boolean: true)), // RTLM20e7f (value: { @Sendable _ in .data(Data([0x01, 0x02])) }, expectedData: .init(bytes: Data([0x01, 0x02]))), - ] as [(value: @Sendable (DispatchQueue) -> InternalLiveMapValue, expectedData: ObjectData)]) - func publishesCorrectObjectMessageForDifferentValueTypes(value: @escaping @Sendable (DispatchQueue) -> InternalLiveMapValue, expectedData: ObjectData) async throws { + ] as [(value: @Sendable (DispatchQueue) -> InternalLiveMapValue, expectedData: ProtocolTypes.ObjectData)]) + func publishesCorrectObjectMessageForDifferentValueTypes(value: @escaping @Sendable (DispatchQueue) -> InternalLiveMapValue, expectedData: ProtocolTypes.ObjectData) async throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let realtimeObjects = MockRealtimeObjects() - var publishedMessage: OutboundObjectMessage? + var publishedMessage: ProtocolTypes.OutboundObjectMessage? realtimeObjects.setPublishAndApplyHandler { messages in publishedMessage = messages.first return .success(()) @@ -1890,13 +1890,13 @@ struct InternalDefaultLiveMapTests { try await map.set(key: "testKey", value: value(internalQueue), coreSDK: coreSDK, realtimeObjects: realtimeObjects) - let expectedMessage = OutboundObjectMessage( - operation: ObjectOperation( + let expectedMessage = ProtocolTypes.OutboundObjectMessage( + operation: ProtocolTypes.ObjectOperation( // RTLM20e2 action: .known(.mapSet), // RTLM20e3 objectId: "map:test@123", - mapSet: MapSet( + mapSet: ProtocolTypes.MapSet( // RTLM20e6 key: "testKey", // RTLM20e7 @@ -1968,7 +1968,7 @@ struct InternalDefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let realtimeObjects = MockRealtimeObjects() - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] realtimeObjects.setPublishAndApplyHandler { messages in publishedMessages.append(contentsOf: messages) return .success(()) @@ -1976,8 +1976,8 @@ struct InternalDefaultLiveMapTests { try await map.remove(key: "testKey", coreSDK: coreSDK, realtimeObjects: realtimeObjects) - let expectedMessage = OutboundObjectMessage( - operation: ObjectOperation( + let expectedMessage = ProtocolTypes.OutboundObjectMessage( + operation: ProtocolTypes.ObjectOperation( // RTLM21e2 action: .known(.mapRemove), // RTLM21e3 diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index f37595450..171d309c3 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -1409,7 +1409,7 @@ struct InternalDefaultRealtimeObjectsTests { } // Track published messages - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] coreSDK.setPublishHandler { messages in publishedMessages.append(contentsOf: messages) return PublishResult(serials: messages.map { _ in "serial_\(UUID().uuidString)" }) @@ -1438,7 +1438,7 @@ struct InternalDefaultRealtimeObjectsTests { #expect(mapInitialValue["entries"] == .object(["stringKey": .object(["data": .object(["string": "stringValue"])])])) // Sense check that create op was applied locally by publishAndApply ( - #expect(returnedMap.testsOnly_data == ["stringKey": InternalObjectsMapEntry(data: ObjectData(string: "stringValue"))]) + #expect(returnedMap.testsOnly_data == ["stringKey": InternalObjectsMapEntry(data: ProtocolTypes.ObjectData(string: "stringValue"))]) #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.mapValue === returnedMap) } @@ -1458,7 +1458,7 @@ struct InternalDefaultRealtimeObjectsTests { } // Track published messages - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] coreSDK.setPublishHandler { messages in publishedMessages.append(contentsOf: messages) return PublishResult(serials: messages.map { _ in "serial_\(UUID().uuidString)" }) @@ -1494,7 +1494,7 @@ struct InternalDefaultRealtimeObjectsTests { } // Track published messages and the generated objectId - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] var maybeGeneratedObjectID: String? var maybeExistingObject: AnyObject? @@ -1564,7 +1564,7 @@ struct InternalDefaultRealtimeObjectsTests { } // Track published messages - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] coreSDK.setPublishHandler { messages in publishedMessages.append(contentsOf: messages) return PublishResult(serials: messages.map { _ in "serial_\(UUID().uuidString)" }) @@ -1608,7 +1608,7 @@ struct InternalDefaultRealtimeObjectsTests { } // Track published messages - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] coreSDK.setPublishHandler { messages in publishedMessages.append(contentsOf: messages) return PublishResult(serials: messages.map { _ in "serial_\(UUID().uuidString)" }) @@ -1644,7 +1644,7 @@ struct InternalDefaultRealtimeObjectsTests { } // Track published messages and the generated objectId - var publishedMessages: [OutboundObjectMessage] = [] + var publishedMessages: [ProtocolTypes.OutboundObjectMessage] = [] var maybeGeneratedObjectID: String? var maybeExistingObject: AnyObject? @@ -1985,7 +1985,7 @@ struct InternalDefaultRealtimeObjectsTests { } // RTO20b: Capture the outbound message published via the core SDK, and return a real serial - var capturedOutboundMessages: [OutboundObjectMessage] = [] + var capturedOutboundMessages: [ProtocolTypes.OutboundObjectMessage] = [] coreSDK.setPublishHandler { messages in capturedOutboundMessages = messages return PublishResult(serials: messages.map { _ in serial }) @@ -2002,7 +2002,7 @@ struct InternalDefaultRealtimeObjectsTests { #expect(outboundMessage.siteCode == nil) // RTO20f: The synthetic message was applied (data is present) - #expect(returnedMap.testsOnly_data == ["key": InternalObjectsMapEntry(data: ObjectData(string: "value"))]) + #expect(returnedMap.testsOnly_data == ["key": InternalObjectsMapEntry(data: ProtocolTypes.ObjectData(string: "value"))]) // RTO20f: The synthetic message was applied with source LOCAL // (confirmed because siteTimeserials were not updated, per RTLM15c / RTLC7c) diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift deleted file mode 100644 index 34cd3b18c..000000000 --- a/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift +++ /dev/null @@ -1,595 +0,0 @@ -import _AblyPluginSupportPrivate -import Ably -@testable import AblyLiveObjects -import Foundation - -// This file is copied from the file objects.test.js in ably-js. - -/// This is a Swift port of the JavaScript ObjectsHelper class used for testing. -final class ObjectsHelper: Sendable { - // MARK: - Constants - - /// Object operation actions - enum Actions: Int { - case mapCreate = 0 - case mapSet = 1 - case mapRemove = 2 - case counterCreate = 3 - case counterInc = 4 - case objectDelete = 5 - case mapClear = 6 - - var stringValue: String { - switch self { - case .mapCreate: - "MAP_CREATE" - case .mapSet: - "MAP_SET" - case .mapRemove: - "MAP_REMOVE" - case .counterCreate: - "COUNTER_CREATE" - case .counterInc: - "COUNTER_INC" - case .objectDelete: - "OBJECT_DELETE" - case .mapClear: - "MAP_CLEAR" - } - } - } - - // MARK: - Properties - - private let rest: ARTRest - - // MARK: - Initialization - - init() async throws { - let options = try await ARTClientOptions(key: Sandbox.fetchSharedAPIKey()) - options.useBinaryProtocol = false - options.environment = "sandbox" - rest = ARTRest(options: options) - } - - // MARK: - Static Properties and Methods - - /// Static access to the Actions enum (equivalent to JavaScript static ACTIONS) - static let ACTIONS = Actions.self - - /// Returns the root keys used in the fixture objects tree - static func fixtureRootKeys() -> [String] { - ["emptyCounter", "initialValueCounter", "referencedCounter", "emptyMap", "referencedMap", "valuesMap"] - } - - // MARK: - Channel Initialization - - /// Sends Objects REST API requests to create objects tree on a provided channel: - /// - /// - root "emptyMap" -> Map#1 {} -- empty map - /// - root "referencedMap" -> Map#2 { "counterKey": } - /// - root "valuesMap" -> Map#3 { "stringKey": "stringValue", "emptyStringKey": "", "bytesKey": , "emptyBytesKey": , "numberKey": 1, "zeroKey": 0, "trueKey": true, "falseKey": false, "mapKey": } - /// - root "emptyCounter" -> Counter#1 -- no initial value counter, should be 0 - /// - root "initialValueCounter" -> Counter#2 count=10 - /// - root "referencedCounter" -> Counter#3 count=20 - func initForChannel(_ channelName: String) async throws { - _ = try await createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "emptyCounter", - createOp: counterCreateRestOp(), - ) - - _ = try await createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "initialValueCounter", - createOp: counterCreateRestOp(number: 10), - ) - - let referencedCounter = try await createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "referencedCounter", - createOp: counterCreateRestOp(number: 20), - ) - - _ = try await createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "emptyMap", - createOp: mapCreateRestOp(), - ) - - let referencedMapData: [String: JSONValue] = [ - "counterKey": .object(["objectId": .string(referencedCounter.objectId)]), - ] - let referencedMap = try await createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "referencedMap", - createOp: mapCreateRestOp(data: referencedMapData), - ) - - let valuesMapData: [String: JSONValue] = [ - "stringKey": .object(["string": .string("stringValue")]), - "emptyStringKey": .object(["string": .string("")]), - "bytesKey": .object(["bytes": .string("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")]), - "emptyBytesKey": .object(["bytes": .string("")]), - "numberKey": .object(["number": .number(1)]), - "zeroKey": .object(["number": .number(0)]), - "trueKey": .object(["boolean": .bool(true)]), - "falseKey": .object(["boolean": .bool(false)]), - "mapKey": .object(["objectId": .string(referencedMap.objectId)]), - ] - _ = try await createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "valuesMap", - createOp: mapCreateRestOp(data: valuesMapData), - ) - } - - // MARK: - Wire Object Messages - - /// Creates a map create operation - func mapCreateOp(objectId: String? = nil, entries: [String: WireValue]? = nil) -> [String: WireValue] { - var mapCreate: [String: WireValue] = [ - "semantics": .number(NSNumber(value: 0)), - ] - - mapCreate["entries"] = .object(entries ?? [:]) - - var operation: [String: WireValue] = [ - "action": .number(NSNumber(value: Actions.mapCreate.rawValue)), - "mapCreate": .object(mapCreate), - ] - - if let objectId { - operation["objectId"] = .string(objectId) - } - - return ["operation": .object(operation)] - } - - /// Creates a map set operation - func mapSetOp(objectId: String, key: String, data: WireValue) -> [String: WireValue] { - [ - "operation": .object([ - "action": .number(NSNumber(value: Actions.mapSet.rawValue)), - "objectId": .string(objectId), - "mapSet": .object([ - "key": .string(key), - "value": data, - ]), - ]), - ] - } - - /// Creates a map remove operation - func mapRemoveOp(objectId: String, key: String) -> [String: WireValue] { - [ - "operation": .object([ - "action": .number(NSNumber(value: Actions.mapRemove.rawValue)), - "objectId": .string(objectId), - "mapRemove": .object([ - "key": .string(key), - ]), - ]), - ] - } - - /// Creates a counter create operation - func counterCreateOp(objectId: String? = nil, count: Int? = nil) -> [String: WireValue] { - var counterCreate: [String: WireValue] = [:] - if let count { - counterCreate["count"] = .number(NSNumber(value: count)) - } - - var operation: [String: WireValue] = [ - "action": .number(NSNumber(value: Actions.counterCreate.rawValue)), - "counterCreate": .object(counterCreate), - ] - - if let objectId { - operation["objectId"] = .string(objectId) - } - - return ["operation": .object(operation)] - } - - /// Creates a counter increment operation - func counterIncOp(objectId: String, number: Int) -> [String: WireValue] { - [ - "operation": .object([ - "action": .number(NSNumber(value: Actions.counterInc.rawValue)), - "objectId": .string(objectId), - "counterInc": .object([ - "number": .number(NSNumber(value: number)), - ]), - ]), - ] - } - - /// Creates an object delete operation - func objectDeleteOp(objectId: String) -> [String: WireValue] { - [ - "operation": .object([ - "action": .number(NSNumber(value: Actions.objectDelete.rawValue)), - "objectId": .string(objectId), - "objectDelete": .object([:]), - ]), - ] - } - - /// Sends a MAP_CLEAR operation to the server via `testsOnly_publish`. - /// - /// MAP_CLEAR is server-initiated and has no production client-side API, - /// but it is enabled over realtime connections on non-prod clusters for testing. - func sendMapClearOnChannel(objects: any RealtimeObjects, objectId: String) async throws { - guard let internallyTypedObjects = objects as? PublicDefaultRealtimeObjects else { - preconditionFailure("Expected PublicDefaultRealtimeObjects") - } - try await internallyTypedObjects.testsOnly_publish(objectMessages: [ - OutboundObjectMessage( - operation: ObjectOperation( - action: .known(.mapClear), - objectId: objectId, - mapClear: WireMapClear(), - ), - ), - ]) - } - - /// Creates a map clear operation - func mapClearOp(objectId: String) -> [String: WireValue] { - [ - "operation": .object([ - "action": .number(NSNumber(value: Actions.mapClear.rawValue)), - "objectId": .string(objectId), - "mapClear": .object([:]), - ]), - ] - } - - /// Creates a map object structure - func mapObject( - objectId: String, - siteTimeserials: [String: String], - initialEntries: [String: WireValue]? = nil, - materialisedEntries: [String: WireValue]? = nil, - tombstone: Bool = false, - clearTimeserial: String? = nil, - ) -> [String: WireValue] { - var mapDict: [String: WireValue] = [ - "semantics": .number(NSNumber(value: 0)), - "entries": .object(materialisedEntries ?? [:]), - ] - - if let clearTimeserial { - mapDict["clearTimeserial"] = .string(clearTimeserial) - } - - var object: [String: WireValue] = [ - "objectId": .string(objectId), - "siteTimeserials": .object(siteTimeserials.mapValues { .string($0) }), - "tombstone": .bool(tombstone), - "map": .object(mapDict), - ] - - if let initialEntries { - let createOp = mapCreateOp(objectId: objectId, entries: initialEntries) - object["createOp"] = createOp["operation"]! - } - - return ["object": .object(object)] - } - - /// Creates a counter object structure - func counterObject( - objectId: String, - siteTimeserials: [String: String], - initialCount: Int? = nil, - materialisedCount: Int? = nil, - tombstone: Bool = false, - ) -> [String: WireValue] { - let materialisedCountValue: WireValue = if let materialisedCount { - .number(NSNumber(value: materialisedCount)) - } else { - .null - } - - var object: [String: WireValue] = [ - "objectId": .string(objectId), - "siteTimeserials": .object(siteTimeserials.mapValues { .string($0) }), - "tombstone": .bool(tombstone), - "counter": .object([ - "count": materialisedCountValue, - ]), - ] - - if let initialCount { - let createOp = counterCreateOp(objectId: objectId, count: initialCount) - object["createOp"] = createOp["operation"]! - } - - return ["object": .object(object)] - } - - /// Creates an object operation message - func objectOperationMessage( - channelName: String, - serial: String, - siteCode: String, - state: [[String: WireValue]]? = nil, - ) -> [String: WireValue] { - let stateWithSerials = state?.map { objectMessage in - var message = objectMessage - message["serial"] = .string(serial) - message["siteCode"] = .string(siteCode) - return message - } - - let stateArray = stateWithSerials?.map { dict in WireValue.object(dict) } ?? [] - - return [ - "action": .number(NSNumber(value: 19)), // OBJECT - "channel": .string(channelName), - "channelSerial": .string(serial), - "state": .array(stateArray), - ] - } - - /// Creates an object state message - func objectStateMessage( - channelName: String, - syncSerial: String, - state: [[String: WireValue]]? = nil, - ) -> [String: WireValue] { - let stateArray = state?.map { dict in WireValue.object(dict) } ?? [] - return [ - "action": .number(NSNumber(value: 20)), // OBJECT_SYNC - "channel": .string(channelName), - "channelSerial": .string(syncSerial), - "state": .array(stateArray), - ] - } - - /// This is the equivalent of the JS ObjectHelper's channel.processMessage(createPM(…)). - private func processDeserializedProtocolMessage( - _ deserialized: [String: WireValue], - channel: ARTRealtimeChannel, - ) async { - await withCheckedContinuation { continuation in - channel.internal.queue.async { - let useBinaryProtocol = channel.realtimeInternal.options.useBinaryProtocol - let jsonLikeEncoderDelegate: ARTJsonLikeEncoderDelegate = useBinaryProtocol ? ARTMsgPackEncoder() : ARTJsonEncoder() - - let encoder = ARTJsonLikeEncoder( - rest: channel.internal.realtime!.rest, - delegate: jsonLikeEncoderDelegate, - logger: channel.internal.logger, - ) - - let foundationObject = deserialized.toPluginSupportDataDictionary - let protocolMessage = withExtendedLifetime(jsonLikeEncoderDelegate) { - encoder.protocolMessage(from: foundationObject)! - } - - channel.internal.onChannelMessage(protocolMessage) - continuation.resume() - } - } - } - - /// Processes an object operation message on a channel - func processObjectOperationMessageOnChannel( - channel: ARTRealtimeChannel, - serial: String, - siteCode: String, - state: [[String: WireValue]]? = nil, - ) async { - await processDeserializedProtocolMessage( - objectOperationMessage( - channelName: channel.name, - serial: serial, - siteCode: siteCode, - state: state, - ), - channel: channel, - ) - } - - /// Processes an object state message on a channel - func processObjectStateMessageOnChannel( - channel: ARTRealtimeChannel, - syncSerial: String, - state: [[String: WireValue]]? = nil, - ) async { - await processDeserializedProtocolMessage( - objectStateMessage( - channelName: channel.name, - syncSerial: syncSerial, - state: state, - ), - channel: channel, - ) - } - - // MARK: - REST API Operations - - /// Result of a REST API operation - struct OperationResult { - let objectId: String - let success: Bool - } - - /// Creates an object and sets it on a map - func createAndSetOnMap( - channelName: String, - mapObjectId: String, - key: String, - createOp: [String: JSONValue], - ) async throws -> OperationResult { - let createResult = try await operationRequest(channelName: channelName, opBody: createOp) - let objectId = createResult.objectId - - let setOp = mapSetRestOp( - objectId: mapObjectId, - key: key, - value: ["objectId": .string(objectId)], - ) - _ = try await operationRequest(channelName: channelName, opBody: setOp) - - return createResult - } - - /// Creates a map create REST operation - func mapCreateRestOp(objectId: String? = nil, nonce: String? = nil, data: [String: JSONValue]? = nil) -> [String: JSONValue] { - var mapCreate: [String: JSONValue] = [ - "semantics": .number(0), - ] - - if let data { - // Wrap each entry value in { "data": value } to match v6 format - let entries = Dictionary(uniqueKeysWithValues: data.map { key, value in - (key, JSONValue.object(["data": value])) - }) - mapCreate["entries"] = .object(entries) - } - - var opBody: [String: JSONValue] = [ - "mapCreate": .object(mapCreate), - ] - - if let objectId { - opBody["objectId"] = .string(objectId) - opBody["nonce"] = .string(nonce ?? "") - } - - return opBody - } - - /// Creates a map set REST operation - func mapSetRestOp(objectId: String, key: String, value: [String: JSONValue]) -> [String: JSONValue] { - [ - "objectId": .string(objectId), - "mapSet": .object([ - "key": .string(key), - "value": .object(value), - ]), - ] - } - - /// Creates a map remove REST operation - func mapRemoveRestOp(objectId: String, key: String) -> [String: JSONValue] { - [ - "objectId": .string(objectId), - "mapRemove": .object([ - "key": .string(key), - ]), - ] - } - - /// Creates a counter create REST operation - func counterCreateRestOp(objectId: String? = nil, nonce: String? = nil, number: Double? = nil) -> [String: JSONValue] { - var counterCreate: [String: JSONValue] = [:] - - if let number { - counterCreate["count"] = .number(number) - } - - var opBody: [String: JSONValue] = [ - "counterCreate": .object(counterCreate), - ] - - if let objectId { - opBody["objectId"] = .string(objectId) - opBody["nonce"] = .string(nonce ?? "") - } - - return opBody - } - - /// Creates a counter increment REST operation - func counterIncRestOp(objectId: String, number: Double) -> [String: JSONValue] { - [ - "objectId": .string(objectId), - "counterInc": .object(["number": .number(number)]), - ] - } - - /// Sends an operation request to the REST API - func operationRequest(channelName: String, opBody: [String: JSONValue]) async throws -> OperationResult { - let path = "/channels/\(channelName)/objects" - - do { - let response = try await rest.requestAsync("POST", path: path, params: nil, body: opBody.toJSONSerializationInput, headers: nil) - - guard (200 ..< 300).contains(response.statusCode) else { - throw NSError( - domain: "ObjectsHelper", - code: response.statusCode, - userInfo: [ - NSLocalizedDescriptionKey: "REST API request failed", - "path": path, - "operation": opBody.toJSONSerializationInput, - ], - ) - } - - guard let firstItem = response.items.first as? [String: Any] else { - throw NSError( - domain: "ObjectsHelper", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "Invalid response format - no items"], - ) - } - - // Extract objectId from the response - let objectId: String - if let objectIds = firstItem["objectIds"] as? [String], let firstObjectId = objectIds.first { - objectId = firstObjectId - } else if let directObjectId = firstItem["objectId"] as? String { - objectId = directObjectId - } else { - throw NSError( - domain: "ObjectsHelper", - code: -1, - userInfo: [NSLocalizedDescriptionKey: "No objectId found in response"], - ) - } - - return OperationResult(objectId: objectId, success: true) - } catch let error as ARTErrorInfo { - throw error - } catch { - throw error - } - } - - // MARK: - Utility Methods - - /// Generates a fake map object ID - func fakeMapObjectId() -> String { - "map:\(randomString())@\(Int(Date().timeIntervalSince1970 * 1000))" - } - - /// Generates a fake counter object ID - func fakeCounterObjectId() -> String { - "counter:\(randomString())@\(Int(Date().timeIntervalSince1970 * 1000))" - } - - // MARK: - Private Methods - - /// Generates a random nonce - private func nonce() -> String { - randomString() - } - - /// Generates a random string - private func randomString() -> String { - let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - return String((0 ..< 16).map { _ in letters.randomElement()! }) - } -} diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift deleted file mode 100644 index c19bc8fa5..000000000 --- a/LiveObjects/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ /dev/null @@ -1,5684 +0,0 @@ -import Ably -@testable import AblyLiveObjects -import Testing - -// This file is copied from the file objects.test.js in ably-js. - -// Disable trailing_closure so that we can pass `action:` to the TestScenario initializer, consistent with the JS code -// swiftlint:disable trailing_closure - -// MARK: - Top-level helpers - -private func realtimeWithObjects(options: ClientHelper.PartialClientOptions) async throws -> ARTRealtime { - try await ClientHelper.realtimeWithObjects(options: options) -} - -private func channelOptionsWithObjects() -> ARTRealtimeChannelOptions { - ClientHelper.channelOptionsWithObjects() -} - -// Swift version of the JS lexicoTimeserial function -// -// Example: -// -// 01726585978590-001@abcdefghij:001 -// |____________| |_| |________| |_| -// | | | | -// timestamp counter seriesId idx -private func lexicoTimeserial(seriesId: String, timestamp: Int64, counter: Int, index: Int? = nil) -> String { - let paddedTimestamp = String(format: "%014d", timestamp) - let paddedCounter = String(format: "%03d", counter) - - var result = "\(paddedTimestamp)-\(paddedCounter)@\(seriesId)" - - if let index { - let paddedIndex = String(format: "%03d", index) - result += ":\(paddedIndex)" - } - - return result -} - -func monitorConnectionThenCloseAndFinishAsync(_ realtime: ARTRealtime, action: @escaping @Sendable () async throws -> Void) async throws { - defer { realtime.connection.close() } - - try await withThrowingTaskGroup { group in - // Monitor connection state - for state in [ARTRealtimeConnectionEvent.failed, .suspended] { - group.addTask { - let (stream, continuation) = AsyncThrowingStream.makeStream() - - let subscription = realtime.connection.on(state) { _ in - realtime.close() - - let error = NSError( - domain: "IntegrationTestsError", - code: 1, - userInfo: [ - NSLocalizedDescriptionKey: "Connection monitoring: state changed to \(state), aborting test", - ], - ) - continuation.finish(throwing: error) - } - continuation.onTermination = { _ in - realtime.connection.off(subscription) - } - - try await stream.first { _ in true } - } - } - - // Perform the action - group.addTask { - try await action() - } - - // Wait for either connection monitoring to throw an error or for the action to complete - guard let result = await group.nextResult() else { - return - } - - group.cancelAll() - try result.get() - } -} - -func waitFixtureChannelIsReady(_: ARTRealtime) async throws { - // TODO: Implement this using the subscription APIs once we've got a spec for those, but this should be fine for now - try await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC) -} - -func waitForMapKeyUpdate(_ updates: AsyncStream, _ key: String) async { - _ = await updates.first { $0.update[key] != nil } -} - -func waitForCounterUpdate(_ updates: AsyncStream) async { - _ = await updates.first { _ in true } -} - -/// Waits for a MAP_CLEAR operation to be applied to a LiveMap, by waiting for an update where -/// all of the specified keys have `.removed` status. -/// -/// The JS equivalent subscribes and checks `message.operation.action === 'map.clear'`, but Swift's -/// `LiveMapUpdate` doesn't expose the operation action — it only provides per-key changes via -/// `update: [String: LiveMapUpdateAction]`. So we use the per-key `.removed` entries as a proxy -/// instead. -func waitForMapClear(_ updates: AsyncStream, expectedRemovedKeys: Set) async { - _ = await updates.first { update in - expectedRemovedKeys.allSatisfy { update.update[$0] == .removed } - } -} - -// Note that Cursor decided to implement this in a different way to the waitForObjectSync that I'd already implemented; TODO pick one of the two approaches (this one might be cleaner). -func waitForObjectOperation(_ objects: any RealtimeObjects, _ action: ObjectOperationAction) async throws { - // Cast to access internal API for testing - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - let objectMessages = internallyTypedObjects.testsOnly_receivedObjectProtocolMessages - - // Wait for an object protocol message containing the specified action - _ = await objectMessages.first { messages in - messages.contains { message in - message.operation?.action == .known(action) - } - } -} - -// I added this @MainActor as an "I don't understand what's going on there; let's try this" when observing that for some reason the setter of setListenerAfterProcessingIncomingMessage was hanging inside `-[ARTSRDelegateController dispatchQueue]`. This seems to avoid it and I have not investigated more deeply 🤷 -@MainActor -func waitForObjectSync(_ realtime: ARTRealtime) async throws { - let testProxyTransport = try #require(realtime.internal.transport as? TestProxyTransport) - - await withCheckedContinuation { (continuation: CheckedContinuation) in - testProxyTransport.setListenerAfterProcessingIncomingMessage { protocolMessage in - if protocolMessage.action == .objectSync { - testProxyTransport.setListenerAfterProcessingIncomingMessage(nil) - continuation.resume() - } - } - } -} - -// MARK: - ARTProtocolMessage test-only extension - -extension ARTProtocolMessage { - /// Extract `InboundObjectMessage`s from the protocol message's state array. - var testsOnly_inboundObjectMessages: [InboundObjectMessage] { - // Claude's explanation, which I don't really have the time to verify or try and do better than — it's only test code so good enough: - // - // > The `state` property on `ARTProtocolMessage` is behind `#ifdef ABLY_SUPPORTS_PLUGINS`, - // > which is defined as a C flag on the ably-cocoa SPM target. However, this define is not - // > visible when the Clang module is built for the `Ably.Private` submodule, so the property - // > is not importable into Swift. We use KVC to access it instead. - guard let stateArray = value(forKey: "state") as? [AnyObject] else { - return [] - } - return stateArray.compactMap { item -> InboundObjectMessage? in - guard let box = item as? DefaultInternalPlugin.ObjectMessageBox - else { return nil } - return box.objectMessage - } - } -} - -// MARK: - Echo/ACK interceptors for apply-on-ACK tests - -/// Intercepts OBJECT messages (action 19) arriving from Realtime, holding them so tests can -/// control the timing of echo delivery, to test ACK-before-echo scenarios. Call when CONNECTING -/// or CONNECTED to intercept messages on the active transport. -/// -/// An echo is a subset of OBJECT messages — specifically, OBJECT messages that originated from -/// this client and are being echoed back. The name "echo" reflects how this interceptor is -/// intended to be used in tests, but it doesn't actually filter for echoes — it intercepts all -/// OBJECT messages. -/// -/// - ``waitForEcho()`` / ``waitForEchoCount(_:)``: resolve immediately if enough OBJECT messages -/// have already been intercepted, otherwise wait for one. Only one call can be pending at a -/// time; subsequent calls will trap. -/// - ``releaseAll()``: replays all held OBJECT messages through the channel's internal message -/// handler. -/// - ``releaseFirst()``: replays only the first held OBJECT message. -/// - ``restore()``: removes the interceptor, restoring normal message handling. -private final class EchoInterceptor: @unchecked Sendable { - private let transport: TestProxyTransport - private let channel: ARTRealtimeChannel - private let lock = NSLock() - private var _heldEchoes: [ARTProtocolMessage] = [] - private var echoContinuation: CheckedContinuation? - - var heldEchoes: [ARTProtocolMessage] { - lock.withLock { _heldEchoes } - } - - init(client: ARTRealtime, channel: ARTRealtimeChannel) { - // swiftlint:disable:next force_cast - transport = client.internal.transport as! TestProxyTransport - self.channel = channel - - transport.setBeforeIncomingMessageModifier { [weak self] message in - guard let self else { - return message - } - - if message.action == .object { - lock.withLock { - _heldEchoes.append(message) - echoContinuation?.resume() - echoContinuation = nil - } - return nil // suppress the OBJECT message - } - return message - } - } - - /// Waits until at least one new OBJECT message has been intercepted since the last - /// ``releaseAll()`` / ``releaseFirst()`` (or since construction). - func waitForEcho() async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - lock.withLock { - if !_heldEchoes.isEmpty { - continuation.resume() - } else { - precondition(echoContinuation == nil, "Only one waitForEcho/waitForEchoCount call can be pending at a time") - echoContinuation = continuation - } - } - } - } - - /// Waits until at least `count` OBJECT messages have been intercepted. - func waitForEchoCount(_ count: Int) async { - while true { - await withCheckedContinuation { (continuation: CheckedContinuation) in - lock.withLock { - if _heldEchoes.count >= count { - continuation.resume() - } else { - precondition(echoContinuation == nil, "Only one waitForEcho/waitForEchoCount call can be pending at a time") - echoContinuation = continuation - } - } - } - let current = lock.withLock { _heldEchoes.count } - if current >= count { break } - } - } - - /// Replays all held OBJECT messages through the channel's internal message handler. - func releaseAll() async { - let echoes = lock.withLock { - let e = _heldEchoes - _heldEchoes.removeAll() - return e - } - for echo in echoes { - nonisolated(unsafe) let echo = echo - await withCheckedContinuation { (continuation: CheckedContinuation) in - channel.internal.queue.async { - self.channel.internal.onChannelMessage(echo) - continuation.resume() - } - } - } - } - - /// Replays only the first held OBJECT message. - func releaseFirst() async { - let echo: ARTProtocolMessage? = lock.withLock { - _heldEchoes.isEmpty ? nil : _heldEchoes.removeFirst() - } - guard let echo else { - return - } - - nonisolated(unsafe) let unsafeEcho = echo - await withCheckedContinuation { (continuation: CheckedContinuation) in - channel.internal.queue.async { - self.channel.internal.onChannelMessage(unsafeEcho) - continuation.resume() - } - } - } - - /// Removes the interceptor, restoring normal message handling. - func restore() { - transport.setBeforeIncomingMessageModifier(nil) - } -} - -/// Intercepts ACK messages (action 1) arriving from Realtime, holding them so tests can control -/// the timing of ACK delivery, to test echo-before-ACK scenarios. Call when CONNECTING or -/// CONNECTED to intercept messages on the active transport. -/// -/// - ``waitForAck()``: resolves immediately if an ACK has already been intercepted, otherwise -/// waits for one. Only one call can be pending at a time; subsequent calls will trap. -/// - ``releaseAll()``: replays all held ACKs through the client's internal ACK handler. -/// - ``restore()``: removes the interceptor, restoring normal message handling. -private final class AckInterceptor: @unchecked Sendable { - private let transport: TestProxyTransport - private let client: ARTRealtime - private let lock = NSLock() - private var _heldAcks: [ARTProtocolMessage] = [] - private var ackContinuation: CheckedContinuation? - - var heldAcks: [ARTProtocolMessage] { - lock.withLock { _heldAcks } - } - - init(client: ARTRealtime) { - self.client = client - // swiftlint:disable:next force_cast - transport = client.internal.transport as! TestProxyTransport - - transport.setBeforeIncomingMessageModifier { [weak self] message in - guard let self else { - return message - } - - if message.action == .ack { - lock.withLock { - _heldAcks.append(message) - ackContinuation?.resume() - ackContinuation = nil - } - return nil // suppress the ACK - } - return message - } - } - - /// Waits until at least one ACK has been intercepted. - func waitForAck() async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - lock.withLock { - if !_heldAcks.isEmpty { - continuation.resume() - } else { - precondition(ackContinuation == nil, "Only one waitForAck call can be pending at a time") - ackContinuation = continuation - } - } - } - } - - /// Replays all held ACK messages through the client's internal ACK handler. - func releaseAll() async { - let acks = lock.withLock { - let a = _heldAcks - _heldAcks.removeAll() - return a - } - for ack in acks { - nonisolated(unsafe) let ack = ack - await withCheckedContinuation { (continuation: CheckedContinuation) in - client.internal.queue.async { - self.client.internal.onAck(ack) - continuation.resume() - } - } - } - } - - /// Removes the interceptor, restoring normal message handling. - func restore() { - transport.setBeforeIncomingMessageModifier(nil) - } -} - -/// Injects an ATTACHED protocol message into the channel with the given flags. -private func injectAttachedMessage(channel: ARTRealtimeChannel, flags: ARTProtocolMessageFlag = []) async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - channel.internal.queue.async { - let pm = ARTProtocolMessage() - pm.action = .attached - pm.channel = channel.name - pm.flags = Int64(flags.rawValue) - channel.internal.onChannelMessage(pm) - continuation.resume() - } - } -} - -// MARK: - Constants - -private let objectsFixturesChannel = "objects_fixtures" - -// MARK: - Top-level fixtures (ported from JS objects.test.js) - -// The value of JS's `Number.MAX_SAFE_INTEGER` — the maximum integer that a `Double` can represent exactly. -private let maxSafeInteger = Double((1 << 53) - 1) - -// Primitive key data fixture used across multiple test scenarios -// liveMapValue field contains the value as LiveMapValue for use in map operations -private let primitiveKeyData: [(key: String, data: [String: JSONValue], liveMapValue: LiveMapValue)] = [ - ( - key: "stringKey", - data: ["string": .string("stringValue")], - liveMapValue: "stringValue" - ), - ( - key: "emptyStringKey", - data: ["string": .string("")], - liveMapValue: "" - ), - ( - key: "bytesKey", - data: ["bytes": .string("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")], - liveMapValue: .data(Data(base64Encoded: "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")!) - ), - ( - key: "emptyBytesKey", - data: ["bytes": .string("")], - liveMapValue: .data(Data(base64Encoded: "")!) - ), - ( - key: "maxSafeIntegerKey", - data: ["number": .number(maxSafeInteger)], - liveMapValue: .number(maxSafeInteger) - ), - ( - key: "negativeMaxSafeIntegerKey", - data: ["number": .number(-maxSafeInteger)], - liveMapValue: .number(-maxSafeInteger) - ), - ( - key: "numberKey", - data: ["number": .number(1)], - liveMapValue: 1 - ), - ( - key: "zeroKey", - data: ["number": .number(0)], - liveMapValue: 0 - ), - ( - key: "trueKey", - data: ["boolean": .bool(true)], - liveMapValue: true - ), - ( - key: "falseKey", - data: ["boolean": .bool(false)], - liveMapValue: false - ), -] - -// Primitive maps fixtures used in map creation and write API scenarios -// entries field contains the map entries in the format expected by ObjectsHelper -// restData field contains the data in the format expected by REST API operations -// liveMapEntries field contains entries as LiveMapValue for direct map operations -private let primitiveMapsFixtures: [(name: String, entries: [String: [String: JSONValue]]?, restData: [String: JSONValue]?, liveMapEntries: [String: LiveMapValue]?)] = [ - (name: "emptyMap", entries: nil, restData: nil, liveMapEntries: nil), - (name: "valuesMap", - entries: Dictionary(uniqueKeysWithValues: primitiveKeyData.map { ($0.key, ["data": .object($0.data)]) }), - restData: Dictionary(uniqueKeysWithValues: primitiveKeyData.map { ($0.key, .object($0.data)) }), - liveMapEntries: Dictionary(uniqueKeysWithValues: primitiveKeyData.map { ($0.key, $0.liveMapValue) })), -] - -// Counters fixtures used in counter creation and write API scenarios -// count field supports both Int and Double types depending on the test scenario -private let countersFixtures: [(name: String, count: Double?)] = [ - (name: "emptyCounter", count: nil), - (name: "zeroCounter", count: 0), - (name: "valueCounter", count: 10), - (name: "negativeValueCounter", count: -10), - (name: "maxSafeIntegerCounter", count: Double(Int.max)), - (name: "negativeMaxSafeIntegerCounter", count: -Double(Int.max)), -] - -// MARK: - Support for parameterised tests - -/// The output of `forScenarios`. One element of the one-dimensional arguments array that is passed to a Swift Testing test. -private struct TestCase: Identifiable, CustomStringConvertible { - var disabled: Bool - var scenario: TestScenario - var options: ClientHelper.PartialClientOptions - var channelName: String - - /// This `Identifiable` conformance allows us to re-run individual test cases from the Xcode UI (https://developer.apple.com/documentation/testing/parameterizedtesting#Run-selected-test-cases) - var id: TestCaseID { - .init(description: scenario.description, options: options) - } - - /// This seems to determine the nice name that you see for this when it's used as a test case parameter. (I can't see anywhere that this is documented; found it by experimentation). - var description: String { - var result = scenario.description - - if let useBinaryProtocol = options.useBinaryProtocol { - result += " (\(useBinaryProtocol ? "binary" : "text"))" - } - - return result - } -} - -/// Enables `TestCase`'s conformance to `Identifiable`. -private struct TestCaseID: Encodable, Hashable { - var description: String - var options: ClientHelper.PartialClientOptions? -} - -/// The input to `forScenarios`. -private struct TestScenario { - var disabled: Bool - var allTransportsAndProtocols: Bool - var description: String - var action: @Sendable (Context) async throws -> Void -} - -private func forScenarios(_ scenarios: [TestScenario]) -> [TestCase] { - scenarios.map { scenario -> [TestCase] in - var clientOptions = ClientHelper.PartialClientOptions(logIdentifier: "client1") - - if scenario.allTransportsAndProtocols { - return [true, false].map { useBinaryProtocol -> TestCase in - clientOptions.useBinaryProtocol = useBinaryProtocol - - return .init( - disabled: scenario.disabled, - scenario: scenario, - options: clientOptions, - channelName: "\(scenario.description) \(useBinaryProtocol ? "binary" : "text")", - ) - } - } else { - return [.init(disabled: scenario.disabled, scenario: scenario, options: clientOptions, channelName: scenario.description)] - } - } - .flatMap(\.self) -} - -private protocol Scenarios { - associatedtype Context - static var scenarios: [TestScenario] { get } -} - -private extension Scenarios { - static var testCases: [TestCase] { - forScenarios(scenarios) - } -} - -// MARK: - Test lifecycle - -/// Creates the fixtures on ``objectsFixturesChannel`` if not yet created. -/// -/// This fulfils the role of JS's `before` hook. -private actor ObjectsFixturesTrait: SuiteTrait, TestScoping { - private actor SetupManager { - private var setupTask: Task? - - func setUpFixtures() async throws { - let setupTask: Task = if let existingSetupTask = self.setupTask { - existingSetupTask - } else { - Task { - let helper = try await ObjectsHelper() - try await helper.initForChannel(objectsFixturesChannel) - } - } - self.setupTask = setupTask - - try await setupTask.value - } - } - - private static let setupManager = SetupManager() - - func provideScope(for _: Test, testCase _: Test.Case?, performing function: () async throws -> Void) async throws { - try await Self.setupManager.setUpFixtures() - try await function() - } -} - -extension Trait where Self == ObjectsFixturesTrait { - static var objectsFixtures: Self { Self() } -} - -// MARK: - Utility types - -/// A class that isolates arbitrary mutable state to the main actor. -/// -/// Intended for allowing a subscription callback to mutate some state that is shared between multiple callbacks. This allows us to port the JS pattern where callbacks synchronously mutate some local variable that's stored outside the callback (in Swift, local variables cannot be isolated to an actor). -@MainActor -class MainActorStorage { - var value: T - - init(value: T) { - self.value = value - } -} - -// MARK: - Test suite - -@Suite( - .tags(.integration), - .objectsFixtures, - // These tests exhibit flakiness (hanging, timeouts, occasional Realtime - // connection limits) when run concurrently, where I think that we had up to - // 100 ARTRealtime instances active at the same time. So we're running them in - // serial to unblock CI builds until we can understand the issue better. See - // https://github.com/ably/ably-liveobjects-swift-plugin/issues/72. - .serialized, -) -private struct ObjectsIntegrationTests { - // TODO: Add the non-parameterised tests - - enum FirstSetOfScenarios: Scenarios { - struct Context { - var objects: any RealtimeObjects - var root: any LiveMap - var objectsHelper: ObjectsHelper - var channelName: String - var channel: ARTRealtimeChannel - var client: ARTRealtime - var clientOptions: ClientHelper.PartialClientOptions - } - - static let scenarios: [TestScenario] = { - let objectSyncSequenceScenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "on ATTACHED without HAS_OBJECTS clears local state", - action: { ctx in - // set a key on root so we can verify it gets cleared after ATTACHED - try await ctx.root.set(key: "foo", value: "bar") - #expect(try #require(ctx.root.get(key: "foo")?.stringValue) == "bar", "Check root has key before ATTACHED") - - // inject ATTACHED without HAS_OBJECTS flag - await injectAttachedMessage(channel: ctx.channel) - - // local state should be cleared — root should have no keys - #expect(try ctx.root.size == 0, "Check root has no keys after ATTACHED without HAS_OBJECTS") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "OBJECT_SYNC sequence builds object tree on channel attachment", - action: { ctx in - let client = ctx.client - - try await waitFixtureChannelIsReady(client) - - let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - let counterKeys = ["emptyCounter", "initialValueCounter", "referencedCounter"] - let mapKeys = ["emptyMap", "referencedMap", "valuesMap"] - let rootKeysCount = counterKeys.count + mapKeys.count - - #expect(try root.size == rootKeysCount, "Check root has correct number of keys") - - for key in counterKeys { - let counter = try #require(try root.get(key: key)) - #expect(counter.liveCounterValue != nil, "Check counter at key=\"\(key)\" in root is of type LiveCounter") - } - - for key in mapKeys { - let map = try #require(try root.get(key: key)) - #expect(map.liveMapValue != nil, "Check map at key=\"\(key)\" in root is of type LiveMap") - } - - let valuesMap = try #require(root.get(key: "valuesMap")?.liveMapValue) - let valueMapKeys = [ - "stringKey", - "emptyStringKey", - "bytesKey", - "emptyBytesKey", - "numberKey", - "zeroKey", - "trueKey", - "falseKey", - "mapKey", - ] - #expect(try valuesMap.size == valueMapKeys.count, "Check nested map has correct number of keys") - for key in valueMapKeys { - #expect(try valuesMap.get(key: key) != nil, "Check value at key=\"\(key)\" in nested map exists") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "OBJECT_SYNC sequence builds object tree with all operations applied", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - - // MAP_CREATE - let map = try await objects.createMap(entries: ["shouldStay": "foo", "shouldDelete": "bar"]) - // COUNTER_CREATE - let counter = try await objects.createCounter(count: 1) - - // Set the values - async let setMapPromise: Void = root.set(key: "map", value: .liveMap(map)) - async let setCounterPromise: Void = root.set(key: "counter", value: .liveCounter(counter)) - _ = try await (setMapPromise, setCounterPromise) - - // Perform the operations - async let setAnotherKeyPromise: Void = map.set(key: "anotherKey", value: "baz") - async let removeKeyPromise: Void = map.remove(key: "shouldDelete") - async let incrementPromise: Void = counter.increment(amount: 10) - _ = try await (setAnotherKeyPromise, removeKeyPromise, incrementPromise) - - // create a new client and check it syncs with the aggregated data - let client2 = try await realtimeWithObjects(options: ctx.clientOptions) - - try await monitorConnectionThenCloseAndFinishAsync(client2) { - let channel2 = client2.channels.get(ctx.channelName, options: channelOptionsWithObjects()) - let objects2 = channel2.objects - - try await channel2.attachAsync() - let root2 = try await objects2.getRoot() - - let counter2 = try #require(root2.get(key: "counter")?.liveCounterValue) - #expect(try counter2.value == 11, "Check counter has correct value") - - let map2 = try #require(root2.get(key: "map")?.liveMapValue) - #expect(try map2.size == 2, "Check map has correct number of keys") - #expect(try #require(map2.get(key: "shouldStay")?.stringValue) == "foo", "Check map has correct value for \"shouldStay\" key") - #expect(try #require(map2.get(key: "anotherKey")?.stringValue) == "baz", "Check map has correct value for \"anotherKey\" key") - #expect(try map2.get(key: "shouldDelete") == nil, "Check map does not have \"shouldDelete\" key") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC sequence does not change references to existing objects", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - let channel = ctx.channel - let client = ctx.client - - let map = try await objects.createMap() - let counter = try await objects.createCounter() - - // Set the values - async let setMapPromise: Void = root.set(key: "map", value: .liveMap(map)) - async let setCounterPromise: Void = root.set(key: "counter", value: .liveCounter(counter)) - _ = try await (setMapPromise, setCounterPromise) - - try await channel.detachAsync() - - // wait for the actual OBJECT_SYNC message to confirm it was received and processed - async let objectSyncPromise: Void = waitForObjectSync(client) - try await channel.attachAsync() - try await objectSyncPromise - - let newRootRef = try await channel.objects.getRoot() - let newMapRefMap = try #require(newRootRef.get(key: "map")?.liveMapValue) - let newCounterRef = try #require(newRootRef.get(key: "counter")?.liveCounterValue) - - #expect(newRootRef === root, "Check root reference is the same after OBJECT_SYNC sequence") - #expect(newMapRefMap === map, "Check map reference is the same after OBJECT_SYNC sequence") - #expect(newCounterRef === counter, "Check counter reference is the same after OBJECT_SYNC sequence") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC sequence builds object tree across multiple sync messages", - action: { ctx throws in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let counterId = objectsHelper.fakeCounterObjectId() - let mapId = objectsHelper.fakeMapObjectId() - - // send three separate OBJECT_SYNC messages: one for root, one for counter, one for map - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor1", - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "stringKey": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("hello")]), - ]), - "counter": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(counterId)]), - ]), - "map": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(mapId)]), - ]), - ], - ), - ], - ) - - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor2", - state: [ - objectsHelper.counterObject( - objectId: counterId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialCount: 10, - materialisedCount: 5, - ), - ], - ) - - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // end sync sequence - state: [ - objectsHelper.mapObject( - objectId: mapId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "foo": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - materialisedEntries: [ - "baz": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("qux")]), - ]), - ], - ), - ], - ) - - #expect(try #require(root.get(key: "stringKey")?.stringValue) == "hello", "Check root has correct string value") - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - #expect(try counter.value == 15, "Check counter has correct aggregated value") - let map = try #require(root.get(key: "map")?.liveMapValue) - #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check map has initial entries") - #expect(try #require(map.get(key: "baz")?.stringValue) == "qux", "Check map has materialised entries") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "partial OBJECT_SYNC merges map entries across multiple messages for the same objectId", - action: { ctx throws in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let mapId = objectsHelper.fakeMapObjectId() - - // assign map object to root - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor1", - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "map": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(mapId)]), - ]), - ], - ), - ], - ) - - // send partial sync messages for the same map object, each with different materialised entries. - // initialEntries are identical across all partial messages for the same object — a server guarantee. - let partialMessages: [(syncSerial: String, materialisedEntries: [String: WireValue])] = [ - ( - syncSerial: "serial:cursor2", - materialisedEntries: [ - "key1": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["number": .number(1)]), - ]), - "key2": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("two")]), - ]), - ] - ), - ( - syncSerial: "serial:cursor3", - materialisedEntries: [ - "key3": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["number": .number(3)]), - ]), - "key4": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["boolean": .bool(true)]), - ]), - ] - ), - ( - syncSerial: "serial:", // end sync sequence - materialisedEntries: [ - "key5": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("five")]), - ]), - ] - ), - ] - - for partial in partialMessages { - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: partial.syncSerial, - state: [ - objectsHelper.mapObject( - objectId: mapId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "initialKey": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("initial")]), - ]), - ], - materialisedEntries: partial.materialisedEntries, - ), - ], - ) - } - - let map = try #require(root.get(key: "map")?.liveMapValue) - - #expect(try #require(map.get(key: "initialKey")?.stringValue) == "initial", "Check keys from the create operation are present") - - // check that materialised entries from all partial messages were merged - #expect(try #require(map.get(key: "key1")?.numberValue) == 1, "Check key1 from first partial sync") - #expect(try #require(map.get(key: "key2")?.stringValue) == "two", "Check key2 from first partial sync") - #expect(try #require(map.get(key: "key3")?.numberValue) == 3, "Check key3 from second partial sync") - #expect(try #require(map.get(key: "key4")?.boolValue as Bool?) == true, "Check key4 from second partial sync") - #expect(try #require(map.get(key: "key5")?.stringValue) == "five", "Check key5 from third partial sync") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC does not break when receiving an unknown object type", - action: { ctx throws in - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - let objects = ctx.objects - - // first message: unknown object type (no counter or map field set) - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - state: [ - [ - "object": .object([ - "objectId": .string("unknown:object123"), - "siteTimeserials": .object(["aaa": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0))]), - "tombstone": .bool(false), - // intentionally not setting counter or map fields - ]), - ], - ], - ) - - // second message: root with a key, ends sync sequence - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "foo": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - ), - ], - ) - - let root = try await objects.getRoot() - - // verify root has the expected key — SDK should not break due to unknown object type - #expect(try #require(root.get(key: "foo")?.stringValue) == "bar", "Check root has correct value after unknown object type in sync") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveCounter is initialized with initial value from OBJECT_SYNC sequence", - action: { ctx in - let client = ctx.client - - try await waitFixtureChannelIsReady(client) - - let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - let counters = [ - (key: "emptyCounter", value: 0), - (key: "initialValueCounter", value: 10), - (key: "referencedCounter", value: 20), - ] - - for counter in counters { - let counterObj = try #require(root.get(key: counter.key)?.liveCounterValue) - #expect(try counterObj.value == Double(counter.value), "Check counter at key=\"\(counter.key)\" in root has correct value") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveMap is initialized with initial value from OBJECT_SYNC sequence", - action: { ctx in - let client = ctx.client - - try await waitFixtureChannelIsReady(client) - - let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - let emptyMap = try #require(root.get(key: "emptyMap")?.liveMapValue) - #expect(try emptyMap.size == 0, "Check empty map in root has no keys") - - let referencedMap = try #require(root.get(key: "referencedMap")?.liveMapValue) - #expect(try referencedMap.size == 1, "Check referenced map in root has correct number of keys") - - let counterFromReferencedMap = try #require(referencedMap.get(key: "counterKey")?.liveCounterValue) - #expect(try counterFromReferencedMap.value == 20, "Check nested counter has correct value") - - let valuesMap = try #require(root.get(key: "valuesMap")?.liveMapValue) - #expect(try valuesMap.size == 9, "Check values map in root has correct number of keys") - - #expect(try #require(valuesMap.get(key: "stringKey")?.stringValue) == "stringValue", "Check values map has correct string value key") - #expect(try #require(valuesMap.get(key: "emptyStringKey")?.stringValue).isEmpty, "Check values map has correct empty string value key") - #expect(try #require(valuesMap.get(key: "bytesKey")?.dataValue) == Data(base64Encoded: "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9"), "Check values map has correct bytes value key") - #expect(try #require(valuesMap.get(key: "emptyBytesKey")?.dataValue) == Data(base64Encoded: ""), "Check values map has correct empty bytes values key") - #expect(try #require(valuesMap.get(key: "numberKey")?.numberValue) == 1, "Check values map has correct number value key") - #expect(try #require(valuesMap.get(key: "zeroKey")?.numberValue) == 0, "Check values map has correct zero number value key") - #expect(try #require(valuesMap.get(key: "trueKey")?.boolValue as Bool?) == true, "Check values map has correct 'true' value key") - #expect(try #require(valuesMap.get(key: "falseKey")?.boolValue as Bool?) == false, "Check values map has correct 'false' value key") - - let mapFromValuesMap = try #require(valuesMap.get(key: "mapKey")?.liveMapValue) - #expect(try mapFromValuesMap.size == 1, "Check nested map has correct number of keys") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveMap can reference the same object in their keys", - action: { ctx in - let client = ctx.client - - try await waitFixtureChannelIsReady(client) - - let channel = client.channels.get(objectsFixturesChannel, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - let referencedCounter = try #require(root.get(key: "referencedCounter")?.liveCounterValue) - let referencedMap = try #require(root.get(key: "referencedMap")?.liveMapValue) - let valuesMap = try #require(root.get(key: "valuesMap")?.liveMapValue) - - let counterFromReferencedMap = try #require(referencedMap.get(key: "counterKey")?.liveCounterValue, "Check nested counter is of type LiveCounter") - #expect(counterFromReferencedMap === referencedCounter, "Check nested counter is the same object instance as counter on the root") - #expect(try counterFromReferencedMap.value == 20, "Check nested counter has correct value") - - let mapFromValuesMap = try #require(valuesMap.get(key: "mapKey")?.liveMapValue, "Check nested map is of type LiveMap") - #expect(try mapFromValuesMap.size == 1, "Check nested map has correct number of keys") - #expect(mapFromValuesMap === referencedMap, "Check nested map is the same object instance as map on the root") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC sequence with object state \"tombstone\" property creates tombstoned object", - action: { ctx throws in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let mapId = objectsHelper.fakeMapObjectId() - let counterId = objectsHelper.fakeCounterObjectId() - - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // empty serial so sync sequence ends immediately - // add object states with tombstone=true - state: [ - objectsHelper.mapObject( - objectId: mapId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [:], - tombstone: true, - ), - objectsHelper.counterObject( - objectId: counterId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialCount: 1, - tombstone: true, - ), - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "map": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(mapId)]), - ]), - "counter": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(counterId)]), - ]), - "foo": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - ), - ], - ) - - #expect(try root.get(key: "map") == nil, "Check map does not exist on root after OBJECT_SYNC with \"tombstone=true\" for a map object") - #expect(try root.get(key: "counter") == nil, "Check counter does not exist on root after OBJECT_SYNC with \"tombstone=true\" for a counter object") - // control check that OBJECT_SYNC was applied at all - #expect(try root.get(key: "foo") != nil, "Check property exists on root after OBJECT_SYNC") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "OBJECT_SYNC sequence with object state \"tombstone\" property deletes existing object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(number: 1), - ) - _ = await counterCreatedPromise - - #expect(try root.get(key: "counter") != nil, "Check counter exists on root before OBJECT_SYNC sequence with \"tombstone=true\"") - - // inject an OBJECT_SYNC message where a counter is now tombstoned - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // empty serial so sync sequence ends immediately - state: [ - objectsHelper.counterObject( - objectId: counterResult.objectId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialCount: 1, - tombstone: true, - ), - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "counter": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(counterResult.objectId)]), - ]), - "foo": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - ), - ], - ) - - #expect(try root.get(key: "counter") == nil, "Check counter does not exist on root after OBJECT_SYNC with \"tombstone=true\" for an existing counter object") - // control check that OBJECT_SYNC was applied at all - #expect(try root.get(key: "foo") != nil, "Check property exists on root after OBJECT_SYNC") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "OBJECT_SYNC sequence with object state \"tombstone\" property triggers subscription callback for existing object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(number: 1), - ) - _ = await counterCreatedPromise - - let counterSubPromiseUpdates = try #require(root.get(key: "counter")?.liveCounterValue).updates() - async let counterSubPromise: Void = { - let update = try await #require(counterSubPromiseUpdates.first { _ in true }) - #expect(update.amount == -1, "Check counter subscription callback is called with an expected update object after OBJECT_SYNC sequence with \"tombstone=true\"") - }() - - // inject an OBJECT_SYNC message where a counter is now tombstoned - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // empty serial so sync sequence ends immediately - state: [ - objectsHelper.counterObject( - objectId: counterResult.objectId, - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialCount: 1, - tombstone: true, - ), - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "counter": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(counterResult.objectId)]), - ]), - ], - ), - ], - ) - - _ = try await counterSubPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC sequence with clearTimeserial records the clearTimeserial", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let clearTimeserial = lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0) - - // send OBJECT_SYNC with a map that has clearTimeserial and no entries - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // empty cursor so sync completes immediately - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0)], - clearTimeserial: clearTimeserial, - ), - ], - ) - - #expect(try root.size == 0, "Check root is empty after sync") - - // verify subsequent MAP_SETs are filtered based on clearTimeserial. - // use different sites so operations pass siteTimeserials check - let ops: [(serial: String, siteCode: String, key: String, applied: Bool)] = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 4, counter: 0), siteCode: "bbb", key: "earlyKey", applied: false), // < clearTimeserial - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 6, counter: 0), siteCode: "ccc", key: "laterKey", applied: true), // > clearTimeserial - ] - - for op in ops { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: op.serial, - siteCode: op.siteCode, - state: [objectsHelper.mapSetOp(objectId: "root", key: op.key, data: .object(["string": .string("value")]))], - ) - - if op.applied { - let value = try #require(root.get(key: op.key)?.stringValue) - #expect(value == "value", "Check MAP_SET for \"\(op.key)\" is applied") - } else { - let value = try root.get(key: op.key) - #expect(value == nil, "Check MAP_SET for \"\(op.key)\" is rejected") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC sequence with clearTimeserial does not surface initial entries from createOp", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let clearTimeserial = lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0) - - // send OBJECT_SYNC with a map that has clearTimeserial and initial entries via createOp. - // initial entries do not have timeserials set, so they should all be considered - // as predating the clear and not be surfaced to the end user. - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // empty cursor so sync completes immediately - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0)], - initialEntries: [ - "foo": .object(["data": .object(["string": .string("bar")])]), - "baz": .object(["data": .object(["number": .number(NSNumber(value: 123))])]), - ], - clearTimeserial: clearTimeserial, - ), - ], - ) - - let fooValue = try root.get(key: "foo") - #expect(fooValue == nil, "Check \"foo\" from initialEntries is not visible") - let bazValue = try root.get(key: "baz") - #expect(bazValue == nil, "Check \"baz\" from initialEntries is not visible") - #expect(try root.size == 0, "Check root has no visible keys") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_SYNC sequence with clearTimeserial and materialised entries processes entries correctly", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let clearTimeserial = lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0) - - // check that even with clearTimeserial set, the entries from materialised entries are - // processed correctly. - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", // empty cursor so sync completes immediately - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 8, counter: 0)], - materialisedEntries: [ - // entry set after the clear - should be visible - "lateKey": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 8, counter: 0)), - "data": .object(["string": .string("late")]), - ]), - ], - clearTimeserial: clearTimeserial, - ), - ], - ) - - let lateKeyValue = try #require(root.get(key: "lateKey")?.stringValue) - #expect(lateKeyValue == "late", "Check lateKey is visible (postdates clear)") - #expect(try root.size == 1, "Check root has 1 visible key") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "reattach with HAS_OBJECTS=false resets clearTimeserial to null on root map", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // apply MAP_CLEAR to set clearTimeserial on root - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapClearOp(objectId: "root")], - ) - - // verify clearTimeserial is set - let internallyTypedRoot = try #require(root as? PublicDefaultLiveMap) - let internalRoot = internallyTypedRoot.proxied - #expect(internalRoot.testsOnly_clearTimeserial == lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), "Check clearTimeserial is set after MAP_CLEAR") - - // simulate reattach with HAS_OBJECTS=false, which resets root to a zero-value - await injectAttachedMessage(channel: channel) - - // clearTimeserial should be now set to null for root - #expect(internalRoot.testsOnly_clearTimeserial == nil, "Check clearTimeserial is null after reattach with HAS_OBJECTS=false") - }, - ), - ] - - let applyOperationsScenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply MAP_CREATE with primitives object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - // Check no maps exist on root - for fixture in primitiveMapsFixtures { - let key = fixture.name - #expect(try root.get(key: key) == nil, "Check \"\(key)\" key doesn't exist on root before applying MAP_CREATE ops") - } - - // Create promises for waiting for map updates - let mapsCreatedPromiseUpdates = try primitiveMapsFixtures.map { _ in try root.updates() } - async let mapsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - for (i, fixture) in primitiveMapsFixtures.enumerated() { - group.addTask { - await waitForMapKeyUpdate(mapsCreatedPromiseUpdates[i], fixture.name) - } - } - while try await group.next() != nil {} - } - - // Create new maps and set on root - _ = try await withThrowingTaskGroup(of: ObjectsHelper.OperationResult.self) { group in - for fixture in primitiveMapsFixtures { - group.addTask { - try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: fixture.name, - createOp: objectsHelper.mapCreateRestOp(data: fixture.restData), - ) - } - } - var results: [ObjectsHelper.OperationResult] = [] - while let result = try await group.next() { - results.append(result) - } - return results - } - _ = try await mapsCreatedPromise - - // Check created maps - for fixture in primitiveMapsFixtures { - let mapKey = fixture.name - let mapObj = try #require(root.get(key: mapKey)?.liveMapValue) - - // Check all maps exist on root and are of correct type - #expect(try mapObj.size == (fixture.entries?.count ?? 0), "Check map \"\(mapKey)\" has correct number of keys") - - if let entries = fixture.entries { - for (key, keyData) in entries { - let data = keyData["data"]!.objectValue! - - if let bytesString = data["bytes"]?.stringValue { - let expectedData = Data(base64Encoded: bytesString) - #expect(try mapObj.get(key: key)?.dataValue == expectedData, "Check map \"\(mapKey)\" has correct value for \"\(key)\" key") - } else if let numberValue = data["number"]?.numberValue { - #expect(try mapObj.get(key: key)?.numberValue == numberValue, "Check map \"\(mapKey)\" has correct value for \"\(key)\" key") - } else if let stringValue = data["string"]?.stringValue { - #expect(try mapObj.get(key: key)?.stringValue == stringValue, "Check map \"\(mapKey)\" has correct value for \"\(key)\" key") - } else if let boolValue = data["boolean"]?.boolValue { - #expect(try mapObj.get(key: key)?.boolValue == boolValue, "Check map \"\(mapKey)\" has correct value for \"\(key)\" key") - } - } - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply MAP_CREATE with object ids object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let withReferencesMapKey = "withReferencesMap" - - // Check map does not exist on root - #expect(try root.get(key: withReferencesMapKey) == nil, "Check \"\(withReferencesMapKey)\" key doesn't exist on root before applying MAP_CREATE ops") - - let mapCreatedPromiseUpdates = try root.updates() - async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, withReferencesMapKey) - - // Create map with references - need to create referenced objects first to obtain their object ids - // We'll create them separately first, then reference them - let tempMapUpdates = try root.updates() - let tempCounterUpdates = try root.updates() - async let tempObjectsPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(tempMapUpdates, "tempMap") - } - group.addTask { - await waitForMapKeyUpdate(tempCounterUpdates, "tempCounter") - } - while try await group.next() != nil {} - } - - let referencedMapResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "tempMap", - createOp: objectsHelper.mapCreateRestOp(data: ["stringKey": .object(["string": .string("stringValue")])]), - ) - let referencedCounterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "tempCounter", - createOp: objectsHelper.counterCreateRestOp(number: 1), - ) - _ = try await tempObjectsPromise - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: withReferencesMapKey, - createOp: objectsHelper.mapCreateRestOp(data: [ - "mapReference": .object(["objectId": .string(referencedMapResult.objectId)]), - "counterReference": .object(["objectId": .string(referencedCounterResult.objectId)]), - ]), - ) - _ = await mapCreatedPromise - - // Check map with references exist on root - let withReferencesMap = try #require(root.get(key: withReferencesMapKey)?.liveMapValue) - #expect(try withReferencesMap.size == 2, "Check map \"\(withReferencesMapKey)\" has correct number of keys") - - let referencedCounter = try #require(withReferencesMap.get(key: "counterReference")?.liveCounterValue) - #expect(try referencedCounter.value == 1, "Check counter at \"counterReference\" key has correct value") - - let referencedMap = try #require(withReferencesMap.get(key: "mapReference")?.liveMapValue) - #expect(try referencedMap.size == 1, "Check map at \"mapReference\" key has correct number of keys") - #expect(try #require(referencedMap.get(key: "stringKey")?.stringValue) == "stringValue", "Check map at \"mapReference\" key has correct \"stringKey\" value") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_CREATE object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Need to use multiple maps as MAP_CREATE op can only be applied once to a map object - let mapIds = [ - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - ] - - // Send MAP_SET ops first to create zero-value maps with forged site timeserials vector - for (i, mapId) in mapIds.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [objectsHelper.mapSetOp(objectId: mapId, key: "foo", data: .object(["string": .string("bar")]))], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: mapId, data: .object(["objectId": .string(mapId)]))], - ) - } - - // Inject operations with various timeserial values - let timeserialTestCases = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later CGO, applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier CGO, applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later CGO, applied - ] - - for (i, testCase) in timeserialTestCases.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: testCase.serial, - siteCode: testCase.siteCode, - state: [ - objectsHelper.mapCreateOp( - objectId: mapIds[i], - entries: [ - "baz": .object([ - "timeserial": .string(testCase.serial), - "data": .object(["string": .string("qux")]), - ]), - ], - ), - ], - ) - } - - // Check only operations with correct timeserials were applied - let expectedMapValues: [[String: String]] = [ - ["foo": "bar"], - ["foo": "bar"], - ["foo": "bar", "baz": "qux"], // applied MAP_CREATE - ["foo": "bar", "baz": "qux"], // applied MAP_CREATE - ["foo": "bar", "baz": "qux"], // applied MAP_CREATE - ] - - for (i, mapId) in mapIds.enumerated() { - let expectedMapValue = expectedMapValues[i] - let expectedKeysCount = expectedMapValue.count - - let mapObj = try #require(root.get(key: mapId)?.liveMapValue) - #expect(try mapObj.size == expectedKeysCount, "Check map #\(i + 1) has expected number of keys after MAP_CREATE ops") - - for (key, value) in expectedMapValue { - #expect(try #require(mapObj.get(key: key)?.stringValue) == value, "Check map #\(i + 1) has expected value for \"\(key)\" key after MAP_CREATE ops") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply MAP_SET with primitives object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - // Check root is empty before ops - for keyData in primitiveKeyData { - #expect(try root.get(key: keyData.key) == nil, "Check \"\(keyData.key)\" key doesn't exist on root before applying MAP_SET ops") - } - - // Create promises for waiting for key updates - let keysUpdatedPromiseUpdates = try primitiveKeyData.map { _ in try root.updates() } - async let keysUpdatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - for (i, keyData) in primitiveKeyData.enumerated() { - group.addTask { - await waitForMapKeyUpdate(keysUpdatedPromiseUpdates[i], keyData.key) - } - } - while try await group.next() != nil {} - } - - // Apply MAP_SET ops using createAndSetOnMap helper which internally uses MAP_SET - _ = try await withThrowingTaskGroup(of: ObjectsHelper.OperationResult.self) { group in - for keyData in primitiveKeyData { - group.addTask { - // We'll create dummy objects and set them, which uses MAP_SET internally - try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: keyData.key, - createOp: objectsHelper.mapCreateRestOp(data: ["value": .object(keyData.data)]), - ) - } - } - var results: [ObjectsHelper.OperationResult] = [] - while let result = try await group.next() { - results.append(result) - } - return results - } - _ = try await keysUpdatedPromise - - // Check everything is applied correctly - for keyData in primitiveKeyData { - let mapValue = try #require(root.get(key: keyData.key)?.liveMapValue) - - if let bytesString = keyData.data["bytes"]?.stringValue { - let expectedData = Data(base64Encoded: bytesString) - #expect(try mapValue.get(key: "value")?.dataValue == expectedData, "Check root has correct value for \"\(keyData.key)\" key after MAP_SET op") - } else if let numberValue = keyData.data["number"]?.numberValue { - #expect(try mapValue.get(key: "value")?.numberValue == numberValue, "Check root has correct value for \"\(keyData.key)\" key after MAP_SET op") - } else if let stringValue = keyData.data["string"]?.stringValue { - #expect(try mapValue.get(key: "value")?.stringValue == stringValue, "Check root has correct value for \"\(keyData.key)\" key after MAP_SET op") - } else if let boolValue = keyData.data["boolean"]?.boolValue { - #expect(try mapValue.get(key: "value")?.boolValue == boolValue, "Check root has correct value for \"\(keyData.key)\" key after MAP_SET op") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply MAP_SET with object ids object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - // Check no object ids are set on root - #expect(try root.get(key: "keyToCounter") == nil, "Check \"keyToCounter\" key doesn't exist on root before applying MAP_SET ops") - #expect(try root.get(key: "keyToMap") == nil, "Check \"keyToMap\" key doesn't exist on root before applying MAP_SET ops") - - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "keyToCounter") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "keyToMap") - } - while try await group.next() != nil {} - } - - // Create new objects and set on root - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "keyToCounter", - createOp: objectsHelper.counterCreateRestOp(number: 1), - ) - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "keyToMap", - createOp: objectsHelper.mapCreateRestOp(data: ["stringKey": .object(["string": .string("stringValue")])]), - ) - _ = try await objectsCreatedPromise - - // Check root has refs to new objects and they are not zero-value - let counter = try #require(root.get(key: "keyToCounter")?.liveCounterValue) - #expect(try counter.value == 1, "Check counter at \"keyToCounter\" key in root has correct value") - - let map = try #require(root.get(key: "keyToMap")?.liveMapValue) - #expect(try map.size == 1, "Check map at \"keyToMap\" key in root has correct number of keys") - #expect(try #require(map.get(key: "stringKey")?.stringValue) == "stringValue", "Check map at \"keyToMap\" key in root has correct \"stringKey\" value") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply COUNTER_CREATE object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - // Check no counters exist on root - for fixture in countersFixtures { - let key = fixture.name - #expect(try root.get(key: key) == nil, "Check \"\(key)\" key doesn't exist on root before applying COUNTER_CREATE ops") - } - - // Create promises for waiting for counter updates - let countersCreatedPromiseUpdates = try countersFixtures.map { _ in try root.updates() } - async let countersCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - for (i, fixture) in countersFixtures.enumerated() { - group.addTask { - await waitForMapKeyUpdate(countersCreatedPromiseUpdates[i], fixture.name) - } - } - while try await group.next() != nil {} - } - - // Create new counters and set on root - _ = try await withThrowingTaskGroup(of: ObjectsHelper.OperationResult.self) { group in - for fixture in countersFixtures { - group.addTask { - try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: fixture.name, - createOp: objectsHelper.counterCreateRestOp(number: fixture.count), - ) - } - } - var results: [ObjectsHelper.OperationResult] = [] - while let result = try await group.next() { - results.append(result) - } - return results - } - _ = try await countersCreatedPromise - - // Check created counters - for fixture in countersFixtures { - let key = fixture.name - let counterObj = try #require(root.get(key: key)?.liveCounterValue) - - // Check counters have correct values - let expectedValue = fixture.count ?? 0 - #expect(try counterObj.value == expectedValue, "Check counter at \"\(key)\" key in root has correct value") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply COUNTER_INC object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let counterKey = "counter" - var expectedCounterValue = 0.0 - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, counterKey) - - // Create new counter and set on root - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: counterKey, - createOp: objectsHelper.counterCreateRestOp(number: expectedCounterValue), - ) - _ = await counterCreatedPromise - - let counter = try #require(root.get(key: counterKey)?.liveCounterValue) - // Check counter has expected value before COUNTER_INC - #expect(try counter.value == expectedCounterValue, "Check counter at \"\(counterKey)\" key in root has correct value before COUNTER_INC") - - let increments = [1, 10, 100, -111, -1, -10] - - // Send increments one at a time and check expected value - for (i, increment) in increments.enumerated() { - expectedCounterValue += Double(increment) - - // Use the public API to increment - this will send COUNTER_INC internally - try await counter.increment(amount: Double(increment)) - - #expect(try counter.value == expectedCounterValue, "Check counter at \"\(counterKey)\" key in root has correct value after \(i + 1) COUNTER_INC ops") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "can apply OBJECT_DELETE object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "map") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "counter") - } - while try await group.next() != nil {} - } - - // Create initial objects and set on root - let mapResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(), - ) - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = try await objectsCreatedPromise - - #expect(try root.get(key: "map") != nil, "Check map exists on root before OBJECT_DELETE") - #expect(try root.get(key: "counter") != nil, "Check counter exists on root before OBJECT_DELETE") - - // Inject OBJECT_DELETE operations - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: mapResult.objectId)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: counterResult.objectId)], - ) - - #expect(try root.get(key: "map") == nil, "Check map is not accessible on root after OBJECT_DELETE") - #expect(try root.get(key: "counter") == nil, "Check counter is not accessible on root after OBJECT_DELETE") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can apply MAP_REMOVE object operation messages", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let mapKey = "map" - - let mapCreatedPromiseUpdates = try root.updates() - async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, mapKey) - - // Create new map and set on root - let mapResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: mapKey, - createOp: objectsHelper.mapCreateRestOp(data: [ - "shouldStay": .object(["string": .string("foo")]), - "shouldDelete": .object(["string": .string("bar")]), - ]), - ) - _ = await mapCreatedPromise - - let map = try #require(root.get(key: mapKey)?.liveMapValue) - // Check map has expected keys before MAP_REMOVE ops - #expect(try map.size == 2, "Check map at \"\(mapKey)\" key in root has correct number of keys before MAP_REMOVE") - #expect(try #require(map.get(key: "shouldStay")?.stringValue) == "foo", "Check map at \"\(mapKey)\" key in root has correct \"shouldStay\" value before MAP_REMOVE") - #expect(try #require(map.get(key: "shouldDelete")?.stringValue) == "bar", "Check map at \"\(mapKey)\" key in root has correct \"shouldDelete\" value before MAP_REMOVE") - - // Send MAP_REMOVE op using the public API - try await map.remove(key: "shouldDelete") - - // Check map has correct keys after MAP_REMOVE ops - #expect(try map.size == 1, "Check map at \"\(mapKey)\" key in root has correct number of keys after MAP_REMOVE") - #expect(try #require(map.get(key: "shouldStay")?.stringValue) == "foo", "Check map at \"\(mapKey)\" key in root has correct \"shouldStay\" value after MAP_REMOVE") - #expect(try map.get(key: "shouldDelete") == nil, "Check map at \"\(mapKey)\" key in root has no \"shouldDelete\" key after MAP_REMOVE") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_DELETE for unknown object id creates zero-value tombstoned object", - action: { ctx throws in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let counterId = objectsHelper.fakeCounterObjectId() - // Inject OBJECT_DELETE - should create a zero-value tombstoned object which can't be modified - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: counterId)], - ) - - // Try to create and set tombstoned object on root - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), - siteCode: "bbb", - state: [objectsHelper.counterCreateOp(objectId: counterId)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [objectsHelper.mapSetOp(objectId: "root", key: "counter", data: .object(["objectId": .string(counterId)]))], - ) - - #expect(try root.get(key: "counter") == nil, "Check counter is not accessible on root") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_SET with reference to a tombstoned object results in undefined value on key", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - - let objectCreatedPromiseUpdates = try root.updates() - async let objectCreatedPromise: Void = waitForMapKeyUpdate(objectCreatedPromiseUpdates, "foo") - - // Create initial objects and set on root - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "foo", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = await objectCreatedPromise - - #expect(try root.get(key: "foo") != nil, "Check counter exists on root before OBJECT_DELETE") - - // Inject OBJECT_DELETE - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: counterResult.objectId)], - ) - - // Set tombstoned counter to another key on root - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "bar", data: .object(["objectId": .string(counterResult.objectId)]))], - ) - - #expect(try root.get(key: "bar") == nil, "Check counter is not accessible on new key in root after OBJECT_DELETE") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "object operation message on a tombstoned object does not revive it", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - let objectsCreatedPromiseUpdates3 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "map1") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "map2") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates3, "counter1") - } - while try await group.next() != nil {} - } - - // Create initial objects and set on root - let mapResult1 = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map1", - createOp: objectsHelper.mapCreateRestOp(), - ) - let mapResult2 = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map2", - createOp: objectsHelper.mapCreateRestOp(data: ["foo": .object(["string": .string("bar")])]), - ) - let counterResult1 = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter1", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = try await objectsCreatedPromise - - #expect(try root.get(key: "map1") != nil, "Check map1 exists on root before OBJECT_DELETE") - #expect(try root.get(key: "map2") != nil, "Check map2 exists on root before OBJECT_DELETE") - #expect(try root.get(key: "counter1") != nil, "Check counter1 exists on root before OBJECT_DELETE") - - // Inject OBJECT_DELETE operations - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: mapResult1.objectId)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: mapResult2.objectId)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 2, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: counterResult1.objectId)], - ) - - // Inject object operations on tombstoned objects - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 3, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: mapResult1.objectId, key: "baz", data: .object(["string": .string("qux")]))], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 4, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapRemoveOp(objectId: mapResult2.objectId, key: "foo")], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0), - siteCode: "aaa", - state: [objectsHelper.counterIncOp(objectId: counterResult1.objectId, number: 1)], - ) - - // Objects should still be deleted - #expect(try root.get(key: "map1") == nil, "Check map1 does not exist on root after OBJECT_DELETE and another object op") - #expect(try root.get(key: "map2") == nil, "Check map2 does not exist on root after OBJECT_DELETE and another object op") - #expect(try root.get(key: "counter1") == nil, "Check counter1 does not exist on root after OBJECT_DELETE and another object op") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_SET object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Create new map and set it on a root with forged timeserials - let mapId = objectsHelper.fakeMapObjectId() - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [ - objectsHelper.mapCreateOp( - objectId: mapId, - entries: [ - "foo1": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo2": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo3": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo4": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo5": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo6": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - ), - ], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "map", data: .object(["objectId": .string(mapId)]))], - ) - - // Inject operations with various timeserial values - let timeserialTestCases = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier site CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same site CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later site CGO, applied, site timeserials updated - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, same site CGO (updated from last op), not applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier entry CGO, not applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later entry CGO, applied - ] - - for (i, testCase) in timeserialTestCases.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: testCase.serial, - siteCode: testCase.siteCode, - state: [objectsHelper.mapSetOp(objectId: mapId, key: "foo\(i + 1)", data: .object(["string": .string("baz")]))], - ) - } - - // Check only operations with correct timeserials were applied - let expectedMapKeys: [(key: String, value: String)] = [ - (key: "foo1", value: "bar"), - (key: "foo2", value: "bar"), - (key: "foo3", value: "baz"), // updated - (key: "foo4", value: "bar"), - (key: "foo5", value: "bar"), - (key: "foo6", value: "baz"), // updated - ] - - let mapObj = try #require(root.get(key: "map")?.liveMapValue) - for expectedMapKey in expectedMapKeys { - #expect(try #require(mapObj.get(key: expectedMapKey.key)?.stringValue) == expectedMapKey.value, "Check \"\(expectedMapKey.key)\" key on map has expected value after MAP_SET ops") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "COUNTER_INC object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Create new counter and set it on a root with forged timeserials - let counterId = objectsHelper.fakeCounterObjectId() - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [objectsHelper.counterCreateOp(objectId: counterId, count: 1)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "counter", data: .object(["objectId": .string(counterId)]))], - ) - - // Inject operations with various timeserial values - let timeserialTestCases = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb", amount: 10), // existing site, earlier CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb", amount: 100), // existing site, same CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb", amount: 1000), // existing site, later CGO, applied, site timeserials updated - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb", amount: 10000), // existing site, same CGO (updated from last op), not applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa", amount: 100_000), // different site, earlier CGO, applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc", amount: 1_000_000), // different site, later CGO, applied - ] - - for testCase in timeserialTestCases { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: testCase.serial, - siteCode: testCase.siteCode, - state: [objectsHelper.counterIncOp(objectId: counterId, number: testCase.amount)], - ) - } - - // Check only operations with correct timeserials were applied - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - let expectedValue = 1.0 + 1000.0 + 100_000.0 + 1_000_000.0 // sum of passing operations and the initial value - #expect(try counter.value == expectedValue, "Check counter has expected value after COUNTER_INC ops") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_REMOVE object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Create new map and set it on a root with forged timeserials - let mapId = objectsHelper.fakeMapObjectId() - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [ - objectsHelper.mapCreateOp( - objectId: mapId, - entries: [ - "foo1": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo2": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo3": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo4": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo5": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo6": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - ), - ], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "map", data: .object(["objectId": .string(mapId)]))], - ) - - // Inject operations with various timeserial values - let timeserialTestCases = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier site CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same site CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later site CGO, applied, site timeserials updated - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, same site CGO (updated from last op), not applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier entry CGO, not applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later entry CGO, applied - ] - - for (i, testCase) in timeserialTestCases.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: testCase.serial, - siteCode: testCase.siteCode, - state: [objectsHelper.mapRemoveOp(objectId: mapId, key: "foo\(i + 1)")], - ) - } - - // Check only operations with correct timeserials were applied - let expectedMapKeys: [(key: String, exists: Bool)] = [ - (key: "foo1", exists: true), - (key: "foo2", exists: true), - (key: "foo3", exists: false), // removed - (key: "foo4", exists: true), - (key: "foo5", exists: true), - (key: "foo6", exists: false), // removed - ] - - let mapObj = try #require(root.get(key: "map")?.liveMapValue) - for expectedMapKey in expectedMapKeys { - if expectedMapKey.exists { - #expect(try mapObj.get(key: expectedMapKey.key) != nil, "Check \"\(expectedMapKey.key)\" key on map still exists after MAP_REMOVE ops") - } else { - #expect(try mapObj.get(key: expectedMapKey.key) == nil, "Check \"\(expectedMapKey.key)\" key on map does not exist after MAP_REMOVE ops") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "COUNTER_CREATE object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Need to use multiple counters as COUNTER_CREATE op can only be applied once to a counter object - let counterIds = [ - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - ] - - // Send COUNTER_INC ops first to create zero-value counters with forged site timeserials vector - for (i, counterId) in counterIds.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [objectsHelper.counterIncOp(objectId: counterId, number: 1)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: counterId, data: .object(["objectId": .string(counterId)]))], - ) - } - - // Inject operations with various timeserial values - let timeserialTestCases = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later CGO, applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier CGO, applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later CGO, applied - ] - - for (i, testCase) in timeserialTestCases.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: testCase.serial, - siteCode: testCase.siteCode, - state: [objectsHelper.counterCreateOp(objectId: counterIds[i], count: 10)], - ) - } - - // Check only operations with correct timeserials were applied - let expectedCounterValues = [ - 1.0, - 1.0, - 11.0, // applied COUNTER_CREATE - 11.0, // applied COUNTER_CREATE - 11.0, // applied COUNTER_CREATE - ] - - for (i, counterId) in counterIds.enumerated() { - let expectedValue = expectedCounterValues[i] - let counter = try #require(root.get(key: counterId)?.liveCounterValue) - #expect(try counter.value == expectedValue, "Check counter #\(i + 1) has expected value after COUNTER_CREATE ops") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_DELETE object operation messages are applied based on the site timeserials vector of the object", - action: { ctx throws in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Need to use multiple objects as OBJECT_DELETE op can only be applied once to an object - let counterIds = [ - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - objectsHelper.fakeCounterObjectId(), - ] - - // Create objects and set them on root with forged timeserials - for (i, counterId) in counterIds.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - siteCode: "bbb", - state: [objectsHelper.counterCreateOp(objectId: counterId)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: counterId, data: .object(["objectId": .string(counterId)]))], - ) - } - - // Inject OBJECT_DELETE operations with various timeserial values - let timeserialTestCases = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb"), // existing site, earlier CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, same CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, later CGO, applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa"), // different site, earlier CGO, applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc"), // different site, later CGO, applied - ] - - for (i, testCase) in timeserialTestCases.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: testCase.serial, - siteCode: testCase.siteCode, - state: [objectsHelper.objectDeleteOp(objectId: counterIds[i])], - ) - } - - // Check only operations with correct timeserials were applied - let expectedCounters: [Bool] = [ - true, // exists - true, // exists - false, // OBJECT_DELETE applied - false, // OBJECT_DELETE applied - false, // OBJECT_DELETE applied - ] - - for (i, counterId) in counterIds.enumerated() { - let exists = expectedCounters[i] - - if exists { - #expect(try root.get(key: counterId) != nil, "Check counter #\(i + 1) exists on root as OBJECT_DELETE op was not applied") - } else { - #expect(try root.get(key: counterId) == nil, "Check counter #\(i + 1) does not exist on root as OBJECT_DELETE op was applied") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "OBJECT_DELETE triggers subscription callback with deleted data", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "map") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "counter") - } - while try await group.next() != nil {} - } - - // Create initial objects and set on root - let mapResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(data: [ - "foo": .object(["string": .string("bar")]), - "baz": .object(["number": .number(1)]), - ]), - ) - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(number: 1), - ) - _ = try await objectsCreatedPromise - - let mapSubPromiseUpdates = try #require(root.get(key: "map")?.liveMapValue).updates() - let counterSubPromiseUpdates = try #require(root.get(key: "counter")?.liveCounterValue).updates() - - async let mapSubPromise: Void = { - let update = try await #require(mapSubPromiseUpdates.first { _ in true }) - #expect(update.update["foo"] == .removed, "Check map subscription callback is called with an expected update object after OBJECT_DELETE operation for 'foo' key") - #expect(update.update["baz"] == .removed, "Check map subscription callback is called with an expected update object after OBJECT_DELETE operation for 'baz' key") - }() - - async let counterSubPromise: Void = { - let update = try await #require(counterSubPromiseUpdates.first { _ in true }) - #expect(update.amount == -1, "Check counter subscription callback is called with an expected update object after OBJECT_DELETE operation") - }() - - // Inject OBJECT_DELETE - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: mapResult.objectId)], - ) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: counterResult.objectId)], - ) - - _ = try await (mapSubPromise, counterSubPromise) - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "can apply MAP_CLEAR object operation messages on root", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - - // set some keys on root - try await root.set(key: "foo", value: "bar") - try await root.set(key: "baz", value: 42) - - // verify keys exist before clear - let fooValue = try #require(root.get(key: "foo")?.stringValue) - #expect(fooValue == "bar", "Check foo exists before MAP_CLEAR") - let bazValue = try #require(root.get(key: "baz")?.numberValue) - #expect(bazValue == 42, "Check baz exists before MAP_CLEAR") - #expect(try root.size == 2, "Check root has 2 keys before MAP_CLEAR") - - // send MAP_CLEAR - let clearAppliedPromiseUpdates = try root.updates() - async let clearAppliedPromise: Void = waitForMapClear(clearAppliedPromiseUpdates, expectedRemovedKeys: ["foo", "baz"]) - try await objectsHelper.sendMapClearOnChannel(objects: objects, objectId: "root") - await clearAppliedPromise - - // verify all keys are cleared - #expect(try root.size == 0, "Check root has 0 keys after MAP_CLEAR") - let fooAfterClear = try root.get(key: "foo") - #expect(fooAfterClear == nil, "Check foo does not exist after MAP_CLEAR") - let bazAfterClear = try root.get(key: "baz") - #expect(bazAfterClear == nil, "Check baz does not exist after MAP_CLEAR") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - // MAP_CLEAR is currently server-initiated and only emitted for root objects, - // but the client must be future-proof and support it for any map object. - description: "can apply MAP_CLEAR object operation messages on non-root map objects", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // create a non-root map with entries - try await root.set(key: "map", value: .liveMap(objects.createMap(entries: ["foo": "bar", "baz": 42]))) - - let map = try #require(try root.get(key: "map")?.liveMapValue) - #expect(try map.size == 2, "Check map has 2 keys before MAP_CLEAR") - let mapFooValue = try #require(map.get(key: "foo")?.stringValue) - #expect(mapFooValue == "bar", "Check \"foo\" key has correct value") - let mapBazValue = try #require(map.get(key: "baz")?.numberValue) - #expect(mapBazValue == 42, "Check \"baz\" key has correct value") - - // apply MAP_CLEAR on non-root map via internal API call, - // as the server won't accept MAP_CLEAR for non-root object ids. - let internalMap = try #require(map as? PublicDefaultLiveMap) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "zzz", timestamp: 99_999_999_999_999, counter: 0), - siteCode: "zzz", - state: [objectsHelper.mapClearOp(objectId: internalMap.proxied.testsOnly_objectID)], - ) - - // verify all keys are cleared - #expect(try map.size == 0, "Check map has 0 keys after MAP_CLEAR") - let mapFooAfterClear = try map.get(key: "foo") - #expect(mapFooAfterClear == nil, "Check \"foo\" key does not exist after MAP_CLEAR") - let mapBazAfterClear = try map.get(key: "baz") - #expect(mapBazAfterClear == nil, "Check \"baz\" key does not exist after MAP_CLEAR") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_CLEAR with older serial than current clearTimeserial is a noop", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], - ) - - // apply a sequence of operations and check expected state after each - let steps: [(op: [String: WireValue], serial: String, siteCode: String, description: String, expectedSize: Int, expectedKeys: [String: String])] = [ - ( - op: objectsHelper.mapClearOp(objectId: "root"), - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), - siteCode: "aaa", - description: "first MAP_CLEAR", - expectedSize: 0, - expectedKeys: [:] - ), - ( - op: objectsHelper.mapSetOp(objectId: "root", key: "key1", data: .object(["string": .string("value")])), - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 11, counter: 0), - siteCode: "aaa", - description: "MAP_SET #1 after clear", - expectedSize: 1, - expectedKeys: ["key1": "value"] - ), - ( - op: objectsHelper.mapClearOp(objectId: "root"), - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), // different site so siteTimeserials check passes, older than first clear - noop - siteCode: "bbb", - description: "second MAP_CLEAR with older serial (noop)", - expectedSize: 1, - expectedKeys: ["key1": "value"] - ), - ] - - for step in steps { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: step.serial, - siteCode: step.siteCode, - state: [step.op], - ) - - #expect(try root.size == step.expectedSize, "Check map size after \(step.description)") - for (key, value) in step.expectedKeys { - let keyValue = try #require(root.get(key: key)?.stringValue) - #expect(keyValue == value, "Check \"\(key)\" after \(step.description)") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_CLEAR does not remove entries with serial greater than clearTimeserial", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // set keys with different timeserials - let keys: [(key: String, serial: String, siteCode: String, survivesClear: Bool)] = [ - (key: "key1", serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa", survivesClear: false), // different site, earlier CGO, cleared - (key: "key2", serial: lexicoTimeserial(seriesId: "aaa", timestamp: 999, counter: 0), siteCode: "aaa", survivesClear: true), // different site, later CGO, survives - (key: "key3", serial: lexicoTimeserial(seriesId: "bbb", timestamp: 5, counter: 0), siteCode: "bbb", survivesClear: false), // same site, earlier CGO, cleared - (key: "key4", serial: lexicoTimeserial(seriesId: "ccc", timestamp: 0, counter: 0), siteCode: "ccc", survivesClear: false), // different site, earlier CGO, cleared - (key: "key5", serial: lexicoTimeserial(seriesId: "ccc", timestamp: 999, counter: 0), siteCode: "ccc", survivesClear: true), // different site, later CGO, survives - ] - - for keyEntry in keys { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: keyEntry.serial, - siteCode: keyEntry.siteCode, - state: [objectsHelper.mapSetOp(objectId: "root", key: keyEntry.key, data: .object(["string": .string(keyEntry.key)]))], - ) - } - - #expect(try root.size == keys.count, "Check map has correct number of keys before MAP_CLEAR") - - // apply MAP_CLEAR with serial between existing keys - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 6, counter: 0), - siteCode: "bbb", - state: [objectsHelper.mapClearOp(objectId: "root")], - ) - - var expectedToSurviveCount = 0 - for keyEntry in keys { - expectedToSurviveCount += keyEntry.survivesClear ? 1 : 0 - if keyEntry.survivesClear { - let keyValue = try #require(root.get(key: keyEntry.key)?.stringValue) - #expect(keyValue == keyEntry.key, "Check \(keyEntry.key) survives MAP_CLEAR") - } else { - let keyValue = try root.get(key: keyEntry.key) - #expect(keyValue == nil, "Check \(keyEntry.key) is cleared") - } - } - #expect(try root.size == expectedToSurviveCount, "Check map has \(expectedToSurviveCount) keys after MAP_CLEAR") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_CLEAR object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - let mapIds = [ - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - objectsHelper.fakeMapObjectId(), - ] - - for (i, mapId) in mapIds.enumerated() { - // for each map, send two operations: - // 1. create a map with visible data that can be verified after MAP_CLEAR. - // use earliest possible timeserial to ensure entries can be cleared by MAP_CLEAR ops - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [ - objectsHelper.mapCreateOp( - objectId: mapId, - entries: ["foo": .object(["timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), "data": .object(["string": .string("bar")])])], - ), - ], - ) - // 2. send a no-op remove to establish site 'ccc' in the map's siteTimeserials at a known serial, - // which the MAP_CLEAR ops below will be compared against - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "ccc", timestamp: 5, counter: 0), - siteCode: "ccc", - state: [objectsHelper.mapRemoveOp(objectId: mapId, key: "baz")], - ) - - // set map on root - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: mapId, data: .object(["objectId": .string(mapId)]))], - ) - } - - // inject MAP_CLEAR operations with various timeserial values - // relative to the lexicoTimeserial('ccc', 5, 0) from the remove op above - let ops: [(serial: String, siteCode: String, cleared: Bool)] = [ - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 4, counter: 0), siteCode: "ccc", cleared: false), // existing site, earlier CGO, not applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 5, counter: 0), siteCode: "ccc", cleared: false), // existing site, same CGO, not applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 6, counter: 0), siteCode: "ccc", cleared: true), // existing site, later CGO, applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb", cleared: true), // different site, earlier CGO, applied - (serial: lexicoTimeserial(seriesId: "ddd", timestamp: 9, counter: 0), siteCode: "ddd", cleared: true), // different site, later CGO, applied - ] - - for (i, op) in ops.enumerated() { - let mapId = mapIds[i] - - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: op.serial, - siteCode: op.siteCode, - state: [objectsHelper.mapClearOp(objectId: mapId)], - ) - - let map = try #require(try root.get(key: mapId)?.liveMapValue) - if op.cleared { - #expect(try map.size == 0, "Check map #\(i + 1) is cleared") - } else { - #expect(try map.size == 1, "Check map #\(i + 1) is not cleared") - let fooValue = try #require(map.get(key: "foo")?.stringValue) - #expect(fooValue == "bar", "Check map #\(i + 1) retains \"foo\" key") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_SET with serial <= clearTimeserial is ignored after MAP_CLEAR", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // apply MAP_CLEAR, stores clearTimeserial on a map - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapClearOp(objectId: "root")], - ) - - // inject MAP_SET operations with various serials relative to clearTimeserial. - // use different site codes to pass siteTimeserials check - let ops: [(serial: String, siteCode: String, key: String, applied: Bool)] = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 5, counter: 0), siteCode: "bbb", key: "early", applied: false), // earlier than clear, ignored - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), siteCode: "aaa", key: "equal", applied: false), // equal to clear, ignored - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 999, counter: 0), siteCode: "bbb", key: "later", applied: true), // later than clear, applied - ] - - for op in ops { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: op.serial, - siteCode: op.siteCode, - state: [objectsHelper.mapSetOp(objectId: "root", key: op.key, data: .object(["string": .string("value")]))], - ) - - if op.applied { - let value = try #require(root.get(key: op.key)?.stringValue) - #expect(value == "value", "Check MAP_SET for \"\(op.key)\" is applied") - } else { - let value = try root.get(key: op.key) - #expect(value == nil, "Check MAP_SET for \"\(op.key)\" is ignored") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_REMOVE with serial <= clearTimeserial is ignored after MAP_CLEAR", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // apply MAP_CLEAR, stores clearTimeserial on a map - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapClearOp(objectId: "root")], - ) - - // inject MAP_REMOVE operations with various serials relative to clearTimeserial. - // use different site codes to pass siteTimeserials check - let ops: [(serial: String, siteCode: String, key: String, applied: Bool)] = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 5, counter: 0), siteCode: "bbb", key: "early", applied: false), // earlier than clear, ignored - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 10, counter: 0), siteCode: "aaa", key: "equal", applied: false), // equal to clear, ignored - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 999, counter: 0), siteCode: "bbb", key: "later", applied: true), // later than clear, applied - ] - - for op in ops { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: op.serial, - siteCode: op.siteCode, - state: [objectsHelper.mapRemoveOp(objectId: "root", key: op.key)], - ) - - let internallyTypedRoot = try #require(root as? PublicDefaultLiveMap) - let internalRoot = internallyTypedRoot.proxied - let underlyingData = internalRoot.testsOnly_data - if op.applied { - let mapEntry = try #require(underlyingData[op.key]) - #expect(mapEntry.tombstone == true, "Check MAP_REMOVE for \"\(op.key)\" is tombstoned") - } else { - #expect(underlyingData[op.key] == nil, "Check MAP_REMOVE for \"\(op.key)\" is ignored") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "MAP_CLEAR removes entries from internal data map", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // set a key on root so the clear has something to remove - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], - ) - #expect(try root.get(key: "foo") != nil, "Check \"foo\" exists before clear") - - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapClearOp(objectId: "root")], - ) - - // entry should be fully removed from internal data - let internallyTypedRoot = try #require(root as? PublicDefaultLiveMap) - let internalRoot = internallyTypedRoot.proxied - let underlyingData = internalRoot.testsOnly_data - #expect(underlyingData["foo"] == nil, "Check \"foo\" is removed from internal data") - }, - ), - ] - - let applyOperationsDuringSyncScenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "object operation messages are buffered during OBJECT_SYNC sequence", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - let client = ctx.client - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - ) - - // Inject operations, they should not be applied as sync is in progress - // Note that unlike in the JS test we do not perform this concurrently because if we were to do that in Swift Concurrency we would not be able to guarantee that the operations are applied in the correct order (if they're not then messages will be discarded due to serials being out of order) - for keyData in primitiveKeyData { - var wireData = keyData.data.mapValues { WireValue(jsonValue: $0) } - - if let bytesValue = wireData["bytes"], client.internal.options.useBinaryProtocol { - let bytesString = try #require(bytesValue.stringValue) - wireData["bytes"] = try .data(#require(.init(base64Encoded: bytesString))) - } - - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: keyData.key, data: .object(wireData))], - ) - } - - // Check root doesn't have data from operations - for keyData in primitiveKeyData { - #expect(try root.get(key: keyData.key) == nil, "Check \"\(keyData.key)\" key doesn't exist on root during OBJECT_SYNC") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "buffered object operation messages are applied when OBJECT_SYNC sequence ends", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - let client = ctx.client - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - ) - - // Inject operations, they should be applied when sync ends - // Note that unlike in the JS test we do not perform this concurrently because if we were to do that in Swift Concurrency we would not be able to guarantee that the operations are applied in the correct order (if they're not then messages will be discarded due to serials being out of order) - for (i, keyData) in primitiveKeyData.enumerated() { - var wireData = keyData.data.mapValues { WireValue(jsonValue: $0) } - - if let bytesValue = wireData["bytes"], client.internal.options.useBinaryProtocol { - let bytesString = try #require(bytesValue.stringValue) - wireData["bytes"] = try .data(#require(.init(base64Encoded: bytesString))) - } - - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: keyData.key, data: .object(wireData))], - ) - } - - // End the sync with empty cursor - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - ) - - // Check everything is applied correctly - for keyData in primitiveKeyData { - if let bytesValue = keyData.data["bytes"] { - if case let .string(base64String) = bytesValue { - let expectedData = Data(base64Encoded: base64String) - #expect(try #require(root.get(key: keyData.key)?.dataValue) == expectedData, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } else { - // Handle other value types - if let stringValue = keyData.data["string"] { - if case let .string(expectedString) = stringValue { - #expect(try #require(root.get(key: keyData.key)?.stringValue) == expectedString, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } else if let numberValue = keyData.data["number"] { - if case let .number(expectedNumber) = numberValue { - #expect(try #require(root.get(key: keyData.key)?.numberValue) == expectedNumber, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } else if let boolValue = keyData.data["boolean"] { - if case let .bool(expectedBool) = boolValue { - #expect(try #require(root.get(key: keyData.key)?.boolValue as Bool?) == expectedBool, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "buffered object operation messages are discarded on ATTACHED", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - ) - - // Inject operation during sync sequence, expect it to be discarded when ATTACHED arrives - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], - ) - - // Any ATTACHED message must clear buffered operations and start a new sync sequence - await injectAttachedMessage(channel: channel, flags: .hasObjects) - - // Inject another operation that should be applied when sync ends - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), - siteCode: "bbb", - state: [objectsHelper.mapSetOp(objectId: "root", key: "baz", data: .object(["string": .string("qux")]))], - ) - - // End sync - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - ) - - // Check root doesn't have data from operations received before ATTACHED - let fooValue = try root.get(key: "foo") - #expect(fooValue == nil, "Check buffered ops before ATTACHED were discarded and not applied on root") - - // Check root has data from operations received after ATTACHED - #expect(try #require(root.get(key: "baz")?.stringValue) == "qux", "Check root has data from operations received after ATTACHED") - }, - ), - .init( - // Note: This comment re regression test is preserved from the JS test it's copied from, but this bug never actually existed in the Swift implementation. - // Regression test: an earlier implementation did not clear buffered operations when receiving - // an ATTACHED with RESUMED=true on an already-attached channel. The RESUMED flag is irrelevant - // — buffering is determined by HAS_OBJECTS, and any ATTACHED must clear buffered operations. - disabled: false, - allTransportsAndProtocols: false, - description: "buffered object operation messages are discarded when already-attached channel receives ATTACHED with RESUMED flag", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Channel is already attached from the test setup - #expect(channel.state == .attached, "Check channel is already attached before test begins") - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - ) - - // Inject operation, expect it to be discarded when ATTACHED arrives (even with RESUMED) - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], - ) - - // The RESUMED flag is irrelevant for LiveObjects buffering — any ATTACHED must clear - // buffered operations and start a new sync sequence - await injectAttachedMessage(channel: channel, flags: [.hasObjects, .resumed]) - - // Inject another operation after ATTACHED - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), - siteCode: "bbb", - state: [objectsHelper.mapSetOp(objectId: "root", key: "baz", data: .object(["string": .string("qux")]))], - ) - - // End sync - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - ) - - // Check root doesn't have data from operations received before ATTACHED - let fooValue = try root.get(key: "foo") - #expect(fooValue == nil, "Check buffered ops before RESUMED ATTACHED were discarded and not applied on root") - - // Check root has data from operations received after ATTACHED - #expect(try #require(root.get(key: "baz")?.stringValue) == "qux", "Check root has data from operations received after RESUMED ATTACHED") - }, - ), - .init( - // Regression test: an earlier implementation incorrectly cleared buffered operations when a new - // OBJECT_SYNC sequence started. Only an ATTACHED message should clear buffered operations, not - // a new OBJECT_SYNC sequence. - disabled: false, - allTransportsAndProtocols: false, - description: "buffered object operation messages are NOT discarded on new OBJECT_SYNC sequence", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - ) - - // Inject operation during first sync sequence - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], - ) - - // Start new sync with new sequence id — buffered operations should NOT be discarded - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "otherserial:cursor", - ) - - // Inject another operation during second sync sequence - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), - siteCode: "bbb", - state: [objectsHelper.mapSetOp(objectId: "root", key: "baz", data: .object(["string": .string("qux")]))], - ) - - // End sync - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "otherserial:", - ) - - // Check root has data from operations received during first sync sequence - let fooStringValue = try #require(root.get(key: "foo")?.stringValue) - #expect(fooStringValue == "bar", "Check root has data from operations received during first OBJECT_SYNC sequence") - - // Check root has data from operations received during second sync - let bazStringValue = try #require(root.get(key: "baz")?.stringValue) - #expect(bazStringValue == "qux", "Check root has data from operations received during second OBJECT_SYNC sequence") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "operations are buffered when OBJECT_SYNC is received after completed sync without expected preceding ATTACHED", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Complete an initial sync sequence first - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - ) - - // Simulate receiving OBJECT_SYNC without preceding ATTACHED. - // Normally, for server-initiated resync the server is expected to send ATTACHED with RESUMED=false first. - // However, if that doesn't happen, the client handles it as a best-effort case by starting to buffer from this point. - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "resync:cursor", - ) - - // Inject operations during this server-initiated resync — they should be buffered - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], - ) - - // Check root doesn't have data yet — operations should be buffered during resync - let fooValueDuringResync = try root.get(key: "foo") - #expect(fooValueDuringResync == nil, "Check \"foo\" key doesn't exist during server-initiated resync") - - // End the resync - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "resync:", - ) - - // Check buffered operations are now applied - let fooStringValue = try #require(root.get(key: "foo")?.stringValue) - #expect(fooStringValue == "bar", "Check root has correct value for \"foo\" key after server-initiated resync completed") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "buffered object operation messages are applied based on the site timeserials vector of the object", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - let mapId = objectsHelper.fakeMapObjectId() - let counterId = objectsHelper.fakeCounterObjectId() - - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - // Add object state messages with non-empty site timeserials - state: [ - // Next map and counter objects will be checked to have correct operations applied on them based on site timeserials - objectsHelper.mapObject( - objectId: mapId, - siteTimeserials: [ - "bbb": lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), - "ccc": lexicoTimeserial(seriesId: "ccc", timestamp: 5, counter: 0), - ], - materialisedEntries: [ - "foo1": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo2": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo3": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "ccc", timestamp: 5, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo4": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo5": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo6": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "ccc", timestamp: 2, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo7": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "ccc", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - "foo8": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "ccc", timestamp: 0, counter: 0)), - "data": .object(["string": .string("bar")]), - ]), - ], - ), - objectsHelper.counterObject( - objectId: counterId, - siteTimeserials: [ - "bbb": lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), - ], - initialCount: 1, - ), - // Add objects to the root so they're discoverable in the object tree - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["aaa": lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)], - initialEntries: [ - "map": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(mapId)]), - ]), - "counter": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0)), - "data": .object(["objectId": .string(counterId)]), - ]), - ], - ), - ], - ) - - // Inject operations with various timeserial values - // Map: - let mapOperations: [(serial: String, siteCode: String)] = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb"), // existing site, earlier site CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb"), // existing site, same site CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 3, counter: 0), siteCode: "bbb"), // existing site, later site CGO, earlier entry CGO, not applied but site timeserial updated - // message with later site CGO, same entry CGO case is not possible, as timeserial from entry would be set for the corresponding site code or be less than that - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 3, counter: 0), siteCode: "bbb"), // existing site, same site CGO (updated from last op), later entry CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 4, counter: 0), siteCode: "bbb"), // existing site, later site CGO, later entry CGO, applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), siteCode: "aaa"), // different site, earlier entry CGO, not applied but site timeserial updated - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 0), siteCode: "aaa"), // different site, same site CGO (updated from last op), later entry CGO, not applied - // different site with matching entry CGO case is not possible, as matching entry timeserial means that that timeserial is in the site timeserials vector - (serial: lexicoTimeserial(seriesId: "ddd", timestamp: 1, counter: 0), siteCode: "ddd"), // different site, later entry CGO, applied - ] - - for (i, operation) in mapOperations.enumerated() { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: operation.serial, - siteCode: operation.siteCode, - state: [objectsHelper.mapSetOp(objectId: mapId, key: "foo\(i + 1)", data: .object(["string": .string("baz")]))], - ) - } - - // Counter: - let counterOperations: [(serial: String, siteCode: String, amount: Double)] = [ - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), siteCode: "bbb", amount: 10), // existing site, earlier CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb", amount: 100), // existing site, same CGO, not applied - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb", amount: 1000), // existing site, later CGO, applied, site timeserials updated - (serial: lexicoTimeserial(seriesId: "bbb", timestamp: 2, counter: 0), siteCode: "bbb", amount: 10000), // existing site, same CGO (updated from last op), not applied - (serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), siteCode: "aaa", amount: 100_000), // different site, earlier CGO, applied - (serial: lexicoTimeserial(seriesId: "ccc", timestamp: 9, counter: 0), siteCode: "ccc", amount: 1_000_000), // different site, later CGO, applied - ] - - for operation in counterOperations { - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: operation.serial, - siteCode: operation.siteCode, - state: [objectsHelper.counterIncOp(objectId: counterId, number: Int(operation.amount))], - ) - } - - // End sync - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - ) - - // Check only operations with correct timeserials were applied - let expectedMapKeys: [(key: String, value: String)] = [ - (key: "foo1", value: "bar"), - (key: "foo2", value: "bar"), - (key: "foo3", value: "bar"), - (key: "foo4", value: "bar"), - (key: "foo5", value: "baz"), // updated - (key: "foo6", value: "bar"), - (key: "foo7", value: "bar"), - (key: "foo8", value: "baz"), // updated - ] - - let map = try #require(root.get(key: "map")?.liveMapValue) - for expectedMapKey in expectedMapKeys { - #expect(try #require(map.get(key: expectedMapKey.key)?.stringValue) == expectedMapKey.value, "Check \"\(expectedMapKey.key)\" key on map has expected value after OBJECT_SYNC has ended") - } - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - let expectedCounterValue = 1.0 + 1000.0 + 100_000.0 + 1_000_000.0 // sum of passing operations and the initial value - #expect(try counter.value == expectedCounterValue, "Check counter has expected value after OBJECT_SYNC has ended") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "subsequent object operation messages are applied immediately after OBJECT_SYNC ended and buffered operations are applied", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - let channelName = ctx.channelName - let client = ctx.client - - // Start new sync sequence with a cursor so client will wait for the next OBJECT_SYNC messages - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:cursor", - ) - - // Inject operations, they should be applied when sync ends - // Note that unlike in the JS test we do not perform this concurrently because if we were to do that in Swift Concurrency we would not be able to guarantee that the operations are applied in the correct order (if they're not then messages will be discarded due to serials being out of order) - for (i, keyData) in primitiveKeyData.enumerated() { - var wireData = keyData.data.mapValues { WireValue(jsonValue: $0) } - - if let bytesValue = wireData["bytes"], client.internal.options.useBinaryProtocol { - let bytesString = try #require(bytesValue.stringValue) - wireData["bytes"] = try .data(#require(.init(base64Encoded: bytesString))) - } - - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: Int64(i), counter: 0), - siteCode: "aaa", - state: [objectsHelper.mapSetOp(objectId: "root", key: keyData.key, data: .object(wireData))], - ) - } - - // End the sync with empty cursor - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - ) - - let keyUpdatedPromiseUpdates = try root.updates() - async let keyUpdatedPromise: Void = waitForMapKeyUpdate(keyUpdatedPromiseUpdates, "foo") - - // Send some more operations - let operationResult = try await objectsHelper.operationRequest( - channelName: channelName, - opBody: objectsHelper.mapSetRestOp( - objectId: "root", - key: "foo", - value: ["string": .string("bar")], - ), - ) - await keyUpdatedPromise - - // Check buffered operations are applied, as well as the most recent operation outside of the sync sequence is applied - for keyData in primitiveKeyData { - if let bytesValue = keyData.data["bytes"] { - if case let .string(base64String) = bytesValue { - let expectedData = Data(base64Encoded: base64String) - #expect(try #require(root.get(key: keyData.key)?.dataValue) == expectedData, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } else { - // Handle other value types - if let stringValue = keyData.data["string"] { - if case let .string(expectedString) = stringValue { - #expect(try #require(root.get(key: keyData.key)?.stringValue) == expectedString, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } else if let numberValue = keyData.data["number"] { - if case let .number(expectedNumber) = numberValue { - #expect(try #require(root.get(key: keyData.key)?.numberValue) == expectedNumber, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } else if let boolValue = keyData.data["boolean"] { - if case let .bool(expectedBool) = boolValue { - #expect(try #require(root.get(key: keyData.key)?.boolValue as Bool?) == expectedBool, "Check root has correct value for \"\(keyData.key)\" key after OBJECT_SYNC has ended and buffered operations are applied") - } - } - } - } - - #expect(try #require(root.get(key: "foo")?.stringValue) == "bar", "Check root has correct value for \"foo\" key from operation received outside of OBJECT_SYNC after other buffered operations were applied") - }, - ), - ] - - let writeApiScenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveCounter.increment sends COUNTER_INC operation", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = await counterCreatedPromise - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - let increments: [Double] = [ - 1, // value=1 - 10, // value=11 - -11, // value=0 - -1, // value=-1 - -10, // value=-11 - 11, // value=0 - Double(Int.max), // value=9223372036854775807 - -Double(Int.max), // value=0 - -Double(Int.max), // value=-9223372036854775807 - ] - var expectedCounterValue = 0.0 - - for (i, increment) in increments.enumerated() { - expectedCounterValue += increment - - try await counter.increment(amount: increment) - - #expect(try counter.value == expectedCounterValue, "Check counter has correct value after \(i + 1) LiveCounter.increment calls") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveCounter.increment throws on invalid input", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = await counterCreatedPromise - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - - // Test invalid numeric values - Swift type system prevents most invalid types - // OMITTED from JS tests due to Swift type system: increment(), increment(null), - // increment('foo'), increment(BigInt(1)), increment(true), increment(Symbol()), - // increment({}), increment([]), increment(counter) - all prevented by Swift's type system - await #expect(throws: Error.self, "Counter value increment should be a valid number") { - try await counter.increment(amount: Double.nan) - } - await #expect(throws: Error.self, "Counter value increment should be a valid number") { - try await counter.increment(amount: Double.infinity) - } - await #expect(throws: Error.self, "Counter value increment should be a valid number") { - try await counter.increment(amount: -Double.infinity) - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveCounter.decrement sends COUNTER_INC operation", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = await counterCreatedPromise - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - let decrements: [Double] = [ - 1, // value=-1 - 10, // value=-11 - -11, // value=0 - -1, // value=1 - -10, // value=11 - 11, // value=0 - Double(Int.max), // value=-9223372036854775807 - -Double(Int.max), // value=0 - -Double(Int.max), // value=9223372036854775807 - ] - var expectedCounterValue = 0.0 - - for (i, decrement) in decrements.enumerated() { - expectedCounterValue -= decrement - - try await counter.decrement(amount: decrement) - - #expect(try counter.value == expectedCounterValue, "Check counter has correct value after \(i + 1) LiveCounter.decrement calls") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveCounter.decrement throws on invalid input", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - - let counterResult = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = await counterCreatedPromise - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - - // Test invalid numeric values - Swift type system prevents most invalid types - // OMITTED from JS tests due to Swift type system: decrement(), decrement(null), - // decrement('foo'), decrement(BigInt(1)), decrement(true), decrement(Symbol()), - // decrement({}), decrement([]), decrement(counter) - all prevented by Swift's type system - await #expect(throws: Error.self, "Counter value decrement should be a valid number") { - try await counter.decrement(amount: Double.nan) - } - await #expect(throws: Error.self, "Counter value decrement should be a valid number") { - try await counter.decrement(amount: Double.infinity) - } - await #expect(throws: Error.self, "Counter value decrement should be a valid number") { - try await counter.decrement(amount: -Double.infinity) - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveMap.set sends MAP_SET operation with primitive values", - action: { ctx in - let root = ctx.root - - _ = try await withThrowingTaskGroup(of: Void.self) { group in - for keyData in primitiveKeyData { - group.addTask { - try await root.set(key: keyData.key, value: keyData.liveMapValue) - } - } - while try await group.next() != nil {} - } - - // Check everything is applied correctly - for keyData in primitiveKeyData { - let actualValue = try #require(try root.get(key: keyData.key)) - - switch keyData.liveMapValue { - case let .data(expectedData): - let actualData = try #require(actualValue.dataValue) - #expect(actualData == expectedData, "Check root has correct value for \"\(keyData.key)\" key after LiveMap.set call") - case let .string(expectedString): - let actualString = try #require(actualValue.stringValue) - #expect(actualString == expectedString, "Check root has correct value for \"\(keyData.key)\" key after LiveMap.set call") - case let .number(expectedNumber): - let actualNumber = try #require(actualValue.numberValue) - #expect(actualNumber == expectedNumber, "Check root has correct value for \"\(keyData.key)\" key after LiveMap.set call") - case let .bool(expectedBool): - let actualBool = try #require(actualValue.boolValue as Bool?) - #expect(actualBool == expectedBool, "Check root has correct value for \"\(keyData.key)\" key after LiveMap.set call") - default: - Issue.record("Unexpected value type in test") - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveMap.set sends MAP_SET operation with reference to another LiveObject", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "counter") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "map") - } - while try await group.next() != nil {} - } - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(), - ) - _ = try await objectsCreatedPromise - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - let map = try #require(root.get(key: "map")?.liveMapValue) - - async let setCounter2Promise: Void = root.set(key: "counter2", value: .liveCounter(counter)) - async let setMap2Promise: Void = root.set(key: "map2", value: .liveMap(map)) - _ = try await (setCounter2Promise, setMap2Promise) - - let counter2 = try #require(root.get(key: "counter2")?.liveCounterValue) - let map2 = try #require(root.get(key: "map2")?.liveMapValue) - - #expect(counter2 === counter, "Check can set a reference to a LiveCounter object on a root via a LiveMap.set call") - #expect(map2 === map, "Check can set a reference to a LiveMap object on a root via a LiveMap.set call") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveMap.set throws on invalid input", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let mapCreatedPromiseUpdates = try root.updates() - async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(), - ) - _ = await mapCreatedPromise - - let map = try #require(root.get(key: "map")?.liveMapValue) - - // OMITTED from JS tests due to Swift type system: - // Key validation: map.set(), map.set(null), map.set(1), map.set(BigInt(1)), - // map.set(true), map.set(Symbol()), map.set({}), map.set([]), map.set(map) - // Value validation: map.set('key'), map.set('key', null), map.set('key', BigInt(1)), - // map.set('key', Symbol()), map.set('key', {}), map.set('key', []) - // All prevented by Swift's type system - String keys and LiveMapValue values are enforced - - // Note: Swift's LiveMap.set(key:value:) method signature enforces String keys and - // LiveMapValue values at compile time, making most JS validation tests unnecessary - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveMap.remove sends MAP_REMOVE operation", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let mapCreatedPromiseUpdates = try root.updates() - async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(data: [ - "foo": .object(["number": .number(1)]), - "bar": .object(["number": .number(1)]), - "baz": .object(["number": .number(1)]), - ]), - ) - _ = await mapCreatedPromise - - let map = try #require(root.get(key: "map")?.liveMapValue) - - async let removeFooPromise: Void = map.remove(key: "foo") - async let removeBarPromise: Void = map.remove(key: "bar") - _ = try await (removeFooPromise, removeBarPromise) - - #expect(try map.get(key: "foo") == nil, "Check can remove a key from a root via a LiveMap.remove call") - #expect(try map.get(key: "bar") == nil, "Check can remove a key from a root via a LiveMap.remove call") - #expect(try #require(map.get(key: "baz")?.numberValue) == 1, "Check non-removed keys are still present on a root after LiveMap.remove call for another keys") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveMap.remove throws on invalid input", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - - let mapCreatedPromiseUpdates = try root.updates() - async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(), - ) - _ = await mapCreatedPromise - - let map = try #require(root.get(key: "map")?.liveMapValue) - - // OMITTED from JS tests due to Swift type system: - // map.remove(), map.remove(null), map.remove(1), map.remove(BigInt(1)), - // map.remove(true), map.remove(Symbol()), map.remove({}), map.remove([]), map.remove(map) - // All prevented by Swift's type system - String key parameter is enforced - - // Note: Swift's LiveMap.remove(key:) method signature enforces String keys at compile time, - // making JS key validation tests unnecessary - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "Objects.createCounter sends COUNTER_CREATE operation", - action: { ctx in - let objects = ctx.objects - - let counters = try await withThrowingTaskGroup(of: (index: Int, counter: any LiveCounter).self, returning: [any LiveCounter].self) { group in - for (index, fixture) in countersFixtures.enumerated() { - group.addTask { - let counter = if let count = fixture.count { - try await objects.createCounter(count: count) - } else { - try await objects.createCounter() - } - return (index: index, counter: counter) - } - } - - var results: [(index: Int, counter: any LiveCounter)] = [] - while let result = try await group.next() { - results.append(result) - } - return results.sorted { $0.index < $1.index }.map(\.counter) - } - - for (i, counter) in counters.enumerated() { - let fixture = countersFixtures[i] - - // Note: counter is guaranteed to exist by Swift type system - // Note: Type check omitted - guaranteed by Swift type system that counter is PublicLiveCounter - #expect(try counter.value == fixture.count ?? 0, "Check counter #\(i + 1) has expected initial value") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveCounter created with Objects.createCounter can be assigned to the object tree", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - - let counterCreatedPromiseUpdates = try root.updates() - async let counterCreatedPromise: Void = waitForMapKeyUpdate(counterCreatedPromiseUpdates, "counter") - - let counter = try await objects.createCounter(count: 1) - try await root.set(key: "counter", value: .liveCounter(counter)) - _ = await counterCreatedPromise - - // Note: Type check omitted - guaranteed by Swift type system that counter is PublicLiveCounter - let rootCounter = try #require(root.get(key: "counter")?.liveCounterValue) - // Note: Type check omitted - guaranteed by Swift type system that rootCounter is PublicLiveCounter - #expect(rootCounter === counter, "Check counter object on root is the same as from create method") - #expect(try rootCounter.value == 1, "Check counter assigned to the object tree has the expected value") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "Objects.createCounter can return LiveCounter with initial value without applying CREATE operation", - action: { ctx in - let objects = ctx.objects - - // prevent publishing of ops to realtime so we guarantee that the initial value comes from local apply-on-ACK, not from a server echo - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages in - PublishResult(serials: objectMessages.map { _ in "fake-serial" }) - }) - - let counter = try await objects.createCounter(count: 1) - #expect(try counter.value == 1, "Check counter has expected initial value") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "Objects.createCounter can return LiveCounter with initial value from applied CREATE operation", - action: { ctx in - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Instead of sending CREATE op to the realtime, echo it immediately to the client - // with forged initial value so we can check that counter gets initialized with a value from a CREATE op - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - var capturedCounterId: String? - - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages throws(ARTErrorInfo) in - do { - let counterId = try #require(objectMessages[0].operation?.objectId) - capturedCounterId = counterId - - // This should result in executing regular operation application procedure and create an object in the pool with forged initial value - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1), - siteCode: "aaa", - state: [objectsHelper.counterCreateOp(objectId: counterId, count: 10)], - ) - } catch { - throw LiveObjectsError.other(error).toARTErrorInfo() - } - return PublishResult(serials: objectMessages.map { _ in "fake-serial" }) - }) - - let counter = try await objects.createCounter(count: 1) - - // The injected CREATE op (value=10) is processed first in the override. - // Then publishAndApply's local CREATE (value=1) is rejected as a duplicate - // COUNTER_CREATE. So the counter retains the injected op's value. - #expect(try counter.value == 10, "Check counter value has the expected initial value from the injected CREATE operation") - #expect(capturedCounterId != nil, "Check that Objects.publish was called with counter ID") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "initial value is not double counted for LiveCounter from Objects.createCounter when CREATE op is received", - action: { ctx in - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Prevent publishing of ops to realtime so we can guarantee order of operations - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages in - // Prevent publishing to realtime but return serials so apply-on-ACK works - PublishResult(serials: objectMessages.map { _ in "fake-serial" }) - }) - - // Create counter locally via apply-on-ACK - let counter = try await objects.createCounter(count: 1) - let internalCounter = try #require(counter as? PublicDefaultLiveCounter) - let counterId = internalCounter.proxied.testsOnly_objectID - - // Now inject CREATE op for a counter with a forged value. it should not be applied - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1), - siteCode: "aaa", - state: [objectsHelper.counterCreateOp(objectId: counterId, count: 10)], - ) - - #expect(try counter.value == 1, "Check counter initial value is not double counted after being created and receiving CREATE operation") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "Objects.createCounter throws on invalid input", - action: { ctx in - let objects = ctx.objects - - // Test invalid numeric values - Swift type system prevents most invalid types - // OMITTED from JS tests due to Swift type system: objects.createCounter(null), - // objects.createCounter('foo'), objects.createCounter(BigInt(1)), objects.createCounter(true), - // objects.createCounter(Symbol()), objects.createCounter({}), objects.createCounter([]), - // objects.createCounter(root) - all prevented by Swift's type system - await #expect(throws: Error.self, "Counter value should be a valid number") { - try await objects.createCounter(count: Double.nan) - } - await #expect(throws: Error.self, "Counter value should be a valid number") { - try await objects.createCounter(count: Double.infinity) - } - await #expect(throws: Error.self, "Counter value should be a valid number") { - try await objects.createCounter(count: -Double.infinity) - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "Objects.createMap sends MAP_CREATE operation with primitive values", - action: { ctx in - let objects = ctx.objects - - let maps = try await withThrowingTaskGroup(of: (index: Int, map: any LiveMap).self, returning: [any LiveMap].self) { group in - for (index, mapFixture) in primitiveMapsFixtures.enumerated() { - group.addTask { - let map = if let entries = mapFixture.liveMapEntries { - try await objects.createMap(entries: entries) - } else { - try await objects.createMap() - } - return (index: index, map: map) - } - } - - var results: [(index: Int, map: any LiveMap)] = [] - while let result = try await group.next() { - results.append(result) - } - return results.sorted { $0.index < $1.index }.map(\.map) - } - - for (i, map) in maps.enumerated() { - let fixture = primitiveMapsFixtures[i] - - // Note: map is guaranteed to exist by Swift type system - // Note: Type check omitted - guaranteed by Swift type system that map is PublicLiveMap - - #expect(try map.size == (fixture.liveMapEntries?.count ?? 0), "Check map #\(i + 1) has correct number of keys") - - if let entries = fixture.liveMapEntries { - for (key, expectedValue) in entries { - let actualValue = try map.get(key: key) - - switch expectedValue { - case let .data(expectedData): - let actualData = try #require(actualValue?.dataValue) - #expect(actualData == expectedData, "Check map #\(i + 1) has correct value for \"\(key)\" key") - case let .string(expectedString): - let actualString = try #require(actualValue?.stringValue) - #expect(actualString == expectedString, "Check map #\(i + 1) has correct value for \"\(key)\" key") - case let .number(expectedNumber): - let actualNumber = try #require(actualValue?.numberValue) - #expect(actualNumber == expectedNumber, "Check map #\(i + 1) has correct value for \"\(key)\" key") - case let .bool(expectedBool): - let actualBool = try #require(actualValue?.boolValue as Bool?) - #expect(actualBool == expectedBool, "Check map #\(i + 1) has correct value for \"\(key)\" key") - case .jsonArray, .jsonObject: - Issue.record("JSON array/object primitives not expected in test data") - case .liveCounter, .liveMap: - Issue.record("Nested objects not expected in primitive test data") - } - } - } - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "Objects.createMap sends MAP_CREATE operation with reference to another LiveObject", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let objects = ctx.objects - - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "counter") - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "map") - } - while try await group.next() != nil {} - } - - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "counter", - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = try await objectsHelper.createAndSetOnMap( - channelName: channelName, - mapObjectId: "root", - key: "map", - createOp: objectsHelper.mapCreateRestOp(), - ) - _ = try await objectsCreatedPromise - - let counter = try #require(root.get(key: "counter")?.liveCounterValue) - let map = try #require(root.get(key: "map")?.liveMapValue) - - let newMap = try await objects.createMap(entries: ["counter": .liveCounter(counter), "map": .liveMap(map)]) - - // Note: newMap is guaranteed to exist by Swift type system - // Note: Type check omitted - guaranteed by Swift type system that newMap is PublicLiveMap - - let newMapCounter = try #require(newMap.get(key: "counter")?.liveCounterValue) - let newMapMap = try #require(newMap.get(key: "map")?.liveMapValue) - - #expect(newMapCounter === counter, "Check can set a reference to a LiveCounter object on a new map via a MAP_CREATE operation") - #expect(newMapMap === map, "Check can set a reference to a LiveMap object on a new map via a MAP_CREATE operation") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "LiveMap created with Objects.createMap can be assigned to the object tree", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - - let mapCreatedPromiseUpdates = try root.updates() - async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") - - let counter = try await objects.createCounter() - let map = try await objects.createMap(entries: ["foo": "bar", "baz": .liveCounter(counter)]) - try await root.set(key: "map", value: .liveMap(map)) - _ = await mapCreatedPromise - - // Note: Type check omitted - guaranteed by Swift type system that map is PublicLiveMap - let rootMap = try #require(root.get(key: "map")?.liveMapValue) - // Note: Type check omitted - guaranteed by Swift type system that rootMap is PublicLiveMap - #expect(rootMap === map, "Check map object on root is the same as from create method") - #expect(try rootMap.size == 2, "Check map assigned to the object tree has the expected number of keys") - #expect(try #require(rootMap.get(key: "foo")?.stringValue) == "bar", "Check map assigned to the object tree has the expected value for its string key") - - let rootMapCounter = try #require(rootMap.get(key: "baz")?.liveCounterValue) - #expect(rootMapCounter === counter, "Check map assigned to the object tree has the expected value for its LiveCounter key") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "Objects.createMap can return LiveMap with initial value without applying CREATE operation", - action: { ctx in - let objects = ctx.objects - - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages in - PublishResult(serials: objectMessages.map { _ in "fake-serial" }) - }) - - // prevent publishing of ops to realtime so we guarantee that the initial value comes from local apply-on-ACK - let map = try await objects.createMap(entries: ["foo": "bar"]) - #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check map has expected initial value") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "Objects.createMap can return LiveMap with initial value from applied CREATE operation", - action: { ctx in - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Instead of sending CREATE op to the realtime, echo it immediately to the client - // with forged initial value so we can check that map gets initialized with a value from a CREATE op - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - var capturedMapId: String? - - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages throws(ARTErrorInfo) in - do { - let mapId = try #require(objectMessages[0].operation?.objectId) - capturedMapId = mapId - - // This should result in executing regular operation application procedure and create an object in the pool with forged initial value - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1), - siteCode: "aaa", - state: [ - objectsHelper.mapCreateOp( - objectId: mapId, - entries: [ - "baz": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1)), - "data": .object(["string": .string("qux")]), - ]), - ], - ), - ], - ) - } catch { - throw LiveObjectsError.other(error).toARTErrorInfo() - } - return PublishResult(serials: objectMessages.map { _ in "fake-serial" }) - }) - - let map = try await objects.createMap(entries: ["foo": "bar"]) - - // The injected CREATE op (with entry "baz") is processed first in the override. - // Then publishAndApply's local CREATE (with entry "foo") is rejected as a duplicate - // MAP_CREATE. So the map retains the injected op's entries. - #expect(try map.get(key: "foo") == nil, "Check key \"foo\" was not set on a map client-side") - #expect(try #require(map.get(key: "baz")?.stringValue) == "qux", "Check key \"baz\" was set on a map from the injected CREATE operation") - #expect(capturedMapId != nil, "Check that Objects.publish was called with map ID") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "initial value is not double counted for LiveMap from Objects.createMap when CREATE op is received", - action: { ctx in - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - let channel = ctx.channel - - // Prevent publishing of ops to realtime but return serials so apply-on-ACK works - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages in - PublishResult(serials: objectMessages.map { _ in "fake-serial" }) - }) - - // Create map locally via apply-on-ACK - let map = try await objects.createMap(entries: ["foo": "bar"]) - let internalMap = try #require(map as? PublicDefaultLiveMap) - let mapId = internalMap.proxied.testsOnly_objectID - - // Now inject CREATE op for a map with a forged value. it should not be applied - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1), - siteCode: "aaa", - state: [ - objectsHelper.mapCreateOp( - objectId: mapId, - entries: [ - "foo": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1)), - "data": .object(["string": .string("qux")]), - ]), - "baz": .object([ - "timeserial": .string(lexicoTimeserial(seriesId: "aaa", timestamp: 1, counter: 1)), - "data": .object(["string": .string("qux")]), - ]), - ], - ), - ], - ) - - #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check key \"foo\" was not overridden by a CREATE operation after creating a map locally") - #expect(try map.get(key: "baz") == nil, "Check key \"baz\" was not set by a CREATE operation after creating a map locally") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "Objects.createMap throws on invalid input", - action: { ctx in - let objects = ctx.objects - - // Test invalid input types - Swift type system prevents most invalid types - // OMITTED from JS tests due to Swift type system: objects.createMap(null), - // objects.createMap('foo'), objects.createMap(1), objects.createMap(BigInt(1)), - // objects.createMap(true), objects.createMap(Symbol()) - all prevented by Swift's type system - - // Test invalid map value types - these would be caught at runtime - // OMITTED from JS tests due to Swift type system: objects.createMap({ key: undefined }), - // objects.createMap({ key: null }), objects.createMap({ key: BigInt(1) }), - // objects.createMap({ key: Symbol() }), objects.createMap({ key: {} }), - // objects.createMap({ key: [] }) - all prevented by Swift's type system requiring specific LiveMapValue types - - // Note: Swift's Objects.createMap(initialData:) method signature enforces [String: Any] initialData - // and LiveMapValue enum cases at compile time, making most JS validation tests unnecessary. - // Any invalid values would be caught during the conversion to LiveMapValue enum cases. - }, - ), - ] - - let liveMapEnumerationScenarios: [TestScenario] = [ - // TODO: Implement these scenarios - ] - - return [ - objectSyncSequenceScenarios, - applyOperationsScenarios, - applyOperationsDuringSyncScenarios, - writeApiScenarios, - liveMapEnumerationScenarios, - ].flatMap(\.self) - }() - } - - @Test(arguments: FirstSetOfScenarios.testCases) - func firstSetOfScenarios(testCase: TestCase) async throws { - guard !testCase.disabled else { - withKnownIssue { - Issue.record("Test case is disabled") - } - return - } - - let objectsHelper = try await ObjectsHelper() - let client = try await realtimeWithObjects(options: testCase.options) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channel = client.channels.get(testCase.channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - try await testCase.scenario.action( - .init( - objects: objects, - root: root, - objectsHelper: objectsHelper, - channelName: testCase.channelName, - channel: channel, - client: client, - clientOptions: testCase.options, - ), - ) - } - } - - @available(iOS 17.0.0, tvOS 17.0.0, *) - enum SubscriptionCallbacksScenarios: Scenarios { - struct Context { - var objects: any RealtimeObjects - var root: any LiveMap - var objectsHelper: ObjectsHelper - var channelName: String - var channel: ARTRealtimeChannel - var sampleMapKey: String - var sampleMapObjectId: String - var sampleCounterKey: String - var sampleCounterObjectId: String - } - - static let scenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can subscribe to the incoming COUNTER_INC operation on a LiveCounter", - action: { ctx in - // Have split this #require into two because one of our formatting tools was trying to remove the parentheses from #require when it was a one-liner, making it invalid Swift 🤷 - let sampleCounterValue = try #require(try ctx.root.get(key: ctx.sampleCounterKey)) - let counter = try #require(sampleCounterValue.liveCounterValue) - - let updates = try counter.updates() - async let subscriptionPromise: Void = { - let update = try #require(await updates.first { _ in true }) - #expect(update.amount == 1, "Check counter subscription callback is called with an expected update object for COUNTER_INC operation") - }() - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.counterIncRestOp(objectId: ctx.sampleCounterObjectId, number: 1), - ) - - try await subscriptionPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can subscribe to multiple incoming operations on a LiveCounter", - action: { @MainActor ctx in - let counter = try #require(ctx.root.get(key: ctx.sampleCounterKey)?.liveCounterValue) - let expectedCounterIncrements = [100.0, -100.0, Double(Int.max), Double(-Int.max)] - let currentUpdateIndex = MainActorStorage(value: 0) - - let subscriber = Subscriber(callbackQueue: .main) - try counter.subscribe(listener: subscriber.createListener()) - async let subscriptionPromise: Void = withCheckedContinuation { continuation in - subscriber.addListener { update, _ in - MainActor.assumeIsolated { - let expectedInc = expectedCounterIncrements[currentUpdateIndex.value] - #expect(update.amount == expectedInc, "Check counter subscription callback is called with an expected update object for \(currentUpdateIndex.value + 1) times") - - if currentUpdateIndex.value == expectedCounterIncrements.count - 1 { - continuation.resume() - } - - currentUpdateIndex.value += 1 - } - } - } - - for increment in expectedCounterIncrements { - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.counterIncRestOp(objectId: ctx.sampleCounterObjectId, number: increment), - ) - } - - await subscriptionPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can subscribe to the incoming MAP_SET operation on a LiveMap", - action: { ctx in - // Have split this #require into two because one of our formatting tools was trying to remove the parentheses from #require when it was a one-liner, making it invalid Swift 🤷 - let sampleMapValue = try #require(try ctx.root.get(key: ctx.sampleMapKey)) - let map = try #require(sampleMapValue.liveMapValue) - - let updates = try map.updates() - async let subscriptionPromise: Void = { - let update = try #require(await updates.first { _ in true }) - // Check that the update contains the expected key with "updated" status - #expect(update.update["stringKey"] == .updated, "Check map subscription callback is called with an expected update object for MAP_SET operation") - }() - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapSetRestOp( - objectId: ctx.sampleMapObjectId, - key: "stringKey", - value: ["string": "stringValue"], - ), - ) - - try await subscriptionPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can subscribe to the incoming MAP_REMOVE operation on a LiveMap", - action: { ctx in - // Have split this #require into two because one of our formatting tools was trying to remove the parentheses from #require when it was a one-liner, making it invalid Swift 🤷 - let sampleMapValue = try #require(try ctx.root.get(key: ctx.sampleMapKey)) - let map = try #require(sampleMapValue.liveMapValue) - - let updates = try map.updates() - async let subscriptionPromise: Void = { - let update = try #require(await updates.first { _ in true }) - // Check that the update contains the expected key with "removed" status - #expect(update.update["stringKey"] == .removed, "Check map subscription callback is called with an expected update object for MAP_REMOVE operation") - }() - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapRemoveRestOp( - objectId: ctx.sampleMapObjectId, - key: "stringKey", - ), - ) - - try await subscriptionPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "can subscribe to multiple incoming operations on a LiveMap", - action: { @MainActor ctx in - let map = try #require(ctx.root.get(key: ctx.sampleMapKey)?.liveMapValue) - let expectedMapUpdates: [[String: LiveMapUpdateAction]] = [ - ["foo": .updated], - ["bar": .updated], - ["foo": .removed], - ["baz": .updated], - ["bar": .removed], - ] - let currentUpdateIndex = MainActorStorage(value: 0) - - let subscriber = Subscriber(callbackQueue: .main) - try map.subscribe(listener: subscriber.createListener()) - async let subscriptionPromise: Void = withCheckedContinuation { continuation in - subscriber.addListener { update, _ in - MainActor.assumeIsolated { - let expectedUpdate = expectedMapUpdates[currentUpdateIndex.value] - #expect(update.update == expectedUpdate, "Check map subscription callback is called with an expected update object for \(currentUpdateIndex.value + 1) times") - - if currentUpdateIndex.value == expectedMapUpdates.count - 1 { - continuation.resume() - } - - currentUpdateIndex.value += 1 - } - } - } - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapSetRestOp( - objectId: ctx.sampleMapObjectId, - key: "foo", - value: ["string": "something"], - ), - ) - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapSetRestOp( - objectId: ctx.sampleMapObjectId, - key: "bar", - value: ["string": "something"], - ), - ) - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapRemoveRestOp( - objectId: ctx.sampleMapObjectId, - key: "foo", - ), - ) - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapSetRestOp( - objectId: ctx.sampleMapObjectId, - key: "baz", - value: ["string": "something"], - ), - ) - - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapRemoveRestOp( - objectId: ctx.sampleMapObjectId, - key: "bar", - ), - ) - - await subscriptionPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - // Deviation from JS: The JS test checks event.message.operation.action === 'map.clear' - // (operation metadata), but Swift's LiveMapUpdate has no operation action field — it - // only exposes per-key changes via update: [String: LiveMapUpdateAction]. So instead - // of checking the operation action, this test verifies that the subscription update - // contains .removed entries for each key that existed on the map before the clear. - // This is something the JS test doesn't verify (it only checks operation metadata, - // not the per-key update entries). - description: "can subscribe to the incoming MAP_CLEAR operation on a LiveMap", - action: { ctx in - let root = ctx.root - let objects = ctx.objects - let objectsHelper = ctx.objectsHelper - - let updates = try root.updates() - async let subscriptionPromise: Void = { - let update = try #require(await updates.first { _ in true }) - // verify per-key removed entries for all keys that existed before the clear. - // the test setup creates sampleMap and sampleCounter on root, so those are - // the keys that should be removed. - #expect(update.update[ctx.sampleMapKey] == .removed, "Check \"\(ctx.sampleMapKey)\" key has .removed action after MAP_CLEAR") - #expect(update.update[ctx.sampleCounterKey] == .removed, "Check \"\(ctx.sampleCounterKey)\" key has .removed action after MAP_CLEAR") - }() - - try await objectsHelper.sendMapClearOnChannel(objects: objects, objectId: "root") - - try await subscriptionPromise - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "can unsubscribe from LiveCounter updates via returned unsubscribe callback", - action: { @MainActor ctx in - let counter = try #require(ctx.root.get(key: ctx.sampleCounterKey)?.liveCounterValue) - let callbackCalled = MainActorStorage(value: 0) - - let subscriber = Subscriber(callbackQueue: .main) - try counter.subscribe(listener: subscriber.createListener()) - async let subscriptionPromise: Void = withCheckedContinuation { continuation in - subscriber.addListener { _, subscriptionResponse in - MainActor.assumeIsolated { - callbackCalled.value += 1 - // unsubscribe from future updates after the first call - subscriptionResponse.unsubscribe() - continuation.resume() - } - } - } - - let increments = 3 - for i in 0 ..< increments { - let counterUpdatesStream = try counter.updates() - async let counterUpdatedPromise: Void = waitForCounterUpdate(counterUpdatesStream) - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.counterIncRestOp(objectId: ctx.sampleCounterObjectId, number: 1), - ) - await counterUpdatedPromise - } - - await subscriptionPromise - - #expect(try counter.value == 3, "Check counter has final expected value after all increments") - #expect(callbackCalled.value == 1, "Check subscription callback was only called once") - }, - ), - // Have not implemented "can unsubscribe from LiveCounter updates via LiveCounter.unsubscribe() call" because this method doesn't exist in the Swift SDK (functions don't have identity) - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "can remove all LiveCounter update listeners via LiveCounter.unsubscribeAll() call", - action: { @MainActor ctx in - let counter = try #require(ctx.root.get(key: ctx.sampleCounterKey)?.liveCounterValue) - let callbacks = 3 - let callbacksCalled = MainActorStorage<[Int]>(value: Array(repeating: 0, count: callbacks)) - - // Create multiple subscribers synchronously - let subscribers = try (0 ..< callbacks).map { _ in - let subscriber = Subscriber(callbackQueue: .main) - try counter.subscribe(listener: subscriber.createListener()) - return subscriber - } - - // Set up subscription promises using TaskGroup - async let subscriptionPromises: Void = withTaskGroup(of: Void.self) { group in - for (index, subscriber) in subscribers.enumerated() { - group.addTask { - await withCheckedContinuation { continuation in - subscriber.addListener { _, _ in - MainActor.assumeIsolated { - callbacksCalled.value[index] += 1 - continuation.resume() - } - } - } - } - } - - // Wait for all subscription tasks to complete - for await _ in group {} - } - - let increments = 3 - for i in 0 ..< increments { - let counterUpdatesStream = try counter.updates() - async let counterUpdatedPromise: Void = waitForCounterUpdate(counterUpdatesStream) - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.counterIncRestOp(objectId: ctx.sampleCounterObjectId, number: 1), - ) - await counterUpdatedPromise - - if i == 0 { - // unsub all after first operation - counter.unsubscribeAll() - } - } - - // Wait for all subscription promises to complete - await subscriptionPromises - - #expect(try counter.value == 3, "Check counter has final expected value after all increments") - for i in 0 ..< callbacks { - #expect(callbacksCalled.value[i] == 1, "Check subscription callback \(i) was called once") - } - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "can unsubscribe from LiveMap updates via returned unsubscribe callback", - action: { @MainActor ctx in - let map = try #require(ctx.root.get(key: ctx.sampleMapKey)?.liveMapValue) - let callbackCalled = MainActorStorage(value: 0) - - let subscriber = Subscriber(callbackQueue: .main) - try map.subscribe(listener: subscriber.createListener()) - async let subscriptionPromise: Void = withCheckedContinuation { continuation in - subscriber.addListener { _, subscriptionResponse in - MainActor.assumeIsolated { - callbackCalled.value += 1 - // unsubscribe from future updates after the first call - subscriptionResponse.unsubscribe() - continuation.resume() - } - } - } - - let mapSets = 3 - for i in 0 ..< mapSets { - let mapUpdatesStream = try map.updates() - async let mapUpdatedPromise: Void = waitForMapKeyUpdate(mapUpdatesStream, "foo-\(i)") - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapSetRestOp( - objectId: ctx.sampleMapObjectId, - key: "foo-\(i)", - value: ["string": "exists"], - ), - ) - await mapUpdatedPromise - } - - await subscriptionPromise - - for i in 0 ..< mapSets { - let value = try #require(map.get(key: "foo-\(i)")?.stringValue) - #expect(value == "exists", "Check map has value for key \"foo-\(i)\" after all map sets") - } - #expect(callbackCalled.value == 1, "Check subscription callback was only called once") - }, - ), - // Have not implemented "can unsubscribe from LiveMap updates via LiveMap.unsubscribe() call" because this method doesn't exist in the Swift SDK (functions don't have identity) - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "can remove all LiveMap update listeners via LiveMap.unsubscribeAll() call", - action: { @MainActor ctx in - let map = try #require(ctx.root.get(key: ctx.sampleMapKey)?.liveMapValue) - let callbacks = 3 - let callbacksCalled = MainActorStorage<[Int]>(value: Array(repeating: 0, count: callbacks)) - - // Create multiple subscribers synchronously - let subscribers = try (0 ..< callbacks).map { _ in - let subscriber = Subscriber(callbackQueue: .main) - try map.subscribe(listener: subscriber.createListener()) - return subscriber - } - - // Set up subscription promises using TaskGroup - async let subscriptionPromises: Void = withTaskGroup(of: Void.self) { group in - for (index, subscriber) in subscribers.enumerated() { - group.addTask { - await withCheckedContinuation { continuation in - subscriber.addListener { _, _ in - MainActor.assumeIsolated { - callbacksCalled.value[index] += 1 - continuation.resume() - } - } - } - } - } - - // Wait for all subscription tasks to complete - for await _ in group {} - } - - let mapSets = 3 - for i in 0 ..< mapSets { - let mapUpdatesStream = try map.updates() - async let mapUpdatedPromise: Void = waitForMapKeyUpdate(mapUpdatesStream, "foo-\(i)") - _ = try await ctx.objectsHelper.operationRequest( - channelName: ctx.channelName, - opBody: ctx.objectsHelper.mapSetRestOp( - objectId: ctx.sampleMapObjectId, - key: "foo-\(i)", - value: ["string": "exists"], - ), - ) - await mapUpdatedPromise - - if i == 0 { - // unsub all after first operation - map.unsubscribeAll() - } - } - - // Wait for all subscription promises to complete - await subscriptionPromises - - for i in 0 ..< mapSets { - let value = try #require(map.get(key: "foo-\(i)")?.stringValue) - #expect(value == "exists", "Check map has value for key \"foo-\(i)\" after all map sets") - } - for i in 0 ..< callbacks { - #expect(callbacksCalled.value[i] == 1, "Check subscription callback \(i) was called once") - } - }, - ), - ] - } - - @available(iOS 17.0.0, tvOS 17.0.0, *) - @Test(arguments: SubscriptionCallbacksScenarios.testCases) - func subscriptionCallbacksScenarios(testCase: TestCase) async throws { - guard !testCase.disabled else { - withKnownIssue { - Issue.record("Test case is disabled") - } - return - } - - let objectsHelper = try await ObjectsHelper() - let client = try await realtimeWithObjects(options: testCase.options) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channel = client.channels.get(testCase.channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - let sampleMapKey = "sampleMap" - let sampleCounterKey = "sampleCounter" - - // Create promises for waiting for object updates - let objectsCreatedPromiseUpdates1 = try root.updates() - let objectsCreatedPromiseUpdates2 = try root.updates() - async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, sampleMapKey) - } - group.addTask { - await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, sampleCounterKey) - } - while try await group.next() != nil {} - } - - // Prepare map and counter objects for use by the scenario - let sampleMapResult = try await objectsHelper.createAndSetOnMap( - channelName: testCase.channelName, - mapObjectId: "root", - key: sampleMapKey, - createOp: objectsHelper.mapCreateRestOp(), - ) - let sampleCounterResult = try await objectsHelper.createAndSetOnMap( - channelName: testCase.channelName, - mapObjectId: "root", - key: sampleCounterKey, - createOp: objectsHelper.counterCreateRestOp(), - ) - _ = try await objectsCreatedPromise - - try await testCase.scenario.action( - .init( - objects: objects, - root: root, - objectsHelper: objectsHelper, - channelName: testCase.channelName, - channel: channel, - sampleMapKey: sampleMapKey, - sampleMapObjectId: sampleMapResult.objectId, - sampleCounterKey: sampleCounterKey, - sampleCounterObjectId: sampleCounterResult.objectId, - ), - ) - } - } - - // TODO: Implement the remaining scenarios - - // MARK: - GC Grace Period - - @Test("gcGracePeriod is set from connectionDetails.objectsGCGracePeriod") - func gcGracePeriod_isSetFromConnectionDetails() async throws { - let client = try await realtimeWithObjects(options: .init()) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - await client.connection.onceAsync(.connected) - - let channel = client.channels.get("channel", options: channelOptionsWithObjects()) - let objects = try #require(channel.objects as? PublicDefaultRealtimeObjects) - let connectionDetails = client.internal.latestConnectionDetails - - // gcGracePeriod should be set after the initial connection - let initialConnectionDetailsGracePeriod = try #require(connectionDetails?.objectsGCGracePeriod) - #expect(objects.testsOnly_gcGracePeriod == initialConnectionDetailsGracePeriod.doubleValue, "Check gcGracePeriod is set after initial connection from connectionDetails.objectsGCGracePeriod") - - let testProxyTransport = try #require(client.internal.transport as? TestProxyTransport) - let connectedProtocolMessage = ARTProtocolMessage() - connectedProtocolMessage.action = .connected - connectedProtocolMessage.connectionDetails = .init(clientId: nil, connectionKey: nil, maxMessageSize: 10, maxFrameSize: 10, maxInboundRate: 10, connectionStateTtl: 10, serverId: "", maxIdleInterval: 10, objectsGCGracePeriod: 0.999, siteCode: nil) // all arbitrary except objectsGCGracePeriod - client.internal.queue.ably_syncNoDeadlock { - testProxyTransport.receive(connectedProtocolMessage) - } - - #expect(objects.testsOnly_gcGracePeriod == 0.999, "Check gcGracePeriod is updated on new CONNECTED event") - } - } - - @Test("gcGracePeriod has a default value if connectionDetails.objectsGCGracePeriod is missing") - func gcGracePeriod_usesDefaultValue() async throws { - let client = try await realtimeWithObjects(options: .init()) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - await client.connection.onceAsync(.connected) - - let channel = client.channels.get("channel", options: channelOptionsWithObjects()) - let objects = try #require(channel.objects as? PublicDefaultRealtimeObjects) - - client.internal.queue.ably_syncNoDeadlock { - objects.testsOnly_proxied.nosync_setGarbageCollectionGracePeriod(0.999) - } - #expect(objects.testsOnly_gcGracePeriod == 0.999) - - // send a CONNECTED event without objectsGCGracePeriod, it should use the default value instead - let testProxyTransport = try #require(client.internal.transport as? TestProxyTransport) - let connectedProtocolMessage = ARTProtocolMessage() - connectedProtocolMessage.action = .connected - connectedProtocolMessage.connectionDetails = .init(clientId: nil, connectionKey: nil, maxMessageSize: 10, maxFrameSize: 10, maxInboundRate: 10, connectionStateTtl: 10, serverId: "", maxIdleInterval: 10, objectsGCGracePeriod: nil, siteCode: nil) // all arbitrary except objectsGCGracePeriod - client.internal.queue.ably_syncNoDeadlock { - testProxyTransport.receive(connectedProtocolMessage) - } - - #expect(objects.testsOnly_gcGracePeriod == InternalDefaultRealtimeObjects.GarbageCollectionOptions.defaultGracePeriod, "Check gcGracePeriod is set to a default value if connectionDetails.objectsGCGracePeriod is missing") - } - } - - // MARK: - Tombstones GC Scenarios - - enum TombstonesGCScenarios: Scenarios { - struct Context { - var root: any LiveMap - var objectsHelper: ObjectsHelper - var channelName: String - var channel: ARTRealtimeChannel - var objects: any RealtimeObjects - var client: ARTRealtime - var waitForTombstonedObjectsToBeCollected: @Sendable (Date) async throws -> Void - } - - static let scenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "tombstoned object is removed from the pool after the GC grace period", - action: { ctx in - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let channel = ctx.channel - let objects = ctx.objects - let waitForTombstonedObjectsToBeCollected = ctx.waitForTombstonedObjectsToBeCollected - - // Wait for counter creation - async let counterCreatedPromise: Void = waitForObjectOperation(ctx.objects, .counterCreate) - - // Send a CREATE op, this adds an object to the pool - let createResult = try await objectsHelper.operationRequest( - channelName: channelName, - opBody: objectsHelper.counterCreateRestOp(number: 1), - ) - let objectId = createResult.objectId - _ = try await counterCreatedPromise - - // Cast to access internal API for testing - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - - #expect(internallyTypedObjects.testsOnly_proxied.testsOnly_objectsPool.entries[objectId] != nil, "Check object exists in the pool after creation") - - // Inject OBJECT_DELETE for the object. This should tombstone the object and make it - // inaccessible to the end user, but still keep it in memory in the local pool - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: lexicoTimeserial(seriesId: "aaa", timestamp: 0, counter: 0), - siteCode: "aaa", - state: [objectsHelper.objectDeleteOp(objectId: objectId)], - ) - - #expect( - internallyTypedObjects.testsOnly_proxied.testsOnly_objectsPool.entries[objectId] != nil, - "Check object exists in the pool immediately after OBJECT_DELETE", - ) - - let poolEntry = try #require(internallyTypedObjects.testsOnly_proxied.testsOnly_objectsPool.entries[objectId]) - #expect( - poolEntry.testsOnly_isTombstone == true, - "Check object's \"tombstone\" flag is set to \"true\" after OBJECT_DELETE", - ) - - let tombstonedAt = try #require(poolEntry.testsOnly_tombstonedAt) - - // Wait for objects tombstoned at this time to be garbage collected - try await waitForTombstonedObjectsToBeCollected(tombstonedAt) - - // Object should be removed from the local pool entirely now, as the GC grace period has passed - #expect( - internallyTypedObjects.testsOnly_proxied.testsOnly_objectsPool.entries[objectId] == nil, - "Check object does not exist in the pool after the GC grace period expiration", - ) - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: true, - description: "tombstoned map entry is removed from the LiveMap after the GC grace period", - action: { ctx in - let root = ctx.root - let objectsHelper = ctx.objectsHelper - let channelName = ctx.channelName - let waitForTombstonedObjectsToBeCollected = ctx.waitForTombstonedObjectsToBeCollected - - let keyUpdatedPromise = try root.updates() - async let keyUpdatedWait: Void = { - await waitForMapKeyUpdate(keyUpdatedPromise, "foo") - }() - - // Set a key on root - _ = try await objectsHelper.operationRequest( - channelName: channelName, - opBody: objectsHelper.mapSetRestOp( - objectId: "root", - key: "foo", - value: ["string": .string("bar")], - ), - ) - await keyUpdatedWait - - #expect( - try #require(root.get(key: "foo")?.stringValue) == "bar", - "Check key \"foo\" exists on root after MAP_SET", - ) - - let keyUpdatedPromise2 = try root.updates() - async let keyUpdatedWait2: Void = { - await waitForMapKeyUpdate(keyUpdatedPromise2, "foo") - }() - - // Remove the key from the root. This should tombstone the map entry and make it - // inaccessible to the end user, but still keep it in memory in the underlying map - _ = try await objectsHelper.operationRequest( - channelName: channelName, - opBody: objectsHelper.mapRemoveRestOp(objectId: "root", key: "foo"), - ) - await keyUpdatedWait2 - - #expect( - try root.get(key: "foo") == nil, - "Check key \"foo\" is inaccessible via public API on root after MAP_REMOVE", - ) - - // Cast to access internal API for testing - let internallyTypedRoot = try #require(root as? PublicDefaultLiveMap) - let internalRoot = internallyTypedRoot.proxied - let underlyingData = internalRoot.testsOnly_data - - #expect( - underlyingData["foo"] != nil, - "Check map entry for \"foo\" exists on root in the underlying data immediately after MAP_REMOVE", - ) - #expect( - underlyingData["foo"]?.tombstone == true, - "Check map entry for \"foo\" on root has \"tombstone\" flag set to \"true\" after MAP_REMOVE", - ) - - let tombstonedAt = try #require(underlyingData["foo"]?.tombstonedAt) - - // Wait for objects tombstoned at this time to be garbage collected - try await waitForTombstonedObjectsToBeCollected(tombstonedAt) - - // The entry should be removed from the underlying map now - let underlyingDataAfterGC = internalRoot.testsOnly_data - #expect( - underlyingDataAfterGC["foo"] == nil, - "Check map entry for \"foo\" does not exist on root in the underlying data after the GC grace period expiration", - ) - }, - ), - ] - } - - @Test(arguments: TombstonesGCScenarios.testCases) - func tombstonesGCScenarios(testCase: TestCase) async throws { - guard !testCase.disabled else { - withKnownIssue { - Issue.record("Test case is disabled") - } - return - } - - // Configure GC options with shorter intervals for testing - var options = testCase.options - let garbageCollectionOptions = InternalDefaultRealtimeObjects.GarbageCollectionOptions( - interval: 0.5, - gracePeriod: .fixed(0.25), - ) - options.garbageCollectionOptions = garbageCollectionOptions - - let objectsHelper = try await ObjectsHelper() - let client = try await realtimeWithObjects(options: options) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channel = client.channels.get(testCase.channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Helper function to wait for enough GC cycles to occur such that objects tombstoned at a specific time should have been garbage collected. This is a slightly different approach to the JS tests, which wait for a certain number of GC cycles to occur, but I think that this is a bit more robust in the face of clock skew between the local clock and whatever was used to generate the tombstonedAt timestamps server-side. - let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - let waitForTombstonedObjectsToBeCollected: @Sendable (Date) async throws -> Void = { (tombstonedAt: Date) in - // Sleep until we're sure we're past tombstonedAt + gracePeriod - let timeUntilGracePeriodExpires = (tombstonedAt + garbageCollectionOptions.gracePeriod.toTimeInterval).timeIntervalSince(.init()) - if timeUntilGracePeriodExpires > 0 { - try await Task.sleep(nanoseconds: UInt64(timeUntilGracePeriodExpires * Double(NSEC_PER_SEC))) - } - - // Wait for the next GC event - await internallyTypedObjects.testsOnly_proxied.testsOnly_completedGarbageCollectionEventsWithoutBuffering.first { _ in true } - } - - try await testCase.scenario.action( - .init( - root: root, - objectsHelper: objectsHelper, - channelName: testCase.channelName, - channel: channel, - objects: objects, - client: client, - waitForTombstonedObjectsToBeCollected: waitForTombstonedObjectsToBeCollected, - ), - ) - } - } - - // MARK: - Apply on ACK tests - - // MARK: Group 1: Operations applied locally on ACK (parameterized) - - enum ApplyOnAckScenarios: Scenarios { - struct Context { - var objects: any RealtimeObjects - var root: any LiveMap - var objectsHelper: ObjectsHelper - var channelName: String - var channel: ARTRealtimeChannel - var client: ARTRealtime - } - - static let scenarios: [TestScenario] = [ - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "creating a LiveCounter applies immediately on ACK", - action: { ctx in - let counter = try await ctx.objects.createCounter(count: 42) - try await ctx.root.set(key: "newCounter", value: .liveCounter(counter)) - - // Value should be visible immediately via apply-on-ACK, not from echo - #expect(try counter.value == 42, "Check counter value is applied immediately on ACK") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveCounter.increment applies operation immediately on ACK", - action: { ctx in - let counter = try await ctx.objects.createCounter(count: 10) - try await ctx.root.set(key: "counter", value: .liveCounter(counter)) - #expect(try counter.value == 10, "Check counter has initial value of 10") - - try await counter.increment(amount: 5) - - #expect(try counter.value == 15, "Check counter value reflects increment applied on ACK") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "creating a LiveMap applies immediately on ACK", - action: { ctx in - let map = try await ctx.objects.createMap(entries: ["foo": "bar"]) - try await ctx.root.set(key: "newMap", value: .liveMap(map)) - - #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check map value is applied immediately on ACK") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveMap.set applies operation immediately on ACK", - action: { ctx in - try await ctx.root.set(key: "key", value: "value") - - #expect(try #require(ctx.root.get(key: "key")?.stringValue) == "value", "Check map set is applied immediately on ACK") - }, - ), - .init( - disabled: false, - allTransportsAndProtocols: false, - description: "LiveMap.remove applies operation immediately on ACK", - action: { ctx in - try await ctx.root.set(key: "keyToRemove", value: "valueToRemove") - #expect(try #require(ctx.root.get(key: "keyToRemove")?.stringValue) == "valueToRemove", "Check key exists") - - try await ctx.root.remove(key: "keyToRemove") - - #expect(try ctx.root.get(key: "keyToRemove") == nil, "Check map remove is applied immediately on ACK") - }, - ), - ] - } - - @Test(arguments: ApplyOnAckScenarios.testCases) - func applyOnAckScenarios(testCase: TestCase) async throws { - guard !testCase.disabled else { - withKnownIssue { - Issue.record("Test case is disabled") - } - return - } - - let objectsHelper = try await ObjectsHelper() - let client = try await realtimeWithObjects(options: testCase.options) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channel = client.channels.get(testCase.channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Hold echoes so we can verify value comes from ACK, not echo - let echoInterceptor = EchoInterceptor(client: client, channel: channel) - defer { echoInterceptor.restore() } - - try await testCase.scenario.action( - .init( - objects: objects, - root: root, - objectsHelper: objectsHelper, - channelName: testCase.channelName, - channel: channel, - client: client, - ), - ) - } - } - - // MARK: Group 2: Does not double-apply - - @Test - func echoAfterAckDoesNotDoubleApply() async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "echoAfterAckDoesNotDoubleApply" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Create a counter with initial value 10 - let counter = try await objects.createCounter(count: 10) - try await root.set(key: "counter", value: .liveCounter(counter)) - - // Set up echo interceptor - let echoInterceptor = EchoInterceptor(client: client, channel: channel) - defer { echoInterceptor.restore() } - - // Increment by 5 — applied via ACK, echo held - try await counter.increment(amount: 5) - #expect(try counter.value == 15, "Check counter value after increment applied on ACK") - - // Wait for the echo to be intercepted - await echoInterceptor.waitForEcho() - - // Release the held echo - await echoInterceptor.releaseAll() - - // Value should still be 15 (not 20 from double-apply) - #expect(try counter.value == 15, "Check counter value is not double-applied after echo") - } - } - - @Test - func ackAfterEchoDoesNotDoubleApply() async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "ackAfterEchoDoesNotDoubleApply" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Create a counter with initial value 10 - let counter = try await objects.createCounter(count: 10) - try await root.set(key: "counter", value: .liveCounter(counter)) - - // Set up ACK interceptor (holds ACKs, lets echoes through) - let ackInterceptor = AckInterceptor(client: client) - defer { ackInterceptor.restore() } - - // Deviation from JS: JS uses a callback-based `waitForCounterUpdate(counter)` - // promise. Swift uses an `AsyncStream`-based subscription via `counter.updates()` - // and `waitForCounterUpdate` to detect when the echo has been applied. - let counterUpdates = try counter.updates() - - // Start increment without awaiting (it won't complete until ACK arrives) - let incrementTask = Task { - try await counter.increment(amount: 5) - } - - // Wait for the echo to be applied - await waitForCounterUpdate(counterUpdates) - - // Value should be 15 from the echo - #expect(try counter.value == 15, "Check counter value after echo received") - - // Release the held ACK - await ackInterceptor.waitForAck() - await ackInterceptor.releaseAll() - - // Wait for increment to complete - try await incrementTask.value - - // Value should still be 15 (not 20 from double-apply) - #expect(try counter.value == 15, "Check counter value is not double-applied after ACK") - } - } - - // MARK: Group 3: Does not incorrectly skip operations - - @Test - func applyOnAckDoesNotUpdateSiteTimeserials() async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "applyOnAckDoesNotUpdateSiteTimeserials" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - let objectsHelper = try await ObjectsHelper() - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Step 1: Set up echo interceptor - let echoInterceptor = EchoInterceptor(client: client, channel: channel) - defer { echoInterceptor.restore() } - - // Step 2: Create a counter with initial value 10 — echo held - let counter = try await objects.createCounter(count: 10) - try await root.set(key: "counter", value: .liveCounter(counter)) - - // Step 3: Wait for both echoes, extract COUNTER_CREATE serial and siteCode. - // createCounter + root.set generate two echoes (COUNTER_CREATE and MAP_SET); - // search all held echoes to find the COUNTER_CREATE. - await echoInterceptor.waitForEchoCount(2) - let heldEchoes = echoInterceptor.heldEchoes - let allStateItems = heldEchoes.flatMap(\.testsOnly_inboundObjectMessages) - let counterCreate = try #require(allStateItems.first { $0.operation?.action == .known(.counterCreate) }) - let counterCreateSerial = try #require(counterCreate.serial) - let counterCreateSiteCode = try #require(counterCreate.siteCode) - - // Step 4: Release the create echo (so siteTimeserials gets set) - await echoInterceptor.releaseAll() - - // Step 5: Increment by 5 — applies via ACK, echo held - try await counter.increment(amount: 5) - #expect(try counter.value == 15, "Check counter value after increment applied on ACK") - - // Step 6: Wait for the increment echo, extract its serial - await echoInterceptor.waitForEcho() - let incrementEchoes = echoInterceptor.heldEchoes - let incrementEcho = incrementEchoes.last! - let incrementStateItems = incrementEcho.testsOnly_inboundObjectMessages - let incrementOp = try #require(incrementStateItems.first { $0.operation?.action == .known(.counterInc) }) - let incrementSerial = try #require(incrementOp.serial) - - // Step 7: Construct injectedSerial = counterCreateSerial + "a" - // This serial is between create and increment, so if siteTimeserials were - // updated by apply-on-ACK, this operation would be rejected - let injectedSerial = counterCreateSerial + "a" - - // Verify our assumptions - #expect(injectedSerial > counterCreateSerial, "injectedSerial > counterCreateSerial") - #expect(injectedSerial < incrementSerial, "injectedSerial < incrementSerial") - - // Step 8: Inject a COUNTER_INC operation with injectedSerial - let internalCounter = try #require(counter as? PublicDefaultLiveCounter) - let counterId = internalCounter.proxied.testsOnly_objectID - await objectsHelper.processObjectOperationMessageOnChannel( - channel: channel, - serial: injectedSerial, - siteCode: counterCreateSiteCode, - state: [objectsHelper.counterIncOp(objectId: counterId, number: 100)], - ) - - // Step 9: Assert counter.value == 115 - // If siteTimeserials had been updated by apply-on-ACK, the injected operation - // would have been rejected, and counter would be 15 - #expect(try counter.value == 115, "Check injected operation was applied (proving siteTimeserials not updated by apply-on-ACK)") - } - } - - // MARK: Group 4: ACKs buffered during OBJECT_SYNC - - @Test - func operationBufferedDuringSyncIsAppliedAfterSyncCompletes() async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "operationBufferedDuringSyncApplied" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - let objectsHelper = try await ObjectsHelper() - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Create counter with value 10 - let counter = try await objects.createCounter(count: 10) - try await root.set(key: "counter", value: .liveCounter(counter)) - #expect(try counter.value == 10, "Check counter initial value") - - let internalCounter = try #require(counter as? PublicDefaultLiveCounter) - let counterId = internalCounter.proxied.testsOnly_objectID - - // Inject ATTACHED with HAS_OBJECTS to trigger SYNCING state - await injectAttachedMessage(channel: channel, flags: .hasObjects) - - // Set up ACK interceptor so we can control when the ACK is delivered - let ackInterceptor = AckInterceptor(client: client) - - // Increment while syncing — don't await because publishAndApply will - // wait for sync to complete, and we need to complete the sync below. - let incrementTask = Task { - try await counter.increment(amount: 5) - } - - // Wait for the ACK to be intercepted, then release it. - // releaseAll() dispatches onto the internal queue and waits for - // the ACK to be processed synchronously (onAck → publish callback - // → sync-wait entry all happen on the same queue dispatch), so by - // the time it returns, publishAndApply has entered the sync-wait. - // (publishAndApplyRejectsOnChannelStateChangeDuringSync uses the - // same mechanism and validates it is sufficient — if publishAndApply - // had not yet entered the sync-wait, the channel state change - // would not cause it to reject.) - await ackInterceptor.waitForAck() - await ackInterceptor.releaseAll() - ackInterceptor.restore() - - // Complete the sync sequence with an OBJECT_SYNC message containing counter=10 - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - state: [ - objectsHelper.counterObject( - objectId: counterId, - siteTimeserials: [:], - materialisedCount: 10, - ), - ], - ) - - // Wait for the increment task to complete - try await incrementTask.value - - // After sync completes, the buffered ACK should be applied - #expect(try counter.value == 15, "Check counter value after sync completes with buffered ACK applied") - } - } - - @Test - func appliedOnAckSerialsIsClearedOnSync() async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "appliedOnAckSerialsCleared" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - let objectsHelper = try await ObjectsHelper() - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Create counter and increment via apply-on-ACK (echo held) - let counter = try await objects.createCounter(count: 10) - try await root.set(key: "counter", value: .liveCounter(counter)) - - let echoInterceptor = EchoInterceptor(client: client, channel: channel) - - try await counter.increment(amount: 5) - #expect(try counter.value == 15, "Check counter value after increment on ACK") - - // Wait for the echo to be intercepted - await echoInterceptor.waitForEcho() - - let internalCounter = try #require(counter as? PublicDefaultLiveCounter) - let counterId = internalCounter.proxied.testsOnly_objectID - - // Inject ATTACHED+HAS_OBJECTS to trigger sync - await injectAttachedMessage(channel: channel, flags: .hasObjects) - - // Complete sync with state that uses a fake siteCode. - // Using a clearly fake siteCode ensures the echo (which has the real siteCode) - // will pass the siteTimeserials check since there's no entry for it. - let fakeSiteSerial = lexicoTimeserial(seriesId: "fakeSite", timestamp: 0, counter: 0) - await objectsHelper.processObjectStateMessageOnChannel( - channel: channel, - syncSerial: "serial:", - state: [ - objectsHelper.mapObject( - objectId: "root", - siteTimeserials: ["fakeSite": fakeSiteSerial], - initialEntries: [ - "counter": .object([ - "timeserial": .string(fakeSiteSerial), - "data": .object(["objectId": .string(counterId)]), - ]), - ], - ), - objectsHelper.counterObject( - objectId: counterId, - siteTimeserials: ["fakeSite": fakeSiteSerial], - materialisedCount: 10, - ), - ], - ) - - // After sync, value should be 10 (from sync state, appliedOnAckSerials cleared) - #expect(try counter.value == 10, "Check counter value is reset to sync state") - - // Release the held echo — should be applied because appliedOnAckSerials was cleared - await echoInterceptor.releaseAll() - echoInterceptor.restore() - - #expect(try counter.value == 15, "Check counter value after releasing echo (proving appliedOnAckSerials was cleared on sync)") - } - } - - // Deviation from JS: JS uses `channel.requestState(targetState)` to trigger - // the channel state change. ably-cocoa doesn't expose `requestState` on channels, - // so we use `setSuspended`/`setFailed`/`detachChannel` on the internal queue - // instead (`setDetached` is not used because it initiates a reattach). We also - // use `Task.yield() + 100ms sleep` instead of JS's `setTimeout(resolve, 0)`. - @Test(arguments: [ - ARTRealtimeChannelState.detached, - ARTRealtimeChannelState.suspended, - ARTRealtimeChannelState.failed, - ]) - func publishAndApplyRejectsOnChannelStateChangeDuringSync(targetState: ARTRealtimeChannelState) async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "publishAndApplyRejects_\(targetState.rawValue)" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Create a counter - let counter = try await objects.createCounter(count: 10) - try await root.set(key: "counter", value: .liveCounter(counter)) - - // Inject ATTACHED+HAS_OBJECTS to trigger SYNCING state - await injectAttachedMessage(channel: channel, flags: .hasObjects) - - // Set up ACK interceptor and start increment - let ackInterceptor = AckInterceptor(client: client) - - let incrementTask = Task { - try await counter.increment(amount: 5) - } - - // Wait for the ACK to arrive - await ackInterceptor.waitForAck() - - // Release ACK so publishAndApply proceeds to sync-wait - await ackInterceptor.releaseAll() - ackInterceptor.restore() - - // Trigger channel state change - await withCheckedContinuation { (continuation: CheckedContinuation) in - channel.internal.queue.async { - switch targetState { - case .suspended: - let params = ChannelStateChangeParams(state: .ok) - channel.internal.setSuspended(params) - case .failed: - let params = ChannelStateChangeParams(state: .ok) - channel.internal.setFailed(params) - case .detached: - // Use detachChannel: directly instead of setDetached: because - // setDetached: initiates a reattach when the channel is attached. - let params = ChannelStateChangeParams(state: .ok) - channel.internal.detachChannel(params) - default: - break - } - continuation.resume() - } - } - - // The increment should throw with error 92008 - do { - try await incrementTask.value - Issue.record("Expected increment to throw during channel state change") - } catch let error as ARTErrorInfo { - #expect(error.code == 92008, "Check error code is 92008 for publishAndApply rejected during sync") - #expect(error.statusCode == 400, "Check statusCode is 400") - } - } - } - - // MARK: Group 5: Subscription events - - // Deviation from JS: JS asserts `{ action: 'counter.inc', number: N }` on each - // event. The Swift `LiveCounterUpdate` protocol only exposes `amount` (no `action` - // field), so we assert on the amount only. - @Test - func subscriptionCallbacksFireForBothLocallyAppliedAndRealtimeReceivedOperations() async throws { - let client = try await realtimeWithObjects(options: .init(logIdentifier: "client1")) - - try await monitorConnectionThenCloseAndFinishAsync(client) { - let channelName = "subscriptionCallbacksApplyOnAck" - let channel = client.channels.get(channelName, options: channelOptionsWithObjects()) - let objects = channel.objects - let objectsHelper = try await ObjectsHelper() - - try await channel.attachAsync() - let root = try await objects.getRoot() - - // Create counter - let counter = try await objects.createCounter(count: 0) - try await root.set(key: "counter", value: .liveCounter(counter)) - - // Subscribe to counter updates - let counterUpdates = try counter.updates() - - // Set up echo interceptor - let echoInterceptor = EchoInterceptor(client: client, channel: channel) - - // Perform increment via SDK — applied locally on ACK with echoes held - try await counter.increment(amount: 5) - #expect(try counter.value == 5, "Check counter value after local increment") - - // The subscription should have fired immediately from the ACK path - // Collect the event - var receivedEvents: [LiveCounterUpdate] = [] - if let event = await counterUpdates.first(where: { _ in true }) { - receivedEvents.append(event) - } - - #expect(receivedEvents.count == 1, "Check 1 subscription event received after local increment") - #expect(receivedEvents[0].amount == 5, "Check event from local apply has amount 5") - - // Restore echo handling - echoInterceptor.restore() - - // Release held echoes (shouldn't cause another event since already applied) - await echoInterceptor.releaseAll() - - // Trigger another increment via REST — this is received over Realtime - let internalCounter = try #require(counter as? PublicDefaultLiveCounter) - let counterId = internalCounter.proxied.testsOnly_objectID - _ = try await objectsHelper.operationRequest( - channelName: channelName, - opBody: objectsHelper.counterIncRestOp(objectId: counterId, number: 10), - ) - - // Wait for counter update from Realtime - if let event = await counterUpdates.first(where: { _ in true }) { - receivedEvents.append(event) - } - - #expect(receivedEvents.count == 2, "Check 2 subscription events received total") - #expect(receivedEvents[1].amount == 10, "Check event from Realtime receive has amount 10") - #expect(try counter.value == 15, "Check final counter value after both operations") - } - } -} - -// swiftlint:enable trailing_closure diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 669c4b017..3e6309d48 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -5,8 +5,8 @@ import Ably final class MockCoreSDK: CoreSDK { /// Synchronizes access to `_publishHandler` and `_publishCallbackHandler`. private let mutex = NSLock() - private nonisolated(unsafe) var _publishHandler: (([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult)? - private nonisolated(unsafe) var _publishCallbackHandler: (([OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void)? + private nonisolated(unsafe) var _publishHandler: (([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult)? + private nonisolated(unsafe) var _publishCallbackHandler: (([ProtocolTypes.OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void)? private let channelStateMutex: DispatchQueueMutex<_AblyPluginSupportPrivate.RealtimeChannelState> private let serverTime: Date @@ -16,10 +16,10 @@ final class MockCoreSDK: CoreSDK { self.serverTime = serverTime } - func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { + func nosync_publish(objectMessages: [ProtocolTypes.OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { // We can't return _publishHandler from `mutex.withLock` because we get "error: runtime support for typed throws function types is only available in macOS 15.0.0 or newer" - var asyncHandler: (([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult)? - var callbackHandler: (([OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void)? + var asyncHandler: (([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult)? + var callbackHandler: (([ProtocolTypes.OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void)? mutex.withLock { asyncHandler = _publishHandler callbackHandler = _publishCallbackHandler @@ -42,7 +42,7 @@ final class MockCoreSDK: CoreSDK { } } - func testsOnly_overridePublish(with _: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { + func testsOnly_overridePublish(with _: @escaping ([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { protocolRequirementNotImplemented() } @@ -53,7 +53,7 @@ final class MockCoreSDK: CoreSDK { /// Sets a custom publish handler for testing. /// /// - Precondition: ``setPublishCallbackHandler(_:)`` must not have been called. - func setPublishHandler(_ handler: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { + func setPublishHandler(_ handler: @escaping ([ProtocolTypes.OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { mutex.withLock { precondition(_publishCallbackHandler == nil, "Cannot set both publishHandler and publishCallbackHandler") _publishHandler = handler @@ -69,7 +69,7 @@ final class MockCoreSDK: CoreSDK { /// explicit control over their ordering. /// /// - Precondition: ``setPublishHandler(_:)`` must not have been called. - func setPublishCallbackHandler(_ handler: @escaping ([OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void) { + func setPublishCallbackHandler(_ handler: @escaping ([ProtocolTypes.OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void) { mutex.withLock { // We use pattern matching instead of `== nil` to avoid "runtime support for typed // throws function types is only available in macOS 15.0.0 or newer". diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift index e6904b1f4..7320bf7b3 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift @@ -7,7 +7,7 @@ final class MockRealtimeObjects: InternalRealtimeObjectsProtocol { /// Synchronizes access to `_publishAndApplyHandler`. private let mutex = NSLock() - private nonisolated(unsafe) var _publishAndApplyHandler: (([OutboundObjectMessage]) -> Result)? + private nonisolated(unsafe) var _publishAndApplyHandler: (([ProtocolTypes.OutboundObjectMessage]) -> Result)? init(objectsPoolDelegate: MockLiveMapObjectsPoolDelegate? = nil) { self.objectsPoolDelegate = objectsPoolDelegate @@ -20,18 +20,18 @@ final class MockRealtimeObjects: InternalRealtimeObjectsProtocol { return objectsPoolDelegate.nosync_objectsPool } - func setPublishAndApplyHandler(_ handler: @escaping ([OutboundObjectMessage]) -> Result) { + func setPublishAndApplyHandler(_ handler: @escaping ([ProtocolTypes.OutboundObjectMessage]) -> Result) { mutex.withLock { _publishAndApplyHandler = handler } } func nosync_publishAndApply( - objectMessages: [OutboundObjectMessage], + objectMessages: [ProtocolTypes.OutboundObjectMessage], coreSDK: CoreSDK, callback: @escaping @Sendable (Result) -> Void, ) { - var handler: (([OutboundObjectMessage]) -> Result)? + var handler: (([ProtocolTypes.OutboundObjectMessage]) -> Result)? mutex.withLock { handler = _publishAndApplyHandler } diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift index 79a434b82..6e595f31f 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift @@ -62,7 +62,7 @@ struct ObjectCreationHelpersTests { let deserializedInitialValue = try #require(try JSONObjectOrArray(jsonString: initialValueString).objectValue) #expect(deserializedInitialValue == [ // RTO11f14a - "semantics": .number(Double(ObjectsMapSemantics.lww.rawValue)), + "semantics": .number(Double(ProtocolTypes.ObjectsMapSemantics.lww.rawValue)), "entries": [ // RTO11f14c1a "mapRef": [ @@ -118,7 +118,7 @@ struct ObjectCreationHelpersTests { #expect(derivedMapCreate.semantics == .known(.lww)) - let expectedEntries: [String: ObjectsMapEntry] = [ + let expectedEntries: [String: ProtocolTypes.ObjectsMapEntry] = [ "mapRef": .init(data: .init(objectId: "referencedMapID")), "counterRef": .init(data: .init(objectId: "referencedCounterID")), "jsonArrayKey": .init(data: .init(json: .array(["arrayItem1", "arrayItem2"]))), diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift index 640d4d831..f0104f8b7 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift @@ -22,11 +22,11 @@ struct ObjectDiffHelpersTests { @Test func detectsRemovedKeys() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), - "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value2")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -42,11 +42,11 @@ struct ObjectDiffHelpersTests { @Test func detectsAddedKeys() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), - "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value2")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -62,10 +62,10 @@ struct ObjectDiffHelpersTests { @Test func detectsUpdatedKeys() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "oldValue")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "oldValue")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "newValue")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "newValue")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -80,10 +80,10 @@ struct ObjectDiffHelpersTests { @Test func ignoresUnchangedKeys() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -98,11 +98,11 @@ struct ObjectDiffHelpersTests { @Test func ignoresTombstonedEntriesInPreviousData() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value1")), - "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ProtocolTypes.ObjectData(string: "value1")), + "key2": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value2")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key2": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), + "key2": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value2")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -119,10 +119,10 @@ struct ObjectDiffHelpersTests { @Test func ignoresTombstonedEntriesInNewData() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ProtocolTypes.ObjectData(string: "value1")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -138,10 +138,10 @@ struct ObjectDiffHelpersTests { @Test func ignoresTombstonedToTombstonedTransition() { let previousData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value1")), + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ProtocolTypes.ObjectData(string: "value1")), ] let newData: [String: InternalObjectsMapEntry] = [ - "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ObjectData(string: "value2")), + "key1": TestFactories.internalMapEntry(tombstonedAt: Date(), data: ProtocolTypes.ObjectData(string: "value2")), ] let update = ObjectDiffHelpers.calculateMapDiff( @@ -157,14 +157,14 @@ struct ObjectDiffHelpersTests { @Test func detectsMultipleChanges() { let previousData: [String: InternalObjectsMapEntry] = [ - "removed": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), - "updated": TestFactories.internalMapEntry(data: ObjectData(string: "oldValue")), - "unchanged": TestFactories.internalMapEntry(data: ObjectData(string: "sameValue")), + "removed": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value1")), + "updated": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "oldValue")), + "unchanged": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "sameValue")), ] let newData: [String: InternalObjectsMapEntry] = [ - "added": TestFactories.internalMapEntry(data: ObjectData(string: "value2")), - "updated": TestFactories.internalMapEntry(data: ObjectData(string: "newValue")), - "unchanged": TestFactories.internalMapEntry(data: ObjectData(string: "sameValue")), + "added": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "value2")), + "updated": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "newValue")), + "unchanged": TestFactories.internalMapEntry(data: ProtocolTypes.ObjectData(string: "sameValue")), ] let update = ObjectDiffHelpers.calculateMapDiff( diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift index d4a4cbba1..db2f8b33f 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift @@ -4,7 +4,7 @@ import Testing @Suite(.tags(.integration)) struct ObjectLifetimesTests { - @Test("LiveObjects functionality works with only a strong reference to channel's public objects property") + @Test("LiveObjects functionality works with only a strong reference to channel's public object property") func withStrongReferenceToPublicObjectsProperty() async throws { // The objects that we'll create. struct CreatedObjects { @@ -15,7 +15,7 @@ struct ObjectLifetimesTests { weak var weakInternalRealtime: ARTRealtimeInternal? weak var weakPublicChannel: ARTRealtimeChannel? weak var weakInternalChannel: ARTRealtimeChannelInternal? - var strongPublicRealtimeObjects: PublicDefaultRealtimeObjects + var strongPublicRealtimeObject: PublicDefaultRealtimeObject weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? } @@ -28,7 +28,7 @@ struct ObjectLifetimesTests { weak var weakInternalRealtime: ARTRealtimeInternal? // weakPublicChannel is gone now weak var weakInternalChannel: ARTRealtimeChannelInternal? - weak var weakPublicRealtimeObjects: PublicDefaultRealtimeObjects? + weak var weakPublicRealtimeObject: PublicDefaultRealtimeObject? weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? } @@ -37,9 +37,9 @@ struct ObjectLifetimesTests { // We disable autoConnect since being connected extends the internal Realtime instance's lifetime (it stays alive whilst connected), and I don't want that interfering with this test. let realtime = try await ClientHelper.realtimeWithObjects(options: .init(autoConnect: false)) let channel = realtime.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) - let anyObjects = channel.objects - // For some reason putting `channel.objects as? PublicDefaultRealtimeObjects` inside the #require gives "no calls to throwing functions occur within 'try' expression" 🤷 - let objects = try #require(anyObjects as? PublicDefaultRealtimeObjects) + let anyObject = channel.object + // For some reason putting `channel.object as? PublicDefaultRealtimeObject` inside the #require gives "no calls to throwing functions occur within 'try' expression" 🤷 + let object = try #require(anyObject as? PublicDefaultRealtimeObject) return .init( realtimeDeallocQueue: realtime.internal.queue, @@ -47,18 +47,18 @@ struct ObjectLifetimesTests { weakInternalRealtime: realtime.internal, weakPublicChannel: channel, weakInternalChannel: channel.internal, - strongPublicRealtimeObjects: objects, - weakInternalRealtimeObjects: objects.testsOnly_proxied, + strongPublicRealtimeObject: object, + weakInternalRealtimeObjects: object.testsOnly_proxied, ) } let createdObjects = try await createObjects() - // The only public object we have a strong reference to is strongPublicRealtimeObjects, so the other public objects should have already been deallocated + // The only public object we have a strong reference to is strongPublicRealtimeObject, so the other public objects should have already been deallocated #expect(createdObjects.weakPublicRealtime == nil) #expect(createdObjects.weakPublicChannel == nil) - // Now we check that, since we still have a strong reference to strongPublicRealtimeObjects, none of the dependencies that it needs in order to function have been deallocated. + // Now we check that, since we still have a strong reference to strongPublicRealtimeObject, none of the dependencies that it needs in order to function have been deallocated. await withCheckedContinuation { continuation in // We wait for everything on realtimeDeallocQueue to execute, to be sure that we'd catch a dealloc that had been enqueued via ably-cocoa's QueuedDealloc mechanism. createdObjects.realtimeDeallocQueue.async { @@ -71,22 +71,22 @@ struct ObjectLifetimesTests { // TODO: test that we can receive events on a LiveObject (https://github.com/ably/ably-liveobjects-swift-plugin/issues/30) - // Note that after this return we no longer have a reference to createdObjects and thus no longer have a strong reference to our public RealtimeObjects instance + // Note that after this return we no longer have a reference to createdObjects and thus no longer have a strong reference to our public RealtimeObject instance return .init( realtimeDeallocQueue: createdObjects.realtimeDeallocQueue, weakInternalRealtime: createdObjects.weakInternalRealtime, weakInternalChannel: createdObjects.weakInternalChannel, - weakPublicRealtimeObjects: createdObjects.strongPublicRealtimeObjects, + weakPublicRealtimeObject: createdObjects.strongPublicRealtimeObject, weakInternalRealtimeObjects: createdObjects.weakInternalRealtimeObjects, ) } let remainingObjects = try await createAndDiscardObjects() - // Check that the public RealtimeObjects has been deallocated now that we've no longer got a strong reference to it - #expect(remainingObjects.weakPublicRealtimeObjects == nil) + // Check that the public RealtimeObject has been deallocated now that we've no longer got a strong reference to it + #expect(remainingObjects.weakPublicRealtimeObject == nil) - // Check that the internal objects that the public RealtimeObjects needed in order to function have now been deallocated + // Check that the internal objects that the public RealtimeObject needed in order to function have now been deallocated await withCheckedContinuation { continuation in // We wait for everything on realtimeDeallocQueue to execute, to be sure that we'd catch a dealloc that had been enqueued via ably-cocoa's QueuedDealloc mechanism. remainingObjects.realtimeDeallocQueue.async { @@ -99,7 +99,11 @@ struct ObjectLifetimesTests { #expect(remainingObjects.weakInternalRealtimeObjects == nil) } - @Test("LiveObjects functionality works with only a strong reference to a public LiveObject") + // TODO: uncomment @Test + // The `@Test` attribute is commented out because this test relies on the public path-based API, + // which is currently an unimplemented skeleton: `RealtimeObject.get()` traps via + // `notImplemented()`. Re-enable (uncomment `@Test`) once that API is implemented. + // @Test("LiveObjects functionality works with only a strong reference to a public LiveObject") func withStrongReferenceToPublicLiveObject() async throws { // Note: This test is very similar to withStrongReferenceToPublicObjectsProperty but "one layer down" — i.e. it checks that instead of a RealtimeObjects reference keeping everything working, a LiveObject reference keeps everything working. Keep these two tests in sync. @@ -112,10 +116,9 @@ struct ObjectLifetimesTests { weak var weakInternalRealtime: ARTRealtimeInternal? weak var weakPublicChannel: ARTRealtimeChannel? weak var weakInternalChannel: ARTRealtimeChannelInternal? - weak var weakPublicRealtimeObjects: PublicDefaultRealtimeObjects? + weak var weakPublicRealtimeObject: PublicDefaultRealtimeObject? weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? - var strongPublicLiveObject: PublicDefaultLiveMap - weak var weakInternalLiveObject: InternalDefaultLiveMap? + var strongPublicLiveObject: DefaultLiveMapPathObject } // What we're left with after discarding a CreatedObjects. @@ -127,23 +130,22 @@ struct ObjectLifetimesTests { weak var weakInternalRealtime: ARTRealtimeInternal? // weakPublicChannel is gone now weak var weakInternalChannel: ARTRealtimeChannelInternal? - // weakPublicRealtimeObjects is gone now + // weakPublicRealtimeObject is gone now weak var weakInternalRealtimeObjects: InternalDefaultRealtimeObjects? - weak var weakPublicLiveObject: PublicDefaultLiveMap? - weak var weakInternalLiveObject: InternalDefaultLiveMap? + weak var weakPublicLiveObject: DefaultLiveMapPathObject? } func createAndDiscardObjects() async throws -> RemainingObjects { func createObjects() async throws -> CreatedObjects { // We disable autoConnect since being connected extends the internal Realtime instance's lifetime (it stays alive whilst connected), and I don't want that interfering with this test. let realtime = try await ClientHelper.realtimeWithObjects() - // Unlike in withStrongReferenceToPublicObjectsProperty, we'll have to allow it to connect, because we need to attach so that getRoot() returns. We'll instead manually close the connection before proceeding with the test + // Unlike in withStrongReferenceToPublicObjectsProperty, we'll have to allow it to connect, because we need to attach so that get() returns. We'll instead manually close the connection before proceeding with the test let channel = realtime.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) try await channel.attachAsync() - let anyObjects = channel.objects - // For some reason putting `channel.objects as? PublicDefaultRealtimeObjects` inside the #require gives "no calls to throwing functions occur within 'try' expression" 🤷 - let objects = try #require(anyObjects as? PublicDefaultRealtimeObjects) - let root = try #require(try await anyObjects.getRoot() as? PublicDefaultLiveMap) + let anyObject = channel.object + // For some reason putting `channel.object as? PublicDefaultRealtimeObject` inside the #require gives "no calls to throwing functions occur within 'try' expression" 🤷 + let object = try #require(anyObject as? PublicDefaultRealtimeObject) + let root = try #require(try await object.get() as? DefaultLiveMapPathObject) // Wait for the connection to close, as mentioned above async let connectionClosedPromise: Void = withCheckedContinuation { continuation in @@ -160,10 +162,9 @@ struct ObjectLifetimesTests { weakInternalRealtime: realtime.internal, weakPublicChannel: channel, weakInternalChannel: channel.internal, - weakPublicRealtimeObjects: objects, - weakInternalRealtimeObjects: objects.testsOnly_proxied, + weakPublicRealtimeObject: object, + weakInternalRealtimeObjects: object.testsOnly_proxied, strongPublicLiveObject: root, - weakInternalLiveObject: root.proxied, ) } @@ -172,7 +173,7 @@ struct ObjectLifetimesTests { // The only public object we have a strong reference to is strongPublicLiveObject, so the other public objects should have already been deallocated #expect(createdObjects.weakPublicRealtime == nil) #expect(createdObjects.weakPublicChannel == nil) - #expect(createdObjects.weakPublicRealtimeObjects == nil) + #expect(createdObjects.weakPublicRealtimeObject == nil) // Now we check that, since we still have a strong reference to strongPublicLiveObject, none of the dependencies that it needs in order to function have been deallocated. await withCheckedContinuation { continuation in @@ -184,7 +185,6 @@ struct ObjectLifetimesTests { #expect(createdObjects.weakInternalRealtime != nil) #expect(createdObjects.weakInternalChannel != nil) #expect(createdObjects.weakInternalRealtimeObjects != nil) - #expect(createdObjects.weakInternalLiveObject != nil) // TODO: test that we can receive events on a LiveObject (https://github.com/ably/ably-liveobjects-swift-plugin/issues/30) @@ -195,7 +195,6 @@ struct ObjectLifetimesTests { weakInternalChannel: createdObjects.weakInternalChannel, weakInternalRealtimeObjects: createdObjects.weakInternalRealtimeObjects, weakPublicLiveObject: createdObjects.strongPublicLiveObject, - weakInternalLiveObject: createdObjects.weakInternalLiveObject, ) } @@ -215,23 +214,27 @@ struct ObjectLifetimesTests { #expect(remainingObjects.weakInternalRealtime == nil) #expect(remainingObjects.weakInternalChannel == nil) #expect(remainingObjects.weakInternalRealtimeObjects == nil) - #expect(remainingObjects.weakInternalLiveObject == nil) } - @Test("Public objects have a stable identity") + // TODO: uncomment @Test + // The `@Test` attribute is commented out because the map-identity half of this test relies on + // `RealtimeObject.get()`, which currently traps via `notImplemented()`. Re-enable (uncomment + // `@Test`) once the public path-based API is implemented and the path-based objects are cached in + // `PublicObjectsStore`. + // @Test("Public objects have a stable identity") func publicObjectIdentity() async throws { let realtime = try await ClientHelper.realtimeWithObjects() defer { realtime.close() } let channel = realtime.channels.get(UUID().uuidString, options: ClientHelper.channelOptionsWithObjects()) try await channel.attachAsync() - let objects = try #require(channel.objects as? PublicDefaultRealtimeObjects) - let root = try #require(try await objects.getRoot() as? PublicDefaultLiveMap) + let object = try #require(channel.object as? PublicDefaultRealtimeObject) + let root = try #require(try await object.get() as? DefaultLiveMapPathObject) - let objectsAgain = try #require(channel.objects as? PublicDefaultRealtimeObjects) - let rootAgain = try #require(try await objectsAgain.getRoot() as? PublicDefaultLiveMap) + let objectAgain = try #require(channel.object as? PublicDefaultRealtimeObject) + let rootAgain = try #require(try await objectAgain.get() as? DefaultLiveMapPathObject) - #expect(objects as AnyObject === objectsAgain as AnyObject) + #expect(object === objectAgain) #expect(root === rootAgain) // TODO: when we have an easy way of populating the ObjectsPool (i.e. once we have a write API) then also test with a non-root LiveMap and a counter (https://github.com/ably/ably-liveobjects-swift-plugin/issues/30) } diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift index 3fe457bf6..73dfad5fb 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift @@ -13,7 +13,7 @@ struct ObjectMessageTests { // @spec OD4c1 @Test func boolean() { - let objectData = ObjectData(boolean: true) + let objectData = ProtocolTypes.ObjectData(boolean: true) let wireData = objectData.toWire(format: .messagePack) // OD4c1: A boolean payload is encoded as a MessagePack boolean type, and the result is set on the ObjectData.boolean attribute @@ -28,7 +28,7 @@ struct ObjectMessageTests { @Test func binary() { let testData = Data([1, 2, 3, 4]) - let objectData = ObjectData(bytes: testData) + let objectData = ProtocolTypes.ObjectData(bytes: testData) let wireData = objectData.toWire(format: .messagePack) // OD4c2: A binary payload is encoded as a MessagePack binary type, and the result is set on the ObjectData.bytes attribute @@ -47,7 +47,7 @@ struct ObjectMessageTests { // @spec OD4c3 @Test(arguments: [15, 42.0]) func number(testNumber: NSNumber) throws { - let objectData = ObjectData(number: testNumber) + let objectData = ProtocolTypes.ObjectData(number: testNumber) let wireData = objectData.toWire(format: .messagePack) // OD4c3 A number payload is encoded as a MessagePack float64 type, and the result is set on the ObjectData.number attribute @@ -66,7 +66,7 @@ struct ObjectMessageTests { @Test func string() { let testString = "hello world" - let objectData = ObjectData(string: testString) + let objectData = ProtocolTypes.ObjectData(string: testString) let wireData = objectData.toWire(format: .messagePack) // OD4c4: A string payload is encoded as a MessagePack string type, and the result is set on the ObjectData.string attribute @@ -84,7 +84,7 @@ struct ObjectMessageTests { (jsonObjectOrArray: [123, "hello world"] as JSONObjectOrArray, expectedJSONString: #"[123,"hello world"]"#), ]) func json(jsonObjectOrArray: JSONObjectOrArray, expectedJSONString: String) { - let objectData = ObjectData(json: jsonObjectOrArray) + let objectData = ProtocolTypes.ObjectData(json: jsonObjectOrArray) let wireData = objectData.toWire(format: .messagePack) #expect(wireData.boolean == nil) @@ -99,7 +99,7 @@ struct ObjectMessageTests { // @spec OD4d1 @Test func boolean() { - let objectData = ObjectData(boolean: true) + let objectData = ProtocolTypes.ObjectData(boolean: true) let wireData = objectData.toWire(format: .json) // OD4d1: A boolean payload is represented as a JSON boolean and set on the ObjectData.boolean attribute @@ -114,7 +114,7 @@ struct ObjectMessageTests { @Test func binary() { let testData = Data([1, 2, 3, 4]) - let objectData = ObjectData(bytes: testData) + let objectData = ProtocolTypes.ObjectData(bytes: testData) let wireData = objectData.toWire(format: .json) // OD4d2: A binary payload is Base64-encoded and represented as a JSON string; the result is set on the ObjectData.bytes attribute @@ -134,7 +134,7 @@ struct ObjectMessageTests { @Test func number() { let testNumber = NSNumber(value: 42) - let objectData = ObjectData(number: testNumber) + let objectData = ProtocolTypes.ObjectData(number: testNumber) let wireData = objectData.toWire(format: .json) // OD4d3: A number payload is represented as a JSON number and set on the ObjectData.number attribute @@ -149,7 +149,7 @@ struct ObjectMessageTests { @Test func string() { let testString = "hello world" - let objectData = ObjectData(string: testString) + let objectData = ProtocolTypes.ObjectData(string: testString) let wireData = objectData.toWire(format: .json) // OD4d4: A string payload is represented as a JSON string and set on the ObjectData.string attribute @@ -167,7 +167,7 @@ struct ObjectMessageTests { (jsonObjectOrArray: [123, "hello world"] as JSONObjectOrArray, expectedJSONString: #"[123,"hello world"]"#), ]) func json(jsonObjectOrArray: JSONObjectOrArray, expectedJSONString: String) { - let objectData = ObjectData(json: jsonObjectOrArray) + let objectData = ProtocolTypes.ObjectData(json: jsonObjectOrArray) let wireData = objectData.toWire(format: .json) #expect(wireData.boolean == nil) @@ -185,7 +185,7 @@ struct ObjectMessageTests { @Test func boolean() throws { let wireData = WireObjectData(boolean: true) - let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types #expect(objectData.boolean == true) @@ -200,7 +200,7 @@ struct ObjectMessageTests { func binary() throws { let testData = Data([1, 2, 3, 4]) let wireData = WireObjectData(bytes: .data(testData)) - let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types #expect(objectData.boolean == nil) @@ -216,7 +216,7 @@ struct ObjectMessageTests { let testData = Data([1, 2, 3, 4]) let base64String = testData.base64EncodedString() let wireData = WireObjectData(bytes: .string(base64String)) - let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types #expect(objectData.boolean == nil) @@ -231,7 +231,7 @@ struct ObjectMessageTests { func number() throws { let testNumber = NSNumber(value: 42) let wireData = WireObjectData(number: testNumber) - let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types #expect(objectData.boolean == nil) @@ -246,7 +246,7 @@ struct ObjectMessageTests { func string() throws { let testString = "hello world" let wireData = WireObjectData(string: testString) - let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) // OD5a1: The payloads in ObjectData.boolean, ObjectData.bytes, ObjectData.number, and ObjectData.string are decoded as their corresponding MessagePack types #expect(objectData.boolean == nil) @@ -261,7 +261,7 @@ struct ObjectMessageTests { func json() throws { let jsonString = "{\"key\":\"value\",\"number\":123}" let wireData = WireObjectData(json: jsonString) - let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) #expect(objectData.boolean == nil) @@ -280,7 +280,7 @@ struct ObjectMessageTests { // Should throw when JSON parsing fails, even in MessagePack format #expect(throws: ARTErrorInfo.self) { - _ = try ObjectData(wireObjectData: wireData, format: .messagePack) + _ = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) } } @@ -303,7 +303,7 @@ struct ObjectMessageTests { // Should throw when JSON is valid but not an object or array #expect(throws: ARTErrorInfo.self) { - _ = try ObjectData(wireObjectData: wireData, format: .messagePack) + _ = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .messagePack) } } } @@ -313,7 +313,7 @@ struct ObjectMessageTests { @Test func boolean() throws { let wireData = WireObjectData(boolean: true) - let objectData = try ObjectData(wireObjectData: wireData, format: .json) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) // OD5b1: The payloads in ObjectData.boolean, ObjectData.number, and ObjectData.string are decoded as their corresponding JSON types #expect(objectData.boolean == true) @@ -328,7 +328,7 @@ struct ObjectMessageTests { func number() throws { let testNumber = NSNumber(value: 42) let wireData = WireObjectData(number: testNumber) - let objectData = try ObjectData(wireObjectData: wireData, format: .json) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) // OD5b1: The payloads in ObjectData.boolean, ObjectData.number, and ObjectData.string are decoded as their corresponding JSON types #expect(objectData.boolean == nil) @@ -343,7 +343,7 @@ struct ObjectMessageTests { func string() throws { let testString = "hello world" let wireData = WireObjectData(string: testString) - let objectData = try ObjectData(wireObjectData: wireData, format: .json) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) // OD5b1: The payloads in ObjectData.boolean, ObjectData.number, and ObjectData.string are decoded as their corresponding JSON types #expect(objectData.boolean == nil) @@ -359,7 +359,7 @@ struct ObjectMessageTests { let testData = Data([1, 2, 3, 4]) let base64String = testData.base64EncodedString() let wireData = WireObjectData(bytes: .string(base64String)) - let objectData = try ObjectData(wireObjectData: wireData, format: .json) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) // OD5b2: The ObjectData.bytes payload is Base64-decoded into a binary value #expect(objectData.boolean == nil) @@ -377,7 +377,7 @@ struct ObjectMessageTests { // Should throw when Base64 decoding fails #expect(throws: ARTErrorInfo.self) { - _ = try ObjectData(wireObjectData: wireData, format: .json) + _ = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) } } @@ -386,7 +386,7 @@ struct ObjectMessageTests { func json() throws { let jsonString = "{\"key\":\"value\",\"number\":123}" let wireData = WireObjectData(json: jsonString) - let objectData = try ObjectData(wireObjectData: wireData, format: .json) + let objectData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) #expect(objectData.boolean == nil) #expect(objectData.bytes == nil) @@ -404,7 +404,7 @@ struct ObjectMessageTests { // Should throw when JSON parsing fails #expect(throws: ARTErrorInfo.self) { - _ = try ObjectData(wireObjectData: wireData, format: .json) + _ = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) } } @@ -427,7 +427,7 @@ struct ObjectMessageTests { // Should throw when JSON is valid but not an object or array #expect(throws: ARTErrorInfo.self) { - _ = try ObjectData(wireObjectData: wireData, format: .json) + _ = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: .json) } } } @@ -441,17 +441,17 @@ struct ObjectMessageTests { EncodingFormat.messagePack.rawValue, ], [ // Test each property type individually - ObjectData(boolean: true), - ObjectData(bytes: Data([1, 2, 3, 4])), - ObjectData(number: NSNumber(value: 42)), - ObjectData(string: "hello world"), - ObjectData(json: .object(["key": "value", "number": 123])), - ObjectData(json: .array([123, "hello world"])), + ProtocolTypes.ObjectData(boolean: true), + ProtocolTypes.ObjectData(bytes: Data([1, 2, 3, 4])), + ProtocolTypes.ObjectData(number: NSNumber(value: 42)), + ProtocolTypes.ObjectData(string: "hello world"), + ProtocolTypes.ObjectData(json: .object(["key": "value", "number": 123])), + ProtocolTypes.ObjectData(json: .array([123, "hello world"])), ]) - func roundTrip(formatRawValue: EncodingFormat.RawValue, originalData: ObjectData) throws { + func roundTrip(formatRawValue: EncodingFormat.RawValue, originalData: ProtocolTypes.ObjectData) throws { let format = try #require(EncodingFormat(rawValue: formatRawValue)) let wireData = originalData.toWire(format: format) - let decodedData = try ObjectData(wireObjectData: wireData, format: format) + let decodedData = try ProtocolTypes.ObjectData(wireObjectData: wireData, format: format) // Compare boolean values #expect(decodedData.boolean == originalData.boolean) diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 6bb3cbafe..53ad9f58f 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -6,7 +6,7 @@ private extension SyncObjectsPool { /// Test-only convenience to create a `SyncObjectsPool` from an array of `(state, serialTimestamp)` pairs, /// wrapping each in an `InboundObjectMessage` and calling `accumulate`. static func testsOnly_fromStates( - _ states: [(state: ObjectState, serialTimestamp: Date?)], + _ states: [(state: ProtocolTypes.ObjectState, serialTimestamp: Date?)], logger: AblyLiveObjects.Logger = TestLogger(), ) -> SyncObjectsPool { var pool = SyncObjectsPool() @@ -327,7 +327,7 @@ struct ObjectsPoolTests { createOp: TestFactories.mapCreateOperation(objectId: "map:existing@1", entries: [ "createOpKey": TestFactories.stringMapEntry(value: "bar").entry, ]), - entries: ["updated": TestFactories.mapEntry(data: ObjectData(string: "updated"))], + entries: ["updated": TestFactories.mapEntry(data: ProtocolTypes.ObjectData(string: "updated"))], ), // Update existing counter TestFactories.counterObjectState( @@ -340,7 +340,7 @@ struct ObjectsPoolTests { TestFactories.mapObjectState( objectId: "map:new@1", siteTimeserials: ["site3": "ts3"], - entries: ["new": TestFactories.mapEntry(data: ObjectData(string: "new"))], + entries: ["new": TestFactories.mapEntry(data: ProtocolTypes.ObjectData(string: "new"))], ), // Create new counter TestFactories.counterObjectState( diff --git a/LiveObjects/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift b/LiveObjects/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift index 5aa56b3be..551380a39 100644 --- a/LiveObjects/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift +++ b/LiveObjects/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift @@ -111,7 +111,7 @@ struct SyncObjectsPoolTests { var pool = SyncObjectsPool() let logger = TestLogger() - var expectedEntries: [String: ObjectsMapEntry] = [:] + var expectedEntries: [String: ProtocolTypes.ObjectsMapEntry] = [:] for i in 1 ... 3 { let (key, entry) = TestFactories.stringMapEntry(key: "key\(i)", value: "value\(i)") expectedEntries[key] = entry From 5ae31f3cbfd73d23866cd6c0574e660e6a31a6b6 Mon Sep 17 00:00:00 2001 From: Marat Alekperov Date: Fri, 24 Jul 2026 00:00:40 +0400 Subject: [PATCH 21/21] Move _AblyPluginSupportPrivate out of Sources/ to the repo root MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The core SDK's sources live in Source/ (singular) while _AblyPluginSupportPrivate lived in Sources/ (plural), so both a Source and a Sources directory sat at the repo root — confusingly similar. This moves the _AblyPluginSupportPrivate target's directory up to the repo root, removing the Sources/ directory entirely so only Source/ remains. _AblyPluginSupportPrivate is an SPM-only target referenced by name, so the only path references are the target's own path: in Package.swift and one mention in Docs/plugins.md; both are updated here. No CocoaPods, Carthage, or Xcode-project changes are involved. A follow-up will move both _AblyPluginSupportPrivate and the core SDK to the idiomatic SwiftPM Sources// layout. Co-Authored-By: Claude Opus 4.8 (1M context) --- Docs/plugins.md | 2 +- Package.swift | 2 +- .../APDependencyStore.m | 0 .../README.md | 0 .../include/APConnectionDetails.h | 0 .../include/APDecodingContext.h | 0 .../include/APDependencyStore.h | 0 .../include/APEncodingFormat.h | 0 .../include/APLiveObjectsPlugin.h | 0 .../include/APLogLevel.h | 0 .../include/APLogger.h | 0 .../include/APPluginAPI.h | 0 .../include/APPublicClientOptions.h | 0 .../include/APPublicErrorInfo.h | 0 .../include/APPublicRealtimeChannel.h | 0 .../include/APPublicRealtimeChannelUnderlyingObjects.h | 0 .../include/APPublishResult.h | 0 .../include/APRealtimeChannel.h | 0 .../include/APRealtimeChannelState.h | 0 .../include/APRealtimeClient.h | 0 20 files changed, 2 insertions(+), 2 deletions(-) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/APDependencyStore.m (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/README.md (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APConnectionDetails.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APDecodingContext.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APDependencyStore.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APEncodingFormat.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APLiveObjectsPlugin.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APLogLevel.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APLogger.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APPluginAPI.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APPublicClientOptions.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APPublicErrorInfo.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APPublicRealtimeChannel.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APPublicRealtimeChannelUnderlyingObjects.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APPublishResult.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APRealtimeChannel.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APRealtimeChannelState.h (100%) rename {Sources/_AblyPluginSupportPrivate => _AblyPluginSupportPrivate}/include/APRealtimeClient.h (100%) diff --git a/Docs/plugins.md b/Docs/plugins.md index 26fe40339..cd5331b9c 100644 --- a/Docs/plugins.md +++ b/Docs/plugins.md @@ -2,7 +2,7 @@ ably-cocoa allows users to pass in Ably-authored plugins via the `ARTClientOptions.plugins` property. These plugins extend the functionality of the SDK. -ably-cocoa and its plugins depend on a library called `_AblyPluginSupportPrivate`, which is a target of this package (found in `Sources/_AblyPluginSupportPrivate`; it was previously in the separate [ably-cocoa-plugin-support repository](https://github.com/ably/ably-cocoa-plugin-support)). This provides an API that plugins can use to access ably-cocoa's internals, and which ably-cocoa can use to communicate with its plugins. +ably-cocoa and its plugins depend on a library called `_AblyPluginSupportPrivate`, which is a target of this package (found in `_AblyPluginSupportPrivate`; it was previously in the separate [ably-cocoa-plugin-support repository](https://github.com/ably/ably-cocoa-plugin-support)). This provides an API that plugins can use to access ably-cocoa's internals, and which ably-cocoa can use to communicate with its plugins. I will expand on this documentation once this mechanism is more mature. [ADR-128: Plugins for ably-cocoa SDK](https://ably.atlassian.net/wiki/spaces/ENG/pages/3838574593/ADR-128+Plugins+for+ably-cocoa+SDK) describes the original design, but is now outdated in some important ways: it is centred on the plugin living in a separate repository so as not to be bound by ably-cocoa's OS version requirements, a constraint that turned out not to exist (per-declaration `@available` annotations let a single package host components with different OS requirements, and the plugin now lives in this repository), and `_AblyPluginSupportPrivate` (called `AblyPlugin` in the ADR) is a standalone library rather than part of the Ably product. diff --git a/Package.swift b/Package.swift index 0cfeae98e..d6a5e8a29 100644 --- a/Package.swift +++ b/Package.swift @@ -55,7 +55,7 @@ let package = Package( // vended as a product. .target( name: "_AblyPluginSupportPrivate", - path: "Sources/_AblyPluginSupportPrivate" + path: "_AblyPluginSupportPrivate" ), .target( name: "Ably", diff --git a/Sources/_AblyPluginSupportPrivate/APDependencyStore.m b/_AblyPluginSupportPrivate/APDependencyStore.m similarity index 100% rename from Sources/_AblyPluginSupportPrivate/APDependencyStore.m rename to _AblyPluginSupportPrivate/APDependencyStore.m diff --git a/Sources/_AblyPluginSupportPrivate/README.md b/_AblyPluginSupportPrivate/README.md similarity index 100% rename from Sources/_AblyPluginSupportPrivate/README.md rename to _AblyPluginSupportPrivate/README.md diff --git a/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h b/_AblyPluginSupportPrivate/include/APConnectionDetails.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h rename to _AblyPluginSupportPrivate/include/APConnectionDetails.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h b/_AblyPluginSupportPrivate/include/APDecodingContext.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h rename to _AblyPluginSupportPrivate/include/APDecodingContext.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h b/_AblyPluginSupportPrivate/include/APDependencyStore.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h rename to _AblyPluginSupportPrivate/include/APDependencyStore.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h b/_AblyPluginSupportPrivate/include/APEncodingFormat.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h rename to _AblyPluginSupportPrivate/include/APEncodingFormat.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h b/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h rename to _AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h b/_AblyPluginSupportPrivate/include/APLogLevel.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APLogLevel.h rename to _AblyPluginSupportPrivate/include/APLogLevel.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APLogger.h b/_AblyPluginSupportPrivate/include/APLogger.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APLogger.h rename to _AblyPluginSupportPrivate/include/APLogger.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h b/_AblyPluginSupportPrivate/include/APPluginAPI.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h rename to _AblyPluginSupportPrivate/include/APPluginAPI.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h b/_AblyPluginSupportPrivate/include/APPublicClientOptions.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h rename to _AblyPluginSupportPrivate/include/APPublicClientOptions.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h b/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h rename to _AblyPluginSupportPrivate/include/APPublicErrorInfo.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h b/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h rename to _AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h b/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h rename to _AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h b/_AblyPluginSupportPrivate/include/APPublishResult.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APPublishResult.h rename to _AblyPluginSupportPrivate/include/APPublishResult.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h b/_AblyPluginSupportPrivate/include/APRealtimeChannel.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h rename to _AblyPluginSupportPrivate/include/APRealtimeChannel.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h b/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h rename to _AblyPluginSupportPrivate/include/APRealtimeChannelState.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h b/_AblyPluginSupportPrivate/include/APRealtimeClient.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h rename to _AblyPluginSupportPrivate/include/APRealtimeClient.h