diff --git a/.github/workflows/check-liveobjects.yaml b/.github/workflows/check-liveobjects.yaml new file mode 100644 index 000000000..223841245 --- /dev/null +++ b/.github/workflows/check-liveobjects.yaml @@ -0,0 +1,100 @@ +# Check workflow for the AblyLiveObjects library product. Adapted from the +# former ably-liveobjects-swift-plugin repo's check.yaml. SPM-based and +# xcodebuild-based jobs are wired up. The remaining jobs from the +# original (lint, documentation, code coverage, example app, +# generate-matrices) still need follow-up work; see commit +# 7ab9a1e for the full inventory. + +name: Check LiveObjects + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +jobs: + liveobjects-build-and-test-spm: + name: SPM (Xcode ${{ matrix.xcode-version }}) + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + xcode-version: ['16.4'] + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.xcode-version }} + + - name: Build the AblyLiveObjects library + run: swift build --target AblyLiveObjects + + - name: Run AblyLiveObjects unit tests + run: swift run LiveObjectsBuildTool test-library --platform macOS --only-unit-tests + + liveobjects-build-release-configuration-spm: + name: SPM, `release` configuration (Xcode ${{ matrix.xcode-version }}) + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + xcode-version: ['16.4'] + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.xcode-version }} + + - name: Build the AblyLiveObjects library in release configuration + run: swift build --target AblyLiveObjects --configuration release + + liveobjects-build-and-test-xcode: + name: Xcode, ${{ matrix.platform }} (Xcode ${{ matrix.xcode-version }}) + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + xcode-version: ['16.4'] + platform: [macOS, iOS, tvOS] + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.xcode-version }} + + - name: Build for testing + run: swift run LiveObjectsBuildTool build-library-for-testing --platform ${{ matrix.platform }} + + - name: Run unit tests + run: swift run LiveObjectsBuildTool test-library --platform ${{ matrix.platform }} --only-unit-tests --without-building + + liveobjects-build-release-configuration-xcode: + name: Xcode, `release` configuration, ${{ matrix.platform }} (Xcode ${{ matrix.xcode-version }}) + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + xcode-version: ['16.4'] + platform: [macOS, iOS, tvOS] + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.xcode-version }} + + - name: Build library + run: swift run LiveObjectsBuildTool build-library --platform ${{ matrix.platform }} --configuration release diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme new file mode 100644 index 000000000..c5d577473 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Package.resolved b/Package.resolved index aba9e456a..af47b1b0e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,61 +1,87 @@ { - "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", - "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" - } - } - ] - }, - "version": 1 + "originHash" : "8ee64927a44ab8598f114b63073c2b250d19877d9e570a204045d5bec921bf79", + "pins" : [ + { + "identity" : "cwlcatchexception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattgallagher/CwlCatchException.git", + "state" : { + "revision" : "07b2ba21d361c223e25e3c1e924288742923f08c", + "version" : "2.2.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" + } + }, + { + "identity" : "swift-argument-parser", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-argument-parser", + "state" : { + "revision" : "626b5b7b2f45e1b0b1c6f4a309296d1d21d7311b", + "version" : "1.7.1" + } + }, + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms", + "state" : { + "revision" : "9d349bcc328ac3c31ce40e746b5882742a0d1272", + "version" : "1.1.3" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "03cc312c2c933ed87abace34044a5dff7a3117c1", + "version" : "1.5.0" + } + }, + { + "identity" : "table", + "kind" : "remoteSourceControl", + "location" : "https://github.com/JanGorman/Table.git", + "state" : { + "revision" : "7b8521c3b1078ba0b347964aec7c503cd6f1ff40", + "version" : "1.1.1" + } + } + ], + "version" : 3 } diff --git a/Package.swift b/Package.swift index 39774c930..ba9b50c60 100644 --- a/Package.swift +++ b/Package.swift @@ -1,4 +1,4 @@ -// swift-tools-version:5.3.0 +// swift-tools-version: 6.1 import PackageDescription @@ -14,20 +14,36 @@ let package = Package( name: "Ably", targets: ["Ably"] ), + .library( + name: "AblyLiveObjects", + targets: ["AblyLiveObjects"] + ), ], 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") + // The next three are used only by the LiveObjectsBuildTool + // executable target, which is a developer/CI tool. None of them are + // linked into the Ably or AblyLiveObjects library products. + .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: [ + // Private plugin-support API surface used by Ably-authored plugins + // (e.g. AblyLiveObjects). Folded in from the former + // ably-cocoa-plugin-support package. + .target( + name: "_AblyPluginSupportPrivate", + path: "merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate" + ), .target( name: "Ably", dependencies: [ .byName(name: "msgpack"), .byName(name: "AblyDeltaCodec"), - .product(name: "_AblyPluginSupportPrivate", package: "ably-cocoa-plugin-support") + .byName(name: "_AblyPluginSupportPrivate"), ], path: "Source", exclude: [ @@ -52,6 +68,55 @@ let package = Package( .headerSearchPath("SocketRocket/Internal/IOConsumer"), ] ), + // LiveObjects functionality, folded in from the former + // ably-liveobjects-swift-plugin package. Requires a higher OS floor + // than ably-cocoa's package-wide minimum — gated via @available + // on public/internal declarations rather than per-target platforms + // (which SPM does not support). + .target( + name: "AblyLiveObjects", + dependencies: [ + .byName(name: "Ably"), + .byName(name: "_AblyPluginSupportPrivate"), + ], + path: "merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects", + exclude: [ + ".swiftformat", + ".swiftlint.yml", + ] + ), + .testTarget( + name: "AblyLiveObjectsTests", + dependencies: [ + .byName(name: "AblyLiveObjects"), + .byName(name: "Ably"), + .byName(name: "_AblyPluginSupportPrivate"), + ], + path: "merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests", + exclude: [ + ".swiftformat", + ".swiftlint.yml", + "CLAUDE.md", + ], + resources: [ + .copy("ably-common"), + ] + ), + // Developer/CI tooling — drives the test runner across platforms, + // fetches simulator destinations, etc. Folded in from the former + // ably-liveobjects-swift-plugin package. Not a library product, so + // it is not visible to consumers and its transitive deps + // (swift-argument-parser, swift-async-algorithms, Table) do not + // affect them at build time. + .executableTarget( + name: "LiveObjectsBuildTool", + dependencies: [ + .product(name: "ArgumentParser", package: "swift-argument-parser"), + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product(name: "Table", package: "Table"), + ], + path: "merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool" + ), .testTarget( name: "AblyTests", dependencies: [ @@ -59,12 +124,16 @@ let package = Package( .byName(name: "AblyTesting"), .byName(name: "AblyTestingObjC"), .byName(name: "Nimble"), - .product(name: "_AblyPluginSupportPrivate", package: "ably-cocoa-plugin-support") + .byName(name: "_AblyPluginSupportPrivate"), ], path: "Test/AblyTests", resources: [ .copy("ably-common") - ] + ], + // Pre-existing test code is written against Swift 5 mode; the + // tools-version bump to 6.1 would otherwise promote the + // existing strict-concurrency warnings to errors. + swiftSettings: [.swiftLanguageMode(.v5)] ), // A handful of tests written in Objective-C (they can't be part of AblyTests because SPM doesn't allow mixed-language targets). .testTarget( @@ -82,7 +151,8 @@ let package = Package( dependencies: [ .byName(name: "Ably"), ], - path: "Test/AblyTesting" + path: "Test/AblyTesting", + swiftSettings: [.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( @@ -94,4 +164,3 @@ let package = Package( ) ] ) - diff --git a/merged-repos/ably-cocoa-plugin-support/.gitignore b/merged-repos/ably-cocoa-plugin-support/.gitignore new file mode 100644 index 000000000..f761f995d --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/.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/merged-repos/ably-cocoa-plugin-support/COPYRIGHT b/merged-repos/ably-cocoa-plugin-support/COPYRIGHT new file mode 100644 index 000000000..165bec7ac --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/COPYRIGHT @@ -0,0 +1 @@ +Copyright 2025 Ably Real-time Ltd (ably.com) diff --git a/merged-repos/ably-cocoa-plugin-support/LICENSE b/merged-repos/ably-cocoa-plugin-support/LICENSE new file mode 100644 index 000000000..d9a10c0d8 --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Package.swift b/merged-repos/ably-cocoa-plugin-support/Package.swift new file mode 100644 index 000000000..787d8e52c --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/README.md b/merged-repos/ably-cocoa-plugin-support/README.md new file mode 100644 index 000000000..91559f098 --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/APDependencyStore.m b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/APDependencyStore.m new file mode 100644 index 000000000..9d4e2d2a3 --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h new file mode 100644 index 000000000..e45056ec9 --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h new file mode 100644 index 000000000..77b3a84d3 --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h new file mode 100644 index 000000000..1c7a95008 --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h new file mode 100644 index 000000000..f8b3da1ea --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h new file mode 100644 index 000000000..d0db6082d --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h new file mode 100644 index 000000000..26c8322cb --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APLogger.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APLogger.h new file mode 100644 index 000000000..4d46a9d3a --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h new file mode 100644 index 000000000..9e64bcf46 --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h new file mode 100644 index 000000000..ed87ad31d --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h new file mode 100644 index 000000000..b9911ffb4 --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h new file mode 100644 index 000000000..9588e8143 --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h new file mode 100644 index 000000000..a6ef97e37 --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h new file mode 100644 index 000000000..9050f5d62 --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h new file mode 100644 index 000000000..4ee93fd26 --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h new file mode 100644 index 000000000..619a48055 --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h new file mode 100644 index 000000000..70a3974ff --- /dev/null +++ b/merged-repos/ably-cocoa-plugin-support/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 diff --git a/merged-repos/ably-liveobjects-swift-plugin/.gitignore b/merged-repos/ably-liveobjects-swift-plugin/.gitignore new file mode 100644 index 000000000..9383ec368 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/.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/merged-repos/ably-liveobjects-swift-plugin/.gitmodules b/merged-repos/ably-liveobjects-swift-plugin/.gitmodules new file mode 100644 index 000000000..aecc76733 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/.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/merged-repos/ably-liveobjects-swift-plugin/.prettierignore b/merged-repos/ably-liveobjects-swift-plugin/.prettierignore new file mode 100644 index 000000000..e2e73d508 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/.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/merged-repos/ably-liveobjects-swift-plugin/.prettierrc b/merged-repos/ably-liveobjects-swift-plugin/.prettierrc new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/merged-repos/ably-liveobjects-swift-plugin/.swift-version b/merged-repos/ably-liveobjects-swift-plugin/.swift-version new file mode 100644 index 000000000..a435f5a56 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/.swift-version @@ -0,0 +1 @@ +6.1 diff --git a/merged-repos/ably-liveobjects-swift-plugin/.swiftformat b/merged-repos/ably-liveobjects-swift-plugin/.swiftformat new file mode 100644 index 000000000..fe6b3ba66 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/.swiftformat @@ -0,0 +1,2 @@ +# Submodules +--exclude ably-cocoa diff --git a/merged-repos/ably-liveobjects-swift-plugin/.swiftlint.yml b/merged-repos/ably-liveobjects-swift-plugin/.swiftlint.yml new file mode 100644 index 000000000..f6d10a1e2 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/.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/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..54782e32f --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + + diff --git a/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme b/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme new file mode 100644 index 000000000..5c088be7a --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme b/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme new file mode 100644 index 000000000..007419881 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme b/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme new file mode 100644 index 000000000..dac481de7 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/contents.xcworkspacedata b/merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..82a77443a --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..a45cc6840 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/CHANGELOG.md b/merged-repos/ably-liveobjects-swift-plugin/CHANGELOG.md new file mode 100644 index 000000000..c51b74820 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/CLAUDE.md b/merged-repos/ably-liveobjects-swift-plugin/CLAUDE.md new file mode 100644 index 000000000..b1b9c7324 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/CONTRIBUTING.md b/merged-repos/ably-liveobjects-swift-plugin/CONTRIBUTING.md new file mode 100644 index 000000000..b99ee9f7b --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/COPYRIGHT b/merged-repos/ably-liveobjects-swift-plugin/COPYRIGHT new file mode 100644 index 000000000..625d86cf7 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/COPYRIGHT @@ -0,0 +1 @@ +Copyright 2024 Ably Real-time Ltd (ably.com) diff --git a/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj new file mode 100644 index 000000000..097bba4c5 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..05de7ab26 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements new file mode 100644 index 000000000..625af03d9 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift new file mode 100644 index 000000000..6e3cd99a3 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..532cd729c --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/ContentView.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/ContentView.swift new file mode 100644 index 000000000..62b93c009 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift new file mode 100644 index 000000000..38308a103 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Secrets.example.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Secrets.example.swift new file mode 100644 index 000000000..99cfda69b --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift new file mode 100644 index 000000000..6009eb514 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift new file mode 100644 index 000000000..dfd1b4da1 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Views/LiveCounterView.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Views/LiveCounterView.swift new file mode 100644 index 000000000..93516d16a --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Views/TaskBoardView.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Views/TaskBoardView.swift new file mode 100644 index 000000000..a3c5ce9fb --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/LICENSE b/merged-repos/ably-liveobjects-swift-plugin/LICENSE new file mode 100644 index 000000000..d9a10c0d8 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/MAINTAINERS.md b/merged-repos/ably-liveobjects-swift-plugin/MAINTAINERS.md new file mode 100644 index 000000000..e5fda9517 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/MAINTAINERS.md @@ -0,0 +1 @@ +This repository is owned by the Ably Ecosystems team. diff --git a/merged-repos/ably-liveobjects-swift-plugin/Mintfile b/merged-repos/ably-liveobjects-swift-plugin/Mintfile new file mode 100644 index 000000000..b2015d8dc --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Mintfile @@ -0,0 +1,2 @@ +realm/SwiftLint@0.59.1 +nicklockwood/SwiftFormat@0.56.1 diff --git a/merged-repos/ably-liveobjects-swift-plugin/Package.resolved b/merged-repos/ably-liveobjects-swift-plugin/Package.resolved new file mode 100644 index 000000000..95a949028 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Package.swift b/merged-repos/ably-liveobjects-swift-plugin/Package.swift new file mode 100644 index 000000000..982af18db --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/README.md b/merged-repos/ably-liveobjects-swift-plugin/README.md new file mode 100644 index 000000000..6ed7a235d --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/.swiftformat b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/.swiftformat new file mode 100644 index 000000000..3dd75866c --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/.swiftformat @@ -0,0 +1,2 @@ +# To avoid clash with SwiftLint's explicit_acl rule +--disable redundantInternal diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/.swiftlint.yml b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/.swiftlint.yml new file mode 100644 index 000000000..85b1c9a91 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift new file mode 100644 index 000000000..90c10d6b9 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift @@ -0,0 +1,47 @@ +internal import _AblyPluginSupportPrivate +import Ably + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/CoreSDK.swift new file mode 100644 index 000000000..0c1dd025f --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -0,0 +1,140 @@ +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). +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift new file mode 100644 index 000000000..76c796ac4 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -0,0 +1,198 @@ +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 +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift new file mode 100644 index 000000000..87a27c68d --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift @@ -0,0 +1,4 @@ +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct DefaultLiveCounterUpdate: LiveCounterUpdate, Equatable { + internal var amount: Double +} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift new file mode 100644 index 000000000..78e9fc6a7 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift @@ -0,0 +1,4 @@ +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct DefaultLiveMapUpdate: LiveMapUpdate, Equatable { + internal var update: [String: LiveMapUpdateAction] +} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift new file mode 100644 index 000000000..5e0bcc897 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -0,0 +1,503 @@ +internal import _AblyPluginSupportPrivate +import Ably +import Foundation + +/// This provides the implementation behind ``PublicDefaultLiveCounter``, via internal versions of the ``LiveCounter`` API. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift new file mode 100644 index 000000000..1b261a989 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -0,0 +1,1122 @@ +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift new file mode 100644 index 000000000..a3e8e0975 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -0,0 +1,1085 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// Protocol that abstracts `InternalDefaultRealtimeObjects`, for testability. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift new file mode 100644 index 000000000..6305ef7a9 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -0,0 +1,166 @@ +import Foundation + +/// Same as the public ``LiveMapValue`` type but with associated values of internal type. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift new file mode 100644 index 000000000..f6f56b2e9 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift @@ -0,0 +1,60 @@ +internal import _AblyPluginSupportPrivate + +/// Provides RTLO spec point functionality common to all LiveObjects. +/// +/// This exists in addition to ``LiveObjectMutableState`` to enable polymorphism. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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() +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift new file mode 100644 index 000000000..1ce8d49d4 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift @@ -0,0 +1,23 @@ +import Foundation + +/// The entries stored in a `LiveMap`'s data. Same as an `ObjectsMapEntry` but with an additional `tombstonedAt` property, per RTLM3a. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal extension InternalObjectsMapEntry { + init(objectsMapEntry: ObjectsMapEntry, tombstonedAt: Date?) { + self.tombstonedAt = tombstonedAt + timeserial = objectsMapEntry.timeserial + data = objectsMapEntry.data + } +} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift new file mode 100644 index 000000000..38b78add3 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -0,0 +1,153 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// This is the equivalent of the `LiveObject` abstract class described in RTLO. +/// +/// ``InternalDefaultLiveCounter`` and ``InternalDefaultLiveMap`` include it by composition. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift new file mode 100644 index 000000000..a5077807b --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift @@ -0,0 +1,28 @@ +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + } + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension LiveObjectUpdate: Equatable where Update: Equatable {} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift new file mode 100644 index 000000000..6c074cc83 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift @@ -0,0 +1,206 @@ +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift new file mode 100644 index 000000000..2755f773d --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift @@ -0,0 +1,58 @@ +import Foundation + +/// Helper methods for calculating diffs between LiveObject data values. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift new file mode 100644 index 000000000..ba7326312 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift @@ -0,0 +1,8 @@ +/// RTO22: Describes the source of an operation being applied. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsPool.swift new file mode 100644 index 000000000..dbecc8683 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -0,0 +1,454 @@ +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 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift new file mode 100644 index 000000000..35ed21d41 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift @@ -0,0 +1,19 @@ +/// 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 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/PublishResult.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/PublishResult.swift new file mode 100644 index 000000000..f7dcb1473 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/PublishResult.swift @@ -0,0 +1,16 @@ +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 10.15, iOS 13, tvOS 13, *) +internal struct PublishResult { + internal var serials: [String?] +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal extension PublishResult { + init(pluginPublishResult: _AblyPluginSupportPrivate.PublishResultProtocol) { + serials = pluginPublishResult.serials.map(\.value) + } +} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SimpleClock.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SimpleClock.swift new file mode 100644 index 000000000..caa74d37a --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SimpleClock.swift @@ -0,0 +1,21 @@ +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +internal final class DefaultSimpleClock: SimpleClock { + internal init() {} + + internal var now: Date { + Date() + } +} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift new file mode 100644 index 000000000..2a764494b --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift @@ -0,0 +1,93 @@ +import Foundation + +/// Handles subscription bookkeeping, providing methods for subscribing and emitting events. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift new file mode 100644 index 000000000..7cd71e411 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift @@ -0,0 +1,87 @@ +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift new file mode 100644 index 000000000..326430017 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift @@ -0,0 +1,20 @@ +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift new file mode 100644 index 000000000..673e05931 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -0,0 +1,681 @@ +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`. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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:)``. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +internal struct MapSet: Equatable { + internal var key: String // MST2a + internal var value: ObjectData? // MST2b +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct MapCreate: Equatable { + internal var semantics: WireEnum // MCR2a + internal var entries: [String: ObjectsMapEntry]? // MCR2b +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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? +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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? +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct ObjectsMapEntry: Equatable { + internal var tombstone: Bool? // OME2a + internal var timeserial: String? // OME2b + internal var data: ObjectData? // OME2c + internal var serialTimestamp: Date? // OME2d +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct ObjectsMap: Equatable { + internal var semantics: WireEnum // OMP3a + internal var entries: [String: ObjectsMapEntry]? // OMP3b + internal var clearTimeserial: String? // OMP3c +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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, + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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, + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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, + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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), + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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) }, + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal extension MapCreateWithObjectId { + init(wireMapCreateWithObjectId: WireMapCreateWithObjectId) { + nonce = wireMapCreateWithObjectId.nonce + initialValue = wireMapCreateWithObjectId.initialValue + } + + func toWire() -> WireMapCreateWithObjectId { + .init(initialValue: initialValue, nonce: nonce) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal extension CounterCreateWithObjectId { + init(wireCounterCreateWithObjectId: WireCounterCreateWithObjectId) { + nonce = wireCounterCreateWithObjectId.nonce + initialValue = wireCounterCreateWithObjectId.initialValue + } + + func toWire() -> WireCounterCreateWithObjectId { + .init(initialValue: initialValue, nonce: nonce) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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), + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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, + ) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/SyncCursor.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/SyncCursor.swift new file mode 100644 index 000000000..9a1cd601f --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/SyncCursor.swift @@ -0,0 +1,40 @@ +import Ably +import Foundation + +/// The `OBJECT_SYNC` sync cursor, as extracted from a `channelSerial` per RTO5a1 and RTO5a4. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireEnum.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireEnum.swift new file mode 100644 index 000000000..653aea01c --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireEnum.swift @@ -0,0 +1,28 @@ +/// 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 10.15, iOS 13, tvOS 13, *) +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 + } + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireEnum: Sendable where Known: Sendable, Known.RawValue: Sendable {} +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireEnum: Equatable where Known: Equatable, Known.RawValue: Equatable {} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift new file mode 100644 index 000000000..afd924691 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -0,0 +1,847 @@ +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`. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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`. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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`. +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal enum WireObjectMessageWireKey: String { + case id + case clientId + case connectionId + case extras + case timestamp + case operation + case object + case serial + case siteCode + case serialTimestamp +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal enum ObjectsMapSemantics: Int { + case lww = 0 +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct WireObjectsMap { + internal var semantics: WireEnum // OMP3a + internal var entries: [String: WireObjectsMapEntry]? // OMP3b + internal var clearTimeserial: String? // OMP3c +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct WireObjectsCounter: Equatable { + internal var count: NSNumber? // OCN2a +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct WireMapSet { + internal var key: String // MST2a + internal var value: WireObjectData? // MST2b +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct WireMapRemove: Equatable { + internal var key: String // MRM2a +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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), + ] + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct WireMapCreate { + internal var semantics: WireEnum // MCR2a + internal var entries: [String: WireObjectsMapEntry]? // MCR2b +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct WireCounterCreate: Equatable { + internal var count: NSNumber? // CCR2a +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct WireCounterInc: Equatable { + internal var number: NSNumber // CIN2a +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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), + ] + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct WireObjectDelete: Equatable { + // Empty struct +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireObjectDelete: WireObjectCodable { + internal init(wireObject _: [String: WireValue]) throws(ARTErrorInfo) { + // No fields to decode + } + + internal var toWireObject: [String: WireValue] { + [:] + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct WireMapClear: Equatable { + // Empty struct +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireMapClear: WireObjectCodable { + internal init(wireObject _: [String: WireValue]) throws(ARTErrorInfo) { + // No fields to decode + } + + internal var toWireObject: [String: WireValue] { + [:] + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct WireMapCreateWithObjectId: Equatable { + internal var initialValue: String // MCRO2a + internal var nonce: String // MCRO2b +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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), + ] + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct WireCounterCreateWithObjectId: Equatable { + internal var initialValue: String // CCRO2a + internal var nonce: String // CCRO2b +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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), + ] + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal struct WireObjectsMapEntry { + internal var tombstone: Bool? // OME2a + internal var timeserial: String? // OME2b + internal var data: WireObjectData? // OME2c + internal var serialTimestamp: Date? // OME2d +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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) +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireObjectsCounter: CustomDebugStringConvertible { + internal var debugDescription: String { + if let count { + "{ count: \(count) }" + } else { + "{ count: nil }" + } + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireMapRemove: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ key: \(key) }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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: ", ") + " }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireCounterCreate: CustomDebugStringConvertible { + internal var debugDescription: String { + if let count { + "{ count: \(count) }" + } else { + "{ count: nil }" + } + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireCounterInc: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ number: \(number) }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireObjectDelete: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireMapCreateWithObjectId: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ initialValue: \(initialValue), nonce: \(nonce) }" + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireCounterCreateWithObjectId: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ initialValue: \(initialValue), nonce: \(nonce) }" + } +} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift new file mode 100644 index 000000000..8bfc72511 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -0,0 +1,42 @@ +internal import _AblyPluginSupportPrivate +import Ably + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Plugin.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Plugin.swift new file mode 100644 index 000000000..57018e77a --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Plugin.swift @@ -0,0 +1,47 @@ +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 +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift new file mode 100644 index 000000000..a5070c42e --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift @@ -0,0 +1,52 @@ +internal import _AblyPluginSupportPrivate + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift new file mode 100644 index 000000000..4e309ddb9 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -0,0 +1,55 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// Our default implementation of ``LiveCounter``. +/// +/// This is largely a wrapper around ``InternalDefaultLiveCounter``. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift new file mode 100644 index 000000000..95feab057 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -0,0 +1,104 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// Our default implementation of ``LiveMap``. +/// +/// This is largely a wrapper around ``InternalDefaultLiveMap``. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift new file mode 100644 index 000000000..3ab533332 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -0,0 +1,132 @@ +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 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift new file mode 100644 index 000000000..29785acf7 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift @@ -0,0 +1,161 @@ +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/PublicTypes.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/PublicTypes.swift new file mode 100644 index 000000000..e3da8a028 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -0,0 +1,412 @@ +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +public typealias LiveObjectLifecycleEventCallback = @Sendable (_ subscription: OnLiveObjectLifecycleEventResponse) -> Void + +/// Describes the events emitted by an ``RealtimeObjects`` object. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +extension LiveMapValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .jsonObject(.init(uniqueKeysWithValues: elements)) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension LiveMapValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .jsonArray(elements) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension LiveMapValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension LiveMapValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension LiveMapValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Assertions.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Assertions.swift new file mode 100644 index 000000000..8638b711a --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Assertions.swift @@ -0,0 +1,8 @@ +/// Stops execution because we tried to use a feature that is not yet implemented. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Data+Extensions.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Data+Extensions.swift new file mode 100644 index 000000000..71aac0826 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Data+Extensions.swift @@ -0,0 +1,21 @@ +import Ably +import Foundation + +/// Errors that can occur during decoding operations. +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal enum DecodingError: Error, Equatable { + case invalidBase64String(String) +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift new file mode 100644 index 000000000..2a95000d8 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift @@ -0,0 +1,9 @@ +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift new file mode 100644 index 000000000..5195b6299 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift @@ -0,0 +1,16 @@ +import Foundation + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift new file mode 100644 index 000000000..d7df6aeb5 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift @@ -0,0 +1,49 @@ +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Errors.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Errors.swift new file mode 100644 index 000000000..6fecf93fc --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Errors.swift @@ -0,0 +1,193 @@ +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. + */ +@available(macOS 10.15, iOS 13, tvOS 13, *) +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`. +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal protocol ConvertibleToLiveObjectsError { + func toLiveObjectsError() -> LiveObjectsError +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal extension ConvertibleToLiveObjectsError { + /// Convenience method to convert directly to an `ARTErrorInfo`. + func toARTErrorInfo() -> ARTErrorInfo { + toLiveObjectsError().toARTErrorInfo() + } +} + +// MARK: - Conversion Extensions + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension DecodingError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireValueDecodingError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireValue.ConversionError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension SyncCursor.Error: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension InboundWireObjectMessage.DecodingError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension StringOrData.DecodingError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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" + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift new file mode 100644 index 000000000..feecdcc1d --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift @@ -0,0 +1,95 @@ +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/JSONValue.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/JSONValue.swift new file mode 100644 index 000000000..c930937fd --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -0,0 +1,323 @@ +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 + } + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension JSONValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(.init(uniqueKeysWithValues: elements)) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension JSONValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension JSONValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension JSONValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension JSONValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension JSONValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +// MARK: - Bridging with JSONSerialization + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension JSONObjectOrArray: ExpressibleByDictionaryLiteral { + internal init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(.init(uniqueKeysWithValues: elements)) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension JSONObjectOrArray: ExpressibleByArrayLiteral { + internal init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Logger.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Logger.swift new file mode 100644 index 000000000..e865ada55 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Logger.swift @@ -0,0 +1,45 @@ +internal import _AblyPluginSupportPrivate + +/// A reference to a line within a source code file. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal protocol Logger: Sendable { + func log(_ message: String, level: _AblyPluginSupportPrivate.LogLevel, codeLocation: CodeLocation) +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift new file mode 100644 index 000000000..9f6932419 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift @@ -0,0 +1,15 @@ +import Foundation + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift new file mode 100644 index 000000000..cea2d1d50 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift @@ -0,0 +1,58 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// Upcasts an instance of an `_AblyPluginSupportPrivate` marker protocol to the concrete type that this marker protocol represents. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift new file mode 100644 index 000000000..8dda1130f --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift @@ -0,0 +1,11 @@ +import Foundation + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WeakRef.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WeakRef.swift new file mode 100644 index 000000000..1cde45afc --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WeakRef.swift @@ -0,0 +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 10.15, iOS 13, tvOS 13, *) +internal struct WeakRef { + internal weak var referenced: Referenced? +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WeakRef: Sendable where Referenced: Sendable {} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireCodable.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireCodable.swift new file mode 100644 index 000000000..c8eb349e1 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireCodable.swift @@ -0,0 +1,404 @@ +import Ably +import Foundation + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal protocol WireEncodable { + var toWireValue: WireValue { get } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal protocol WireDecodable { + init(wireValue: WireValue) throws(ARTErrorInfo) +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal typealias WireCodable = WireDecodable & WireEncodable + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal protocol WireObjectEncodable: WireEncodable { + var toWireObject: [String: WireValue] { get } +} + +// Default implementation of `WireEncodable` conformance for `WireObjectEncodable` +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal extension WireObjectEncodable { + var toWireValue: WireValue { + .object(toWireObject) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal protocol WireObjectDecodable: WireDecodable { + init(wireObject: [String: WireValue]) throws(ARTErrorInfo) +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal enum WireValueDecodingError: Error { + case valueIsNotObject + case noValueForKey(String) + case wrongTypeForKey(String, actualValue: WireValue) + case failedToDecodeFromRawValue(String) +} + +// Default implementation of `WireDecodable` conformance for `WireObjectDecodable` +@available(macOS 10.15, iOS 13, tvOS 13, *) +internal extension WireObjectDecodable { + init(wireValue: WireValue) throws(ARTErrorInfo) { + guard case let .object(wireObject) = wireValue else { + throw WireValueDecodingError.valueIsNotObject.toARTErrorInfo() + } + + self = try .init(wireObject: wireObject) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 10.15, iOS 13, tvOS 13, *) +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireValue.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireValue.swift new file mode 100644 index 000000000..65a77d65e --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireValue.swift @@ -0,0 +1,260 @@ +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 + } + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireValue: ExpressibleByDictionaryLiteral { + internal init(dictionaryLiteral elements: (String, WireValue)...) { + self = .object(.init(uniqueKeysWithValues: elements)) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireValue: ExpressibleByArrayLiteral { + internal init(arrayLiteral elements: WireValue...) { + self = .array(elements) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireValue: ExpressibleByStringLiteral { + internal init(stringLiteral value: String) { + self = .string(value) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireValue: ExpressibleByIntegerLiteral { + internal init(integerLiteral value: Int) { + self = .number(value as NSNumber) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireValue: ExpressibleByFloatLiteral { + internal init(floatLiteral value: Double) { + self = .number(value as NSNumber) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension WireValue: ExpressibleByBooleanLiteral { + internal init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +// MARK: - Bridging with ably-cocoa + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + } + }) + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/BuildTool.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/BuildTool.swift new file mode 100644 index 000000000..fabc37482 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/BuildTool.swift @@ -0,0 +1,846 @@ +import ArgumentParser +import AsyncAlgorithms +import Foundation +import Table + +@main +@available(macOS 14, *) +struct LiveObjectsBuildTool: AsyncParsableCommand { + static let configuration = CommandConfiguration( + commandName: "liveobjects-build-tool", + 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/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/Configuration.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/Configuration.swift new file mode 100644 index 000000000..48ddbc6b0 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationFetcher.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationFetcher.swift new file mode 100644 index 000000000..ceb6427fd --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationPredicate.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationPredicate.swift new file mode 100644 index 000000000..82b245861 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationSpecifier.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationSpecifier.swift new file mode 100644 index 000000000..25f1f8c69 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationStrategy.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationStrategy.swift new file mode 100644 index 000000000..5acdd4d2e --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationStrategy.swift @@ -0,0 +1,6 @@ +import Foundation + +enum DestinationStrategy { + case fixed(platform: String) + case lookup(destinationPredicate: DestinationPredicate) +} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/Error.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/Error.swift new file mode 100644 index 000000000..1978ed975 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/Error.swift @@ -0,0 +1,6 @@ +import Foundation + +enum Error: Swift.Error { + case terminatedWithExitCode(Int32) + case simulatorLookupFailed(message: String) +} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/Platform.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/Platform.swift new file mode 100644 index 000000000..f8147ea27 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/ProcessRunner.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/ProcessRunner.swift new file mode 100644 index 000000000..61ccb3d01 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/String+Decoding.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/String+Decoding.swift new file mode 100644 index 000000000..13565bdbc --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/XcodeRunner.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/XcodeRunner.swift new file mode 100644 index 000000000..7d039de0b --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/XcodeRunner.swift @@ -0,0 +1,76 @@ +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) + } + + // Always point xcodebuild at ably-cocoa's workspace rather than + // letting it auto-discover. Without this, xcodebuild would pick up + // Ably.xcodeproj (which has no AblyLiveObjects scheme). The workspace + // exposes the SPM package's schemes alongside ably-cocoa's Xcode + // project ones. + arguments.append(contentsOf: ["-workspace", "Ably.xcworkspace"]) + + 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/merged-repos/ably-liveobjects-swift-plugin/TestPlans/AllTests.xctestplan b/merged-repos/ably-liveobjects-swift-plugin/TestPlans/AllTests.xctestplan new file mode 100644 index 000000000..ac6758141 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/TestPlans/UnitTests.xctestplan b/merged-repos/ably-liveobjects-swift-plugin/TestPlans/UnitTests.xctestplan new file mode 100644 index 000000000..fc6f413af --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/.swiftformat b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/.swiftformat new file mode 100644 index 000000000..b7ca949e2 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/.swiftlint.yml b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/.swiftlint.yml new file mode 100644 index 000000000..d7c5fc369 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift new file mode 100644 index 000000000..fbb9ef899 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/CLAUDE.md b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/CLAUDE.md new file mode 100644 index 000000000..ab3e56b56 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift new file mode 100644 index 000000000..4cde079dc --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift @@ -0,0 +1,67 @@ +import Ably + +// Helpers for using ably-cocoa with Swift concurrency and typed throws. + +@available(macOS 10.15, iOS 13, tvOS 13, *) +@available(macOS 10.15, iOS 13, tvOS 13, *) +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() + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +@available(macOS 10.15, iOS 13, tvOS 13, *) +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() + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension ARTConnectionProtocol { + @discardableResult + func onceAsync(_ event: ARTRealtimeConnectionEvent) async -> ARTConnectionStateChange { + await withCheckedContinuation { continuation in + once(event) { stateChange in + continuation.resume(returning: stateChange) + } + } + } +} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift new file mode 100644 index 000000000..876416449 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift new file mode 100644 index 000000000..04892098a --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift new file mode 100644 index 000000000..d88edf51e --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift new file mode 100644 index 000000000..5b733bff0 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift new file mode 100644 index 000000000..b30500214 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift @@ -0,0 +1,8 @@ +import Testing + +@available(macOS 10.15, iOS 13, tvOS 13, *) +@available(macOS 10.15, iOS 13, tvOS 13, *) +extension Tag { + /// Tests that integrate with ably-cocoa. Usually long-running. + @Tag static var integration: Self +} diff --git a/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift new file mode 100644 index 000000000..55b63a008 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift new file mode 100644 index 000000000..05867de49 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift @@ -0,0 +1,43 @@ +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)") + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift new file mode 100644 index 000000000..a81046281 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift new file mode 100644 index 000000000..6d5a89203 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift new file mode 100644 index 000000000..f37595450 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift new file mode 100644 index 000000000..34cd3b18c --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift new file mode 100644 index 000000000..099ef6ae8 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -0,0 +1,5699 @@ +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +private func realtimeWithObjects(options: ClientHelper.PartialClientOptions) async throws -> ARTRealtime { + try await ClientHelper.realtimeWithObjects(options: options) +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +private func channelOptionsWithObjects() -> ARTRealtimeChannelOptions { + ClientHelper.channelOptionsWithObjects() +} + +// Swift version of the JS lexicoTimeserial function +// +// Example: +// +// 01726585978590-001@abcdefghij:001 +// |____________| |_| |________| |_| +// | | | | +// timestamp counter seriesId idx +@available(macOS 10.15, iOS 13, tvOS 13, *) +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 + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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`. +@available(macOS 10.15, iOS 13, tvOS 13, *) +private struct TestCaseID: Encodable, Hashable { + var description: String + var options: ClientHelper.PartialClientOptions? +} + +/// The input to `forScenarios`. +@available(macOS 10.15, iOS 13, tvOS 13, *) +private struct TestScenario { + var disabled: Bool + var allTransportsAndProtocols: Bool + var description: String + var action: @Sendable (Context) async throws -> Void +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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) +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +private protocol Scenarios { + associatedtype Context + static var scenarios: [TestScenario] { get } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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. +@available(macOS 10.15, iOS 13, tvOS 13, *) +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() + } +} + +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift new file mode 100644 index 000000000..d7d29e00f --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JSONValueTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JSONValueTests.swift new file mode 100644 index 000000000..bdbef4f32 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift new file mode 100644 index 000000000..981f1d30e --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift new file mode 100644 index 000000000..669c4b017 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift new file mode 100644 index 000000000..a012c94cf --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift new file mode 100644 index 000000000..e6904b1f4 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift new file mode 100644 index 000000000..8e1fc1ef4 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift new file mode 100644 index 000000000..79a434b82 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift new file mode 100644 index 000000000..640d4d831 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift new file mode 100644 index 000000000..d4a4cbba1 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift new file mode 100644 index 000000000..3fe457bf6 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift new file mode 100644 index 000000000..eeba2093c --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -0,0 +1,405 @@ +import _AblyPluginSupportPrivate +@testable import AblyLiveObjects +import Testing + +@available(macOS 10.15, iOS 13, tvOS 13, *) +@available(macOS 10.15, iOS 13, tvOS 13, *) +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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/SyncCursorTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/SyncCursorTests.swift new file mode 100644 index 000000000..be056fba1 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift new file mode 100644 index 000000000..5aa56b3be --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift new file mode 100644 index 000000000..a3cfcea65 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/WireValueTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/WireValueTests.swift new file mode 100644 index 000000000..7f29db0ee --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ably-common b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ably-common new file mode 120000 index 000000000..522d92698 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ably-common @@ -0,0 +1 @@ +../../../../Test/AblyTests/ably-common \ No newline at end of file diff --git a/merged-repos/ably-liveobjects-swift-plugin/images/SwiftSDK-LiveObjects-github.png b/merged-repos/ably-liveobjects-swift-plugin/images/SwiftSDK-LiveObjects-github.png new file mode 100644 index 000000000..ed504e0d3 Binary files /dev/null and b/merged-repos/ably-liveobjects-swift-plugin/images/SwiftSDK-LiveObjects-github.png differ diff --git a/merged-repos/ably-liveobjects-swift-plugin/images/unit-tests-test-plan-screenshot.png b/merged-repos/ably-liveobjects-swift-plugin/images/unit-tests-test-plan-screenshot.png new file mode 100644 index 000000000..5934ce80a Binary files /dev/null and b/merged-repos/ably-liveobjects-swift-plugin/images/unit-tests-test-plan-screenshot.png differ diff --git a/merged-repos/ably-liveobjects-swift-plugin/package-lock.json b/merged-repos/ably-liveobjects-swift-plugin/package-lock.json new file mode 100644 index 000000000..7ac4a172f --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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/merged-repos/ably-liveobjects-swift-plugin/package.json b/merged-repos/ably-liveobjects-swift-plugin/package.json new file mode 100644 index 000000000..94eb34922 --- /dev/null +++ b/merged-repos/ably-liveobjects-swift-plugin/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 ." + } +}