From b38ce55f50b4705ae5be52860373d8297b331ab4 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 27 May 2025 13:56:59 +0100 Subject: [PATCH 001/225] Initial commit This is copied from ably-chat-swift at 3518483. A few notes about changes I've made here: - Have kept the sandbox code for the tests; not yet sure whether we'll use it or whether we'll make ably-cocoa expose some testing APIs. - Increased minimum Xcode version to 16.3 so that we can use Swift 6.1 features, and bumped SwiftLint and SwiftFormat versions. - Have removed some of the contributing guidance (e.g. about what error types we use internally); will reintroduce if turns out to be necessary. - Briefly considered pulling BuildTool out into a separate repo, but didn't seem like a priority right now so have just copied its code. --- .github/workflows/check.yaml | 289 ++++++ .gitignore | 14 + .gitmodules | 3 + .prettierignore | 5 + .prettierrc | 1 + .swift-version | 1 + .swiftlint.yml | 112 +++ .../xcshareddata/WorkspaceSettings.xcsettings | 8 + .../AblyLiveObjects-Package.xcscheme | 110 +++ .../xcschemes/AblyLiveObjects.xcscheme | 87 ++ .../xcshareddata/xcschemes/BuildTool.xcscheme | 78 ++ .../contents.xcworkspacedata | 13 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 87 ++ CHANGELOG.md | 3 + CONTRIBUTING.md | 114 +++ COPYRIGHT | 1 + .../project.pbxproj | 394 +++++++++ .../contents.xcworkspacedata | 7 + .../xcshareddata/IDEWorkspaceChecks.plist | 8 + .../xcshareddata/swiftpm/Package.resolved | 24 + .../AblyLiveObjectsExample.entitlements | 12 + .../AblyLiveObjectsExampleApp.swift | 10 + .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 63 ++ .../Assets.xcassets/Contents.json | 6 + .../AblyLiveObjectsExample/ContentView.swift | 21 + .../Preview Assets.xcassets/Contents.json | 6 + LICENSE | 176 ++++ MAINTAINERS.md | 1 + Mintfile | 2 + Package.resolved | 87 ++ Package.swift | 79 ++ README.md | 29 + Sources/AblyLiveObjects/.swiftformat | 2 + Sources/AblyLiveObjects/.swiftlint.yml | 3 + Sources/AblyLiveObjects/AblyLiveObjects.swift | 5 + Sources/BuildTool/BuildTool.swift | 826 ++++++++++++++++++ Sources/BuildTool/Configuration.swift | 12 + Sources/BuildTool/DestinationFetcher.swift | 41 + Sources/BuildTool/DestinationPredicate.swift | 7 + Sources/BuildTool/DestinationSpecifier.swift | 15 + Sources/BuildTool/DestinationStrategy.swift | 6 + Sources/BuildTool/Error.swift | 6 + Sources/BuildTool/Platform.swift | 38 + Sources/BuildTool/ProcessRunner.swift | 79 ++ Sources/BuildTool/String+Decoding.swift | 16 + Sources/BuildTool/XcodeRunner.swift | 69 ++ TestPlans/AllTests.xctestplan | 24 + TestPlans/UnitTests.xctestplan | 29 + .../AblyLiveObjectsTests.swift | 8 + .../Helpers/Sandbox.swift | 50 ++ Tests/AblyLiveObjectsTests/ably-common | 1 + images/unit-tests-test-plan-screenshot.png | Bin 0 -> 118545 bytes package-lock.json | 30 + package.json | 12 + 56 files changed, 3149 insertions(+) create mode 100644 .github/workflows/check.yaml create mode 100644 .gitignore create mode 100644 .gitmodules create mode 100644 .prettierignore create mode 100644 .prettierrc create mode 100644 .swift-version create mode 100644 .swiftlint.yml create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme create mode 100644 AblyLiveObjects.xcworkspace/contents.xcworkspacedata create mode 100644 AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 COPYRIGHT create mode 100644 Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj create mode 100644 Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist create mode 100644 Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved create mode 100644 Example/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements create mode 100644 Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift create mode 100644 Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 Example/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json create mode 100644 Example/AblyLiveObjectsExample/ContentView.swift create mode 100644 Example/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json create mode 100644 LICENSE create mode 100644 MAINTAINERS.md create mode 100644 Mintfile create mode 100644 Package.resolved create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/AblyLiveObjects/.swiftformat create mode 100644 Sources/AblyLiveObjects/.swiftlint.yml create mode 100644 Sources/AblyLiveObjects/AblyLiveObjects.swift create mode 100644 Sources/BuildTool/BuildTool.swift create mode 100644 Sources/BuildTool/Configuration.swift create mode 100644 Sources/BuildTool/DestinationFetcher.swift create mode 100644 Sources/BuildTool/DestinationPredicate.swift create mode 100644 Sources/BuildTool/DestinationSpecifier.swift create mode 100644 Sources/BuildTool/DestinationStrategy.swift create mode 100644 Sources/BuildTool/Error.swift create mode 100644 Sources/BuildTool/Platform.swift create mode 100644 Sources/BuildTool/ProcessRunner.swift create mode 100644 Sources/BuildTool/String+Decoding.swift create mode 100644 Sources/BuildTool/XcodeRunner.swift create mode 100644 TestPlans/AllTests.xctestplan create mode 100644 TestPlans/UnitTests.xctestplan create mode 100644 Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift create mode 100644 Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift create mode 160000 Tests/AblyLiveObjectsTests/ably-common create mode 100644 images/unit-tests-test-plan-screenshot.png create mode 100644 package-lock.json create mode 100644 package.json diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml new file mode 100644 index 000000000..668bed56d --- /dev/null +++ b/.github/workflows/check.yaml @@ -0,0 +1,289 @@ +name: Check + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main +jobs: + lint: + runs-on: macos-15 + + # From actions/cache documentation linked to below + env: + MINT_PATH: .mint/lib + MINT_LINK_PATH: .mint/bin + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + # This step can be removed once the runners’ default version of Xcode is 16.3 or above + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16.3 + + # We use caching for Mint because at the time of writing SwiftLint took about 5 minutes to build in CI, which is unacceptably slow. + # https://github.com/actions/cache/blob/40c3b67b2955d93d83b27ed164edd0756bc24049/examples.md#swift---mint + - uses: actions/cache@v4 + with: + path: .mint + key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }} + restore-keys: | + ${{ runner.os }}-mint- + + - run: npm ci + - run: brew install mint + - run: mint bootstrap + + - run: swift run BuildTool lint + + # TODO: Restore in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/2 once we've seen what form the LiveObjects spec takes + # + # spec-coverage: + # runs-on: macos-15 + # steps: + # - uses: actions/checkout@v4 + # with: + # submodules: true + # + # # This step can be removed once the runners’ default version of Xcode is 16.3 or above + # - uses: maxim-lobanov/setup-xcode@v1 + # with: + # xcode-version: 16.3 + # + # - name: Spec coverage + # run: swift run BuildTool spec-coverage --spec-commit-sha 2f88b1b + # env: + # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + generate-matrices: + runs-on: macos-15 + outputs: + matrix: ${{ steps.generation-step.outputs.matrix }} + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + # This step can be removed once the runners’ default version of Xcode is 16.3 or above + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16.3 + + - id: generation-step + run: swift run BuildTool generate-matrices >> $GITHUB_OUTPUT + + build-and-test-spm: + name: SPM (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withoutPlatform }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + # https://forums.swift.org/t/warnings-as-errors-for-libraries-frameworks/58393/2 + - run: swift build -Xswiftc -warnings-as-errors + - run: swift test -Xswiftc -warnings-as-errors + + build-release-configuration-spm: + name: SPM, `release` configuration (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withoutPlatform }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + # https://forums.swift.org/t/warnings-as-errors-for-libraries-frameworks/58393/2 + - run: swift build -Xswiftc -warnings-as-errors --configuration release + + build-and-test-xcode: + name: Xcode, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + # We run these as two separate steps so that we can easily see the execution time of each step. + + - name: Build for testing + run: swift run BuildTool build-library-for-testing --platform ${{ matrix.platform }} + + - name: Run tests + run: swift run BuildTool test-library --platform ${{ matrix.platform }} + + code-coverage: + name: Generate code coverage + runs-on: macos-15 + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + # This step can be removed once the runners’ default version of Xcode is 16.3 or above + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16.3 + + - run: swift run BuildTool generate-code-coverage --result-bundle-path CodeCoverage.xcresult + + # Generate a Markdown report of the code coverage information and add it to the workflow run. + # + # This tool is the best option that I could find after a brief look at the options. There are a few things that I wish it could do: + # + # - post a message on the pull request, like they do on Kotlin + # - offer more fine-grained configuration about which data to include in the report (I only care about code coverage, not test results, and I don't care about code coverage of the AblyLiveObjectsTests target) + # + # but it'll do for now (we can always fork or have another look for tooling later). + - uses: slidoapp/xcresulttool@v3.1.0 + with: + path: CodeCoverage.xcresult + # This title will be used for the sidebar item that this job adds to GitHub results page for this workflow + title: Code coverage results + # Turning off as much non-code-coverage information as it lets me + show-passed-tests: false + if: success() || failure() + + build-release-configuration-xcode: + name: Xcode, `release` configuration, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + - name: Build library + run: swift run BuildTool build-library --platform ${{ matrix.platform }} --configuration release + + check-example-app: + name: Example app, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) + runs-on: macos-15 + needs: generate-matrices + + strategy: + fail-fast: false + matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: ${{ matrix.tooling.xcodeVersion }} + + - name: Build example app + run: swift run BuildTool build-example-app --platform ${{ matrix.platform }} + + check-documentation: + runs-on: macos-15 + + permissions: + deployments: write + id-token: write + contents: read + + steps: + - uses: actions/checkout@v4 + with: + submodules: true + + # This step can be removed once the runners’ default version of Xcode is 16.3 or above + - uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: 16.3 + + # Dry run upload-action to get base-path url + - name: Dry-Run Upload (to get url) + id: preupload + uses: ably/sdk-upload-action@v2 + with: + mode: preempt + sourcePath: .build/plugins/Swift-DocC/outputs/AblyLiveObjects.doccarchive # Path to the Swift DocC output folder + githubToken: ${{ secrets.GITHUB_TOKEN }} + artifactName: AblyLiveObjects + + # Build the documentation using Swift DocC + - name: Build documentation + run: | + swift package generate-documentation --target AblyLiveObjects --disable-indexing \ + --hosting-base-path "${{ steps.preupload.outputs.base-path }}" \ + --transform-for-static-hosting + working-directory: ${{ github.workspace }} + + # Configure AWS credentials for uploading documentation + - name: Configure AWS Credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + aws-region: eu-west-2 + role-to-assume: arn:aws:iam::${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}:role/ably-sdk-builds-ably-cocoa-liveobjects-plugin + role-session-name: "${{ github.run_id }}-${{ github.run_number }}" + + # Upload the generated documentation + - name: Upload Documentation + uses: ably/sdk-upload-action@v2 + with: + sourcePath: .build/plugins/Swift-DocC/outputs/AblyLiveObjects.doccarchive # Path to the Swift DocC output folder + githubToken: ${{ secrets.GITHUB_TOKEN }} + artifactName: AblyLiveObjects # Optional root-level directory name + landingPagePath: documentation/ablyliveobjects + + # We use this job as a marker that all of the required checks have completed. + # This allows us to configure a single required status check in our branch + # protection rules instead of having to type loads of different check names + # into the branch protection web UI (and keep this list up to date as we + # tweak the matrices). + all-checks-completed: + runs-on: ubuntu-latest + needs: + - lint + # - spec-coverage + - build-and-test-spm + - build-release-configuration-spm + - build-and-test-xcode + - build-release-configuration-xcode + - check-example-app + - check-documentation + - code-coverage + + steps: + - name: No-op + run: "true" diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..f8c1a11c0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +# 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 + +/node_modules +/.mint diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..aecc76733 --- /dev/null +++ b/.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/.prettierignore b/.prettierignore new file mode 100644 index 000000000..e0a7f7973 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,5 @@ +# Don’t try and format the asset catalogue JSON files, which are managed by Xcode +*.xcassets/ + +# Submodules +Tests/AblyLiveObjectsTests/ably-common diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/.prettierrc @@ -0,0 +1 @@ +{} diff --git a/.swift-version b/.swift-version new file mode 100644 index 000000000..a435f5a56 --- /dev/null +++ b/.swift-version @@ -0,0 +1 @@ +6.1 diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 000000000..4319a3602 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,112 @@ +excluded: + - .build + +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/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings new file mode 100644 index 000000000..54782e32f --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings @@ -0,0 +1,8 @@ + + + + + IDEWorkspaceSharedSettings_AutocreateContextsIfNeeded + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme new file mode 100644 index 000000000..5c088be7a --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme @@ -0,0 +1,110 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme new file mode 100644 index 000000000..007419881 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme @@ -0,0 +1,87 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme new file mode 100644 index 000000000..dac481de7 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AblyLiveObjects.xcworkspace/contents.xcworkspacedata b/AblyLiveObjects.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..82a77443a --- /dev/null +++ b/AblyLiveObjects.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,13 @@ + + + + + + + + + diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..3a5e8dd44 --- /dev/null +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,87 @@ +{ + "originHash" : "a67c16228290f1b8c671d836ff39d317eedc33a6344ba3a66295d1127aa6241a", + "pins" : [ + { + "identity" : "ably-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/ably-cocoa", + "state" : { + "revision" : "c5347707e4f9fb907df0e5c334de445899a79783", + "version" : "1.2.40" + } + }, + { + "identity" : "delta-codec-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/delta-codec-cocoa", + "state" : { + "revision" : "3ee62ea40a63996b55818d44b3f0e56d8753be88", + "version" : "1.3.3" + } + }, + { + "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/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 000000000..a56de9840 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,3 @@ +# Change Log + +This will be updated once we've done our first release. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..b55bd395d --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,114 @@ +# 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 will run only the unit tests. These tests are very quick to execute, so it's a useful option to have for quick feedback when developing. + +Here's how to set this 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. + +### Throwing errors + +- The public API of the SDK should use typed throws, and the thrown errors should be of type `ARTErrorInfo`. + +#### 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. +``` + +## 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 +- Update the following (we have https://github.com/ably/ably-chat-swift/issues/277 for adding a script to do this): + - the `version` constant in [`Sources/AblyLiveObjects/Version.swift`](Sources/AblyLiveObjects/Version.swift) + - the `from: "…"` in the SPM installation instructions in [`README.md`](README.md) +- Go to [Github releases](https://github.com/ably/ably-cocoa-liveobjects-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/COPYRIGHT b/COPYRIGHT new file mode 100644 index 000000000..625d86cf7 --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1 @@ +Copyright 2024 Ably Real-time Ltd (ably.com) diff --git a/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj b/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj new file mode 100644 index 000000000..097bba4c5 --- /dev/null +++ b/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/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 000000000..919434a62 --- /dev/null +++ b/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 000000000..18d981003 --- /dev/null +++ b/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 000000000..05de7ab26 --- /dev/null +++ b/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/Example/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements b/Example/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements new file mode 100644 index 000000000..625af03d9 --- /dev/null +++ b/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/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift b/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift new file mode 100644 index 000000000..15d0f145f --- /dev/null +++ b/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift @@ -0,0 +1,10 @@ +import SwiftUI + +@main +struct AblyLiveObjectsExampleApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json b/Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/Example/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..532cd729c --- /dev/null +++ b/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/Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json b/Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Example/AblyLiveObjectsExample/ContentView.swift b/Example/AblyLiveObjectsExample/ContentView.swift new file mode 100644 index 000000000..93f301c92 --- /dev/null +++ b/Example/AblyLiveObjectsExample/ContentView.swift @@ -0,0 +1,21 @@ +import AblyLiveObjects +import SwiftUI + +struct ContentView: View { + /// Just used to check that we can successfully import and use the AblyLiveObjects library. TODO remove this once we start building the library + @State private var ablyLiveObjectsClient = AblyLiveObjectsClient() + + var body: some View { + VStack { + Image(systemName: "globe") + .imageScale(.large) + .foregroundStyle(.tint) + Text("Hello, world!") + } + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/Example/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json b/Example/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/Example/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..d9a10c0d8 --- /dev/null +++ b/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/MAINTAINERS.md b/MAINTAINERS.md new file mode 100644 index 000000000..e5fda9517 --- /dev/null +++ b/MAINTAINERS.md @@ -0,0 +1 @@ +This repository is owned by the Ably Ecosystems team. diff --git a/Mintfile b/Mintfile new file mode 100644 index 000000000..b2015d8dc --- /dev/null +++ b/Mintfile @@ -0,0 +1,2 @@ +realm/SwiftLint@0.59.1 +nicklockwood/SwiftFormat@0.56.1 diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 000000000..149410521 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,87 @@ +{ + "originHash" : "334ffa0bc53636f87e6002c166aa5b75c3bd633c67ad43e001ee31ab03f11e2b", + "pins" : [ + { + "identity" : "ably-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/ably-cocoa", + "state" : { + "revision" : "c5347707e4f9fb907df0e5c334de445899a79783", + "version" : "1.2.40" + } + }, + { + "identity" : "delta-codec-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/delta-codec-cocoa", + "state" : { + "revision" : "3ee62ea40a63996b55818d44b3f0e56d8753be88", + "version" : "1.3.3" + } + }, + { + "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/Package.swift b/Package.swift new file mode 100644 index 000000000..5c4875eac --- /dev/null +++ b/Package.swift @@ -0,0 +1,79 @@ +// 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", + from: "1.2.40", + ), + .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", + ), + ], + ), + .testTarget( + name: "AblyLiveObjectsTests", + dependencies: [ + "AblyLiveObjects", + ], + 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/README.md b/README.md new file mode 100644 index 000000000..9ac6273ab --- /dev/null +++ b/README.md @@ -0,0 +1,29 @@ +# Ably LiveObjects plugin for ably-cocoa SDK + +This is a work in progress plugin that enables LiveObjects functionality in the [ably-cocoa](https://github.com/ably/ably-cocoa/) SDK. It is not yet ready to be used. + +## Supported Platforms + +- macOS 11 and above +- iOS 14 and above +- tvOS 14 and above + +## Requirements + +Xcode 16.3 or later. + +## Example app + +This repository contains an example app, written using SwiftUI, which demonstrates how to use the SDK. 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, open the `AblyLiveObjects.xcworkspace` workspace in Xcode and run the `AblyLiveObjectsExample` target. If you wish to run it on an iOS or tvOS device, you’ll need to set up code signing. + +## Contributing + +For guidance on how to contribute to this project, see the [contributing guidelines](CONTRIBUTING.md). diff --git a/Sources/AblyLiveObjects/.swiftformat b/Sources/AblyLiveObjects/.swiftformat new file mode 100644 index 000000000..6252d93b7 --- /dev/null +++ b/Sources/AblyLiveObjects/.swiftformat @@ -0,0 +1,2 @@ +# To avoid clash with SwiftLint’s explicit_acl rule +--disable redundantInternal diff --git a/Sources/AblyLiveObjects/.swiftlint.yml b/Sources/AblyLiveObjects/.swiftlint.yml new file mode 100644 index 000000000..b3580378b --- /dev/null +++ b/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/Sources/AblyLiveObjects/AblyLiveObjects.swift b/Sources/AblyLiveObjects/AblyLiveObjects.swift new file mode 100644 index 000000000..24ea75c73 --- /dev/null +++ b/Sources/AblyLiveObjects/AblyLiveObjects.swift @@ -0,0 +1,5 @@ +// Placeholder file. + +public class AblyLiveObjectsClient { + public init() {} +} diff --git a/Sources/BuildTool/BuildTool.swift b/Sources/BuildTool/BuildTool.swift new file mode 100644 index 000000000..fbfb10288 --- /dev/null +++ b/Sources/BuildTool/BuildTool.swift @@ -0,0 +1,826 @@ +import ArgumentParser +import AsyncAlgorithms +import Foundation +import Table + +@main +@available(macOS 14, *) +struct BuildTool: AsyncParsableCommand { + static let configuration = CommandConfiguration( + subcommands: [ + BuildLibrary.self, + BuildLibraryForTesting.self, + TestLibrary.self, + BuildExampleApp.self, + GenerateMatrices.self, + Lint.self, + SpecCoverage.self, + BuildDocumentation.self, + GenerateCodeCoverage.self, + ], + ) +} + +@available(macOS 14, *) +struct BuildLibrary: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Build the AblyLiveObjects library", + ) + + @Option var configuration: Configuration? + @Option var platform: Platform + + mutating func run() async throws { + let destinationSpecifier = try await platform.resolve() + let scheme = "AblyLiveObjects" + + try await XcodeRunner.runXcodebuild(action: "build", configuration: configuration, scheme: scheme, destination: destinationSpecifier) + } +} + +@available(macOS 14, *) +struct BuildLibraryForTesting: AsyncParsableCommand { + static let configuration = CommandConfiguration( + abstract: "Build the AblyLiveObjects library for testing", + discussion: "After running this command, you can run the test-library command.", + ) + + @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: "You need to run the build-library-for-testing command before running this command.", + ) + + @Option var platform: Platform + + mutating func run() async throws { + let destinationSpecifier = try await platform.resolve() + let scheme = "AblyLiveObjects" + + try await XcodeRunner.runXcodebuild(action: "test-without-building", scheme: scheme, destination: destinationSpecifier) + } +} + +@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() + + 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.3"].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/Sources/BuildTool/Configuration.swift b/Sources/BuildTool/Configuration.swift new file mode 100644 index 000000000..48ddbc6b0 --- /dev/null +++ b/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/Sources/BuildTool/DestinationFetcher.swift b/Sources/BuildTool/DestinationFetcher.swift new file mode 100644 index 000000000..2d4f99c52 --- /dev/null +++ b/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/Sources/BuildTool/DestinationPredicate.swift b/Sources/BuildTool/DestinationPredicate.swift new file mode 100644 index 000000000..82b245861 --- /dev/null +++ b/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/Sources/BuildTool/DestinationSpecifier.swift b/Sources/BuildTool/DestinationSpecifier.swift new file mode 100644 index 000000000..25f1f8c69 --- /dev/null +++ b/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/Sources/BuildTool/DestinationStrategy.swift b/Sources/BuildTool/DestinationStrategy.swift new file mode 100644 index 000000000..5acdd4d2e --- /dev/null +++ b/Sources/BuildTool/DestinationStrategy.swift @@ -0,0 +1,6 @@ +import Foundation + +enum DestinationStrategy { + case fixed(platform: String) + case lookup(destinationPredicate: DestinationPredicate) +} diff --git a/Sources/BuildTool/Error.swift b/Sources/BuildTool/Error.swift new file mode 100644 index 000000000..1978ed975 --- /dev/null +++ b/Sources/BuildTool/Error.swift @@ -0,0 +1,6 @@ +import Foundation + +enum Error: Swift.Error { + case terminatedWithExitCode(Int32) + case simulatorLookupFailed(message: String) +} diff --git a/Sources/BuildTool/Platform.swift b/Sources/BuildTool/Platform.swift new file mode 100644 index 000000000..c963d2c1e --- /dev/null +++ b/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-18-0", deviceType: "iPhone-16")) + case .tvOS: + .lookup(destinationPredicate: .init(runtime: "tvOS-18-0", 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/Sources/BuildTool/ProcessRunner.swift b/Sources/BuildTool/ProcessRunner.swift new file mode 100644 index 000000000..6219c0138 --- /dev/null +++ b/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/Sources/BuildTool/String+Decoding.swift b/Sources/BuildTool/String+Decoding.swift new file mode 100644 index 000000000..13565bdbc --- /dev/null +++ b/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/Sources/BuildTool/XcodeRunner.swift b/Sources/BuildTool/XcodeRunner.swift new file mode 100644 index 000000000..16dcb9eee --- /dev/null +++ b/Sources/BuildTool/XcodeRunner.swift @@ -0,0 +1,69 @@ +import Foundation + +@available(macOS 14, *) +enum XcodeRunner { + static func runXcodebuild(action: String?, configuration: Configuration? = nil, scheme: String, destination: DestinationSpecifier, testPlan: String? = nil, resultBundlePath: String? = nil) async throws { + var arguments: [String] = [] + + if let action { + arguments.append(action) + } + + if let configuration { + arguments.append(contentsOf: ["-configuration", configuration.rawValue]) + } + + arguments.append(contentsOf: ["-scheme", scheme]) + arguments.append(contentsOf: ["-destination", destination.xcodebuildArgument]) + + if let testPlan { + arguments.append(contentsOf: ["-testPlan", testPlan]) + } + + if let resultBundlePath { + arguments.append(contentsOf: ["-resultBundlePath", resultBundlePath]) + } + + /* + Note: I was previously passing SWIFT_TREAT_WARNINGS_AS_ERRORS=YES here, but am no longer able to do so, for the following reasons: + + 1. After adding a new package dependency, Xcode started trying to pass + the Swift compiler the -suppress-warnings flag when compiling one of + the newly-added transitive dependencies. This clashes with the + -warnings-as-errors flag that Xcode adds when you set + SWIFT_TREAT_WARNINGS_AS_ERRORS=YES, leading to a compiler error like + + > error: Conflicting options '-warnings-as-errors' and '-suppress-warnings' (in target 'InternalCollectionsUtilities' from project 'swift-collections') + + It’s not clear _why_ Xcode is adding this flag (see + https://forums.swift.org/t/warnings-as-errors-in-sub-packages/70810), + but perhaps it’s because of what I mention in point 2 below. + + It seems that there is no way to tell Xcode, when building your own + Swift package, “treat warnings as errors, but only for my package, and + not for its dependencies”. + + 2. So, I thought that I’d try making Xcode remove the + -suppress-warnings flag by additionally passing + SWIFT_SUPPRESS_WARNINGS=NO, but this also doesn’t work because it turns + out that one of our dependencies (swift-async-algorithms) actually does + have some warnings, causing the build to fail. + + tl;dr: There doesn’t seem to be a way to treat warnings as errors when + compiling the package from Package.swift using Xcode. + + It’s probably OK, though, because we also compile the package with SPM, + and hopefully that will flag any warnings in CI (unless there’s some + class of warnings I’m not aware of that only appear when compiling + against the tvOS or iOS SDK). + + (I imagine that using .unsafeFlags(["-warnings-as-errors"]) in the + manifest might work, but then that’d stop other people from being able + to use us as a dependency. I suppose we could, in CI at least, do + something like modifying the manifest as part of the build process, but + that seems like a nuisance.) + */ + + try await ProcessRunner.run(executableName: "xcodebuild", arguments: arguments) + } +} diff --git a/TestPlans/AllTests.xctestplan b/TestPlans/AllTests.xctestplan new file mode 100644 index 000000000..ac6758141 --- /dev/null +++ b/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/TestPlans/UnitTests.xctestplan b/TestPlans/UnitTests.xctestplan new file mode 100644 index 000000000..fc6f413af --- /dev/null +++ b/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/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift new file mode 100644 index 000000000..8e4bf37c4 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -0,0 +1,8 @@ +import Testing + +struct AblyLiveObjectsTests { + @Test + func example() { + // just to get code-coverage task passing + } +} diff --git a/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift b/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift new file mode 100644 index 000000000..2d447ea5e --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift @@ -0,0 +1,50 @@ +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 + } +} diff --git a/Tests/AblyLiveObjectsTests/ably-common b/Tests/AblyLiveObjectsTests/ably-common new file mode 160000 index 000000000..60fd9cf10 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/ably-common @@ -0,0 +1 @@ +Subproject commit 60fd9cf106abb1d6292fdd87d63ea33c552c8f33 diff --git a/images/unit-tests-test-plan-screenshot.png b/images/unit-tests-test-plan-screenshot.png new file mode 100644 index 0000000000000000000000000000000000000000..5934ce80a4119283d562e60f09f55226b2535101 GIT binary patch literal 118545 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 VJe%>| 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

9yK$!MTP#F?7`G-54RL}@_| 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%0D7D!Mm6T2 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 literal 0 HcmV?d00001 diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 000000000..59e4ef29a --- /dev/null +++ b/package-lock.json @@ -0,0 +1,30 @@ +{ + "name": "ably-cocoa-liveobjects-plugin-dev-tooling", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "ably-cocoa-liveobjects-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/package.json b/package.json new file mode 100644 index 000000000..fb991e127 --- /dev/null +++ b/package.json @@ -0,0 +1,12 @@ +{ + "name": "ably-cocoa-liveobjects-plugin-dev-tooling", + "version": "0.1.0", + "description": "Development tooling for the ably-cocoa-liveobjects-plugin repo", + "devDependencies": { + "prettier": "^3.3.3" + }, + "scripts": { + "prettier:check": "prettier --check .", + "prettier:fix": "prettier --write ." + } +} From ce8c022f7e8f3551de3c8ae0ad398f59d8639137 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 27 May 2025 15:20:54 +0100 Subject: [PATCH 002/225] Add a first attempt at the public API Based on that of ably-js at f6fbe35. This only is a first attempt so that we have _something_ to implement, and I didn't want to get bogged down by making lots of small decisions right now. Have created #4 for revisiting for improved Swiftiness. The documentation comments are copied from JS. Used Cursor to convert it to Swift syntax and then made some minor edits myself. --- .../Public/ARTRealtimeChannel+Objects.swift | 8 + .../AblyLiveObjects/Public/PublicTypes.swift | 292 ++++++++++++++++++ .../AblyLiveObjects/Utility/Assertions.swift | 7 + 3 files changed, 307 insertions(+) create mode 100644 Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift create mode 100644 Sources/AblyLiveObjects/Public/PublicTypes.swift create mode 100644 Sources/AblyLiveObjects/Utility/Assertions.swift diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift new file mode 100644 index 000000000..3e2cc956f --- /dev/null +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -0,0 +1,8 @@ +import Ably + +public extension ARTRealtimeChannel { + /// An ``Objects`` object. + var objects: Objects { + notYetImplemented() + } +} diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift new file mode 100644 index 000000000..74054837d --- /dev/null +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -0,0 +1,292 @@ +import Ably + +/// A callback used in ``LiveObject`` to listen for updates to the object. +/// +/// - Parameter update: The update object describing the changes made to the object. +public typealias LiveObjectUpdateCallback = (_ update: T) -> Void + +/// The callback used for the events emitted by ``Objects``. +public typealias ObjectsEventCallback = () -> Void + +/// The callback used for the lifecycle events emitted by ``LiveObject``. +public typealias LiveObjectLifecycleEventCallback = () -> Void + +/// A function passed to ``Objects/batch(callback:)`` to group multiple Objects operations into a single channel message. +/// +/// - Parameter batchContext: A ``BatchContext`` object that allows grouping Objects operations for this batch. +public typealias BatchCallback = (_ batchContext: BatchContext) -> Void + +/// Describes the events emitted by an ``Objects`` object. +public enum ObjectsEvent { + /// The local copy of Objects on a channel is currently being synchronized with the Ably service. + case syncing + /// The local copy of Objects on a channel has been synchronized with the Ably service. + case synced +} + +/// Describes the events emitted by a ``LiveObject`` object. +public enum LiveObjectLifecycleEvent { + /// Indicates that the object has been deleted from the Objects pool and should no longer be interacted with. + case deleted +} + +/// Enables the Objects to be read, modified and subscribed to for a channel. +public protocol Objects { + /// 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: any LiveMap) 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: Int) async throws(ARTErrorInfo) -> any LiveCounter + + /// Creates a new ``LiveCounter`` object instance with a value of zero. + func createCounter() async throws(ARTErrorInfo) -> any LiveCounter + + /// Allows you to group multiple operations together and send them to the Ably service in a single channel message. + /// As a result, other clients will receive the changes as a single channel message after the batch function has completed. + /// + /// This method accepts a synchronous callback, which is provided with a ``BatchContext`` object. + /// Use the context object to access Objects on a channel and batch operations for them. + /// + /// The objects' data is not modified inside the callback function. Instead, the objects will be updated + /// when the batched operations are applied by the Ably service and echoed back to the client. + /// + /// - Parameter callback: A batch callback function used to group operations together. + func batch(callback: BatchCallback) async throws + + /// 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. + func on(event: ObjectsEvent, callback: 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 (``PrimitiveObjectValue``), or another ``LiveObject``. +public enum LiveMapValue { + case primitive(PrimitiveObjectValue) + case liveMap(any LiveMap) + case liveCounter(any LiveCounter) +} + +/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. +public protocol OnObjectsEventResponse { + /// Deregisters the listener passed to the `on` call. + func off() +} + +/// Enables grouping multiple Objects operations together by providing `BatchContext*` wrapper objects. +public protocol BatchContext { + /// Mirrors the ``Objects/getRoot()`` method and returns a ``BatchContextLiveMap`` wrapper for the root object on a channel. + /// + /// - Returns: A ``BatchContextLiveMap`` object. + func getRoot() -> BatchContextLiveMap +} + +/// A wrapper around the ``LiveMap`` object that enables batching operations inside a ``BatchCallback``. +public protocol BatchContextLiveMap: AnyObject { + /// Mirrors the ``LiveMap/get(key:)`` method and returns the value associated with a key in the map. + /// + /// - Parameter key: The key to retrieve the value for. + /// - Returns: A ``LiveObject``, a primitive type (string, number, boolean, 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) -> LiveMapValue? + + /// Returns the number of key-value pairs in the map. + var size: Int { get } + + /// Similar to the ``LiveMap/set(key:value:)`` method, but instead, it adds an operation to set a key in the map with the provided value to the current batch, to be sent in a single message to the Ably service. + /// + /// 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?) + + /// Similar to the ``LiveMap/remove(key:)`` method, but instead, it adds an operation to remove a key from the map to the current batch, to be sent in a single message to the Ably service. + /// + /// 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 set the value for. + func remove(key: String) +} + +/// A wrapper around the ``LiveCounter`` object that enables batching operations inside a ``BatchCallback``. +public protocol BatchContextLiveCounter: AnyObject { + /// Returns the current value of the counter. + var value: Int { get } + + /// Similar to the ``LiveCounter/increment(amount:)`` method, but instead, it adds an operation to increment the counter value to the current batch, to be sent in a single message to the Ably service. + /// + /// 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: Int) + + /// An alias for calling [`increment(-amount)`](doc:BatchContextLiveCounter/increment(amount:)). + /// + /// - Parameter amount: The amount by which to decrease the counter value. + func decrement(amount: Int) +} + +/// 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, or binary data (see ``PrimitiveObjectValue``). +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, 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) -> LiveMapValue? + + /// Returns the number of key-value pairs in the map. + var size: Int { get } + + /// Returns an array of key-value pairs for every entry in the map. + var entries: [(key: String, value: LiveMapValue)] { get } + + /// Returns an array of keys in the map. + var keys: [String] { get } + + /// Returns an iterable of values in the map. + var values: [LiveMapValue] { get } + + /// Sends an operation to the Ably system to set a key on this `LiveMap` object to a specified value. + /// + /// This does not modify the underlying data of this object. Instead, the change is applied when + /// the published operation is echoed back to the client and applied to the object. + /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. + /// + /// - Parameters: + /// - key: The key to set the value for. + /// - value: The value to assign to the key. + func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) + + /// Sends an operation to the Ably system to remove a key from this `LiveMap` object. + /// + /// This does not modify the underlying data of this object. Instead, the change is applied when + /// the published operation is echoed back to the client and applied to the object. + /// To get notified when object gets updated, use the ``LiveObject/subscribe(listener:)`` method. + /// + /// - Parameter key: The key to remove. + func remove(key: String) async throws(ARTErrorInfo) +} + +/// Describes whether an entry in ``LiveMapUpdate/update`` represents an update or a removal. +public enum LiveMapUpdateAction { + /// The value of a key in the map was updated. + case updated + /// The value of a key in the map was removed. + case removed +} + +/// Represents an update to a ``LiveMap`` object, describing the keys that were updated or removed. +public protocol LiveMapUpdate { + /// 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 } +} + +/// Represents a primitive value that can be stored in a ``LiveMap``. +public enum PrimitiveObjectValue { + case string(String) + case number(Double) + case bool(Bool) + case data(Data) +} + +/// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. +public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { + /// Returns the current value of the counter. + var value: Int { get } + + /// 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: Int) 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: Int) async throws(ARTErrorInfo) +} + +/// Represents an update to a ``LiveCounter`` object. +public protocol LiveCounterUpdate { + /// Holds the numerical change to the counter value. + var amount: Int { get } +} + +/// Describes the common interface for all conflict-free data structures supported by the Objects. +public protocol LiveObject: AnyObject { + /// 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. + func subscribe(listener: LiveObjectUpdateCallback) -> 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. + func on(event: LiveObjectLifecycleEvent, callback: LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse + + /// Removes all registrations that match both the specified listener and the specified event. + /// + /// - Parameters: + /// - event: The named event. + /// - callback: The event listener. + func off(event: LiveObjectLifecycleEvent, callback: LiveObjectLifecycleEventCallback) + + /// Deregisters all registrations, for all events and listeners. + func offAll() +} + +/// Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. +public protocol SubscribeResponse { + /// Deregisters the listener passed to the `subscribe` call. + func unsubscribe() +} + +/// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. +public protocol OnLiveObjectLifecycleEventResponse { + /// Deregisters the listener passed to the `on` call. + func off() +} diff --git a/Sources/AblyLiveObjects/Utility/Assertions.swift b/Sources/AblyLiveObjects/Utility/Assertions.swift new file mode 100644 index 000000000..bc1045e27 --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/Assertions.swift @@ -0,0 +1,7 @@ +/// Stops execution because we tried to use a feature that is not yet implemented. +internal func notYetImplemented(_ message: @autoclosure () -> String = String(), file _: StaticString = #file, line _: UInt = #line) -> Never { + fatalError({ + let returnedMessage = message() + return "Not yet implemented\(returnedMessage.isEmpty ? "" : ": \(returnedMessage)")" + }()) +} From de3f497129ad94ea07607cc2e061eaa4ff47b7f9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 2 Jun 2025 16:41:59 -0300 Subject: [PATCH 003/225] Add missing subheading Missed in b38ce55. --- CONTRIBUTING.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b55bd395d..03012495e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,6 +44,8 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with ` - The public API of the SDK should use typed throws, and the thrown errors should be of type `ARTErrorInfo`. +### 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: From 29c6aa873b76d9689df4e89066aff9cfc401808d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 28 May 2025 17:14:53 +0100 Subject: [PATCH 004/225] Temporarily switch to getting ably-cocoa via a submodule MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I think it'll be a bit quicker to iterate on development of ably-cocoa alongside this plugin this way, instead of e.g. - using SPM to pin to a commit — you have to push and then tell SPM to resolve dependencies each time you want to test a change locally - using SPM to point to a local copy of the repo — this doesn't work cross-machine and doesn't work in CI We'll revert this (but describe how to use it if quick ably-cocoa iteration is again needed) nearer launch in #7. --- .gitmodules | 3 +++ .prettierignore | 1 + .swiftformat | 2 ++ .swiftlint.yml | 2 ++ .../xcshareddata/swiftpm/Package.resolved | 11 +---------- CONTRIBUTING.md | 6 ++++++ Package.resolved | 11 +---------- Package.swift | 3 +-- ably-cocoa | 1 + 9 files changed, 18 insertions(+), 22 deletions(-) create mode 100644 .swiftformat create mode 160000 ably-cocoa diff --git a/.gitmodules b/.gitmodules index aecc76733..007962dae 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,3 +1,6 @@ [submodule "Tests/AblyLiveObjectsTests/ably-common"] path = Tests/AblyLiveObjectsTests/ably-common url = https://github.com/ably/ably-common +[submodule "ably-cocoa"] + path = ably-cocoa + url = git@github.com:ably/ably-cocoa.git diff --git a/.prettierignore b/.prettierignore index e0a7f7973..ad33706e8 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,4 @@ # Submodules Tests/AblyLiveObjectsTests/ably-common +ably-cocoa diff --git a/.swiftformat b/.swiftformat new file mode 100644 index 000000000..fe6b3ba66 --- /dev/null +++ b/.swiftformat @@ -0,0 +1,2 @@ +# Submodules +--exclude ably-cocoa diff --git a/.swiftlint.yml b/.swiftlint.yml index 4319a3602..cd4f1384c 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -1,5 +1,7 @@ excluded: - .build + # Submodules + - ably-cocoa strict: true diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index 3a5e8dd44..bfe1d5811 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "a67c16228290f1b8c671d836ff39d317eedc33a6344ba3a66295d1127aa6241a", + "originHash" : "6884be3a1fb838d3f9a3288b0cb994d0dfce4e90c7d648bca08e61fb802c9cda", "pins" : [ - { - "identity" : "ably-cocoa", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ably/ably-cocoa", - "state" : { - "revision" : "c5347707e4f9fb907df0e5c334de445899a79783", - "version" : "1.2.40" - } - }, { "identity" : "delta-codec-cocoa", "kind" : "remoteSourceControl", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 03012495e..b33b6452f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,6 +97,12 @@ Example: // @specNotApplicable CHA-EX3a - Our API does not have a concept of "partial options" unlike the JS API which this spec item considers. ``` +## Developing ably-cocoa alongside this plugin + +For the initial stage of development of this plugin, where we need to also iterate heavily on ably-cocoa, I've added ably-cocoa as a Git submodule, which can be found in [`ably-cocoa`](./ably-cocoa). This allows you to edit ably-cocoa from within this repo's Xcode workspace. + +Nearer launch, we'll remove this submodule in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/7. + ## Release process For each release, the following needs to be done: diff --git a/Package.resolved b/Package.resolved index 149410521..773dea53b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,15 +1,6 @@ { - "originHash" : "334ffa0bc53636f87e6002c166aa5b75c3bd633c67ad43e001ee31ab03f11e2b", + "originHash" : "9d42be7ef9d81adeb6ac28ccc2a7a4dd43dbf0d952b6f8331e73ab665d36df3a", "pins" : [ - { - "identity" : "ably-cocoa", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ably/ably-cocoa", - "state" : { - "revision" : "c5347707e4f9fb907df0e5c334de445899a79783", - "version" : "1.2.40" - } - }, { "identity" : "delta-codec-cocoa", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 5c4875eac..21a381c42 100644 --- a/Package.swift +++ b/Package.swift @@ -19,8 +19,7 @@ let package = Package( ], dependencies: [ .package( - url: "https://github.com/ably/ably-cocoa", - from: "1.2.40", + path: "ably-cocoa", ), .package( url: "https://github.com/apple/swift-argument-parser", diff --git a/ably-cocoa b/ably-cocoa new file mode 160000 index 000000000..924ece809 --- /dev/null +++ b/ably-cocoa @@ -0,0 +1 @@ +Subproject commit 924ece809eacf128bea9652f98b4e795dd30077b From 412884473fa8c3a418676e2816f1a8020730bdc9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 29 May 2025 14:04:09 +0100 Subject: [PATCH 005/225] Implement initial structure of plugin This implements the plugin structure described in ADR-128 [1]. It makes use of the plugins mechanism added to ably-cocoa in [2]. It provides the implementation of the `ARTRealtimeChannel.objects` property (but does not implement any of this object's functionality) and demonstrates how to use the logging functionality exposed by ably-cocoa via its plugins API. [1] https://ably.atlassian.net/wiki/spaces/ENG/pages/3838574593/ADR-128+Plugins+for+ably-cocoa+SDK [2] https://github.com/ably/ably-cocoa/pull/2053 --- .gitignore | 3 ++ .../AblyLiveObjectsExampleApp.swift | 11 ++++- .../AblyLiveObjectsExample/ContentView.swift | 11 ++--- .../Secrets.example.swift | 9 ++++ Package.swift | 4 ++ README.md | 25 +++++++++- Sources/AblyLiveObjects/AblyLiveObjects.swift | 5 -- .../AblyLiveObjects/DefaultLiveObjects.swift | 49 +++++++++++++++++++ .../Internal/DefaultInternalPlugin.swift | 43 ++++++++++++++++ .../Public/ARTRealtimeChannel+Objects.swift | 3 +- Sources/AblyLiveObjects/Public/Plugin.swift | 28 +++++++++++ .../Utility/APLogger+Swift.swift | 8 +++ Sources/BuildTool/BuildTool.swift | 14 ++++++ .../AblyLiveObjectsTests.swift | 20 +++++++- ably-cocoa | 2 +- 15 files changed, 218 insertions(+), 17 deletions(-) create mode 100644 Example/AblyLiveObjectsExample/Secrets.example.swift delete mode 100644 Sources/AblyLiveObjects/AblyLiveObjects.swift create mode 100644 Sources/AblyLiveObjects/DefaultLiveObjects.swift create mode 100644 Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift create mode 100644 Sources/AblyLiveObjects/Public/Plugin.swift create mode 100644 Sources/AblyLiveObjects/Utility/APLogger+Swift.swift diff --git a/.gitignore b/.gitignore index f8c1a11c0..1ebfccef0 100644 --- a/.gitignore +++ b/.gitignore @@ -12,3 +12,6 @@ docs-coverage-report /node_modules /.mint + +# User-created file that contains their Ably API key +/Example/AblyLiveObjectsExample/Secrets.swift diff --git a/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift b/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift index 15d0f145f..cfeb88ce2 100644 --- a/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift +++ b/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift @@ -1,10 +1,19 @@ +import Ably +import AblyLiveObjects import SwiftUI @main struct AblyLiveObjectsExampleApp: App { + @State private var realtime = { + let clientOptions = ARTClientOptions(key: Secrets.ablyAPIKey) + clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] + + return ARTRealtime(options: clientOptions) + }() + var body: some Scene { WindowGroup { - ContentView() + ContentView(realtime: realtime) } } } diff --git a/Example/AblyLiveObjectsExample/ContentView.swift b/Example/AblyLiveObjectsExample/ContentView.swift index 93f301c92..faa2db05b 100644 --- a/Example/AblyLiveObjectsExample/ContentView.swift +++ b/Example/AblyLiveObjectsExample/ContentView.swift @@ -1,9 +1,9 @@ +import Ably import AblyLiveObjects import SwiftUI struct ContentView: View { - /// Just used to check that we can successfully import and use the AblyLiveObjects library. TODO remove this once we start building the library - @State private var ablyLiveObjectsClient = AblyLiveObjectsClient() + var realtime: ARTRealtime var body: some View { VStack { @@ -11,11 +11,10 @@ struct ContentView: View { .imageScale(.large) .foregroundStyle(.tint) Text("Hello, world!") + + let channel = realtime.channels.get("myChannel") + Text("`channel.objects`: `\(String(describing: channel.objects))`") } .padding() } } - -#Preview { - ContentView() -} diff --git a/Example/AblyLiveObjectsExample/Secrets.example.swift b/Example/AblyLiveObjectsExample/Secrets.example.swift new file mode 100644 index 000000000..99cfda69b --- /dev/null +++ b/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/Package.swift b/Package.swift index 21a381c42..fef2737be 100644 --- a/Package.swift +++ b/Package.swift @@ -46,6 +46,10 @@ let package = Package( name: "Ably", package: "ably-cocoa", ), + .product( + name: "AblyPlugin", + package: "ably-cocoa", + ), ], ), .testTarget( diff --git a/README.md b/README.md index 9ac6273ab..103ff6401 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,25 @@ This is a work in progress plugin that enables LiveObjects functionality in the Xcode 16.3 or later. +## Installation + +For now, here is a code snippet demonstrating how, after installing this package and ably-cocoa using Swift Package Manager, you can set up the LiveObjects plugin and access its functionality. + +```swift +import Ably +import AblyLiveObjects + +let clientOptions = ARTClientOptions(key: /* */) +clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] + +let realtime = ARTRealtime(options: clientOptions) + +// You can now access LiveObjects functionality via a channel's `objects` property: +let channel = realtime.channels.get("myChannel") +let rootObject = try await channel.objects.getRoot() +// …and so on +``` + ## Example app This repository contains an example app, written using SwiftUI, which demonstrates how to use the SDK. The code for this app is in the [`Example`](Example) directory. @@ -22,7 +41,11 @@ In order to allow the app to use modern SwiftUI features, it supports the follow - iOS 17 and above - tvOS 17 and above -To run the app, open the `AblyLiveObjects.xcworkspace` workspace in Xcode and run the `AblyLiveObjectsExample` target. If you wish to run it on an iOS or tvOS device, you’ll need to set up code signing. +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. ## Contributing diff --git a/Sources/AblyLiveObjects/AblyLiveObjects.swift b/Sources/AblyLiveObjects/AblyLiveObjects.swift deleted file mode 100644 index 24ea75c73..000000000 --- a/Sources/AblyLiveObjects/AblyLiveObjects.swift +++ /dev/null @@ -1,5 +0,0 @@ -// Placeholder file. - -public class AblyLiveObjectsClient { - public init() {} -} diff --git a/Sources/AblyLiveObjects/DefaultLiveObjects.swift b/Sources/AblyLiveObjects/DefaultLiveObjects.swift new file mode 100644 index 000000000..ced620b63 --- /dev/null +++ b/Sources/AblyLiveObjects/DefaultLiveObjects.swift @@ -0,0 +1,49 @@ +import Ably +internal import AblyPlugin + +/// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. +internal class DefaultLiveObjects: Objects { + private weak var channel: ARTRealtimeChannel? + private let logger: AblyPlugin.Logger + private let pluginAPI: AblyPlugin.PluginAPIProtocol + + internal init(channel: ARTRealtimeChannel, logger: AblyPlugin.Logger, pluginAPI: AblyPlugin.PluginAPIProtocol) { + self.channel = channel + self.logger = logger + self.pluginAPI = pluginAPI + } + + // MARK: `Objects` protocol + + internal func getRoot() async throws(ARTErrorInfo) -> any LiveMap { + notYetImplemented() + } + + internal func createMap(entries _: any LiveMap) async throws(ARTErrorInfo) -> any LiveMap { + notYetImplemented() + } + + internal func createMap() async throws(ARTErrorInfo) -> any LiveMap { + notYetImplemented() + } + + internal func createCounter(count _: Int) async throws(ARTErrorInfo) -> any LiveCounter { + notYetImplemented() + } + + internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter { + notYetImplemented() + } + + internal func batch(callback _: (any BatchContext) -> Void) async throws { + notYetImplemented() + } + + internal func on(event _: ObjectsEvent, callback _: () -> Void) -> any OnObjectsEventResponse { + notYetImplemented() + } + + internal func offAll() { + notYetImplemented() + } +} diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift new file mode 100644 index 000000000..32ff20ff9 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -0,0 +1,43 @@ +internal import AblyPlugin + +// We explicitly import the NSObject class, else it seems to get transitively imported from `internal import AblyPlugin`, leading to the error "Class cannot be declared public because its superclass is internal". +import ObjectiveC.NSObject + +/// The default implementation of `AblyPlugin`'s `LiveObjectsInternalPluginProtocol`. Implements the interface that ably-cocoa uses to access the functionality provided by the LiveObjects plugin. +@objc +internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInternalPluginProtocol { + private let pluginAPI: AblyPlugin.PluginAPIProtocol + + internal init(pluginAPI: AblyPlugin.PluginAPIProtocol) { + 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 value that should be returned by `ARTRealtimeChannel.objects`. + /// + /// We expect this value to have been previously set by ``prepare(_:)``. + internal static func objectsProperty(for channel: ARTRealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultLiveObjects { + guard let pluginData = pluginAPI.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! DefaultLiveObjects + } + + // MARK: - LiveObjectsInternalPluginProtocol + + // Populates the channel's `objects` property. + internal func prepare(_ channel: ARTRealtimeChannel) { + let logger = pluginAPI.logger(for: channel) + + logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) + let liveObjects = DefaultLiveObjects(channel: channel, logger: logger, pluginAPI: pluginAPI) + pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) + } +} diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index 3e2cc956f..b538b7eea 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -1,8 +1,9 @@ import Ably +internal import AblyPlugin public extension ARTRealtimeChannel { /// An ``Objects`` object. var objects: Objects { - notYetImplemented() + DefaultInternalPlugin.objectsProperty(for: self, pluginAPI: AblyPlugin.PluginAPI.sharedInstance()) } } diff --git a/Sources/AblyLiveObjects/Public/Plugin.swift b/Sources/AblyLiveObjects/Public/Plugin.swift new file mode 100644 index 000000000..20c709cd1 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/Plugin.swift @@ -0,0 +1,28 @@ +internal import AblyPlugin + +// We explicitly import the NSObject class, else it seems to get transitively imported from `internal import AblyPlugin`, 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) +/// +/// // You can now access LiveObjects functionality via a channel's `objects` property: +/// let channel = realtime.channels.get("myChannel") +/// let rootObject = try await channel.objects.getRoot() +/// // …and so on +/// ``` +@objc +public class Plugin: NSObject { + // MARK: - Informal conformance to AblyPlugin.LiveObjectsPluginProtocol + + @objc private static let internalPlugin = DefaultInternalPlugin(pluginAPI: AblyPlugin.PluginAPI.sharedInstance()) +} diff --git a/Sources/AblyLiveObjects/Utility/APLogger+Swift.swift b/Sources/AblyLiveObjects/Utility/APLogger+Swift.swift new file mode 100644 index 000000000..276621d1f --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/APLogger+Swift.swift @@ -0,0 +1,8 @@ +internal import AblyPlugin + +internal extension AblyPlugin.Logger { + /// A convenience method that provides default values for `file` and `line`. + func log(_ message: String, level: ARTLogLevel, fileID: String = #fileID, line: Int = #line) { + log(message, with: level, file: fileID, line: line) + } +} diff --git a/Sources/BuildTool/BuildTool.swift b/Sources/BuildTool/BuildTool.swift index fbfb10288..12e380e8d 100644 --- a/Sources/BuildTool/BuildTool.swift +++ b/Sources/BuildTool/BuildTool.swift @@ -106,6 +106,20 @@ struct BuildExampleApp: AsyncParsableCommand { 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) } } diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index 8e4bf37c4..eeccc7c58 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -1,8 +1,24 @@ +import Ably +@testable import AblyLiveObjects import Testing struct AblyLiveObjectsTests { @Test - func example() { - // just to get code-coverage task passing + 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 DefaultLiveObjects) } } diff --git a/ably-cocoa b/ably-cocoa index 924ece809..7f1cc483a 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 924ece809eacf128bea9652f98b4e795dd30077b +Subproject commit 7f1cc483a66289325599c380e2a8baace148a505 From 372a94393db6e6cc577f3a4d4d0e53b0b13e0f16 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 3 Jun 2025 11:52:06 -0300 Subject: [PATCH 006/225] Add empty implementations of the core-to-plugin API Adds empty implementations of the API that the LiveObjects plugin must implement, as added in ably-cocoa commit be7640f. We'll implement these methods, and refine their meaning, later. Demonstrates how the calls to the plugin API find their way to the DefaultLiveObjects object, and how we convert the ObjectMessage type that we emit to ably-cocoa into the nice Swift struct that we'll use internally. --- .../AblyLiveObjects/DefaultLiveObjects.swift | 14 +++++ .../Internal/DefaultInternalPlugin.swift | 59 +++++++++++++++++++ .../Protocol/ObjectMessage.swift | 2 + ably-cocoa | 2 +- 4 files changed, 76 insertions(+), 1 deletion(-) create mode 100644 Sources/AblyLiveObjects/Protocol/ObjectMessage.swift diff --git a/Sources/AblyLiveObjects/DefaultLiveObjects.swift b/Sources/AblyLiveObjects/DefaultLiveObjects.swift index ced620b63..6b7697738 100644 --- a/Sources/AblyLiveObjects/DefaultLiveObjects.swift +++ b/Sources/AblyLiveObjects/DefaultLiveObjects.swift @@ -46,4 +46,18 @@ internal class DefaultLiveObjects: Objects { internal func offAll() { notYetImplemented() } + + // MARK: Handling channel events + + internal func onChannelAttached(hasObjects _: Bool) { + notYetImplemented() + } + + internal func handleObjectProtocolMessage(objectMessages _: [ObjectMessage]) { + notYetImplemented() + } + + internal func handleObjectSyncProtocolMessage(objectMessages _: [ObjectMessage], protocolMessageChannelSerial _: String) { + notYetImplemented() + } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 32ff20ff9..bd5e57274 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -40,4 +40,63 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte let liveObjects = DefaultLiveObjects(channel: channel, logger: logger, pluginAPI: pluginAPI) pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } + + /// Retrieves the internally-typed `objects` property for the channel. + private func objectsProperty(for channel: ARTRealtimeChannel) -> DefaultLiveObjects { + Self.objectsProperty(for: channel, pluginAPI: pluginAPI) + } + + /// A class that wraps an ``ObjectMessage``. + /// + /// We need this intermediate type because we want `ObjectMessage` to be a struct — because it's nicer to work with internally — but a struct can't conform to the class-bound `AblyPlugin.ObjectMessage` protocol. + private final class ObjectMessageBox: AblyPlugin.ObjectMessageProtocol { + internal let objectMessage: ObjectMessage + + init(objectMessage: ObjectMessage) { + self.objectMessage = objectMessage + } + } + + internal func decodeObjectMessage(_: [AnyHashable: Any]) -> any AblyPlugin.ObjectMessageProtocol { + // TODO: do something with the dictionary + let objectMessage = ObjectMessage() + return ObjectMessageBox(objectMessage: objectMessage) + } + + internal func encodeObjectMessage(_ publicObjectMessage: any AblyPlugin.ObjectMessageProtocol) -> [AnyHashable: Any] { + guard publicObjectMessage is ObjectMessageBox else { + preconditionFailure("Expected to receive the same ObjectMessage type as we emit") + } + + notYetImplemented() + } + + internal func onChannelAttached(_ channel: ARTRealtimeChannel, hasObjects: Bool) { + objectsProperty(for: channel).onChannelAttached(hasObjects: hasObjects) + } + + internal func handleObjectProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], channel: ARTRealtimeChannel) { + guard let objectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { + preconditionFailure("Expected to receive the same ObjectMessage type as we emit") + } + + let objectMessages = objectMessageBoxes.map(\.objectMessage) + + objectsProperty(for: channel).handleObjectProtocolMessage( + objectMessages: objectMessages, + ) + } + + internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], protocolMessageChannelSerial: String, channel: ARTRealtimeChannel) { + guard let objectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { + preconditionFailure("Expected to receive the same ObjectMessage type as we emit") + } + + let objectMessages = objectMessageBoxes.map(\.objectMessage) + + objectsProperty(for: channel).handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: protocolMessageChannelSerial, + ) + } } diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift new file mode 100644 index 000000000..e8cd261c1 --- /dev/null +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -0,0 +1,2 @@ +/// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. +internal struct ObjectMessage {} diff --git a/ably-cocoa b/ably-cocoa index 7f1cc483a..2df793916 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 7f1cc483a66289325599c380e2a8baace148a505 +Subproject commit 2df793916aaaf9389aab23da48e0c402706212c8 From 541fafd303979e6cbae209227c0a583deca354bb Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 11 Jun 2025 09:05:21 -0300 Subject: [PATCH 007/225] Add an initial attempt at some Cursor rules Haven't tried them out loads yet, but I wrote them whilst I was experimenting with getting it to build something, and thought they were worth committing. I'm sure there's plenty of iteration to come. --- .cursor/rules/specification.mdc | 12 ++++++++++++ .cursor/rules/swift.mdc | 10 ++++++++++ .cursor/rules/testing.mdc | 9 +++++++++ .gitignore | 3 +++ 4 files changed, 34 insertions(+) create mode 100644 .cursor/rules/specification.mdc create mode 100644 .cursor/rules/swift.mdc create mode 100644 .cursor/rules/testing.mdc diff --git a/.cursor/rules/specification.mdc b/.cursor/rules/specification.mdc new file mode 100644 index 000000000..d9f35765d --- /dev/null +++ b/.cursor/rules/specification.mdc @@ -0,0 +1,12 @@ +--- +description: Defines the meaning of the "Specification Document" (a.k.a. "specification" or "spec") +globs: +alwaysApply: false +--- +- When we refer to the _Specification Document_, we mean the contents of the file `textile/features.textile` in the repository https://github.com/ably/specification. +- If you need to consult the Specification Document, then check it out as a Git repository locally. You should check it out to the path `cursor-support/specification` in this repository. If this directory already exists, then you can assume that the repository is already checked out and is up to date; do not try checking it out again. +- The Specification Document is sometimes referred to as simply "the specification" or "the spec". +- The Specification Document specifies much of the behaviour of this codebase. +- If you are given a task that requires knowledge of the Specification Document, you must consult the Specification Document before proceeding. You must never make a guess about the contents of the Specification Document. +- If you are given a task that requires you to interpret the Specification Document, but the Specification Document is unclear, be sure to mention this. +- The Specification Document is structured as a list of specification points, each with an identifier. An example identifier is "OD1". In the Specification Document, the start of specification point OD1 would be represented by the string @(OD1)@. These specification points are sometimes referred to as "specification items". diff --git a/.cursor/rules/swift.mdc b/.cursor/rules/swift.mdc new file mode 100644 index 000000000..1c15a1783 --- /dev/null +++ b/.cursor/rules/swift.mdc @@ -0,0 +1,10 @@ +--- +description: Guidelines for writing Swift +globs: +alwaysApply: false +--- +When writing Swift: + +- Be sure to satisfy SwiftLint's `explicit_acl` rule ("All declarations should specify Access Control Level keywords explicitly). + - When writing an `extension` of a type, favour placing the access level on the declaration of the extension rather than each of its individual members. + - This does not apply when writing test code. \ No newline at end of file diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc new file mode 100644 index 000000000..2d3b325b2 --- /dev/null +++ b/.cursor/rules/testing.mdc @@ -0,0 +1,9 @@ +--- +description: Guidelines for writing tests +globs: +alwaysApply: false +--- +When writing tests: + +- Use the Swift Testing framework (`import Testing`), not XCTest. +- When writing tests, follow the guidelines given under "Attributing tests to a spec point" in the file `CONTRIBUTING.md` in order to tag the unit tests with the relevant specification points. diff --git a/.gitignore b/.gitignore index 1ebfccef0..c62449f92 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,6 @@ docs-coverage-report # 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 From 67f2bd7b279fbbe863aca5ea668ca82a2f04d2b1 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 6 Jun 2025 11:08:06 -0300 Subject: [PATCH 008/225] Copy JSONValue from ably-chat-swift at 3518483 I'll try and reuse some of this stuff for the decoding of ObjectMessages. --- .../Utility/InternalError.swift | 36 +++ .../AblyLiveObjects/Utility/JSONCodable.swift | 301 ++++++++++++++++++ .../AblyLiveObjects/Utility/JSONValue.swift | 217 +++++++++++++ .../AblyLiveObjectsTests/JSONValueTests.swift | 157 +++++++++ 4 files changed, 711 insertions(+) create mode 100644 Sources/AblyLiveObjects/Utility/InternalError.swift create mode 100644 Sources/AblyLiveObjects/Utility/JSONCodable.swift create mode 100644 Sources/AblyLiveObjects/Utility/JSONValue.swift create mode 100644 Tests/AblyLiveObjectsTests/JSONValueTests.swift diff --git a/Sources/AblyLiveObjects/Utility/InternalError.swift b/Sources/AblyLiveObjects/Utility/InternalError.swift new file mode 100644 index 000000000..b16698e09 --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/InternalError.swift @@ -0,0 +1,36 @@ +import Ably + +/// An error thrown by the internals of the LiveObjects SDK. +/// +/// Copied from ably-chat-swift; will decide what to do about it. +internal enum InternalError: Error { + case errorInfo(ARTErrorInfo) + case other(Other) + + internal enum Other { + case jsonValueDecodingError(JSONValueDecodingError) + } + + /// Returns the error that this should be converted to when exposed via the SDK's public API. + internal func toARTErrorInfo() -> ARTErrorInfo { + switch self { + case let .errorInfo(errorInfo): + errorInfo + case let .other(other): + // For now we just treat all errors that are not backed by an ARTErrorInfo as non-recoverable user errors + .create(withCode: Int(ARTErrorCode.badRequest.rawValue), message: "\(other)") + } + } +} + +internal extension ARTErrorInfo { + func toInternalError() -> InternalError { + .errorInfo(self) + } +} + +internal extension JSONValueDecodingError { + func toInternalError() -> InternalError { + .other(.jsonValueDecodingError(self)) + } +} diff --git a/Sources/AblyLiveObjects/Utility/JSONCodable.swift b/Sources/AblyLiveObjects/Utility/JSONCodable.swift new file mode 100644 index 000000000..0496c33d0 --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/JSONCodable.swift @@ -0,0 +1,301 @@ +import Ably +import Foundation + +internal protocol JSONEncodable { + var toJSONValue: JSONValue { get } +} + +internal protocol JSONDecodable { + init(jsonValue: JSONValue) throws(InternalError) +} + +internal typealias JSONCodable = JSONDecodable & JSONEncodable + +internal protocol JSONObjectEncodable: JSONEncodable { + var toJSONObject: [String: JSONValue] { get } +} + +// Default implementation of `JSONEncodable` conformance for `JSONObjectEncodable` +internal extension JSONObjectEncodable { + var toJSONValue: JSONValue { + .object(toJSONObject) + } +} + +internal protocol JSONObjectDecodable: JSONDecodable { + init(jsonObject: [String: JSONValue]) throws(InternalError) +} + +internal enum JSONValueDecodingError: Error { + case valueIsNotObject + case noValueForKey(String) + case wrongTypeForKey(String, actualValue: JSONValue) + case failedToDecodeFromRawValue(String) +} + +// Default implementation of `JSONDecodable` conformance for `JSONObjectDecodable` +internal extension JSONObjectDecodable { + init(jsonValue: JSONValue) throws(InternalError) { + guard case let .object(jsonObject) = jsonValue else { + throw JSONValueDecodingError.valueIsNotObject.toInternalError() + } + + self = try .init(jsonObject: jsonObject) + } +} + +internal typealias JSONObjectCodable = JSONObjectDecodable & JSONObjectEncodable + +// MARK: - Extracting primitive values from a dictionary + +/// This extension adds some helper methods for extracting values from a dictionary of `JSONValue` values; you may find them helpful when implementing `JSONCodable`. +internal extension [String: JSONValue] { + /// If this dictionary contains a value for `key`, and this value has case `object`, this returns the associated value. + /// + /// - Throws: + /// - `JSONValueDecodingError.noValueForKey` if the key is absent + /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `object` + func objectValueForKey(_ key: String) throws(InternalError) -> [String: JSONValue] { + guard let value = self[key] else { + throw JSONValueDecodingError.noValueForKey(key).toInternalError() + } + + guard case let .object(objectValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + } + + 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `object` or `null` + func optionalObjectValueForKey(_ key: String) throws(InternalError) -> [String: JSONValue]? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .object(objectValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + } + + return objectValue + } + + /// If this dictionary contains a value for `key`, and this value has case `array`, this returns the associated value. + /// + /// - Throws: + /// - `JSONValueDecodingError.noValueForKey` if the key is absent + /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `array` + func arrayValueForKey(_ key: String) throws(InternalError) -> [JSONValue] { + guard let value = self[key] else { + throw JSONValueDecodingError.noValueForKey(key).toInternalError() + } + + guard case let .array(arrayValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + } + + 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `array` or `null` + func optionalArrayValueForKey(_ key: String) throws(InternalError) -> [JSONValue]? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .array(arrayValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + } + + return arrayValue + } + + /// If this dictionary contains a value for `key`, and this value has case `string`, this returns the associated value. + /// + /// - Throws: + /// - `JSONValueDecodingError.noValueForKey` if the key is absent + /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` + func stringValueForKey(_ key: String) throws(InternalError) -> String { + guard let value = self[key] else { + throw JSONValueDecodingError.noValueForKey(key).toInternalError() + } + + guard case let .string(stringValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + } + + 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` + func optionalStringValueForKey(_ key: String) throws(InternalError) -> String? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .string(stringValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + } + + return stringValue + } + + /// If this dictionary contains a value for `key`, and this value has case `number`, this returns the associated value. + /// + /// - Throws: + /// - `JSONValueDecodingError.noValueForKey` if the key is absent + /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` + func numberValueForKey(_ key: String) throws(InternalError) -> Double { + guard let value = self[key] else { + throw JSONValueDecodingError.noValueForKey(key).toInternalError() + } + + guard case let .number(numberValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + } + + 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` + func optionalNumberValueForKey(_ key: String) throws(InternalError) -> Double? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .number(numberValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + } + + return numberValue + } + + /// If this dictionary contains a value for `key`, and this value has case `bool`, this returns the associated value. + /// + /// - Throws: + /// - `JSONValueDecodingError.noValueForKey` if the key is absent + /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `bool` + func boolValueForKey(_ key: String) throws(InternalError) -> Bool { + guard let value = self[key] else { + throw JSONValueDecodingError.noValueForKey(key).toInternalError() + } + + guard case let .bool(boolValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + } + + return boolValue + } + + /// If this dictionary contains a value for `key`, and this value has case `bool`, this returns the associated value. + /// + /// - Throws: + /// - `JSONValueDecodingError.noValueForKey` if the key is absent + /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `bool` + func optionalBoolValueForKey(_ key: String) throws(InternalError) -> Bool? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + guard case let .bool(boolValue) = value else { + throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + } + + return boolValue + } +} + +// MARK: - Extracting dates from a dictionary + +internal extension [String: JSONValue] { + /// 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: + /// - `JSONValueDecodingError.noValueForKey` if the key is absent + /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` + func ablyProtocolDateValueForKey(_ key: String) throws(InternalError) -> Date { + let millisecondsSinceEpoch = try numberValueForKey(key) + + 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` + func optionalAblyProtocolDateValueForKey(_ key: String) throws(InternalError) -> Date? { + guard let millisecondsSinceEpoch = try optionalNumberValueForKey(key) else { + return nil + } + + return dateFromMillisecondsSinceEpoch(millisecondsSinceEpoch) + } + + private func dateFromMillisecondsSinceEpoch(_ millisecondsSinceEpoch: Double) -> Date { + .init(timeIntervalSince1970: millisecondsSinceEpoch / 1000) + } +} + +// MARK: - Extracting RawRepresentable values from a dictionary + +internal extension [String: JSONValue] { + /// 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: + /// - `JSONValueDecodingError.noValueForKey` if the key is absent + /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` + /// - `JSONValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` + func rawRepresentableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> 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: + /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` + /// - `JSONValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` + func optionalRawRepresentableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> 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(InternalError) -> T where T.RawValue == String { + guard let value = T(rawValue: rawValue) else { + throw JSONValueDecodingError.failedToDecodeFromRawValue(rawValue).toInternalError() + } + + return value + } +} diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift new file mode 100644 index 000000000..721ab89d6 --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -0,0 +1,217 @@ +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`. +internal 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`. + internal 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`. + internal 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`. + internal 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`. + internal 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`. + internal var boolValue: Bool? { + if case let .bool(boolValue) = self { + boolValue + } else { + nil + } + } + + /// Returns true if and only if this `JSONValue` has case `null`. + internal var isNull: Bool { + if case .null = self { + true + } else { + false + } + } +} + +extension JSONValue: ExpressibleByDictionaryLiteral { + internal init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(.init(uniqueKeysWithValues: elements)) + } +} + +extension JSONValue: ExpressibleByArrayLiteral { + internal init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + +extension JSONValue: ExpressibleByStringLiteral { + internal init(stringLiteral value: String) { + self = .string(value) + } +} + +extension JSONValue: ExpressibleByIntegerLiteral { + internal init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +extension JSONValue: ExpressibleByFloatLiteral { + internal init(floatLiteral value: Double) { + self = .number(value) + } +} + +extension JSONValue: ExpressibleByBooleanLiteral { + internal init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +// MARK: - Bridging with ably-cocoa + +internal extension JSONValue { + /// Creates a `JSONValue` from an ably-cocoa deserialized JSON object. + /// + /// Specifically, `ablyCocoaData` can be: + /// + /// - a non-`nil` value of `ARTBaseMessage`’s `data` property + /// - an element of `ARTHTTPPaginatedResult`’s `items` array + init(ablyCocoaData: Any) { + switch ablyCocoaData { + case let dictionary as [String: Any]: + self = .object(dictionary.mapValues { .init(ablyCocoaData: $0) }) + case let array as [Any]: + self = .array(array.map { .init(ablyCocoaData: $0) }) + 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(number.doubleValue) + } + case is NSNull: + self = .null + default: + // ably-cocoa is not conforming to our assumptions; either its behaviour is wrong or our assumptions are wrong. Either way, bring this loudly to our attention instead of trying to carry on + preconditionFailure("JSONValue(ablyCocoaData:) was given \(ablyCocoaData)") + } + } + + /// Creates a `JSONValue` from an ably-cocoa deserialized JSON message extras object. Specifically, `ablyCocoaExtras` can be a non-`nil` value of `ARTBaseMessage`’s `extras` property. + static func objectFromAblyCocoaExtras(_ ablyCocoaExtras: any ARTJsonCompatible) -> [String: JSONValue] { + // (This is based on the fact that, in reality, I believe that `extras` is always a JSON object; see https://github.com/ably/ably-cocoa/issues/2002 for improving ably-cocoa’s API to reflect this) + + let jsonValue = JSONValue(ablyCocoaData: ablyCocoaExtras) + guard case let .object(jsonObject) = jsonValue else { + // ably-cocoa is not conforming to our assumptions; either its behaviour is wrong or our assumptions are wrong. Either way, bring this loudly to our attention instead of trying to carry on + preconditionFailure("JSONValue.objectFromAblyCocoaExtras(_:) was given \(ablyCocoaExtras)") + } + + return jsonObject + } + + /// Creates an ably-cocoa deserialized JSON object from a `JSONValue`. + /// + /// Specifically, the value of this property can be used as: + /// + /// - `ARTBaseMessage`’s `data` property + /// - the `data` argument that’s passed to `ARTRealtime`’s `request(…)` method + /// - the `data` argument that’s passed to `ARTRealtime`’s `publish(…)` method + var toAblyCocoaData: Any { + switch self { + case let .object(underlying): + underlying.toAblyCocoaDataDictionary + case let .array(underlying): + underlying.map(\.toAblyCocoaData) + case let .string(underlying): + underlying + case let .number(underlying): + underlying + case let .bool(underlying): + underlying + case .null: + NSNull() + } + } +} + +internal extension [String: JSONValue] { + /// Creates an ably-cocoa deserialized JSON object from a dictionary that has string keys and `JSONValue` values. + /// + /// Specifically, the value of this property can be used as: + /// + /// - `ARTBaseMessage`’s `data` property + /// - the `data` argument that’s passed to `ARTRealtime`’s `request(…)` method + /// - the `data` argument that’s passed to `ARTRealtime`’s `publish(…)` method + var toAblyCocoaDataDictionary: [String: Any] { + mapValues(\.toAblyCocoaData) + } + + /// Creates an ably-cocoa `ARTJsonCompatible` object from a dictionary that has string keys and `JSONValue` values. + var toARTJsonCompatible: ARTJsonCompatible { + toAblyCocoaDataDictionary as ARTJsonCompatible + } +} diff --git a/Tests/AblyLiveObjectsTests/JSONValueTests.swift b/Tests/AblyLiveObjectsTests/JSONValueTests.swift new file mode 100644 index 000000000..90d31c124 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/JSONValueTests.swift @@ -0,0 +1,157 @@ +@testable import AblyLiveObjects +import Foundation +import Testing + +struct JSONValueTests { + // MARK: Conversion from ably-cocoa data + + @Test(arguments: [ + // object + (ablyCocoaPresenceData: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), + // array + (ablyCocoaPresenceData: ["someElement"], expectedResult: ["someElement"]), + // string + (ablyCocoaPresenceData: "someString", expectedResult: "someString"), + // number + (ablyCocoaPresenceData: NSNumber(value: 0), expectedResult: 0), + (ablyCocoaPresenceData: NSNumber(value: 1), expectedResult: 1), + (ablyCocoaPresenceData: NSNumber(value: 123), expectedResult: 123), + (ablyCocoaPresenceData: NSNumber(value: 123.456), expectedResult: 123.456), + // bool + (ablyCocoaPresenceData: NSNumber(value: true), expectedResult: true), + (ablyCocoaPresenceData: NSNumber(value: false), expectedResult: false), + // null + (ablyCocoaPresenceData: NSNull(), expectedResult: .null), + ] as[(ablyCocoaPresenceData: Sendable, expectedResult: JSONValue?)]) + func initWithAblyCocoaPresenceData(ablyCocoaData: Sendable, expectedResult: JSONValue?) { + #expect(JSONValue(ablyCocoaData: ablyCocoaData) == expectedResult) + } + + // Tests that it correctly handles an object deserialized by `JSONSerialization` (which is what ably-cocoa uses for deserialization). + @Test + func initWithAblyCocoaData_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 ablyCocoaData = 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(ablyCocoaData: ablyCocoaData) == expected) + } + + // MARK: Conversion to ably-cocoa 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()), + ] as[(value: JSONValue, expectedResult: Sendable)]) + func toAblyCocoaData(value: JSONValue, expectedResult: Sendable) throws { + let resultAsNSObject = try #require(value.toAblyCocoaData 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 serialization), and that the result of this serialization is what we’d expect. + @Test + func toAblyCocoaData_endToEnd() throws { + let value: 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.toAblyCocoaData, 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) + } +} From 4671db18d87ee9d2a7a4b9d38c566dfc0116fb07 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 11 Jun 2025 10:31:12 -0300 Subject: [PATCH 009/225] Rename JSONValue ably-cocoa methods for usage with AblyPlugin --- .../AblyLiveObjects/Utility/JSONValue.swift | 59 +++++++------------ .../AblyLiveObjectsTests/JSONValueTests.swift | 44 +++++++------- 2 files changed, 42 insertions(+), 61 deletions(-) diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift index 721ab89d6..d878edc26 100644 --- a/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -129,18 +129,15 @@ extension JSONValue: ExpressibleByBooleanLiteral { // MARK: - Bridging with ably-cocoa internal extension JSONValue { - /// Creates a `JSONValue` from an ably-cocoa deserialized JSON object. + /// Creates a `JSONValue` from an AblyPlugin deserialized JSON object. /// - /// Specifically, `ablyCocoaData` can be: - /// - /// - a non-`nil` value of `ARTBaseMessage`’s `data` property - /// - an element of `ARTHTTPPaginatedResult`’s `items` array - init(ablyCocoaData: Any) { - switch ablyCocoaData { + /// Specifically, `ablyCocoaData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. + init(ablyPluginData: Any) { + switch ablyPluginData { case let dictionary as [String: Any]: - self = .object(dictionary.mapValues { .init(ablyCocoaData: $0) }) + self = .object(dictionary.mapValues { .init(ablyPluginData: $0) }) case let array as [Any]: - self = .array(array.map { .init(ablyCocoaData: $0) }) + self = .array(array.map { .init(ablyPluginData: $0) }) case let string as String: self = .string(string) case let number as NSNumber: @@ -156,36 +153,29 @@ internal extension JSONValue { self = .null default: // ably-cocoa is not conforming to our assumptions; either its behaviour is wrong or our assumptions are wrong. Either way, bring this loudly to our attention instead of trying to carry on - preconditionFailure("JSONValue(ablyCocoaData:) was given \(ablyCocoaData)") + preconditionFailure("JSONValue(ablyPluginData:) was given \(ablyPluginData)") } } - /// Creates a `JSONValue` from an ably-cocoa deserialized JSON message extras object. Specifically, `ablyCocoaExtras` can be a non-`nil` value of `ARTBaseMessage`’s `extras` property. - static func objectFromAblyCocoaExtras(_ ablyCocoaExtras: any ARTJsonCompatible) -> [String: JSONValue] { - // (This is based on the fact that, in reality, I believe that `extras` is always a JSON object; see https://github.com/ably/ably-cocoa/issues/2002 for improving ably-cocoa’s API to reflect this) - - let jsonValue = JSONValue(ablyCocoaData: ablyCocoaExtras) + /// Creates a `JSONValue` from an AblyPlugin deserialized JSON object. Specifically, `ablyPluginData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. + static func objectFromAblyPluginData(_ ablyPluginData: [String: Any]) -> [String: JSONValue] { + let jsonValue = JSONValue(ablyPluginData: ablyPluginData) guard case let .object(jsonObject) = jsonValue else { - // ably-cocoa is not conforming to our assumptions; either its behaviour is wrong or our assumptions are wrong. Either way, bring this loudly to our attention instead of trying to carry on - preconditionFailure("JSONValue.objectFromAblyCocoaExtras(_:) was given \(ablyCocoaExtras)") + preconditionFailure() } return jsonObject } - /// Creates an ably-cocoa deserialized JSON object from a `JSONValue`. - /// - /// Specifically, the value of this property can be used as: + /// Creates an AblyPlugin deserialized JSON object from a `JSONValue`. /// - /// - `ARTBaseMessage`’s `data` property - /// - the `data` argument that’s passed to `ARTRealtime`’s `request(…)` method - /// - the `data` argument that’s passed to `ARTRealtime`’s `publish(…)` method - var toAblyCocoaData: Any { + /// Used by `[String: JSONValue].toAblyPluginDataDictionary`. + var toAblyPluginData: Any { switch self { case let .object(underlying): - underlying.toAblyCocoaDataDictionary + underlying.toAblyPluginDataDictionary case let .array(underlying): - underlying.map(\.toAblyCocoaData) + underlying.map(\.toAblyPluginData) case let .string(underlying): underlying case let .number(underlying): @@ -199,19 +189,10 @@ internal extension JSONValue { } internal extension [String: JSONValue] { - /// Creates an ably-cocoa deserialized JSON object from a dictionary that has string keys and `JSONValue` values. + /// Creates an AblyPlugin deserialized JSON object from a dictionary that has string keys and `JSONValue` values. /// - /// Specifically, the value of this property can be used as: - /// - /// - `ARTBaseMessage`’s `data` property - /// - the `data` argument that’s passed to `ARTRealtime`’s `request(…)` method - /// - the `data` argument that’s passed to `ARTRealtime`’s `publish(…)` method - var toAblyCocoaDataDictionary: [String: Any] { - mapValues(\.toAblyCocoaData) - } - - /// Creates an ably-cocoa `ARTJsonCompatible` object from a dictionary that has string keys and `JSONValue` values. - var toARTJsonCompatible: ARTJsonCompatible { - toAblyCocoaDataDictionary as ARTJsonCompatible + /// Specifically, the value of this property can be returned from `APLiveObjectsPlugin.encodeObjectMessage:`. + var toAblyPluginDataDictionary: [String: Any] { + mapValues(\.toAblyPluginData) } } diff --git a/Tests/AblyLiveObjectsTests/JSONValueTests.swift b/Tests/AblyLiveObjectsTests/JSONValueTests.swift index 90d31c124..aea084244 100644 --- a/Tests/AblyLiveObjectsTests/JSONValueTests.swift +++ b/Tests/AblyLiveObjectsTests/JSONValueTests.swift @@ -3,33 +3,33 @@ import Foundation import Testing struct JSONValueTests { - // MARK: Conversion from ably-cocoa data + // MARK: Conversion from AblyPlugin data @Test(arguments: [ // object - (ablyCocoaPresenceData: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), + (ablyPluginData: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), // array - (ablyCocoaPresenceData: ["someElement"], expectedResult: ["someElement"]), + (ablyPluginData: ["someElement"], expectedResult: ["someElement"]), // string - (ablyCocoaPresenceData: "someString", expectedResult: "someString"), + (ablyPluginData: "someString", expectedResult: "someString"), // number - (ablyCocoaPresenceData: NSNumber(value: 0), expectedResult: 0), - (ablyCocoaPresenceData: NSNumber(value: 1), expectedResult: 1), - (ablyCocoaPresenceData: NSNumber(value: 123), expectedResult: 123), - (ablyCocoaPresenceData: NSNumber(value: 123.456), expectedResult: 123.456), + (ablyPluginData: NSNumber(value: 0), expectedResult: 0), + (ablyPluginData: NSNumber(value: 1), expectedResult: 1), + (ablyPluginData: NSNumber(value: 123), expectedResult: 123), + (ablyPluginData: NSNumber(value: 123.456), expectedResult: 123.456), // bool - (ablyCocoaPresenceData: NSNumber(value: true), expectedResult: true), - (ablyCocoaPresenceData: NSNumber(value: false), expectedResult: false), + (ablyPluginData: NSNumber(value: true), expectedResult: true), + (ablyPluginData: NSNumber(value: false), expectedResult: false), // null - (ablyCocoaPresenceData: NSNull(), expectedResult: .null), - ] as[(ablyCocoaPresenceData: Sendable, expectedResult: JSONValue?)]) - func initWithAblyCocoaPresenceData(ablyCocoaData: Sendable, expectedResult: JSONValue?) { - #expect(JSONValue(ablyCocoaData: ablyCocoaData) == expectedResult) + (ablyPluginData: NSNull(), expectedResult: .null), + ] as[(ablyPluginData: Sendable, expectedResult: JSONValue?)]) + func initWithAblyPluginData(ablyPluginData: Sendable, expectedResult: JSONValue?) { + #expect(JSONValue(ablyPluginData: ablyPluginData) == expectedResult) } // Tests that it correctly handles an object deserialized by `JSONSerialization` (which is what ably-cocoa uses for deserialization). @Test - func initWithAblyCocoaData_endToEnd() throws { + func initWithAblyPluginData_endToEnd() throws { let jsonString = """ { "someArray": [ @@ -51,7 +51,7 @@ struct JSONValueTests { } """ - let ablyCocoaData = try JSONSerialization.jsonObject(with: #require(jsonString.data(using: .utf8))) + let ablyPluginData = try JSONSerialization.jsonObject(with: #require(jsonString.data(using: .utf8))) let expected: JSONValue = [ "someArray": [ @@ -72,10 +72,10 @@ struct JSONValueTests { ], ] - #expect(JSONValue(ablyCocoaData: ablyCocoaData) == expected) + #expect(JSONValue(ablyPluginData: ablyPluginData) == expected) } - // MARK: Conversion to ably-cocoa data + // MARK: Conversion to AblyPlugin data @Test(arguments: [ // object @@ -95,15 +95,15 @@ struct JSONValueTests { // null (value: .null, expectedResult: NSNull()), ] as[(value: JSONValue, expectedResult: Sendable)]) - func toAblyCocoaData(value: JSONValue, expectedResult: Sendable) throws { - let resultAsNSObject = try #require(value.toAblyCocoaData as? NSObject) + func toAblyPluginData(value: JSONValue, expectedResult: Sendable) throws { + let resultAsNSObject = try #require(value.toAblyPluginData 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 serialization), and that the result of this serialization is what we’d expect. @Test - func toAblyCocoaData_endToEnd() throws { + func toAblyPluginData_endToEnd() throws { let value: JSONValue = [ "someArray": [ [ @@ -146,7 +146,7 @@ struct JSONValueTests { let jsonSerializationOptions: JSONSerialization.WritingOptions = [.sortedKeys] - let valueData = try JSONSerialization.data(withJSONObject: value.toAblyCocoaData, options: jsonSerializationOptions) + let valueData = try JSONSerialization.data(withJSONObject: value.toAblyPluginData, options: jsonSerializationOptions) let expectedData = try { let serialized = try JSONSerialization.jsonObject(with: #require(expectedJSONString.data(using: .utf8))) return try JSONSerialization.data(withJSONObject: serialized, options: jsonSerializationOptions) From fd1e91c94495648d889247060f10e3162f5494d2 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 12 Jun 2025 13:37:41 -0300 Subject: [PATCH 010/225] Switch JSONValue's `number` case to use NSNumber We need to be able to communicate to ably-cocoa's MessagePack encoder that it should encode certain values (e.g. enums) as integers, not doubles. This seems the quickest way of doing that. Don't _love_ it, because NSNumber is not a very fun type to work with or make assertions about. Will probably revisit when we introduce further MessagePack-specific types (e.g. binary data). --- Sources/AblyLiveObjects/Utility/JSONCodable.swift | 13 ++++++------- Sources/AblyLiveObjects/Utility/JSONValue.swift | 12 ++++++------ 2 files changed, 12 insertions(+), 13 deletions(-) diff --git a/Sources/AblyLiveObjects/Utility/JSONCodable.swift b/Sources/AblyLiveObjects/Utility/JSONCodable.swift index 0496c33d0..81c9f71a5 100644 --- a/Sources/AblyLiveObjects/Utility/JSONCodable.swift +++ b/Sources/AblyLiveObjects/Utility/JSONCodable.swift @@ -163,7 +163,7 @@ internal extension [String: JSONValue] { /// - Throws: /// - `JSONValueDecodingError.noValueForKey` if the key is absent /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` - func numberValueForKey(_ key: String) throws(InternalError) -> Double { + func numberValueForKey(_ key: String) throws(InternalError) -> NSNumber { guard let value = self[key] else { throw JSONValueDecodingError.noValueForKey(key).toInternalError() } @@ -178,7 +178,7 @@ internal extension [String: JSONValue] { /// 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` - func optionalNumberValueForKey(_ key: String) throws(InternalError) -> Double? { + func optionalNumberValueForKey(_ key: String) throws(InternalError) -> NSNumber? { guard let value = self[key] else { return nil } @@ -242,7 +242,7 @@ internal extension [String: JSONValue] { /// - `JSONValueDecodingError.noValueForKey` if the key is absent /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` func ablyProtocolDateValueForKey(_ key: String) throws(InternalError) -> Date { - let millisecondsSinceEpoch = try numberValueForKey(key) + let millisecondsSinceEpoch = try numberValueForKey(key).uint64Value return dateFromMillisecondsSinceEpoch(millisecondsSinceEpoch) } @@ -251,15 +251,14 @@ internal extension [String: JSONValue] { /// /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` func optionalAblyProtocolDateValueForKey(_ key: String) throws(InternalError) -> Date? { - guard let millisecondsSinceEpoch = try optionalNumberValueForKey(key) else { + guard let millisecondsSinceEpoch = try optionalNumberValueForKey(key)?.uint64Value else { return nil } - return dateFromMillisecondsSinceEpoch(millisecondsSinceEpoch) } - private func dateFromMillisecondsSinceEpoch(_ millisecondsSinceEpoch: Double) -> Date { - .init(timeIntervalSince1970: millisecondsSinceEpoch / 1000) + private func dateFromMillisecondsSinceEpoch(_ millisecondsSinceEpoch: UInt64) -> Date { + .init(timeIntervalSince1970: Double(millisecondsSinceEpoch) / 1000) } } diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift index d878edc26..c08ef605c 100644 --- a/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -3,7 +3,7 @@ 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: +/// `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 = [ @@ -29,7 +29,7 @@ internal indirect enum JSONValue: Sendable, Equatable { case object([String: JSONValue]) case array([JSONValue]) case string(String) - case number(Double) + case number(NSNumber) case bool(Bool) case null @@ -63,7 +63,7 @@ internal indirect enum JSONValue: Sendable, Equatable { } /// If this `JSONValue` has case `number`, this returns the associated value. Else, it returns `nil`. - internal var numberValue: Double? { + internal var numberValue: NSNumber? { if case let .number(numberValue) = self { numberValue } else { @@ -110,13 +110,13 @@ extension JSONValue: ExpressibleByStringLiteral { extension JSONValue: ExpressibleByIntegerLiteral { internal init(integerLiteral value: Int) { - self = .number(Double(value)) + self = .number(value as NSNumber) } } extension JSONValue: ExpressibleByFloatLiteral { internal init(floatLiteral value: Double) { - self = .number(value) + self = .number(value as NSNumber) } } @@ -147,7 +147,7 @@ internal extension JSONValue { } else if number === kCFBooleanFalse { self = .bool(false) } else { - self = .number(number.doubleValue) + self = .number(number) } case is NSNull: self = .null From a694c567d88658cac3b2e1cfb9ad22a0e1488333 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 6 Jun 2025 10:57:10 -0300 Subject: [PATCH 011/225] Implement the protocol-level interactions with ably-cocoa This implements enough for us to: - be able to encode and decode ObjectMessages received in an OBJECT or OBJECT_SYNC ProtocolMessage - receive these ProtocolMessages from ably-cocoa - send these ProtocolMessages via ably-cocoa's AblyPlugin API and find out the result of waiting for an ACK - be informed by ably-cocoa when a channel becomes attached, and the value of the HAS_OBJECTS flag It also adds a smoke test for all of the above. The ablyLiveObjects_mapValuesWithTypedThrow method is copied from ably-chat-swift at 3518483. Stuff that was generated by Cursor: - the majority of the encoding and decoding of WireObjectMessage, after I provided it with an initial example for one type - the tests for this encoding and decoding, with guidance from me and some manual tweaks - the additions to JSONCodable.swift - the code for managing the shared sandbox API key --- .cursor/rules/testing.mdc | 4 +- CONTRIBUTING.md | 1 + .../AblyLiveObjects/DefaultLiveObjects.swift | 44 +- .../Internal/DefaultInternalPlugin.swift | 76 ++- .../Protocol/ObjectMessage.swift | 2 - .../AblyLiveObjects/Protocol/WireEnum.swift | 25 + .../Protocol/WireObjectMessage.swift | 452 +++++++++++++ .../Public/ARTRealtimeChannel+Objects.swift | 9 + .../Utility/Dictionary+Extensions.swift | 8 + .../Utility/InternalError.swift | 7 + .../AblyLiveObjects/Utility/JSONCodable.swift | 56 ++ .../AblyLiveObjectsTests.swift | 101 +++ .../Helpers/Ably+Concurrency.swift | 38 ++ .../Helpers/Sandbox.swift | 42 ++ .../WireObjectMessageTests.swift | 636 ++++++++++++++++++ ably-cocoa | 2 +- 16 files changed, 1471 insertions(+), 32 deletions(-) delete mode 100644 Sources/AblyLiveObjects/Protocol/ObjectMessage.swift create mode 100644 Sources/AblyLiveObjects/Protocol/WireEnum.swift create mode 100644 Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift create mode 100644 Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift create mode 100644 Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift create mode 100644 Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc index 2d3b325b2..6497a5a9f 100644 --- a/.cursor/rules/testing.mdc +++ b/.cursor/rules/testing.mdc @@ -6,4 +6,6 @@ alwaysApply: false When writing tests: - Use the Swift Testing framework (`import Testing`), not XCTest. -- When writing tests, follow the guidelines given under "Attributing tests to a spec point" in the file `CONTRIBUTING.md` in order to tag the unit tests with the relevant specification points. +- Do not use `fatalError` in response to a test expectation failure. Favour the usage of Swift Testing's `#require` macro. +- Only add labels to test cases or suites when the label is different to the name of the suite `struct` or test method. +- When writing tests, follow the guidelines given under "Attributing tests to a spec point" in the file `CONTRIBUTING.md` in order to tag the unit tests with the relevant specification points. Pay particular attention to the difference between the meaning of `@spec` and `@specPartial` and be sure not to write `@spec` multiple times for the same specification point. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index b33b6452f..75fe28d04 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,7 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with ` ### Throwing errors - The public API of the SDK should use typed throws, and the thrown errors should be of type `ARTErrorInfo`. +- `Dictionary.mapValues` does not support typed throws. We have our own extension `ablyLiveObjects_mapValuesWithTypedThrow` which does; use this. ### Testing guidelines diff --git a/Sources/AblyLiveObjects/DefaultLiveObjects.swift b/Sources/AblyLiveObjects/DefaultLiveObjects.swift index 6b7697738..99f7bc5ff 100644 --- a/Sources/AblyLiveObjects/DefaultLiveObjects.swift +++ b/Sources/AblyLiveObjects/DefaultLiveObjects.swift @@ -7,10 +7,18 @@ internal class DefaultLiveObjects: Objects { private let logger: AblyPlugin.Logger private let pluginAPI: AblyPlugin.PluginAPIProtocol + // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. + private let receivedObjectProtocolMessages: AsyncStream<[InboundWireObjectMessage]> + private let receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundWireObjectMessage]>.Continuation + private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundWireObjectMessage]> + private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundWireObjectMessage]>.Continuation + internal init(channel: ARTRealtimeChannel, logger: AblyPlugin.Logger, pluginAPI: AblyPlugin.PluginAPIProtocol) { self.channel = channel self.logger = logger self.pluginAPI = pluginAPI + (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() + (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() } // MARK: `Objects` protocol @@ -49,15 +57,39 @@ internal class DefaultLiveObjects: Objects { // MARK: Handling channel events - internal func onChannelAttached(hasObjects _: Bool) { - notYetImplemented() + internal private(set) var testsOnly_onChannelAttachedHasObjects: Bool? + internal func onChannelAttached(hasObjects: Bool) { + testsOnly_onChannelAttachedHasObjects = hasObjects } - internal func handleObjectProtocolMessage(objectMessages _: [ObjectMessage]) { - notYetImplemented() + internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundWireObjectMessage]> { + receivedObjectProtocolMessages } - internal func handleObjectSyncProtocolMessage(objectMessages _: [ObjectMessage], protocolMessageChannelSerial _: String) { - notYetImplemented() + internal func handleObjectProtocolMessage(wireObjectMessages: [InboundWireObjectMessage]) { + receivedObjectProtocolMessagesContinuation.yield(wireObjectMessages) + } + + internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundWireObjectMessage]> { + receivedObjectSyncProtocolMessages + } + + internal func handleObjectSyncProtocolMessage(wireObjectMessages: [InboundWireObjectMessage], protocolMessageChannelSerial _: String) { + receivedObjectSyncProtocolMessagesContinuation.yield(wireObjectMessages) + } + + // MARK: - Sending `OBJECT` ProtocolMessage + + // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. + internal func testsOnly_sendObject(objectMessages: [OutboundWireObjectMessage]) async throws(InternalError) { + guard let channel else { + return + } + + try await DefaultInternalPlugin.sendObject( + objectMessages: objectMessages, + channel: channel, + pluginAPI: pluginAPI, + ) } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index bd5e57274..8ea3844b1 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -46,29 +46,38 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte Self.objectsProperty(for: channel, pluginAPI: pluginAPI) } - /// A class that wraps an ``ObjectMessage``. + /// A class that wraps a ``WireObjectMessage``. /// - /// We need this intermediate type because we want `ObjectMessage` to be a struct — because it's nicer to work with internally — but a struct can't conform to the class-bound `AblyPlugin.ObjectMessage` protocol. - private final class ObjectMessageBox: AblyPlugin.ObjectMessageProtocol { - internal let objectMessage: ObjectMessage + /// We need this intermediate type because we want `WireObjectMessage` to be a struct — because it's nicer to work with internally — but a struct can't conform to the class-bound `AblyPlugin.WireObjectMessage` protocol. + private final class WireObjectMessageBox: AblyPlugin.ObjectMessageProtocol where T: Sendable { + internal let wireObjectMessage: T - init(objectMessage: ObjectMessage) { - self.objectMessage = objectMessage + init(wireObjectMessage: T) { + self.wireObjectMessage = wireObjectMessage } } - internal func decodeObjectMessage(_: [AnyHashable: Any]) -> any AblyPlugin.ObjectMessageProtocol { - // TODO: do something with the dictionary - let objectMessage = ObjectMessage() - return ObjectMessageBox(objectMessage: objectMessage) + internal func decodeObjectMessage(_ serialized: [String: Any], context: DecodingContextProtocol, error errorPtr: AutoreleasingUnsafeMutablePointer?) -> (any ObjectMessageProtocol)? { + let jsonObject = JSONValue.objectFromAblyPluginData(serialized) + + do { + let wireObjectMessage = try InboundWireObjectMessage( + jsonObject: jsonObject, + decodingContext: context, + ) + return WireObjectMessageBox(wireObjectMessage: wireObjectMessage) + } catch { + errorPtr?.pointee = error.toARTErrorInfo() + return nil + } } - internal func encodeObjectMessage(_ publicObjectMessage: any AblyPlugin.ObjectMessageProtocol) -> [AnyHashable: Any] { - guard publicObjectMessage is ObjectMessageBox else { - preconditionFailure("Expected to receive the same ObjectMessage type as we emit") + internal func encodeObjectMessage(_ publicObjectMessage: any AblyPlugin.ObjectMessageProtocol) -> [String: Any] { + guard let wireObjectMessageBox = publicObjectMessage as? WireObjectMessageBox else { + preconditionFailure("Expected to receive the same WireObjectMessage type as we emit") } - notYetImplemented() + return wireObjectMessageBox.wireObjectMessage.toJSONObject.toAblyPluginDataDictionary } internal func onChannelAttached(_ channel: ARTRealtimeChannel, hasObjects: Bool) { @@ -76,27 +85,50 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } internal func handleObjectProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], channel: ARTRealtimeChannel) { - guard let objectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { - preconditionFailure("Expected to receive the same ObjectMessage type as we emit") + guard let wireObjectMessageBoxes = publicObjectMessages as? [WireObjectMessageBox] else { + preconditionFailure("Expected to receive the same WireObjectMessage type as we emit") } - let objectMessages = objectMessageBoxes.map(\.objectMessage) + let wireObjectMessages = wireObjectMessageBoxes.map(\.wireObjectMessage) objectsProperty(for: channel).handleObjectProtocolMessage( - objectMessages: objectMessages, + wireObjectMessages: wireObjectMessages, ) } internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], protocolMessageChannelSerial: String, channel: ARTRealtimeChannel) { - guard let objectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { - preconditionFailure("Expected to receive the same ObjectMessage type as we emit") + guard let objectMessageBoxes = publicObjectMessages as? [WireObjectMessageBox] else { + preconditionFailure("Expected to receive the same WireObjectMessage type as we emit") } - let objectMessages = objectMessageBoxes.map(\.objectMessage) + let wireObjectMessages = objectMessageBoxes.map(\.wireObjectMessage) objectsProperty(for: channel).handleObjectSyncProtocolMessage( - objectMessages: objectMessages, + wireObjectMessages: wireObjectMessages, protocolMessageChannelSerial: protocolMessageChannelSerial, ) } + + // MARK: - Sending `OBJECT` ProtocolMessage + + internal static func sendObject( + objectMessages: [OutboundWireObjectMessage], + channel: ARTRealtimeChannel, + pluginAPI: PluginAPIProtocol, + ) async throws(InternalError) { + let objectMessageBoxes: [WireObjectMessageBox] = objectMessages.map { .init(wireObjectMessage: $0) } + + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + pluginAPI.sendObject( + withObjectMessages: objectMessageBoxes, + channel: channel, + ) { error in + if let error { + continuation.resume(returning: .failure(error.toInternalError())) + } else { + continuation.resume(returning: .success(())) + } + } + }.get() + } } diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift deleted file mode 100644 index e8cd261c1..000000000 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ /dev/null @@ -1,2 +0,0 @@ -/// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. -internal struct ObjectMessage {} diff --git a/Sources/AblyLiveObjects/Protocol/WireEnum.swift b/Sources/AblyLiveObjects/Protocol/WireEnum.swift new file mode 100644 index 000000000..9df4f47e9 --- /dev/null +++ b/Sources/AblyLiveObjects/Protocol/WireEnum.swift @@ -0,0 +1,25 @@ +/// An enum extracted from a wire representation that either belongs to one of a set of known values or is a new, unknown value. +internal enum WireEnum where Known: RawRepresentable { + case known(Known) + case unknown(Known.RawValue) + + internal init(rawValue: Known.RawValue) { + if let known = Known(rawValue: rawValue) { + self = .known(known) + } else { + self = .unknown(rawValue) + } + } + + internal var rawValue: Known.RawValue { + switch self { + case let .known(known): + known.rawValue + case let .unknown(rawValue): + rawValue + } + } +} + +extension WireEnum: Sendable where Known: Sendable, Known.RawValue: Sendable {} +extension WireEnum: Equatable where Known: Equatable, Known.RawValue: Equatable {} diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift new file mode 100644 index 000000000..1a18e641b --- /dev/null +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -0,0 +1,452 @@ +internal import AblyPlugin +import Foundation + +/// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. +internal struct InboundWireObjectMessage { + // TODO: Spec has `id`, `connectionId`, `timestamp`, `clientId`, `serial`, `sideCode` as non-nullable but I don't think this is right; raised https://github.com/ably/specification/issues/334 + internal var id: String? // OM2a + internal var clientId: String? // OM2b + internal var connectionId: String? // OM2c + internal var extras: [String: JSONValue]? // OM2d + internal var timestamp: Date? // OM2e + internal var operation: WireObjectOperation? // OM2f + internal var object: WireObjectState? // OM2g + internal var serial: String? // OM2h + internal var siteCode: String? // OM2i +} + +/// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. +internal struct OutboundWireObjectMessage { + internal var id: String? // OM2a + internal var clientId: String? // OM2b + internal var connectionId: String? + internal var extras: [String: JSONValue]? // OM2d + internal var timestamp: Date? // OM2e + internal var operation: WireObjectOperation? // OM2f + internal var object: WireObjectState? // OM2g + internal var serial: String? // OM2h + internal var siteCode: String? // OM2i +} + +/// The keys for decoding an `InboundWireObjectMessage` or encoding an `OutboundWireObjectMessage`. +internal enum WireObjectMessageJSONKey: String { + case id + case clientId + case connectionId + case extras + case timestamp + case operation + case object + case serial + case siteCode +} + +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( + jsonObject: [String: JSONValue], + decodingContext: AblyPlugin.DecodingContextProtocol + ) throws(InternalError) { + // OM2a + if let id = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.id.rawValue) { + self.id = id + } else if let parentID = decodingContext.parentID { + id = "\(parentID):\(decodingContext.indexInParent)" + } + + clientId = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.clientId.rawValue) + + // OM2c + if let connectionId = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.connectionId.rawValue) { + self.connectionId = connectionId + } else if let parentConnectionID = decodingContext.parentConnectionID { + connectionId = parentConnectionID + } + + extras = try jsonObject.optionalObjectValueForKey(WireObjectMessageJSONKey.extras.rawValue) + + // OM2e + if let timestamp = try jsonObject.optionalAblyProtocolDateValueForKey(WireObjectMessageJSONKey.timestamp.rawValue) { + self.timestamp = timestamp + } else if let parentTimestamp = decodingContext.parentTimestamp { + timestamp = parentTimestamp + } + + operation = try jsonObject.optionalDecodableValueForKey(WireObjectMessageJSONKey.operation.rawValue) + object = try jsonObject.optionalDecodableValueForKey(WireObjectMessageJSONKey.object.rawValue) + serial = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.serial.rawValue) + siteCode = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.siteCode.rawValue) + } +} + +extension OutboundWireObjectMessage: JSONObjectEncodable { + internal var toJSONObject: [String: JSONValue] { + var result: [String: JSONValue] = [:] + + if let id { + result[WireObjectMessageJSONKey.id.rawValue] = .string(id) + } + if let connectionId { + result[WireObjectMessageJSONKey.connectionId.rawValue] = .string(connectionId) + } + if let timestamp { + result[WireObjectMessageJSONKey.timestamp.rawValue] = .number(NSNumber(value: (timestamp.timeIntervalSince1970) * 1000)) + } + if let siteCode { + result[WireObjectMessageJSONKey.siteCode.rawValue] = .string(siteCode) + } + if let serial { + result[WireObjectMessageJSONKey.serial.rawValue] = .string(serial) + } + if let clientId { + result[WireObjectMessageJSONKey.clientId.rawValue] = .string(clientId) + } + if let extras { + result[WireObjectMessageJSONKey.extras.rawValue] = .object(extras) + } + if let operation { + result[WireObjectMessageJSONKey.operation.rawValue] = .object(operation.toJSONObject) + } + if let object { + result[WireObjectMessageJSONKey.object.rawValue] = .object(object.toJSONObject) + } + return result + } +} + +// OOP2 +internal enum ObjectOperationAction: Int { + case mapCreate = 0 + case mapSet = 1 + case mapRemove = 2 + case counterCreate = 3 + case counterInc = 4 + case objectDelete = 5 +} + +// MAP2 +internal enum MapSemantics: Int { + case lww = 0 +} + +internal struct WireObjectOperation { + internal var action: WireEnum // OOP3a + internal var objectId: String // OOP3b + internal var mapOp: WireMapOp? // OOP3c + internal var counterOp: WireCounterOp? // OOP3d + internal var map: WireMap? // OOP3e + internal var counter: WireCounter? // OOP3f + internal var nonce: String? // OOP3g + // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 + internal var initialValue: Data? // OOP3h + internal var initialValueEncoding: String? // OOP3i +} + +extension WireObjectOperation: JSONObjectCodable { + internal enum JSONKey: String { + case action + case objectId + case mapOp + case counterOp + case map + case counter + case nonce + case initialValue + case initialValueEncoding + } + + internal init(jsonObject: [String: JSONValue]) throws(InternalError) { + action = try jsonObject.wireEnumValueForKey(JSONKey.action.rawValue) + objectId = try jsonObject.stringValueForKey(JSONKey.objectId.rawValue) + mapOp = try jsonObject.optionalDecodableValueForKey(JSONKey.mapOp.rawValue) + counterOp = try jsonObject.optionalDecodableValueForKey(JSONKey.counterOp.rawValue) + map = try jsonObject.optionalDecodableValueForKey(JSONKey.map.rawValue) + counter = try jsonObject.optionalDecodableValueForKey(JSONKey.counter.rawValue) + nonce = try jsonObject.optionalStringValueForKey(JSONKey.nonce.rawValue) + initialValueEncoding = try jsonObject.optionalStringValueForKey(JSONKey.initialValueEncoding.rawValue) + } + + internal var toJSONObject: [String: JSONValue] { + var result: [String: JSONValue] = [ + JSONKey.action.rawValue: .number(action.rawValue as NSNumber), + JSONKey.objectId.rawValue: .string(objectId), + ] + + if let mapOp { + result[JSONKey.mapOp.rawValue] = .object(mapOp.toJSONObject) + } + if let counterOp { + result[JSONKey.counterOp.rawValue] = .object(counterOp.toJSONObject) + } + if let map { + result[JSONKey.map.rawValue] = .object(map.toJSONObject) + } + if let counter { + result[JSONKey.counter.rawValue] = .object(counter.toJSONObject) + } + if let nonce { + result[JSONKey.nonce.rawValue] = .string(nonce) + } + if let initialValueEncoding { + result[JSONKey.initialValueEncoding.rawValue] = .string(initialValueEncoding) + } + + return result + } +} + +internal struct WireObjectState { + internal var objectId: String // OST2a + internal var siteTimeserials: [String: String] // OST2b + internal var tombstone: Bool // OST2c + internal var createOp: WireObjectOperation? // OST2d + internal var map: WireMap? // OST2e + internal var counter: WireCounter? // OST2f +} + +extension WireObjectState: JSONObjectCodable { + internal enum JSONKey: String { + case objectId + case siteTimeserials + case tombstone + case createOp + case map + case counter + } + + internal init(jsonObject: [String: JSONValue]) throws(InternalError) { + objectId = try jsonObject.stringValueForKey(JSONKey.objectId.rawValue) + siteTimeserials = try jsonObject.objectValueForKey(JSONKey.siteTimeserials.rawValue).ablyLiveObjects_mapValuesWithTypedThrow { value throws(InternalError) in + guard case let .string(string) = value else { + throw JSONValueDecodingError.wrongTypeForKey(JSONKey.siteTimeserials.rawValue, actualValue: value).toInternalError() + } + return string + } + tombstone = try jsonObject.boolValueForKey(JSONKey.tombstone.rawValue) + createOp = try jsonObject.optionalDecodableValueForKey(JSONKey.createOp.rawValue) + map = try jsonObject.optionalDecodableValueForKey(JSONKey.map.rawValue) + counter = try jsonObject.optionalDecodableValueForKey(JSONKey.counter.rawValue) + } + + internal var toJSONObject: [String: JSONValue] { + var result: [String: JSONValue] = [ + JSONKey.objectId.rawValue: .string(objectId), + JSONKey.siteTimeserials.rawValue: .object(siteTimeserials.mapValues { .string($0) }), + JSONKey.tombstone.rawValue: .bool(tombstone), + ] + + if let createOp { + result[JSONKey.createOp.rawValue] = .object(createOp.toJSONObject) + } + if let map { + result[JSONKey.map.rawValue] = .object(map.toJSONObject) + } + if let counter { + result[JSONKey.counter.rawValue] = .object(counter.toJSONObject) + } + + return result + } +} + +internal struct WireMapOp { + internal var key: String // MOP2a + internal var data: WireObjectData? // MOP2b +} + +extension WireMapOp: JSONObjectCodable { + internal enum JSONKey: String { + case key + case data + } + + internal init(jsonObject: [String: JSONValue]) throws(InternalError) { + key = try jsonObject.stringValueForKey(JSONKey.key.rawValue) + data = try jsonObject.optionalDecodableValueForKey(JSONKey.data.rawValue) + } + + internal var toJSONObject: [String: JSONValue] { + var result: [String: JSONValue] = [ + JSONKey.key.rawValue: .string(key), + ] + + if let data { + result[JSONKey.data.rawValue] = .object(data.toJSONObject) + } + + return result + } +} + +internal struct WireCounterOp { + internal var amount: NSNumber // COP2a +} + +extension WireCounterOp: JSONObjectCodable { + internal enum JSONKey: String { + case amount + } + + internal init(jsonObject: [String: JSONValue]) throws(InternalError) { + amount = try jsonObject.numberValueForKey(JSONKey.amount.rawValue) + } + + internal var toJSONObject: [String: JSONValue] { + [ + JSONKey.amount.rawValue: .number(amount), + ] + } +} + +internal struct WireMap { + internal var semantics: WireEnum // MAP3a + internal var entries: [String: WireMapEntry]? // MAP3b +} + +extension WireMap: JSONObjectCodable { + internal enum JSONKey: String { + case semantics + case entries + } + + internal init(jsonObject: [String: JSONValue]) throws(InternalError) { + semantics = try jsonObject.wireEnumValueForKey(JSONKey.semantics.rawValue) + entries = try jsonObject.optionalObjectValueForKey(JSONKey.entries.rawValue)?.ablyLiveObjects_mapValuesWithTypedThrow { value throws(InternalError) in + guard case let .object(object) = value else { + throw JSONValueDecodingError.wrongTypeForKey(JSONKey.entries.rawValue, actualValue: value).toInternalError() + } + return try WireMapEntry(jsonObject: object) + } + } + + internal var toJSONObject: [String: JSONValue] { + var result: [String: JSONValue] = [ + JSONKey.semantics.rawValue: .number(semantics.rawValue as NSNumber), + ] + + if let entries { + result[JSONKey.entries.rawValue] = .object(entries.mapValues { .object($0.toJSONObject) }) + } + + return result + } +} + +internal struct WireCounter { + internal var count: NSNumber? // CNT2a +} + +extension WireCounter: JSONObjectCodable { + internal enum JSONKey: String { + case count + } + + internal init(jsonObject: [String: JSONValue]) throws(InternalError) { + count = try jsonObject.optionalNumberValueForKey(JSONKey.count.rawValue) + } + + internal var toJSONObject: [String: JSONValue] { + var result: [String: JSONValue] = [:] + if let count { + result[JSONKey.count.rawValue] = .number(count) + } + return result + } +} + +internal struct WireMapEntry { + internal var tombstone: Bool? // ME2a + internal var timeserial: String? // ME2b + internal var data: WireObjectData // ME2c +} + +extension WireMapEntry: JSONObjectCodable { + internal enum JSONKey: String { + case tombstone + case timeserial + case data + } + + internal init(jsonObject: [String: JSONValue]) throws(InternalError) { + tombstone = try jsonObject.optionalBoolValueForKey(JSONKey.tombstone.rawValue) + timeserial = try jsonObject.optionalStringValueForKey(JSONKey.timeserial.rawValue) + data = try jsonObject.decodableValueForKey(JSONKey.data.rawValue) + } + + internal var toJSONObject: [String: JSONValue] { + var result: [String: JSONValue] = [ + JSONKey.data.rawValue: .object(data.toJSONObject), + ] + + if let tombstone { + result[JSONKey.tombstone.rawValue] = .bool(tombstone) + } + if let timeserial { + result[JSONKey.timeserial.rawValue] = .string(timeserial) + } + + return result + } +} + +internal struct WireObjectData { + internal var objectId: String? // OD2a + internal var encoding: String? // OD2b + internal var boolean: Bool? // OD2c + // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 + internal var bytes: Data? // OD2d + internal var number: NSNumber? // OD2e + internal var string: String? // OD2f +} + +extension WireObjectData: JSONObjectCodable { + internal enum JSONKey: String { + case objectId + case encoding + case boolean + case bytes + case number + case string + } + + internal init(jsonObject: [String: JSONValue]) throws(InternalError) { + objectId = try jsonObject.optionalStringValueForKey(JSONKey.objectId.rawValue) + encoding = try jsonObject.optionalStringValueForKey(JSONKey.encoding.rawValue) + boolean = try jsonObject.optionalBoolValueForKey(JSONKey.boolean.rawValue) + number = try jsonObject.optionalNumberValueForKey(JSONKey.number.rawValue) + string = try jsonObject.optionalStringValueForKey(JSONKey.string.rawValue) + } + + internal var toJSONObject: [String: JSONValue] { + var result: [String: JSONValue] = [:] + + if let objectId { + result[JSONKey.objectId.rawValue] = .string(objectId) + } + if let encoding { + result[JSONKey.encoding.rawValue] = .string(encoding) + } + if let boolean { + result[JSONKey.boolean.rawValue] = .bool(boolean) + } + if let number { + result[JSONKey.number.rawValue] = .number(number) + } + if let string { + result[JSONKey.string.rawValue] = .string(string) + } + + return result + } +} diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index b538b7eea..155290e65 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -4,6 +4,15 @@ internal import AblyPlugin public extension ARTRealtimeChannel { /// An ``Objects`` object. var objects: Objects { + internallyTypedObjects + } + + private var internallyTypedObjects: DefaultLiveObjects { DefaultInternalPlugin.objectsProperty(for: self, pluginAPI: AblyPlugin.PluginAPI.sharedInstance()) } + + /// For tests to access the non-public API of `DefaultLiveObjects`. + internal var testsOnly_internallyTypedObjects: DefaultLiveObjects { + internallyTypedObjects + } } diff --git a/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift b/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift new file mode 100644 index 000000000..9635b9ebd --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift @@ -0,0 +1,8 @@ +internal extension Dictionary { + /// Behaves like `Dictionary.mapValues`, but the thrown error has the same type as that thrown by the transform. (`mapValues` uses `rethrows`, which is always an untyped throw.) + func ablyLiveObjects_mapValuesWithTypedThrow(_ transform: (Value) throws(E) -> T) throws(E) -> [Key: T] where E: Error { + try .init(uniqueKeysWithValues: map { key, value throws(E) in + try (key, transform(value)) + }) + } +} diff --git a/Sources/AblyLiveObjects/Utility/InternalError.swift b/Sources/AblyLiveObjects/Utility/InternalError.swift index b16698e09..e015c82c6 100644 --- a/Sources/AblyLiveObjects/Utility/InternalError.swift +++ b/Sources/AblyLiveObjects/Utility/InternalError.swift @@ -9,6 +9,7 @@ internal enum InternalError: Error { internal enum Other { case jsonValueDecodingError(JSONValueDecodingError) + case inboundWireObjectMessageDecodingError(InboundWireObjectMessage.DecodingError) } /// Returns the error that this should be converted to when exposed via the SDK's public API. @@ -34,3 +35,9 @@ internal extension JSONValueDecodingError { .other(.jsonValueDecodingError(self)) } } + +internal extension InboundWireObjectMessage.DecodingError { + func toInternalError() -> InternalError { + .other(.inboundWireObjectMessageDecodingError(self)) + } +} diff --git a/Sources/AblyLiveObjects/Utility/JSONCodable.swift b/Sources/AblyLiveObjects/Utility/JSONCodable.swift index 81c9f71a5..d844bf13d 100644 --- a/Sources/AblyLiveObjects/Utility/JSONCodable.swift +++ b/Sources/AblyLiveObjects/Utility/JSONCodable.swift @@ -298,3 +298,59 @@ internal extension [String: JSONValue] { return value } } + +// MARK: - Extracting WireEnum values from a dictionary + +internal extension [String: JSONValue] { + /// 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: + /// - `JSONValueDecodingError.noValueForKey` if the key is absent + /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` + func wireEnumValueForKey(_ key: String, type _: Known.Type = Known.self) throws(InternalError) -> 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` + func optionalWireEnumValueForKey(_ key: String, type _: Known.Type = Known.self) throws(InternalError) -> WireEnum? where Known.RawValue == Int { + guard let rawValue = try optionalNumberValueForKey(key)?.intValue else { + return nil + } + return WireEnum(rawValue: rawValue) + } +} + +// MARK: - Extracting JSONDecodable values from a dictionary + +internal extension [String: JSONValue] { + /// If this dictionary contains a value for `key`, this attempts to decode it into an instance of `T` using its `init(jsonValue:)` initializer. + /// + /// - Throws: + /// - `JSONValueDecodingError.noValueForKey` if the key is absent + /// - Any error thrown by `T.init(jsonValue:)` + func decodableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> T { + guard let value = self[key] else { + throw JSONValueDecodingError.noValueForKey(key).toInternalError() + } + + return try T(jsonValue: value) + } + + /// If this dictionary contains a value for `key`, this attempts to decode it into an instance of `T` using its `init(jsonValue:)` 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(jsonValue:)` + func optionalDecodableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> T? { + guard let value = self[key] else { + return nil + } + + if case .null = value { + return nil + } + + return try T(jsonValue: value) + } +} diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index eeccc7c58..c9e27cfac 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -1,5 +1,6 @@ import Ably @testable import AblyLiveObjects +import AblyPlugin import Testing struct AblyLiveObjectsTests { @@ -21,4 +22,104 @@ struct AblyLiveObjectsTests { // Check that the `channel.objects` property works and gives the internal type we expect #expect(channel.objects is DefaultLiveObjects) } + + /// 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_internallyTypedObjects.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_internallyTypedObjects.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. + + // (This objectId comes from copying that which was given in an expected value in an error message from Realtime) + let realtimeCreatedMapObjectID = "map:iC4Nq8EbTSEmw-_tDJdVV8HfiBvJGpZmO_WbGbh0_-4@\(currentAblyTimestamp)" + + try await channel.testsOnly_internallyTypedObjects.testsOnly_sendObject(objectMessages: [ + OutboundWireObjectMessage( + operation: .init( + action: .known(.mapCreate), + objectId: realtimeCreatedMapObjectID, + 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_internallyTypedObjects.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) { + do throws(InternalError) { + try await channel.testsOnly_internallyTypedObjects.testsOnly_sendObject(objectMessages: [ + .init(), + ]) + } catch { + throw error.toARTErrorInfo() + } + } + + // (These are just based on what I observed in the NACK) + #expect(invalidObjectThrownError.code == 92000) + #expect(invalidObjectThrownError.message == "invalid object message: object operation required") + } } diff --git a/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift b/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift new file mode 100644 index 000000000..4a0bab3f4 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift @@ -0,0 +1,38 @@ +import Ably + +// Helpers for using ably-cocoa with Swift concurrency and typed throws. + +extension ARTRealtimeChannelProtocol { + func attachAsync() async throws(ARTErrorInfo) { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + attach { error in + if let error { + continuation.resume(returning: .failure(error)) + } else { + continuation.resume(returning: .success(())) + } + } + }.get() + } +} + +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() + } +} diff --git a/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift b/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift index 2d447ea5e..d88edf51e 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift @@ -47,4 +47,46 @@ enum Sandbox { // 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/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift new file mode 100644 index 000000000..5a1a6880f --- /dev/null +++ b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -0,0 +1,636 @@ +@testable import AblyLiveObjects +import AblyPlugin +import Foundation +import Testing + +enum WireObjectMessageTests { + // Helper: Fake decoding context + final class FakeDecodingContext: AblyPlugin.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 json: [String: JSONValue] = [ + "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(jsonObject: json, 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 json: [String: JSONValue] = [:] + let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: nil, indexInParent: 0) + let msg = try InboundWireObjectMessage(jsonObject: json, 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 json: [String: JSONValue] = [:] + let ctx = FakeDecodingContext(parentID: "parent1", parentConnectionID: nil, parentTimestamp: nil, indexInParent: 2) + let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + #expect(msg.id == "parent1:2") + } + + // @specOneOf(2/2) OM2a + @Test + func idFromParent_whenAbsentInParent() throws { + let json: [String: JSONValue] = [:] + let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: nil, indexInParent: 2) + let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + #expect(msg.id == nil) + } + + // @spec OM2c + @Test(arguments: [nil, "parentConn1"]) + func connectionIdFromParent(parentValue: String?) throws { + let json: [String: JSONValue] = [:] + let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: parentValue, parentTimestamp: nil, indexInParent: 0) + let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + #expect(msg.connectionId == parentValue) + } + + // @spec OM2e + @Test(arguments: [nil, Date(timeIntervalSince1970: 1_234_567_890)]) + func timestampFromParent(parentValue: Date?) throws { + let json: [String: JSONValue] = [:] + let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: parentValue, indexInParent: 0) + let msg = try InboundWireObjectMessage(jsonObject: json, 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", + mapOp: nil, + counterOp: nil, + map: nil, + counter: nil, + nonce: nil, + initialValue: nil, + initialValueEncoding: nil, + ), + object: nil, + serial: "s1", + siteCode: "siteA", + ) + let json = msg.toJSONObject + #expect(json == [ + "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 json = msg.toJSONObject + #expect(json == [ + "id": "id1", + "timestamp": .number(NSNumber(value: Int(timestamp.timeIntervalSince1970 * 1000))), + ]) + } + } + + struct WireObjectOperationTests { + @Test + func decodesAllFields() throws { + let json: [String: JSONValue] = [ + "action": 0, // mapCreate + "objectId": "obj1", + "mapOp": ["key": "key1", "data": ["string": "value1"]], + "counterOp": ["amount": 42], + "map": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], + "counter": ["count": 42], + "nonce": "nonce1", + "initialValueEncoding": "utf8", + ] + let op = try WireObjectOperation(jsonObject: json) + #expect(op.action == .known(.mapCreate)) + #expect(op.objectId == "obj1") + #expect(op.mapOp?.key == "key1") + #expect(op.mapOp?.data?.string == "value1") + #expect(op.counterOp?.amount == 42) + #expect(op.map?.semantics == .known(.lww)) + #expect(op.map?.entries?["key1"]?.data.string == "value1") + #expect(op.map?.entries?["key1"]?.tombstone == false) + #expect(op.counter?.count == 42) + #expect(op.nonce == "nonce1") + #expect(op.initialValueEncoding == "utf8") + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let json: [String: JSONValue] = [ + "action": 0, + "objectId": "obj1", + ] + let op = try WireObjectOperation(jsonObject: json) + #expect(op.action == .known(.mapCreate)) + #expect(op.objectId == "obj1") + #expect(op.mapOp == nil) + #expect(op.counterOp == nil) + #expect(op.map == nil) + #expect(op.counter == nil) + #expect(op.nonce == nil) + #expect(op.initialValue == nil) + #expect(op.initialValueEncoding == nil) + } + + @Test + func decodesWithUnknownAction() throws { + let json: [String: JSONValue] = [ + "action": 999, // Unknown WireObjectOperation + "objectId": "obj1", + ] + let op = try WireObjectOperation(jsonObject: json) + #expect(op.action == .unknown(999)) + } + + @Test + func encodesAllFields() { + let op = WireObjectOperation( + action: .known(.mapCreate), + objectId: "obj1", + mapOp: WireMapOp(key: "key1", data: WireObjectData(string: "value1")), + counterOp: WireCounterOp(amount: 42), + map: WireMap( + semantics: .known(.lww), + entries: ["key1": WireMapEntry(tombstone: false, timeserial: nil, data: WireObjectData(string: "value1"))], + ), + counter: WireCounter(count: 42), + nonce: "nonce1", + initialValue: nil, + initialValueEncoding: "utf8", + ) + let json = op.toJSONObject + #expect(json == [ + "action": 0, + "objectId": "obj1", + "mapOp": ["key": "key1", "data": ["string": "value1"]], + "counterOp": ["amount": 42], + "map": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], + "counter": ["count": 42], + "nonce": "nonce1", + "initialValueEncoding": "utf8", + ]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let op = WireObjectOperation( + action: .known(.mapCreate), + objectId: "obj1", + mapOp: nil, + counterOp: nil, + map: nil, + counter: nil, + nonce: nil, + initialValue: nil, + initialValueEncoding: nil, + ) + let json = op.toJSONObject + #expect(json == [ + "action": 0, + "objectId": "obj1", + ]) + } + } + + struct WireObjectStateTests { + @Test + func decodesAllFields() throws { + let json: [String: JSONValue] = [ + "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(jsonObject: json) + #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 json: [String: JSONValue] = [ + "objectId": "obj1", + "siteTimeserials": [:], + "tombstone": false, + ] + let state = try WireObjectState(jsonObject: json) + #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", + mapOp: nil, + counterOp: nil, + map: nil, + counter: nil, + nonce: nil, + initialValue: nil, + initialValueEncoding: nil, + ), + map: WireMap( + semantics: .known(.lww), + entries: ["key1": WireMapEntry(tombstone: false, timeserial: nil, data: WireObjectData(string: "value1"))], + ), + counter: WireCounter(count: 42), + ) + let json = state.toJSONObject + #expect(json == [ + "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 json = state.toJSONObject + #expect(json == [ + "objectId": "obj1", + "siteTimeserials": [:], + "tombstone": false, + ]) + } + } + + struct WireObjectDataTests { + @Test + func decodesAllFields() throws { + let json: [String: JSONValue] = [ + "objectId": "obj1", + "encoding": "utf8", + "boolean": true, + "number": 42, + "string": "value1", + ] + let data = try WireObjectData(jsonObject: json) + #expect(data.objectId == "obj1") + #expect(data.encoding == "utf8") + #expect(data.boolean == true) + #expect(data.number == 42) + #expect(data.string == "value1") + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let json: [String: JSONValue] = [:] + let data = try WireObjectData(jsonObject: json) + #expect(data.objectId == nil) + #expect(data.encoding == 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", + encoding: "utf8", + boolean: true, + bytes: nil, + number: 42, + string: "value1", + ) + let json = data.toJSONObject + #expect(json == [ + "objectId": "obj1", + "encoding": "utf8", + "boolean": true, + "number": 42, + "string": "value1", + ]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let data = WireObjectData( + objectId: nil, + encoding: nil, + boolean: nil, + bytes: nil, + number: nil, + string: nil, + ) + let json = data.toJSONObject + #expect(json.isEmpty) + } + } + + struct WireMapOpTests { + @Test + func decodesAllFields() throws { + let json: [String: JSONValue] = [ + "key": "key1", + "data": ["string": "value1"], + ] + let op = try WireMapOp(jsonObject: json) + #expect(op.key == "key1") + #expect(op.data?.string == "value1") + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let json: [String: JSONValue] = ["key": "key1"] + let op = try WireMapOp(jsonObject: json) + #expect(op.key == "key1") + #expect(op.data == nil) + } + + @Test + func encodesAllFields() { + let op = WireMapOp( + key: "key1", + data: WireObjectData(string: "value1"), + ) + let json = op.toJSONObject + #expect(json == [ + "key": "key1", + "data": ["string": "value1"], + ]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let op = WireMapOp( + key: "key1", + data: nil, + ) + let json = op.toJSONObject + #expect(json == [ + "key": "key1", + ]) + } + } + + struct WireCounterOpTests { + @Test + func decodesAllFields() throws { + let json: [String: JSONValue] = ["amount": 42] + let op = try WireCounterOp(jsonObject: json) + #expect(op.amount == 42) + } + + @Test + func encodesAllFields() { + let op = WireCounterOp(amount: 42) + let json = op.toJSONObject + #expect(json == ["amount": 42]) + } + } + + struct WireMapTests { + @Test + func decodesAllFields() throws { + let json: [String: JSONValue] = [ + "semantics": 0, + "entries": [ + "key1": ["data": ["string": "value1"], "tombstone": false, "timeserial": "ts1"], + "key2": ["data": ["string": "value2"], "tombstone": true], + ], + ] + let map = try WireMap(jsonObject: 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: JSONValue] = ["semantics": 0] + let map = try WireMap(jsonObject: json) + #expect(map.semantics == .known(.lww)) + #expect(map.entries == nil) + } + + @Test + func decodesWithUnknownSemantics() throws { + let json: [String: JSONValue] = [ + "semantics": 999, // Unknown MapSemantics + ] + let map = try WireMap(jsonObject: json) + #expect(map.semantics == .unknown(999)) + } + + @Test + func encodesAllFields() { + let map = WireMap( + semantics: .known(.lww), + entries: [ + "key1": WireMapEntry(tombstone: false, timeserial: "ts1", data: WireObjectData(string: "value1")), + "key2": WireMapEntry(tombstone: true, timeserial: nil, data: WireObjectData(string: "value2")), + ], + ) + let json = map.toJSONObject + #expect(json == [ + "semantics": 0, + "entries": [ + "key1": ["data": ["string": "value1"], "tombstone": false, "timeserial": "ts1"], + "key2": ["data": ["string": "value2"], "tombstone": true], + ], + ]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let map = WireMap( + semantics: .known(.lww), + entries: nil, + ) + let json = map.toJSONObject + #expect(json == [ + "semantics": 0, + ]) + } + } + + struct WireCounterTests { + @Test + func decodesAllFields() throws { + let json: [String: JSONValue] = ["count": 42] + let counter = try WireCounter(jsonObject: json) + #expect(counter.count == 42) + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let json: [String: JSONValue] = [:] + let counter = try WireCounter(jsonObject: json) + #expect(counter.count == nil) + } + + @Test + func encodesAllFields() { + let counter = WireCounter(count: 42) + let json = counter.toJSONObject + #expect(json == ["count": 42]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let counter = WireCounter(count: nil) + let json = counter.toJSONObject + #expect(json.isEmpty) + } + } + + struct WireMapEntryTests { + @Test + func decodesAllFields() throws { + let json: [String: JSONValue] = [ + "data": ["string": "value1"], + "tombstone": true, + "timeserial": "ts1", + ] + let entry = try WireMapEntry(jsonObject: json) + #expect(entry.data.string == "value1") + #expect(entry.tombstone == true) + #expect(entry.timeserial == "ts1") + } + + @Test + func decodesWithOptionalFieldsAbsent() throws { + let json: [String: JSONValue] = ["data": ["string": "value1"]] + let entry = try WireMapEntry(jsonObject: json) + #expect(entry.data.string == "value1") + #expect(entry.tombstone == nil) + #expect(entry.timeserial == nil) + } + + @Test + func encodesAllFields() { + let entry = WireMapEntry( + tombstone: true, + timeserial: "ts1", + data: WireObjectData(string: "value1"), + ) + let json = entry.toJSONObject + #expect(json == [ + "data": ["string": "value1"], + "tombstone": true, + "timeserial": "ts1", + ]) + } + + @Test + func encodesWithOptionalFieldsNil() { + let entry = WireMapEntry( + tombstone: nil, + timeserial: nil, + data: WireObjectData(string: "value1"), + ) + let json = entry.toJSONObject + #expect(json == [ + "data": ["string": "value1"], + ]) + } + } +} diff --git a/ably-cocoa b/ably-cocoa index 2df793916..5fc038cb2 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 2df793916aaaf9389aab23da48e0c402706212c8 +Subproject commit 5fc038cb2b481f51b630211771afd16762af152e From bbeeb6fba7425bdd2b86ef57c2b74c091a7f3c27 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 10:50:12 -0300 Subject: [PATCH 012/225] Tweak some Cursor rules --- .cursor/rules/specification.mdc | 2 ++ .cursor/rules/swift.mdc | 5 ++++- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/.cursor/rules/specification.mdc b/.cursor/rules/specification.mdc index d9f35765d..c0c086e0d 100644 --- a/.cursor/rules/specification.mdc +++ b/.cursor/rules/specification.mdc @@ -10,3 +10,5 @@ alwaysApply: false - If you are given a task that requires knowledge of the Specification Document, you must consult the Specification Document before proceeding. You must never make a guess about the contents of the Specification Document. - If you are given a task that requires you to interpret the Specification Document, but the Specification Document is unclear, be sure to mention this. - The Specification Document is structured as a list of specification points, each with an identifier. An example identifier is "OD1". In the Specification Document, the start of specification point OD1 would be represented by the string @(OD1)@. These specification points are sometimes referred to as "specification items". +- Some specification points have subpoints. For example REC2 has (amongst others) the subpoint RSC2a, which has subpoints REC2a1 and REC2a2. +- The LiveObjects functionality is referred to the in the Specification simply as "Objects". diff --git a/.cursor/rules/swift.mdc b/.cursor/rules/swift.mdc index 1c15a1783..0af35c323 100644 --- a/.cursor/rules/swift.mdc +++ b/.cursor/rules/swift.mdc @@ -7,4 +7,7 @@ When writing Swift: - Be sure to satisfy SwiftLint's `explicit_acl` rule ("All declarations should specify Access Control Level keywords explicitly). - When writing an `extension` of a type, favour placing the access level on the declaration of the extension rather than each of its individual members. - - This does not apply when writing test code. \ No newline at end of file + - This does not apply when writing test code. +- When writing initializer expressions, when the type that is being initialized can be inferred, favour using the implicit `.init(…)` form instead of explicitly writing the type name. +- When writing enum value expressions, when the type that is being initialized can be inferred, favour using the implicit `.caseName` form instead of explicitly writing the type name. +- When writing JSONValue or WireValue types, favour using the literal syntax enabled by their conformance to the `ExpressibleBy*Literal` protocols where possible. From 3baca83e15b59bcad931d5f06864c60fe53250cc Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 19 Jun 2025 15:27:46 -0300 Subject: [PATCH 013/225] Remove specific error types from InternalError.Other Have to add a bunch of boilerplate each time we introduce a new error type, to no current benefit. --- .../Utility/InternalError.swift | 18 ++++++------------ .../InternalErrorTests.swift | 18 ++++++++++++++++++ 2 files changed, 24 insertions(+), 12 deletions(-) create mode 100644 Tests/AblyLiveObjectsTests/InternalErrorTests.swift diff --git a/Sources/AblyLiveObjects/Utility/InternalError.swift b/Sources/AblyLiveObjects/Utility/InternalError.swift index e015c82c6..e9fbf7058 100644 --- a/Sources/AblyLiveObjects/Utility/InternalError.swift +++ b/Sources/AblyLiveObjects/Utility/InternalError.swift @@ -8,8 +8,8 @@ internal enum InternalError: Error { case other(Other) internal enum Other { - case jsonValueDecodingError(JSONValueDecodingError) - case inboundWireObjectMessageDecodingError(InboundWireObjectMessage.DecodingError) + // In ably-chat-swift we have different cases here for different types of errors thrown within the codebase, but we didn't figure out what to actually _do_ with these different types of errors (see implementation of toARTErrorInfo which squashes everything down to the same error), so let's not bother with that for now + case generic(Error) } /// Returns the error that this should be converted to when exposed via the SDK's public API. @@ -24,20 +24,14 @@ internal enum InternalError: Error { } } -internal extension ARTErrorInfo { - func toInternalError() -> InternalError { - .errorInfo(self) - } -} - -internal extension JSONValueDecodingError { +internal extension Error { func toInternalError() -> InternalError { - .other(.jsonValueDecodingError(self)) + .other(.generic(self)) } } -internal extension InboundWireObjectMessage.DecodingError { +internal extension ARTErrorInfo { func toInternalError() -> InternalError { - .other(.inboundWireObjectMessageDecodingError(self)) + .errorInfo(self) } } diff --git a/Tests/AblyLiveObjectsTests/InternalErrorTests.swift b/Tests/AblyLiveObjectsTests/InternalErrorTests.swift new file mode 100644 index 000000000..ac5c9333b --- /dev/null +++ b/Tests/AblyLiveObjectsTests/InternalErrorTests.swift @@ -0,0 +1,18 @@ +import Ably +@testable import AblyLiveObjects +import Testing + +struct InternalErrorTests { + @Test + func artErrorInfo_toInternalError() { + let errorInfo = ARTErrorInfo(domain: "foo", code: 3) + + // Check that we get errorInfo instead of the protocol extension on Swift.Error + switch errorInfo.toInternalError() { + case .errorInfo: + break + case .other: + Issue.record("Expected .errorInfo") + } + } +} From 1d5657ed68b10f59765dc729648ed23cf2778521 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 10:06:42 -0300 Subject: [PATCH 014/225] Introduce non-wire versions of the ObjectMessage types We're going to introduce an additional decoding step in which the ObjectMessages sent and received over the wire need to have some binary and JSON data encoded per the rules of the specification. So introduce new types that will represent the ObjectMessage after it's gone through this upcoming processing. Most of this code was generated by Cursor at my instruction. --- .../AblyLiveObjects/DefaultLiveObjects.swift | 22 +- .../Internal/DefaultInternalPlugin.swift | 42 ++-- .../Protocol/ObjectMessage.swift | 237 ++++++++++++++++++ .../Protocol/WireObjectMessage.swift | 2 + .../AblyLiveObjectsTests.swift | 2 +- 5 files changed, 273 insertions(+), 32 deletions(-) create mode 100644 Sources/AblyLiveObjects/Protocol/ObjectMessage.swift diff --git a/Sources/AblyLiveObjects/DefaultLiveObjects.swift b/Sources/AblyLiveObjects/DefaultLiveObjects.swift index 99f7bc5ff..117ce6787 100644 --- a/Sources/AblyLiveObjects/DefaultLiveObjects.swift +++ b/Sources/AblyLiveObjects/DefaultLiveObjects.swift @@ -8,10 +8,10 @@ internal class DefaultLiveObjects: Objects { private let pluginAPI: AblyPlugin.PluginAPIProtocol // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. - private let receivedObjectProtocolMessages: AsyncStream<[InboundWireObjectMessage]> - private let receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundWireObjectMessage]>.Continuation - private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundWireObjectMessage]> - private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundWireObjectMessage]>.Continuation + private let receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> + private let receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation + private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> + private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation internal init(channel: ARTRealtimeChannel, logger: AblyPlugin.Logger, pluginAPI: AblyPlugin.PluginAPIProtocol) { self.channel = channel @@ -62,26 +62,26 @@ internal class DefaultLiveObjects: Objects { testsOnly_onChannelAttachedHasObjects = hasObjects } - internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundWireObjectMessage]> { + internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { receivedObjectProtocolMessages } - internal func handleObjectProtocolMessage(wireObjectMessages: [InboundWireObjectMessage]) { - receivedObjectProtocolMessagesContinuation.yield(wireObjectMessages) + internal func handleObjectProtocolMessage(objectMessages: [InboundObjectMessage]) { + receivedObjectProtocolMessagesContinuation.yield(objectMessages) } - internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundWireObjectMessage]> { + internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { receivedObjectSyncProtocolMessages } - internal func handleObjectSyncProtocolMessage(wireObjectMessages: [InboundWireObjectMessage], protocolMessageChannelSerial _: String) { - receivedObjectSyncProtocolMessagesContinuation.yield(wireObjectMessages) + internal func handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial _: String) { + receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) } // MARK: - Sending `OBJECT` ProtocolMessage // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. - internal func testsOnly_sendObject(objectMessages: [OutboundWireObjectMessage]) async throws(InternalError) { + internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { guard let channel else { return } diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 8ea3844b1..43ed497b1 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -46,14 +46,14 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte Self.objectsProperty(for: channel, pluginAPI: pluginAPI) } - /// A class that wraps a ``WireObjectMessage``. + /// A class that wraps an object message. /// - /// We need this intermediate type because we want `WireObjectMessage` to be a struct — because it's nicer to work with internally — but a struct can't conform to the class-bound `AblyPlugin.WireObjectMessage` protocol. - private final class WireObjectMessageBox: AblyPlugin.ObjectMessageProtocol where T: Sendable { - internal let wireObjectMessage: T + /// 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 `AblyPlugin.ObjectMessageProtocol`. + private final class ObjectMessageBox: AblyPlugin.ObjectMessageProtocol where T: Sendable { + internal let objectMessage: T - init(wireObjectMessage: T) { - self.wireObjectMessage = wireObjectMessage + init(objectMessage: T) { + self.objectMessage = objectMessage } } @@ -65,7 +65,8 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte jsonObject: jsonObject, decodingContext: context, ) - return WireObjectMessageBox(wireObjectMessage: wireObjectMessage) + let objectMessage = InboundObjectMessage(wireObjectMessage: wireObjectMessage) + return ObjectMessageBox(objectMessage: objectMessage) } catch { errorPtr?.pointee = error.toARTErrorInfo() return nil @@ -73,11 +74,12 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } internal func encodeObjectMessage(_ publicObjectMessage: any AblyPlugin.ObjectMessageProtocol) -> [String: Any] { - guard let wireObjectMessageBox = publicObjectMessage as? WireObjectMessageBox else { - preconditionFailure("Expected to receive the same WireObjectMessage type as we emit") + guard let outboundObjectMessageBox = publicObjectMessage as? ObjectMessageBox else { + preconditionFailure("Expected to receive the same OutboundObjectMessage type as we emit") } - return wireObjectMessageBox.wireObjectMessage.toJSONObject.toAblyPluginDataDictionary + let wireObjectMessage = outboundObjectMessageBox.objectMessage.toWire() + return wireObjectMessage.toJSONObject.toAblyPluginDataDictionary } internal func onChannelAttached(_ channel: ARTRealtimeChannel, hasObjects: Bool) { @@ -85,26 +87,26 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } internal func handleObjectProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], channel: ARTRealtimeChannel) { - guard let wireObjectMessageBoxes = publicObjectMessages as? [WireObjectMessageBox] else { - preconditionFailure("Expected to receive the same WireObjectMessage type as we emit") + guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { + preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") } - let wireObjectMessages = wireObjectMessageBoxes.map(\.wireObjectMessage) + let objectMessages = inboundObjectMessageBoxes.map(\.objectMessage) objectsProperty(for: channel).handleObjectProtocolMessage( - wireObjectMessages: wireObjectMessages, + objectMessages: objectMessages, ) } internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], protocolMessageChannelSerial: String, channel: ARTRealtimeChannel) { - guard let objectMessageBoxes = publicObjectMessages as? [WireObjectMessageBox] else { - preconditionFailure("Expected to receive the same WireObjectMessage type as we emit") + guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { + preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") } - let wireObjectMessages = objectMessageBoxes.map(\.wireObjectMessage) + let objectMessages = inboundObjectMessageBoxes.map(\.objectMessage) objectsProperty(for: channel).handleObjectSyncProtocolMessage( - wireObjectMessages: wireObjectMessages, + objectMessages: objectMessages, protocolMessageChannelSerial: protocolMessageChannelSerial, ) } @@ -112,11 +114,11 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte // MARK: - Sending `OBJECT` ProtocolMessage internal static func sendObject( - objectMessages: [OutboundWireObjectMessage], + objectMessages: [OutboundObjectMessage], channel: ARTRealtimeChannel, pluginAPI: PluginAPIProtocol, ) async throws(InternalError) { - let objectMessageBoxes: [WireObjectMessageBox] = objectMessages.map { .init(wireObjectMessage: $0) } + let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in pluginAPI.sendObject( diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift new file mode 100644 index 000000000..153bfc5e6 --- /dev/null +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -0,0 +1,237 @@ +import Foundation + +// This file contains the ObjectMessage types that we use within the codebase. We convert them to and from the corresponding wire types (e.g. `InboundWireObjectMessage`) for sending and receiving over the wire. + +/// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. +internal struct InboundObjectMessage { + 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 +} + +/// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. +internal struct OutboundObjectMessage { + 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 struct ObjectOperation { + internal var action: WireEnum // OOP3a + internal var objectId: String // OOP3b + internal var mapOp: MapOp? // OOP3c + internal var counterOp: WireCounterOp? // OOP3d + internal var map: Map? // OOP3e + internal var counter: WireCounter? // OOP3f + internal var nonce: String? // OOP3g + // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 + internal var initialValue: Data? // OOP3h + internal var initialValueEncoding: String? // OOP3i +} + +internal struct ObjectData { + internal var objectId: String? // OD2a + internal var encoding: String? // OD2b + internal var boolean: Bool? // OD2c + // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 + internal var bytes: Data? // OD2d + internal var number: NSNumber? // OD2e + internal var string: String? // OD2f +} + +internal struct MapOp { + internal var key: String // MOP2a + internal var data: ObjectData? // MOP2b +} + +internal struct MapEntry { + internal var tombstone: Bool? // ME2a + internal var timeserial: String? // ME2b + internal var data: ObjectData // ME2c +} + +internal struct Map { + internal var semantics: WireEnum // MAP3a + internal var entries: [String: MapEntry]? // MAP3b +} + +internal struct ObjectState { + internal var objectId: String // OST2a + internal var siteTimeserials: [String: String] // OST2b + internal var tombstone: Bool // OST2c + internal var createOp: ObjectOperation? // OST2d + internal var map: Map? // OST2e + internal var counter: WireCounter? // OST2f +} + +internal extension InboundObjectMessage { + /// Initializes an `InboundObjectMessage` from an `InboundWireObjectMessage`. + init(wireObjectMessage: InboundWireObjectMessage) { + id = wireObjectMessage.id + clientId = wireObjectMessage.clientId + connectionId = wireObjectMessage.connectionId + extras = wireObjectMessage.extras + timestamp = wireObjectMessage.timestamp + operation = wireObjectMessage.operation.map { .init(wireObjectOperation: $0) } + object = wireObjectMessage.object.map { .init(wireObjectState: $0) } + serial = wireObjectMessage.serial + siteCode = wireObjectMessage.siteCode + } +} + +internal extension OutboundObjectMessage { + /// Converts this `OutboundObjectMessage` to an `OutboundWireObjectMessage`. + func toWire() -> OutboundWireObjectMessage { + .init( + id: id, + clientId: clientId, + connectionId: connectionId, + extras: extras, + timestamp: timestamp, + operation: operation?.toWire(), + object: object?.toWire(), + serial: serial, + siteCode: siteCode, + ) + } +} + +internal extension ObjectOperation { + /// Initializes an `ObjectOperation` from a `WireObjectOperation`. + init(wireObjectOperation: WireObjectOperation) { + action = wireObjectOperation.action + objectId = wireObjectOperation.objectId + mapOp = wireObjectOperation.mapOp.map { .init(wireMapOp: $0) } + counterOp = wireObjectOperation.counterOp + map = wireObjectOperation.map.map { .init(wireMap: $0) } + counter = wireObjectOperation.counter + nonce = wireObjectOperation.nonce + initialValue = wireObjectOperation.initialValue + initialValueEncoding = wireObjectOperation.initialValueEncoding + } + + /// Converts this `ObjectOperation` to a `WireObjectOperation`. + func toWire() -> WireObjectOperation { + .init( + action: action, + objectId: objectId, + mapOp: mapOp?.toWire(), + counterOp: counterOp, + map: map?.toWire(), + counter: counter, + nonce: nonce, + initialValue: initialValue, + initialValueEncoding: initialValueEncoding, + ) + } +} + +internal extension ObjectData { + /// Initializes an `ObjectData` from a `WireObjectData`. + init(wireObjectData: WireObjectData) { + objectId = wireObjectData.objectId + encoding = wireObjectData.encoding + boolean = wireObjectData.boolean + bytes = wireObjectData.bytes + number = wireObjectData.number + string = wireObjectData.string + } + + /// Converts this `ObjectData` to a `WireObjectData`. + func toWire() -> WireObjectData { + .init( + objectId: objectId, + encoding: encoding, + boolean: boolean, + bytes: bytes, + number: number, + string: string, + ) + } +} + +internal extension MapOp { + /// Initializes a `MapOp` from a `WireMapOp`. + init(wireMapOp: WireMapOp) { + key = wireMapOp.key + data = wireMapOp.data.map { .init(wireObjectData: $0) } + } + + /// Converts this `MapOp` to a `WireMapOp`. + func toWire() -> WireMapOp { + .init( + key: key, + data: data?.toWire(), + ) + } +} + +internal extension MapEntry { + /// Initializes a `MapEntry` from a `WireMapEntry`. + init(wireMapEntry: WireMapEntry) { + tombstone = wireMapEntry.tombstone + timeserial = wireMapEntry.timeserial + data = .init(wireObjectData: wireMapEntry.data) + } + + /// Converts this `MapEntry` to a `WireMapEntry`. + func toWire() -> WireMapEntry { + .init( + tombstone: tombstone, + timeserial: timeserial, + data: data.toWire(), + ) + } +} + +internal extension Map { + /// Initializes a `Map` from a `WireMap`. + init(wireMap: WireMap) { + semantics = wireMap.semantics + entries = wireMap.entries?.mapValues { .init(wireMapEntry: $0) } + } + + /// Converts this `Map` to a `WireMap`. + func toWire() -> WireMap { + .init( + semantics: semantics, + entries: entries?.mapValues { $0.toWire() }, + ) + } +} + +internal extension ObjectState { + /// Initializes an `ObjectState` from a `WireObjectState`. + init(wireObjectState: WireObjectState) { + objectId = wireObjectState.objectId + siteTimeserials = wireObjectState.siteTimeserials + tombstone = wireObjectState.tombstone + createOp = wireObjectState.createOp.map { .init(wireObjectOperation: $0) } + map = wireObjectState.map.map { .init(wireMap: $0) } + counter = wireObjectState.counter + } + + /// Converts this `ObjectState` to a `WireObjectState`. + func toWire() -> WireObjectState { + .init( + objectId: objectId, + siteTimeserials: siteTimeserials, + tombstone: tombstone, + createOp: createOp?.toWire(), + map: map?.toWire(), + counter: counter, + ) + } +} diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 1a18e641b..4fdebc71c 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -1,6 +1,8 @@ internal import AblyPlugin import Foundation +// This file contains the ObjectMessage types that we send and receive over the wire. We convert them to and from the corresponding non-wire types (e.g. `InboundObjectMessage`) for use within the codebase. + /// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. internal struct InboundWireObjectMessage { // TODO: Spec has `id`, `connectionId`, `timestamp`, `clientId`, `serial`, `sideCode` as non-nullable but I don't think this is right; raised https://github.com/ably/specification/issues/334 diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index c9e27cfac..c999a22d9 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -91,7 +91,7 @@ struct AblyLiveObjectsTests { let realtimeCreatedMapObjectID = "map:iC4Nq8EbTSEmw-_tDJdVV8HfiBvJGpZmO_WbGbh0_-4@\(currentAblyTimestamp)" try await channel.testsOnly_internallyTypedObjects.testsOnly_sendObject(objectMessages: [ - OutboundWireObjectMessage( + OutboundObjectMessage( operation: .init( action: .known(.mapCreate), objectId: realtimeCreatedMapObjectID, From c6a3f8051ff5582d8bf4a379db6e09ddd92552b9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 14:10:11 -0300 Subject: [PATCH 015/225] Fix a documentation comment --- Sources/AblyLiveObjects/Utility/JSONCodable.swift | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/Sources/AblyLiveObjects/Utility/JSONCodable.swift b/Sources/AblyLiveObjects/Utility/JSONCodable.swift index d844bf13d..a8cec9be1 100644 --- a/Sources/AblyLiveObjects/Utility/JSONCodable.swift +++ b/Sources/AblyLiveObjects/Utility/JSONCodable.swift @@ -211,11 +211,9 @@ internal extension [String: JSONValue] { return boolValue } - /// If this dictionary contains a value for `key`, and this value has case `bool`, this returns the associated value. + /// 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: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `bool` + /// - Throws: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `bool` or `null` func optionalBoolValueForKey(_ key: String) throws(InternalError) -> Bool? { guard let value = self[key] else { return nil From d60d61ddf10c14caa121a4312aa32e0065cbb36f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 20 Jun 2025 15:27:04 -0300 Subject: [PATCH 016/225] Update ably-cocoa Introduces the `format` argument for encoding and decoding ObjectMessages; we'll use this argument shortly to handle binary data. --- .../Internal/DefaultInternalPlugin.swift | 14 ++++++++++++-- ably-cocoa | 2 +- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 43ed497b1..312a95a28 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -57,7 +57,13 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } } - internal func decodeObjectMessage(_ serialized: [String: Any], context: DecodingContextProtocol, error errorPtr: AutoreleasingUnsafeMutablePointer?) -> (any ObjectMessageProtocol)? { + internal func decodeObjectMessage( + _ serialized: [String: Any], + context: DecodingContextProtocol, + // TODO: use + format: EncodingFormat, + error errorPtr: AutoreleasingUnsafeMutablePointer?, + ) -> (any ObjectMessageProtocol)? { let jsonObject = JSONValue.objectFromAblyPluginData(serialized) do { @@ -73,7 +79,11 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } } - internal func encodeObjectMessage(_ publicObjectMessage: any AblyPlugin.ObjectMessageProtocol) -> [String: Any] { + internal func encodeObjectMessage( + _ publicObjectMessage: any AblyPlugin.ObjectMessageProtocol, + // TODO: use + format: EncodingFormat, + ) -> [String: Any] { guard let outboundObjectMessageBox = publicObjectMessage as? ObjectMessageBox else { preconditionFailure("Expected to receive the same OutboundObjectMessage type as we emit") } diff --git a/ably-cocoa b/ably-cocoa index 5fc038cb2..9b2bfe48b 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 5fc038cb2b481f51b630211771afd16762af152e +Subproject commit 9b2bfe48b4c8c070404957758869b8918e8eade3 From 3746582843e8c78daa11a65a34b402a021b2d11d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 11:46:39 -0300 Subject: [PATCH 017/225] Introduce a WireValue type This provides us with a JSONValue-like type which also supports binary data. We'll use this in an upcoming commit for encoding and decoding the binary data contained within an ObjectMessage. (Note about naming: the term "wire object" is a bit overloaded now; it's used in protocols like WireObjectCodable and it also appears as a prefix to the name of WireObjectMessage, and the two meanings have nothing to do with each other. I didn't have a better idea for naming though and didn't want to get hung up on it.) This was initially implemented by asking Cursor to copy JSONValue; I then realised that I'd made some mistakes in what I'd asked it to do, so had to make a bunch of corrections myself and also introduced the ExtendedJSONValue type. The MessagePack decoding tests (the ones that make comparisons to hand-crafted byte arrays) were generated by Cursor, and I have not verified the correctness of these byte arrays; my main assurance that they're probably correct is that the tests pass, and that it feels unlikely that Cursor and ably-cocoa's MessagePack encoder contain overlapping mistakes in their handling of MessagePack data. --- .../Internal/DefaultInternalPlugin.swift | 6 +- .../Protocol/WireObjectMessage.swift | 250 ++++++------ .../Utility/ExtendedJSONValue.swift | 100 +++++ .../AblyLiveObjects/Utility/JSONValue.swift | 113 +++--- .../{JSONCodable.swift => WireCodable.swift} | 206 ++++++---- .../AblyLiveObjects/Utility/WireValue.swift | 249 ++++++++++++ .../AblyLiveObjectsTests/JSONValueTests.swift | 50 +-- .../WireObjectMessageTests.swift | 160 ++++---- .../AblyLiveObjectsTests/WireValueTests.swift | 379 ++++++++++++++++++ 9 files changed, 1148 insertions(+), 365 deletions(-) create mode 100644 Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift rename Sources/AblyLiveObjects/Utility/{JSONCodable.swift => WireCodable.swift} (63%) create mode 100644 Sources/AblyLiveObjects/Utility/WireValue.swift create mode 100644 Tests/AblyLiveObjectsTests/WireValueTests.swift diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 312a95a28..e9eeb8dc1 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -64,11 +64,11 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte format: EncodingFormat, error errorPtr: AutoreleasingUnsafeMutablePointer?, ) -> (any ObjectMessageProtocol)? { - let jsonObject = JSONValue.objectFromAblyPluginData(serialized) + let wireObject = WireValue.objectFromAblyPluginData(serialized) do { let wireObjectMessage = try InboundWireObjectMessage( - jsonObject: jsonObject, + wireObject: wireObject, decodingContext: context, ) let objectMessage = InboundObjectMessage(wireObjectMessage: wireObjectMessage) @@ -89,7 +89,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } let wireObjectMessage = outboundObjectMessageBox.objectMessage.toWire() - return wireObjectMessage.toJSONObject.toAblyPluginDataDictionary + return wireObjectMessage.toWireObject.toAblyPluginDataDictionary } internal func onChannelAttached(_ channel: ARTRealtimeChannel, hasObjects: Bool) { diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 4fdebc71c..43d138a00 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -31,7 +31,7 @@ internal struct OutboundWireObjectMessage { } /// The keys for decoding an `InboundWireObjectMessage` or encoding an `OutboundWireObjectMessage`. -internal enum WireObjectMessageJSONKey: String { +internal enum WireObjectMessageWireKey: String { case id case clientId case connectionId @@ -57,71 +57,79 @@ internal extension InboundWireObjectMessage { /// Decodes the `ObjectMessage` and then uses the containing `ProtocolMessage` to populate some absent fields per the rules of the specification. init( - jsonObject: [String: JSONValue], + wireObject: [String: WireValue], decodingContext: AblyPlugin.DecodingContextProtocol ) throws(InternalError) { // OM2a - if let id = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.id.rawValue) { + if let id = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.id.rawValue) { self.id = id } else if let parentID = decodingContext.parentID { id = "\(parentID):\(decodingContext.indexInParent)" } - clientId = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.clientId.rawValue) + clientId = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.clientId.rawValue) // OM2c - if let connectionId = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.connectionId.rawValue) { + if let connectionId = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.connectionId.rawValue) { self.connectionId = connectionId } else if let parentConnectionID = decodingContext.parentConnectionID { connectionId = parentConnectionID } - extras = try jsonObject.optionalObjectValueForKey(WireObjectMessageJSONKey.extras.rawValue) + // Convert WireValue extras to JSONValue extras + if let wireExtras = try wireObject.optionalObjectValueForKey(WireObjectMessageWireKey.extras.rawValue) { + extras = try wireExtras.ablyLiveObjects_mapValuesWithTypedThrow { wireValue throws(InternalError) in + try wireValue.toJSONValue + } + } else { + extras = nil + } // OM2e - if let timestamp = try jsonObject.optionalAblyProtocolDateValueForKey(WireObjectMessageJSONKey.timestamp.rawValue) { + if let timestamp = try wireObject.optionalAblyProtocolDateValueForKey(WireObjectMessageWireKey.timestamp.rawValue) { self.timestamp = timestamp } else if let parentTimestamp = decodingContext.parentTimestamp { timestamp = parentTimestamp } - operation = try jsonObject.optionalDecodableValueForKey(WireObjectMessageJSONKey.operation.rawValue) - object = try jsonObject.optionalDecodableValueForKey(WireObjectMessageJSONKey.object.rawValue) - serial = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.serial.rawValue) - siteCode = try jsonObject.optionalStringValueForKey(WireObjectMessageJSONKey.siteCode.rawValue) + 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) } } -extension OutboundWireObjectMessage: JSONObjectEncodable { - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [:] +extension OutboundWireObjectMessage: WireObjectEncodable { + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [:] if let id { - result[WireObjectMessageJSONKey.id.rawValue] = .string(id) + result[WireObjectMessageWireKey.id.rawValue] = .string(id) } if let connectionId { - result[WireObjectMessageJSONKey.connectionId.rawValue] = .string(connectionId) + result[WireObjectMessageWireKey.connectionId.rawValue] = .string(connectionId) } if let timestamp { - result[WireObjectMessageJSONKey.timestamp.rawValue] = .number(NSNumber(value: (timestamp.timeIntervalSince1970) * 1000)) + result[WireObjectMessageWireKey.timestamp.rawValue] = .number(NSNumber(value: (timestamp.timeIntervalSince1970) * 1000)) } if let siteCode { - result[WireObjectMessageJSONKey.siteCode.rawValue] = .string(siteCode) + result[WireObjectMessageWireKey.siteCode.rawValue] = .string(siteCode) } if let serial { - result[WireObjectMessageJSONKey.serial.rawValue] = .string(serial) + result[WireObjectMessageWireKey.serial.rawValue] = .string(serial) } if let clientId { - result[WireObjectMessageJSONKey.clientId.rawValue] = .string(clientId) + result[WireObjectMessageWireKey.clientId.rawValue] = .string(clientId) } if let extras { - result[WireObjectMessageJSONKey.extras.rawValue] = .object(extras) + // Convert JSONValue extras to WireValue extras + result[WireObjectMessageWireKey.extras.rawValue] = .object(extras.mapValues { .init(jsonValue: $0) }) } if let operation { - result[WireObjectMessageJSONKey.operation.rawValue] = .object(operation.toJSONObject) + result[WireObjectMessageWireKey.operation.rawValue] = .object(operation.toWireObject) } if let object { - result[WireObjectMessageJSONKey.object.rawValue] = .object(object.toJSONObject) + result[WireObjectMessageWireKey.object.rawValue] = .object(object.toWireObject) } return result } @@ -155,8 +163,8 @@ internal struct WireObjectOperation { internal var initialValueEncoding: String? // OOP3i } -extension WireObjectOperation: JSONObjectCodable { - internal enum JSONKey: String { +extension WireObjectOperation: WireObjectCodable { + internal enum WireKey: String { case action case objectId case mapOp @@ -168,40 +176,40 @@ extension WireObjectOperation: JSONObjectCodable { case initialValueEncoding } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - action = try jsonObject.wireEnumValueForKey(JSONKey.action.rawValue) - objectId = try jsonObject.stringValueForKey(JSONKey.objectId.rawValue) - mapOp = try jsonObject.optionalDecodableValueForKey(JSONKey.mapOp.rawValue) - counterOp = try jsonObject.optionalDecodableValueForKey(JSONKey.counterOp.rawValue) - map = try jsonObject.optionalDecodableValueForKey(JSONKey.map.rawValue) - counter = try jsonObject.optionalDecodableValueForKey(JSONKey.counter.rawValue) - nonce = try jsonObject.optionalStringValueForKey(JSONKey.nonce.rawValue) - initialValueEncoding = try jsonObject.optionalStringValueForKey(JSONKey.initialValueEncoding.rawValue) + internal init(wireObject: [String: WireValue]) throws(InternalError) { + action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) + objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) + mapOp = try wireObject.optionalDecodableValueForKey(WireKey.mapOp.rawValue) + counterOp = try wireObject.optionalDecodableValueForKey(WireKey.counterOp.rawValue) + map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) + counter = try wireObject.optionalDecodableValueForKey(WireKey.counter.rawValue) + nonce = try wireObject.optionalStringValueForKey(WireKey.nonce.rawValue) + initialValueEncoding = try wireObject.optionalStringValueForKey(WireKey.initialValueEncoding.rawValue) } - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [ - JSONKey.action.rawValue: .number(action.rawValue as NSNumber), - JSONKey.objectId.rawValue: .string(objectId), + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.action.rawValue: .number(action.rawValue as NSNumber), + WireKey.objectId.rawValue: .string(objectId), ] if let mapOp { - result[JSONKey.mapOp.rawValue] = .object(mapOp.toJSONObject) + result[WireKey.mapOp.rawValue] = .object(mapOp.toWireObject) } if let counterOp { - result[JSONKey.counterOp.rawValue] = .object(counterOp.toJSONObject) + result[WireKey.counterOp.rawValue] = .object(counterOp.toWireObject) } if let map { - result[JSONKey.map.rawValue] = .object(map.toJSONObject) + result[WireKey.map.rawValue] = .object(map.toWireObject) } if let counter { - result[JSONKey.counter.rawValue] = .object(counter.toJSONObject) + result[WireKey.counter.rawValue] = .object(counter.toWireObject) } if let nonce { - result[JSONKey.nonce.rawValue] = .string(nonce) + result[WireKey.nonce.rawValue] = .string(nonce) } if let initialValueEncoding { - result[JSONKey.initialValueEncoding.rawValue] = .string(initialValueEncoding) + result[WireKey.initialValueEncoding.rawValue] = .string(initialValueEncoding) } return result @@ -217,8 +225,8 @@ internal struct WireObjectState { internal var counter: WireCounter? // OST2f } -extension WireObjectState: JSONObjectCodable { - internal enum JSONKey: String { +extension WireObjectState: WireObjectCodable { + internal enum WireKey: String { case objectId case siteTimeserials case tombstone @@ -227,35 +235,35 @@ extension WireObjectState: JSONObjectCodable { case counter } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - objectId = try jsonObject.stringValueForKey(JSONKey.objectId.rawValue) - siteTimeserials = try jsonObject.objectValueForKey(JSONKey.siteTimeserials.rawValue).ablyLiveObjects_mapValuesWithTypedThrow { value throws(InternalError) in + internal init(wireObject: [String: WireValue]) throws(InternalError) { + objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) + siteTimeserials = try wireObject.objectValueForKey(WireKey.siteTimeserials.rawValue).ablyLiveObjects_mapValuesWithTypedThrow { value throws(InternalError) in guard case let .string(string) = value else { - throw JSONValueDecodingError.wrongTypeForKey(JSONKey.siteTimeserials.rawValue, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(WireKey.siteTimeserials.rawValue, actualValue: value).toInternalError() } return string } - tombstone = try jsonObject.boolValueForKey(JSONKey.tombstone.rawValue) - createOp = try jsonObject.optionalDecodableValueForKey(JSONKey.createOp.rawValue) - map = try jsonObject.optionalDecodableValueForKey(JSONKey.map.rawValue) - counter = try jsonObject.optionalDecodableValueForKey(JSONKey.counter.rawValue) + 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 toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [ - JSONKey.objectId.rawValue: .string(objectId), - JSONKey.siteTimeserials.rawValue: .object(siteTimeserials.mapValues { .string($0) }), - JSONKey.tombstone.rawValue: .bool(tombstone), + 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[JSONKey.createOp.rawValue] = .object(createOp.toJSONObject) + result[WireKey.createOp.rawValue] = .object(createOp.toWireObject) } if let map { - result[JSONKey.map.rawValue] = .object(map.toJSONObject) + result[WireKey.map.rawValue] = .object(map.toWireObject) } if let counter { - result[JSONKey.counter.rawValue] = .object(counter.toJSONObject) + result[WireKey.counter.rawValue] = .object(counter.toWireObject) } return result @@ -267,24 +275,24 @@ internal struct WireMapOp { internal var data: WireObjectData? // MOP2b } -extension WireMapOp: JSONObjectCodable { - internal enum JSONKey: String { +extension WireMapOp: WireObjectCodable { + internal enum WireKey: String { case key case data } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - key = try jsonObject.stringValueForKey(JSONKey.key.rawValue) - data = try jsonObject.optionalDecodableValueForKey(JSONKey.data.rawValue) + internal init(wireObject: [String: WireValue]) throws(InternalError) { + key = try wireObject.stringValueForKey(WireKey.key.rawValue) + data = try wireObject.optionalDecodableValueForKey(WireKey.data.rawValue) } - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [ - JSONKey.key.rawValue: .string(key), + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.key.rawValue: .string(key), ] if let data { - result[JSONKey.data.rawValue] = .object(data.toJSONObject) + result[WireKey.data.rawValue] = .object(data.toWireObject) } return result @@ -295,18 +303,18 @@ internal struct WireCounterOp { internal var amount: NSNumber // COP2a } -extension WireCounterOp: JSONObjectCodable { - internal enum JSONKey: String { +extension WireCounterOp: WireObjectCodable { + internal enum WireKey: String { case amount } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - amount = try jsonObject.numberValueForKey(JSONKey.amount.rawValue) + internal init(wireObject: [String: WireValue]) throws(InternalError) { + amount = try wireObject.numberValueForKey(WireKey.amount.rawValue) } - internal var toJSONObject: [String: JSONValue] { + internal var toWireObject: [String: WireValue] { [ - JSONKey.amount.rawValue: .number(amount), + WireKey.amount.rawValue: .number(amount), ] } } @@ -316,29 +324,29 @@ internal struct WireMap { internal var entries: [String: WireMapEntry]? // MAP3b } -extension WireMap: JSONObjectCodable { - internal enum JSONKey: String { +extension WireMap: WireObjectCodable { + internal enum WireKey: String { case semantics case entries } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - semantics = try jsonObject.wireEnumValueForKey(JSONKey.semantics.rawValue) - entries = try jsonObject.optionalObjectValueForKey(JSONKey.entries.rawValue)?.ablyLiveObjects_mapValuesWithTypedThrow { value throws(InternalError) in + internal init(wireObject: [String: WireValue]) throws(InternalError) { + semantics = try wireObject.wireEnumValueForKey(WireKey.semantics.rawValue) + entries = try wireObject.optionalObjectValueForKey(WireKey.entries.rawValue)?.ablyLiveObjects_mapValuesWithTypedThrow { value throws(InternalError) in guard case let .object(object) = value else { - throw JSONValueDecodingError.wrongTypeForKey(JSONKey.entries.rawValue, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(WireKey.entries.rawValue, actualValue: value).toInternalError() } - return try WireMapEntry(jsonObject: object) + return try WireMapEntry(wireObject: object) } } - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [ - JSONKey.semantics.rawValue: .number(semantics.rawValue as NSNumber), + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.semantics.rawValue: .number(semantics.rawValue as NSNumber), ] if let entries { - result[JSONKey.entries.rawValue] = .object(entries.mapValues { .object($0.toJSONObject) }) + result[WireKey.entries.rawValue] = .object(entries.mapValues { .object($0.toWireObject) }) } return result @@ -349,19 +357,19 @@ internal struct WireCounter { internal var count: NSNumber? // CNT2a } -extension WireCounter: JSONObjectCodable { - internal enum JSONKey: String { +extension WireCounter: WireObjectCodable { + internal enum WireKey: String { case count } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - count = try jsonObject.optionalNumberValueForKey(JSONKey.count.rawValue) + internal init(wireObject: [String: WireValue]) throws(InternalError) { + count = try wireObject.optionalNumberValueForKey(WireKey.count.rawValue) } - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [:] + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [:] if let count { - result[JSONKey.count.rawValue] = .number(count) + result[WireKey.count.rawValue] = .number(count) } return result } @@ -373,29 +381,29 @@ internal struct WireMapEntry { internal var data: WireObjectData // ME2c } -extension WireMapEntry: JSONObjectCodable { - internal enum JSONKey: String { +extension WireMapEntry: WireObjectCodable { + internal enum WireKey: String { case tombstone case timeserial case data } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - tombstone = try jsonObject.optionalBoolValueForKey(JSONKey.tombstone.rawValue) - timeserial = try jsonObject.optionalStringValueForKey(JSONKey.timeserial.rawValue) - data = try jsonObject.decodableValueForKey(JSONKey.data.rawValue) + internal init(wireObject: [String: WireValue]) throws(InternalError) { + tombstone = try wireObject.optionalBoolValueForKey(WireKey.tombstone.rawValue) + timeserial = try wireObject.optionalStringValueForKey(WireKey.timeserial.rawValue) + data = try wireObject.decodableValueForKey(WireKey.data.rawValue) } - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [ - JSONKey.data.rawValue: .object(data.toJSONObject), + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.data.rawValue: .object(data.toWireObject), ] if let tombstone { - result[JSONKey.tombstone.rawValue] = .bool(tombstone) + result[WireKey.tombstone.rawValue] = .bool(tombstone) } if let timeserial { - result[JSONKey.timeserial.rawValue] = .string(timeserial) + result[WireKey.timeserial.rawValue] = .string(timeserial) } return result @@ -412,8 +420,8 @@ internal struct WireObjectData { internal var string: String? // OD2f } -extension WireObjectData: JSONObjectCodable { - internal enum JSONKey: String { +extension WireObjectData: WireObjectCodable { + internal enum WireKey: String { case objectId case encoding case boolean @@ -422,31 +430,31 @@ extension WireObjectData: JSONObjectCodable { case string } - internal init(jsonObject: [String: JSONValue]) throws(InternalError) { - objectId = try jsonObject.optionalStringValueForKey(JSONKey.objectId.rawValue) - encoding = try jsonObject.optionalStringValueForKey(JSONKey.encoding.rawValue) - boolean = try jsonObject.optionalBoolValueForKey(JSONKey.boolean.rawValue) - number = try jsonObject.optionalNumberValueForKey(JSONKey.number.rawValue) - string = try jsonObject.optionalStringValueForKey(JSONKey.string.rawValue) + internal init(wireObject: [String: WireValue]) throws(InternalError) { + objectId = try wireObject.optionalStringValueForKey(WireKey.objectId.rawValue) + encoding = try wireObject.optionalStringValueForKey(WireKey.encoding.rawValue) + boolean = try wireObject.optionalBoolValueForKey(WireKey.boolean.rawValue) + number = try wireObject.optionalNumberValueForKey(WireKey.number.rawValue) + string = try wireObject.optionalStringValueForKey(WireKey.string.rawValue) } - internal var toJSONObject: [String: JSONValue] { - var result: [String: JSONValue] = [:] + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [:] if let objectId { - result[JSONKey.objectId.rawValue] = .string(objectId) + result[WireKey.objectId.rawValue] = .string(objectId) } if let encoding { - result[JSONKey.encoding.rawValue] = .string(encoding) + result[WireKey.encoding.rawValue] = .string(encoding) } if let boolean { - result[JSONKey.boolean.rawValue] = .bool(boolean) + result[WireKey.boolean.rawValue] = .bool(boolean) } if let number { - result[JSONKey.number.rawValue] = .number(number) + result[WireKey.number.rawValue] = .number(number) } if let string { - result[JSONKey.string.rawValue] = .string(string) + result[WireKey.string.rawValue] = .string(string) } return result diff --git a/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift b/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift new file mode 100644 index 000000000..dc43adc3d --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift @@ -0,0 +1,100 @@ +import Foundation + +/// Like ``JSONValue``, but provides 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(NSNumber) + case bool(Bool) + case null + case extra(Extra) +} + +// MARK: - Bridging with Foundation + +internal extension ExtendedJSONValue { + /// Creates an `ExtendedJSONValue` from an object. + /// + /// The rules for what `deserialized` will accept are the same as those of `JSONValue.init(jsonSerializationOutput)`, with one addition: any nonsupported values are passed to the `createExtraValue` function, and the result of this function will be used to create an `ExtendedJSONValue` of case `.extra`. + init(deserialized: Any, createExtraValue: (Any) -> Extra) { + switch deserialized { + case let dictionary as [String: Any]: + self = .object(dictionary.mapValues { .init(deserialized: $0, createExtraValue: createExtraValue) }) + case let array as [Any]: + self = .array(array.map { .init(deserialized: $0, 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(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.toJSONSerializationInputElemtn`, 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(serializeExtraValue: (Extra) -> Any) -> Any { + switch self { + case let .object(underlying): + underlying.mapValues { $0.serialized(serializeExtraValue: serializeExtraValue) } + case let .array(underlying): + underlying.map { $0.serialized(serializeExtraValue: serializeExtraValue) } + case let .string(underlying): + underlying + case let .number(underlying): + underlying + case let .bool(underlying): + underlying + case .null: + NSNull() + case let .extra(extra): + serializeExtraValue(extra) + } + } +} + +internal extension ExtendedJSONValue where Extra == Never { + var serialized: Any { + // swiftlint:disable:next trailing_closure + serialized(serializeExtraValue: { _ in }) + } +} + +// MARK: - Transforming the extra data + +internal extension ExtendedJSONValue { + /// Converts this `ExtendedJSONValue` to an `ExtendedJSONValue` using a given transformation. + func map(_ transform: @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(transform) + }) + case let .array(underlying): + try .array(underlying.map { element throws(Failure) in + try element.map(transform) + }) + case let .string(underlying): + .string(underlying) + case let .number(underlying): + .number(underlying) + case let .bool(underlying): + .bool(underlying) + case .null: + .null + case let .extra(extra): + try .extra(transform(extra)) + } + } +} diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift index c08ef605c..839cd54ef 100644 --- a/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -126,73 +126,84 @@ extension JSONValue: ExpressibleByBooleanLiteral { } } -// MARK: - Bridging with ably-cocoa +// MARK: - Bridging with JSONSerialization internal extension JSONValue { - /// Creates a `JSONValue` from an AblyPlugin deserialized JSON object. + /// Creates a `JSONValue` from the output of Foundation's `JSONSerialization`. /// - /// Specifically, `ablyCocoaData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. - init(ablyPluginData: Any) { - switch ablyPluginData { - case let dictionary as [String: Any]: - self = .object(dictionary.mapValues { .init(ablyPluginData: $0) }) - case let array as [Any]: - self = .array(array.map { .init(ablyPluginData: $0) }) - 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(number) - } - case is NSNull: - self = .null - default: - // ably-cocoa is not conforming to our assumptions; either its behaviour is wrong or our assumptions are wrong. Either way, bring this loudly to our attention instead of trying to carry on - preconditionFailure("JSONValue(ablyPluginData:) was given \(ablyPluginData)") - } + /// 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) { + // swiftlint:disable:next trailing_closure + let extended = ExtendedJSONValue(deserialized: jsonSerializationOutput, 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) } - /// Creates a `JSONValue` from an AblyPlugin deserialized JSON object. Specifically, `ablyPluginData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. - static func objectFromAblyPluginData(_ ablyPluginData: [String: Any]) -> [String: JSONValue] { - let jsonValue = JSONValue(ablyPluginData: ablyPluginData) - guard case let .object(jsonObject) = jsonValue else { - preconditionFailure() - } + /// 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 + } +} - return jsonObject +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) } +} - /// Creates an AblyPlugin deserialized JSON object from a `JSONValue`. - /// - /// Used by `[String: JSONValue].toAblyPluginDataDictionary`. - var toAblyPluginData: Any { - switch self { +internal extension [JSONValue] { + /// Converts an array that has `JSONValue` values into an input for Foundation's `JSONSerialization`. + var toJSONSerializationInput: [Any] { + map(\.toJSONSerializationInputElement) + } +} + +// MARK: - Conversion to/from ExtendedJSONValue + +internal extension JSONValue { + init(extendedJSONValue: ExtendedJSONValue) { + switch extendedJSONValue { case let .object(underlying): - underlying.toAblyPluginDataDictionary + self = .object(underlying.mapValues { .init(extendedJSONValue: $0) }) case let .array(underlying): - underlying.map(\.toAblyPluginData) + self = .array(underlying.map { .init(extendedJSONValue: $0) }) case let .string(underlying): - underlying + self = .string(underlying) case let .number(underlying): - underlying + self = .number(underlying) case let .bool(underlying): - underlying + self = .bool(underlying) case .null: - NSNull() + self = .null } } -} -internal extension [String: JSONValue] { - /// Creates an AblyPlugin deserialized JSON object from a dictionary that has string keys and `JSONValue` values. - /// - /// Specifically, the value of this property can be returned from `APLiveObjectsPlugin.encodeObjectMessage:`. - var toAblyPluginDataDictionary: [String: Any] { - mapValues(\.toAblyPluginData) + 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 + } } } diff --git a/Sources/AblyLiveObjects/Utility/JSONCodable.swift b/Sources/AblyLiveObjects/Utility/WireCodable.swift similarity index 63% rename from Sources/AblyLiveObjects/Utility/JSONCodable.swift rename to Sources/AblyLiveObjects/Utility/WireCodable.swift index a8cec9be1..b00b7edd9 100644 --- a/Sources/AblyLiveObjects/Utility/JSONCodable.swift +++ b/Sources/AblyLiveObjects/Utility/WireCodable.swift @@ -1,67 +1,67 @@ import Ably import Foundation -internal protocol JSONEncodable { - var toJSONValue: JSONValue { get } +internal protocol WireEncodable { + var toWireValue: WireValue { get } } -internal protocol JSONDecodable { - init(jsonValue: JSONValue) throws(InternalError) +internal protocol WireDecodable { + init(wireValue: WireValue) throws(InternalError) } -internal typealias JSONCodable = JSONDecodable & JSONEncodable +internal typealias WireCodable = WireDecodable & WireEncodable -internal protocol JSONObjectEncodable: JSONEncodable { - var toJSONObject: [String: JSONValue] { get } +internal protocol WireObjectEncodable: WireEncodable { + var toWireObject: [String: WireValue] { get } } -// Default implementation of `JSONEncodable` conformance for `JSONObjectEncodable` -internal extension JSONObjectEncodable { - var toJSONValue: JSONValue { - .object(toJSONObject) +// Default implementation of `WireEncodable` conformance for `WireObjectEncodable` +internal extension WireObjectEncodable { + var toWireValue: WireValue { + .object(toWireObject) } } -internal protocol JSONObjectDecodable: JSONDecodable { - init(jsonObject: [String: JSONValue]) throws(InternalError) +internal protocol WireObjectDecodable: WireDecodable { + init(wireObject: [String: WireValue]) throws(InternalError) } -internal enum JSONValueDecodingError: Error { +internal enum WireValueDecodingError: Error { case valueIsNotObject case noValueForKey(String) - case wrongTypeForKey(String, actualValue: JSONValue) + case wrongTypeForKey(String, actualValue: WireValue) case failedToDecodeFromRawValue(String) } -// Default implementation of `JSONDecodable` conformance for `JSONObjectDecodable` -internal extension JSONObjectDecodable { - init(jsonValue: JSONValue) throws(InternalError) { - guard case let .object(jsonObject) = jsonValue else { - throw JSONValueDecodingError.valueIsNotObject.toInternalError() +// Default implementation of `WireDecodable` conformance for `WireObjectDecodable` +internal extension WireObjectDecodable { + init(wireValue: WireValue) throws(InternalError) { + guard case let .object(wireObject) = wireValue else { + throw WireValueDecodingError.valueIsNotObject.toInternalError() } - self = try .init(jsonObject: jsonObject) + self = try .init(wireObject: wireObject) } } -internal typealias JSONObjectCodable = JSONObjectDecodable & JSONObjectEncodable +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 `JSONValue` values; you may find them helpful when implementing `JSONCodable`. -internal extension [String: JSONValue] { +/// This extension adds some helper methods for extracting values from a dictionary of `WireValue` values; you may find them helpful when implementing `WireCodable`. +internal extension [String: WireValue] { /// If this dictionary contains a value for `key`, and this value has case `object`, this returns the associated value. /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `object` - func objectValueForKey(_ key: String) throws(InternalError) -> [String: JSONValue] { + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `object` + func objectValueForKey(_ key: String) throws(InternalError) -> [String: WireValue] { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toInternalError() } guard case let .object(objectValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return objectValue @@ -69,8 +69,8 @@ internal extension [String: JSONValue] { /// 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `object` or `null` - func optionalObjectValueForKey(_ key: String) throws(InternalError) -> [String: JSONValue]? { + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `object` or `null` + func optionalObjectValueForKey(_ key: String) throws(InternalError) -> [String: WireValue]? { guard let value = self[key] else { return nil } @@ -80,7 +80,7 @@ internal extension [String: JSONValue] { } guard case let .object(objectValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return objectValue @@ -89,15 +89,15 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `array`, this returns the associated value. /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `array` - func arrayValueForKey(_ key: String) throws(InternalError) -> [JSONValue] { + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `array` + func arrayValueForKey(_ key: String) throws(InternalError) -> [WireValue] { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toInternalError() } guard case let .array(arrayValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return arrayValue @@ -105,8 +105,8 @@ internal extension [String: JSONValue] { /// 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `array` or `null` - func optionalArrayValueForKey(_ key: String) throws(InternalError) -> [JSONValue]? { + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `array` or `null` + func optionalArrayValueForKey(_ key: String) throws(InternalError) -> [WireValue]? { guard let value = self[key] else { return nil } @@ -116,7 +116,7 @@ internal extension [String: JSONValue] { } guard case let .array(arrayValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return arrayValue @@ -125,15 +125,15 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `string`, this returns the associated value. /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` func stringValueForKey(_ key: String) throws(InternalError) -> String { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toInternalError() } guard case let .string(stringValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return stringValue @@ -141,7 +141,7 @@ internal extension [String: JSONValue] { /// 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` func optionalStringValueForKey(_ key: String) throws(InternalError) -> String? { guard let value = self[key] else { return nil @@ -152,7 +152,7 @@ internal extension [String: JSONValue] { } guard case let .string(stringValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return stringValue @@ -161,15 +161,15 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `number`, this returns the associated value. /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` func numberValueForKey(_ key: String) throws(InternalError) -> NSNumber { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toInternalError() } guard case let .number(numberValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return numberValue @@ -177,7 +177,7 @@ internal extension [String: JSONValue] { /// 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` func optionalNumberValueForKey(_ key: String) throws(InternalError) -> NSNumber? { guard let value = self[key] else { return nil @@ -188,7 +188,7 @@ internal extension [String: JSONValue] { } guard case let .number(numberValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return numberValue @@ -197,15 +197,15 @@ internal extension [String: JSONValue] { /// If this dictionary contains a value for `key`, and this value has case `bool`, this returns the associated value. /// /// - Throws: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `bool` + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `bool` func boolValueForKey(_ key: String) throws(InternalError) -> Bool { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toInternalError() } guard case let .bool(boolValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } return boolValue @@ -213,7 +213,7 @@ internal extension [String: JSONValue] { /// 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `bool` or `null` + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `bool` or `null` func optionalBoolValueForKey(_ key: String) throws(InternalError) -> Bool? { guard let value = self[key] else { return nil @@ -224,21 +224,57 @@ internal extension [String: JSONValue] { } guard case let .bool(boolValue) = value else { - throw JSONValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() } 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(InternalError) -> Data { + guard let value = self[key] else { + throw WireValueDecodingError.noValueForKey(key).toInternalError() + } + + guard case let .data(dataValue) = value else { + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + } + + 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(InternalError) -> 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).toInternalError() + } + + return dataValue + } } // MARK: - Extracting dates from a dictionary -internal extension [String: JSONValue] { +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: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` func ablyProtocolDateValueForKey(_ key: String) throws(InternalError) -> Date { let millisecondsSinceEpoch = try numberValueForKey(key).uint64Value @@ -247,7 +283,7 @@ internal extension [String: JSONValue] { /// 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` func optionalAblyProtocolDateValueForKey(_ key: String) throws(InternalError) -> Date? { guard let millisecondsSinceEpoch = try optionalNumberValueForKey(key)?.uint64Value else { return nil @@ -262,13 +298,13 @@ internal extension [String: JSONValue] { // MARK: - Extracting RawRepresentable values from a dictionary -internal extension [String: JSONValue] { +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: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` - /// - `JSONValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` + /// - `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(InternalError) -> T where T.RawValue == String { let rawValue = try stringValueForKey(key) @@ -278,8 +314,8 @@ internal extension [String: JSONValue] { /// 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: - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `string` or `null` - /// - `JSONValueDecodingError.failedToDecodeFromRawValue` if `init(rawValue:)` returns `nil` + /// - `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(InternalError) -> T? where T.RawValue == String { guard let rawValue = try optionalStringValueForKey(key) else { return nil @@ -290,7 +326,7 @@ internal extension [String: JSONValue] { private func rawRepresentableValueFromRawValue(_ rawValue: String, type _: T.Type = T.self) throws(InternalError) -> T where T.RawValue == String { guard let value = T(rawValue: rawValue) else { - throw JSONValueDecodingError.failedToDecodeFromRawValue(rawValue).toInternalError() + throw WireValueDecodingError.failedToDecodeFromRawValue(rawValue).toInternalError() } return value @@ -299,12 +335,12 @@ internal extension [String: JSONValue] { // MARK: - Extracting WireEnum values from a dictionary -internal extension [String: JSONValue] { +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: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` + /// - `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(InternalError) -> WireEnum where Known.RawValue == Int { let rawValue = try numberValueForKey(key).intValue return WireEnum(rawValue: rawValue) @@ -312,7 +348,7 @@ internal extension [String: JSONValue] { /// 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: `JSONValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` + /// - Throws: `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` or `null` func optionalWireEnumValueForKey(_ key: String, type _: Known.Type = Known.self) throws(InternalError) -> WireEnum? where Known.RawValue == Int { guard let rawValue = try optionalNumberValueForKey(key)?.intValue else { return nil @@ -321,26 +357,26 @@ internal extension [String: JSONValue] { } } -// MARK: - Extracting JSONDecodable values from a dictionary +// MARK: - Extracting WireDecodable values from a dictionary -internal extension [String: JSONValue] { - /// If this dictionary contains a value for `key`, this attempts to decode it into an instance of `T` using its `init(jsonValue:)` initializer. +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: - /// - `JSONValueDecodingError.noValueForKey` if the key is absent - /// - Any error thrown by `T.init(jsonValue:)` - func decodableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> T { + /// - `WireValueDecodingError.noValueForKey` if the key is absent + /// - Any error thrown by `T.init(wireValue:)` + func decodableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> T { guard let value = self[key] else { - throw JSONValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toInternalError() } - return try T(jsonValue: value) + 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(jsonValue:)` initializer. If this dictionary does not contain a value for `key`, or if the value for `key` has case `null`, it returns `nil`. + /// 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(jsonValue:)` - func optionalDecodableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> T? { + /// - Throws: Any error thrown by `T.init(wireValue:)` + func optionalDecodableValueForKey(_ key: String, type _: T.Type = T.self) throws(InternalError) -> T? { guard let value = self[key] else { return nil } @@ -349,6 +385,6 @@ internal extension [String: JSONValue] { return nil } - return try T(jsonValue: value) + return try T(wireValue: value) } } diff --git a/Sources/AblyLiveObjects/Utility/WireValue.swift b/Sources/AblyLiveObjects/Utility/WireValue.swift new file mode 100644 index 000000000..e2e6f5d8a --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/WireValue.swift @@ -0,0 +1,249 @@ +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 AblyPlugin, or which we may put inside a serialized wire object that we send to AblyPlugin. +/// +/// 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). +internal indirect enum WireValue: Sendable, Equatable { + case object([String: WireValue]) + case array([WireValue]) + case string(String) + case number(NSNumber) + case bool(Bool) + case null + case data(Data) + + // MARK: - Convenience getters for associated values + + /// If this `WireValue` has case `object`, this returns the associated value. Else, it returns `nil`. + internal var objectValue: [String: WireValue]? { + if case let .object(objectValue) = self { + objectValue + } else { + nil + } + } + + /// If this `WireValue` has case `array`, this returns the associated value. Else, it returns `nil`. + internal var arrayValue: [WireValue]? { + if case let .array(arrayValue) = self { + arrayValue + } else { + nil + } + } + + /// If this `WireValue` has case `string`, this returns the associated value. Else, it returns `nil`. + internal var stringValue: String? { + if case let .string(stringValue) = self { + stringValue + } else { + nil + } + } + + /// If this `WireValue` has case `number`, this returns the associated value. Else, it returns `nil`. + internal var numberValue: NSNumber? { + if case let .number(numberValue) = self { + numberValue + } else { + nil + } + } + + /// If this `WireValue` has case `bool`, this returns the associated value. Else, it returns `nil`. + internal var boolValue: Bool? { + if case let .bool(boolValue) = self { + boolValue + } else { + nil + } + } + + /// If this `WireValue` has case `data`, this returns the associated value. Else, it returns `nil`. + internal var dataValue: Data? { + if case let .data(dataValue) = self { + dataValue + } else { + nil + } + } + + /// Returns true if and only if this `WireValue` has case `null`. + internal var isNull: Bool { + if case .null = self { + true + } else { + false + } + } +} + +extension WireValue: ExpressibleByDictionaryLiteral { + internal init(dictionaryLiteral elements: (String, WireValue)...) { + self = .object(.init(uniqueKeysWithValues: elements)) + } +} + +extension WireValue: ExpressibleByArrayLiteral { + internal init(arrayLiteral elements: WireValue...) { + self = .array(elements) + } +} + +extension WireValue: ExpressibleByStringLiteral { + internal init(stringLiteral value: String) { + self = .string(value) + } +} + +extension WireValue: ExpressibleByIntegerLiteral { + internal init(integerLiteral value: Int) { + self = .number(value as NSNumber) + } +} + +extension WireValue: ExpressibleByFloatLiteral { + internal init(floatLiteral value: Double) { + self = .number(value as NSNumber) + } +} + +extension WireValue: ExpressibleByBooleanLiteral { + internal init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + +// MARK: - Bridging with ably-cocoa + +internal extension WireValue { + /// Creates a `WireValue` from an AblyPlugin deserialized wire object. + /// + /// Specifically, `ablyPluginData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. + init(ablyPluginData: Any) { + // swiftlint:disable:next trailing_closure + let extendedJSONValue = ExtendedJSONValue(deserialized: ablyPluginData, 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(ablyPluginData:) was given unsupported value \(deserializedExtraValue)") + }) + + self.init(extendedJSONValue: extendedJSONValue) + } + + /// Creates a `WireValue` from an AblyPlugin deserialized wire object. Specifically, `ablyPluginData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. + static func objectFromAblyPluginData(_ ablyPluginData: [String: Any]) -> [String: WireValue] { + let wireValue = WireValue(ablyPluginData: ablyPluginData) + guard case let .object(wireObject) = wireValue else { + preconditionFailure() + } + + return wireObject + } + + /// Creates an AblyPlugin deserialized wire object from a `WireValue`. + /// + /// Used by `[String: WireValue].toAblyPluginDataDictionary`. + var toAblyPluginData: Any { + // swiftlint:disable:next trailing_closure + toExtendedJSONValue.serialized(serializeExtraValue: { extendedValue in + switch extendedValue { + case let .data(data): + data + } + }) + } +} + +internal extension [String: WireValue] { + /// Creates an AblyPlugin 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 toAblyPluginDataDictionary: [String: Any] { + mapValues(\.toAblyPluginData) + } +} + +// MARK: - Conversion to/from ExtendedJSONValue + +internal extension WireValue { + enum ExtraValue { + case data(Data) + } + + init(extendedJSONValue: ExtendedJSONValue) { + switch extendedJSONValue { + case let .object(underlying): + self = .object(underlying.mapValues { .init(extendedJSONValue: $0) }) + case let .array(underlying): + self = .array(underlying.map { .init(extendedJSONValue: $0) }) + case let .string(underlying): + self = .string(underlying) + case let .number(underlying): + self = .number(underlying) + case let .bool(underlying): + self = .bool(underlying) + case .null: + self = .null + case let .extra(extra): + switch extra { + case let .data(data): + self = .data(data) + } + } + } + + var toExtendedJSONValue: ExtendedJSONValue { + switch self { + case let .object(underlying): + .object(underlying.mapValues(\.toExtendedJSONValue)) + case let .array(underlying): + .array(underlying.map(\.toExtendedJSONValue)) + case let .string(underlying): + .string(underlying) + case let .number(underlying): + .number(underlying) + case let .bool(underlying): + .bool(underlying) + case .null: + .null + case let .data(data): + .extra(.data(data)) + } + } +} + +// MARK: - Conversion to/from JSONValue + +internal extension WireValue { + /// Converts a `JSONValue` to its corresponding `WireValue`. + init(jsonValue: JSONValue) { + // swiftlint:disable:next array_init + self.init(extendedJSONValue: jsonValue.toExtendedJSONValue.map { (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(InternalError) { + let neverExtended = try toExtendedJSONValue.map { extra throws(InternalError) -> Never in + switch extra { + case .data: + throw ConversionError.dataCannotBeConvertedToJSONValue.toInternalError() + } + } + + return .init(extendedJSONValue: neverExtended) + } + } +} diff --git a/Tests/AblyLiveObjectsTests/JSONValueTests.swift b/Tests/AblyLiveObjectsTests/JSONValueTests.swift index aea084244..02b92d485 100644 --- a/Tests/AblyLiveObjectsTests/JSONValueTests.swift +++ b/Tests/AblyLiveObjectsTests/JSONValueTests.swift @@ -3,33 +3,33 @@ import Foundation import Testing struct JSONValueTests { - // MARK: Conversion from AblyPlugin data + // MARK: Conversion from JSONSerialization output @Test(arguments: [ // object - (ablyPluginData: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), + (jsonSerializationOutput: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), // array - (ablyPluginData: ["someElement"], expectedResult: ["someElement"]), + (jsonSerializationOutput: ["someElement"], expectedResult: ["someElement"]), // string - (ablyPluginData: "someString", expectedResult: "someString"), + (jsonSerializationOutput: "someString", expectedResult: "someString"), // number - (ablyPluginData: NSNumber(value: 0), expectedResult: 0), - (ablyPluginData: NSNumber(value: 1), expectedResult: 1), - (ablyPluginData: NSNumber(value: 123), expectedResult: 123), - (ablyPluginData: NSNumber(value: 123.456), expectedResult: 123.456), + (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 - (ablyPluginData: NSNumber(value: true), expectedResult: true), - (ablyPluginData: NSNumber(value: false), expectedResult: false), + (jsonSerializationOutput: NSNumber(value: true), expectedResult: true), + (jsonSerializationOutput: NSNumber(value: false), expectedResult: false), // null - (ablyPluginData: NSNull(), expectedResult: .null), - ] as[(ablyPluginData: Sendable, expectedResult: JSONValue?)]) - func initWithAblyPluginData(ablyPluginData: Sendable, expectedResult: JSONValue?) { - #expect(JSONValue(ablyPluginData: ablyPluginData) == expectedResult) + (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` (which is what ably-cocoa uses for deserialization). + // Tests that it correctly handles an object deserialized by `JSONSerialization`. @Test - func initWithAblyPluginData_endToEnd() throws { + func initWithJSONSerializationOutput_endToEnd() throws { let jsonString = """ { "someArray": [ @@ -51,7 +51,7 @@ struct JSONValueTests { } """ - let ablyPluginData = try JSONSerialization.jsonObject(with: #require(jsonString.data(using: .utf8))) + let jsonSerializationOutput = try JSONSerialization.jsonObject(with: #require(jsonString.data(using: .utf8))) let expected: JSONValue = [ "someArray": [ @@ -72,10 +72,10 @@ struct JSONValueTests { ], ] - #expect(JSONValue(ablyPluginData: ablyPluginData) == expected) + #expect(JSONValue(jsonSerializationOutput: jsonSerializationOutput) == expected) } - // MARK: Conversion to AblyPlugin data + // MARK: Conversion to JSONSerialization input @Test(arguments: [ // object @@ -95,16 +95,16 @@ struct JSONValueTests { // null (value: .null, expectedResult: NSNull()), ] as[(value: JSONValue, expectedResult: Sendable)]) - func toAblyPluginData(value: JSONValue, expectedResult: Sendable) throws { - let resultAsNSObject = try #require(value.toAblyPluginData as? NSObject) + 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` (which is what ably-cocoa uses for serialization), and that the result of this serialization is what we’d expect. + // 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 toAblyPluginData_endToEnd() throws { - let value: JSONValue = [ + func toJSONSerializationInput_endToEnd() throws { + let value: [String: JSONValue] = [ "someArray": [ [ "someStringKey": "someString", @@ -146,7 +146,7 @@ struct JSONValueTests { let jsonSerializationOptions: JSONSerialization.WritingOptions = [.sortedKeys] - let valueData = try JSONSerialization.data(withJSONObject: value.toAblyPluginData, options: jsonSerializationOptions) + 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) diff --git a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift index 5a1a6880f..49dbefd53 100644 --- a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -22,7 +22,7 @@ enum WireObjectMessageTests { @Test func decodesAllFields() throws { let timestamp = Date(timeIntervalSince1970: 1_234_567_890) - let json: [String: JSONValue] = [ + let wire: [String: WireValue] = [ "id": "id1", "clientId": "client1", "connectionId": "conn1", @@ -34,7 +34,7 @@ enum WireObjectMessageTests { "siteCode": "siteA", ] let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: nil, indexInParent: 0) - let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) #expect(msg.id == "id1") #expect(msg.clientId == "client1") #expect(msg.connectionId == "conn1") @@ -48,9 +48,9 @@ enum WireObjectMessageTests { @Test func optionalFieldsAbsent() throws { - let json: [String: JSONValue] = [:] + let wire: [String: WireValue] = [:] let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: nil, indexInParent: 0) - let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) #expect(msg.id == nil) #expect(msg.clientId == nil) #expect(msg.connectionId == nil) @@ -65,36 +65,36 @@ enum WireObjectMessageTests { // @specOneOf(1/2) OM2a @Test func idFromParent_whenPresentInParent() throws { - let json: [String: JSONValue] = [:] + let wire: [String: WireValue] = [:] let ctx = FakeDecodingContext(parentID: "parent1", parentConnectionID: nil, parentTimestamp: nil, indexInParent: 2) - let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) #expect(msg.id == "parent1:2") } // @specOneOf(2/2) OM2a @Test func idFromParent_whenAbsentInParent() throws { - let json: [String: JSONValue] = [:] + let wire: [String: WireValue] = [:] let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: nil, indexInParent: 2) - let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) #expect(msg.id == nil) } // @spec OM2c @Test(arguments: [nil, "parentConn1"]) func connectionIdFromParent(parentValue: String?) throws { - let json: [String: JSONValue] = [:] + let wire: [String: WireValue] = [:] let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: parentValue, parentTimestamp: nil, indexInParent: 0) - let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + 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 json: [String: JSONValue] = [:] + let wire: [String: WireValue] = [:] let ctx = FakeDecodingContext(parentID: nil, parentConnectionID: nil, parentTimestamp: parentValue, indexInParent: 0) - let msg = try InboundWireObjectMessage(jsonObject: json, decodingContext: ctx) + let msg = try InboundWireObjectMessage(wireObject: wire, decodingContext: ctx) #expect(msg.timestamp == parentValue) } } @@ -124,8 +124,8 @@ enum WireObjectMessageTests { serial: "s1", siteCode: "siteA", ) - let json = msg.toJSONObject - #expect(json == [ + let wire = msg.toWireObject + #expect(wire == [ "id": "id1", "clientId": "client1", "connectionId": "conn1", @@ -151,8 +151,8 @@ enum WireObjectMessageTests { serial: nil, siteCode: nil, ) - let json = msg.toJSONObject - #expect(json == [ + let wire = msg.toWireObject + #expect(wire == [ "id": "id1", "timestamp": .number(NSNumber(value: Int(timestamp.timeIntervalSince1970 * 1000))), ]) @@ -162,7 +162,7 @@ enum WireObjectMessageTests { struct WireObjectOperationTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = [ + let wire: [String: WireValue] = [ "action": 0, // mapCreate "objectId": "obj1", "mapOp": ["key": "key1", "data": ["string": "value1"]], @@ -172,7 +172,7 @@ enum WireObjectMessageTests { "nonce": "nonce1", "initialValueEncoding": "utf8", ] - let op = try WireObjectOperation(jsonObject: json) + let op = try WireObjectOperation(wireObject: wire) #expect(op.action == .known(.mapCreate)) #expect(op.objectId == "obj1") #expect(op.mapOp?.key == "key1") @@ -188,11 +188,11 @@ enum WireObjectMessageTests { @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = [ + let wire: [String: WireValue] = [ "action": 0, "objectId": "obj1", ] - let op = try WireObjectOperation(jsonObject: json) + let op = try WireObjectOperation(wireObject: wire) #expect(op.action == .known(.mapCreate)) #expect(op.objectId == "obj1") #expect(op.mapOp == nil) @@ -206,11 +206,11 @@ enum WireObjectMessageTests { @Test func decodesWithUnknownAction() throws { - let json: [String: JSONValue] = [ + let wire: [String: WireValue] = [ "action": 999, // Unknown WireObjectOperation "objectId": "obj1", ] - let op = try WireObjectOperation(jsonObject: json) + let op = try WireObjectOperation(wireObject: wire) #expect(op.action == .unknown(999)) } @@ -230,8 +230,8 @@ enum WireObjectMessageTests { initialValue: nil, initialValueEncoding: "utf8", ) - let json = op.toJSONObject - #expect(json == [ + let wire = op.toWireObject + #expect(wire == [ "action": 0, "objectId": "obj1", "mapOp": ["key": "key1", "data": ["string": "value1"]], @@ -256,8 +256,8 @@ enum WireObjectMessageTests { initialValue: nil, initialValueEncoding: nil, ) - let json = op.toJSONObject - #expect(json == [ + let wire = op.toWireObject + #expect(wire == [ "action": 0, "objectId": "obj1", ]) @@ -267,7 +267,7 @@ enum WireObjectMessageTests { struct WireObjectStateTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = [ + let wire: [String: WireValue] = [ "objectId": "obj1", "siteTimeserials": ["site1": "ts1"], "tombstone": true, @@ -275,7 +275,7 @@ enum WireObjectMessageTests { "map": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], "counter": ["count": 42], ] - let state = try WireObjectState(jsonObject: json) + let state = try WireObjectState(wireObject: wire) #expect(state.objectId == "obj1") #expect(state.siteTimeserials["site1"] == "ts1") #expect(state.tombstone == true) @@ -289,12 +289,12 @@ enum WireObjectMessageTests { @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = [ + let wire: [String: WireValue] = [ "objectId": "obj1", "siteTimeserials": [:], "tombstone": false, ] - let state = try WireObjectState(jsonObject: json) + let state = try WireObjectState(wireObject: wire) #expect(state.objectId == "obj1") #expect(state.siteTimeserials.isEmpty) #expect(state.tombstone == false) @@ -326,8 +326,8 @@ enum WireObjectMessageTests { ), counter: WireCounter(count: 42), ) - let json = state.toJSONObject - #expect(json == [ + let wire = state.toWireObject + #expect(wire == [ "objectId": "obj1", "siteTimeserials": ["site1": "ts1"], "tombstone": true, @@ -347,8 +347,8 @@ enum WireObjectMessageTests { map: nil, counter: nil, ) - let json = state.toJSONObject - #expect(json == [ + let wire = state.toWireObject + #expect(wire == [ "objectId": "obj1", "siteTimeserials": [:], "tombstone": false, @@ -359,14 +359,14 @@ enum WireObjectMessageTests { struct WireObjectDataTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = [ + let json: [String: WireValue] = [ "objectId": "obj1", "encoding": "utf8", "boolean": true, "number": 42, "string": "value1", ] - let data = try WireObjectData(jsonObject: json) + let data = try WireObjectData(wireObject: json) #expect(data.objectId == "obj1") #expect(data.encoding == "utf8") #expect(data.boolean == true) @@ -376,8 +376,8 @@ enum WireObjectMessageTests { @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = [:] - let data = try WireObjectData(jsonObject: json) + let json: [String: WireValue] = [:] + let data = try WireObjectData(wireObject: json) #expect(data.objectId == nil) #expect(data.encoding == nil) #expect(data.boolean == nil) @@ -396,8 +396,8 @@ enum WireObjectMessageTests { number: 42, string: "value1", ) - let json = data.toJSONObject - #expect(json == [ + let wire = data.toWireObject + #expect(wire == [ "objectId": "obj1", "encoding": "utf8", "boolean": true, @@ -416,27 +416,27 @@ enum WireObjectMessageTests { number: nil, string: nil, ) - let json = data.toJSONObject - #expect(json.isEmpty) + let wire = data.toWireObject + #expect(wire.isEmpty) } } struct WireMapOpTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = [ + let json: [String: WireValue] = [ "key": "key1", "data": ["string": "value1"], ] - let op = try WireMapOp(jsonObject: json) + let op = try WireMapOp(wireObject: json) #expect(op.key == "key1") #expect(op.data?.string == "value1") } @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = ["key": "key1"] - let op = try WireMapOp(jsonObject: json) + let json: [String: WireValue] = ["key": "key1"] + let op = try WireMapOp(wireObject: json) #expect(op.key == "key1") #expect(op.data == nil) } @@ -447,8 +447,8 @@ enum WireObjectMessageTests { key: "key1", data: WireObjectData(string: "value1"), ) - let json = op.toJSONObject - #expect(json == [ + let wire = op.toWireObject + #expect(wire == [ "key": "key1", "data": ["string": "value1"], ]) @@ -460,8 +460,8 @@ enum WireObjectMessageTests { key: "key1", data: nil, ) - let json = op.toJSONObject - #expect(json == [ + let wire = op.toWireObject + #expect(wire == [ "key": "key1", ]) } @@ -470,30 +470,30 @@ enum WireObjectMessageTests { struct WireCounterOpTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = ["amount": 42] - let op = try WireCounterOp(jsonObject: json) + let json: [String: WireValue] = ["amount": 42] + let op = try WireCounterOp(wireObject: json) #expect(op.amount == 42) } @Test func encodesAllFields() { let op = WireCounterOp(amount: 42) - let json = op.toJSONObject - #expect(json == ["amount": 42]) + let wire = op.toWireObject + #expect(wire == ["amount": 42]) } } struct WireMapTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = [ + let json: [String: WireValue] = [ "semantics": 0, "entries": [ "key1": ["data": ["string": "value1"], "tombstone": false, "timeserial": "ts1"], "key2": ["data": ["string": "value2"], "tombstone": true], ], ] - let map = try WireMap(jsonObject: json) + let map = try WireMap(wireObject: json) #expect(map.semantics == .known(.lww)) #expect(map.entries?["key1"]?.data.string == "value1") #expect(map.entries?["key1"]?.tombstone == false) @@ -505,18 +505,18 @@ enum WireObjectMessageTests { @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = ["semantics": 0] - let map = try WireMap(jsonObject: json) + let json: [String: WireValue] = ["semantics": 0] + let map = try WireMap(wireObject: json) #expect(map.semantics == .known(.lww)) #expect(map.entries == nil) } @Test func decodesWithUnknownSemantics() throws { - let json: [String: JSONValue] = [ + let json: [String: WireValue] = [ "semantics": 999, // Unknown MapSemantics ] - let map = try WireMap(jsonObject: json) + let map = try WireMap(wireObject: json) #expect(map.semantics == .unknown(999)) } @@ -529,8 +529,8 @@ enum WireObjectMessageTests { "key2": WireMapEntry(tombstone: true, timeserial: nil, data: WireObjectData(string: "value2")), ], ) - let json = map.toJSONObject - #expect(json == [ + let wire = map.toWireObject + #expect(wire == [ "semantics": 0, "entries": [ "key1": ["data": ["string": "value1"], "tombstone": false, "timeserial": "ts1"], @@ -545,8 +545,8 @@ enum WireObjectMessageTests { semantics: .known(.lww), entries: nil, ) - let json = map.toJSONObject - #expect(json == [ + let wire = map.toWireObject + #expect(wire == [ "semantics": 0, ]) } @@ -555,42 +555,42 @@ enum WireObjectMessageTests { struct WireCounterTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = ["count": 42] - let counter = try WireCounter(jsonObject: json) + let json: [String: WireValue] = ["count": 42] + let counter = try WireCounter(wireObject: json) #expect(counter.count == 42) } @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = [:] - let counter = try WireCounter(jsonObject: json) + let json: [String: WireValue] = [:] + let counter = try WireCounter(wireObject: json) #expect(counter.count == nil) } @Test func encodesAllFields() { let counter = WireCounter(count: 42) - let json = counter.toJSONObject - #expect(json == ["count": 42]) + let wire = counter.toWireObject + #expect(wire == ["count": 42]) } @Test func encodesWithOptionalFieldsNil() { let counter = WireCounter(count: nil) - let json = counter.toJSONObject - #expect(json.isEmpty) + let wire = counter.toWireObject + #expect(wire.isEmpty) } } struct WireMapEntryTests { @Test func decodesAllFields() throws { - let json: [String: JSONValue] = [ + let json: [String: WireValue] = [ "data": ["string": "value1"], "tombstone": true, "timeserial": "ts1", ] - let entry = try WireMapEntry(jsonObject: json) + let entry = try WireMapEntry(wireObject: json) #expect(entry.data.string == "value1") #expect(entry.tombstone == true) #expect(entry.timeserial == "ts1") @@ -598,8 +598,8 @@ enum WireObjectMessageTests { @Test func decodesWithOptionalFieldsAbsent() throws { - let json: [String: JSONValue] = ["data": ["string": "value1"]] - let entry = try WireMapEntry(jsonObject: json) + let json: [String: WireValue] = ["data": ["string": "value1"]] + let entry = try WireMapEntry(wireObject: json) #expect(entry.data.string == "value1") #expect(entry.tombstone == nil) #expect(entry.timeserial == nil) @@ -612,8 +612,8 @@ enum WireObjectMessageTests { timeserial: "ts1", data: WireObjectData(string: "value1"), ) - let json = entry.toJSONObject - #expect(json == [ + let wire = entry.toWireObject + #expect(wire == [ "data": ["string": "value1"], "tombstone": true, "timeserial": "ts1", @@ -627,8 +627,8 @@ enum WireObjectMessageTests { timeserial: nil, data: WireObjectData(string: "value1"), ) - let json = entry.toJSONObject - #expect(json == [ + let wire = entry.toWireObject + #expect(wire == [ "data": ["string": "value1"], ]) } diff --git a/Tests/AblyLiveObjectsTests/WireValueTests.swift b/Tests/AblyLiveObjectsTests/WireValueTests.swift new file mode 100644 index 000000000..91e557e09 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/WireValueTests.swift @@ -0,0 +1,379 @@ +import Ably.Private +@testable import AblyLiveObjects +import Foundation +import Testing + +struct WireValueTests { + // MARK: Conversion from AblyPlugin data + + @Test(arguments: [ + // object + (ablyPluginData: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), + // array + (ablyPluginData: ["someElement"], expectedResult: ["someElement"]), + // string + (ablyPluginData: "someString", expectedResult: "someString"), + // number + (ablyPluginData: NSNumber(value: 0), expectedResult: 0), + (ablyPluginData: NSNumber(value: 1), expectedResult: 1), + (ablyPluginData: NSNumber(value: 123), expectedResult: 123), + (ablyPluginData: NSNumber(value: 123.456), expectedResult: 123.456), + // bool + (ablyPluginData: NSNumber(value: true), expectedResult: true), + (ablyPluginData: NSNumber(value: false), expectedResult: false), + // null + (ablyPluginData: NSNull(), expectedResult: .null), + // data + (ablyPluginData: Data([0x01, 0x02, 0x03]), expectedResult: .data(Data([0x01, 0x02, 0x03]))), + ] as[(ablyPluginData: Sendable, expectedResult: WireValue?)]) + func initWithAblyPluginData(ablyPluginData: Sendable, expectedResult: WireValue?) { + #expect(WireValue(ablyPluginData: ablyPluginData) == expectedResult) + } + + // Tests that it correctly handles an object deserialized by `JSONSerialization` (which is what ably-cocoa uses for JSON deserialization). + @Test + func initWithAblyPluginData_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 ablyPluginData = 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(ablyPluginData: ablyPluginData) == expected) + } + + // Tests that it correctly handles an object deserialized by `ARTMsgPackEncoder` (which is what ably-cocoa uses for MessagePack deserialization). + @Test + func initWithAblyPluginData_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 ablyPluginData = 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(ablyPluginData: ablyPluginData) == expected) + } + + // MARK: Conversion to AblyPlugin 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 toAblyPluginData(value: WireValue, expectedResult: Sendable) throws { + let resultAsNSObject = try #require(value.toAblyPluginData 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 toAblyPluginData_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.toAblyPluginData, 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 toAblyPluginData_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.toAblyPluginData) + + // 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) + } +} From a1961279eb5c1c1053219f69356f50bd78b9239c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 15:33:02 -0300 Subject: [PATCH 018/225] Implement spec points that say not to access certain fields Based on [1] at 2e975cb. [1] https://github.com/ably/specification/pull/335 --- Sources/AblyLiveObjects/Protocol/ObjectMessage.swift | 10 +++++++--- .../AblyLiveObjects/Protocol/WireObjectMessage.swift | 11 ++++++++--- .../WireObjectMessageTests.swift | 12 ++++++++++-- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 153bfc5e6..d5bd3671b 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -117,9 +117,13 @@ internal extension ObjectOperation { counterOp = wireObjectOperation.counterOp map = wireObjectOperation.map.map { .init(wireMap: $0) } counter = wireObjectOperation.counter - nonce = wireObjectOperation.nonce - initialValue = wireObjectOperation.initialValue - initialValueEncoding = wireObjectOperation.initialValueEncoding + + // Do not access on inbound data, per OOP3g + nonce = nil + // Do not access on inbound data, per OOP3h + initialValue = nil + // Do not access on inbound data, per OOP3i + initialValueEncoding = nil } /// Converts this `ObjectOperation` to a `WireObjectOperation`. diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 43d138a00..b53228f6b 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -158,7 +158,7 @@ internal struct WireObjectOperation { internal var map: WireMap? // OOP3e internal var counter: WireCounter? // OOP3f internal var nonce: String? // OOP3g - // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 + // TODO: Not yet clear how to encode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 internal var initialValue: Data? // OOP3h internal var initialValueEncoding: String? // OOP3i } @@ -183,8 +183,13 @@ extension WireObjectOperation: WireObjectCodable { counterOp = try wireObject.optionalDecodableValueForKey(WireKey.counterOp.rawValue) map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) counter = try wireObject.optionalDecodableValueForKey(WireKey.counter.rawValue) - nonce = try wireObject.optionalStringValueForKey(WireKey.nonce.rawValue) - initialValueEncoding = try wireObject.optionalStringValueForKey(WireKey.initialValueEncoding.rawValue) + + // Do not access on inbound data, per OOP3g + nonce = nil + // Do not access on inbound data, per OOP3h + initialValue = nil + // Do not access on inbound data, per OOP3i + initialValueEncoding = nil } internal var toWireObject: [String: WireValue] { diff --git a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift index 49dbefd53..bf2081683 100644 --- a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -160,6 +160,9 @@ enum WireObjectMessageTests { } struct WireObjectOperationTests { + // @spec OOP3g + // @spec OOP3h + // @spec OOP3i @Test func decodesAllFields() throws { let wire: [String: WireValue] = [ @@ -182,8 +185,13 @@ enum WireObjectMessageTests { #expect(op.map?.entries?["key1"]?.data.string == "value1") #expect(op.map?.entries?["key1"]?.tombstone == false) #expect(op.counter?.count == 42) - #expect(op.nonce == "nonce1") - #expect(op.initialValueEncoding == "utf8") + + // Per OOP3g we should not try and extract this + #expect(op.nonce == nil) + // Per OOP3h we should not try and extract this + #expect(op.initialValueEncoding == nil) + // Per OOP3i we should not try and extract this + #expect(op.initialValue == nil) } @Test From c05f1f121160171a178a9b71c84fb332e78caecf Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 18:01:12 -0300 Subject: [PATCH 019/225] Add methods for (de)serializing JSONValue (from) to string Preparation for decoding and encoding JSON-valued ObjectData. We also introduce a type that represents a JSON value or array; as well as representing JSONSerialization's supported top-level objects, we'll also shortly use it to represent the kind of JSON data that you can put in ObjectData. --- .../AblyLiveObjects/Utility/JSONValue.swift | 83 +++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift index 839cd54ef..d03b86e51 100644 --- a/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -156,6 +156,41 @@ internal extension JSONValue { } } +// MARK: - JSON objects and arrays + +/// A subset of ``JSONValue`` that has only `object` or `array` cases. +internal enum JSONObjectOrArray: Equatable { + case object([String: JSONValue]) + case array([JSONValue]) + + internal enum ConversionError: Swift.Error { + case incompatibleJSONValue(JSONValue) + } + + internal init(jsonValue: JSONValue) throws(InternalError) { + self = switch jsonValue { + case let .array(array): + .array(array) + case let .object(object): + .object(object) + case .bool, .number, .string, .null: + throw ConversionError.incompatibleJSONValue(jsonValue).toInternalError() + } + } +} + +extension JSONObjectOrArray: ExpressibleByDictionaryLiteral { + internal init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .object(.init(uniqueKeysWithValues: elements)) + } +} + +extension JSONObjectOrArray: ExpressibleByArrayLiteral { + internal init(arrayLiteral elements: JSONValue...) { + self = .array(elements) + } +} + internal extension [String: JSONValue] { /// Converts a dictionary that has string keys and `JSONValue` values into an input for Foundation's `JSONSerialization`. var toJSONSerializationInput: [String: Any] { @@ -207,3 +242,51 @@ internal extension JSONValue { } } } + +// MARK: Serializing to and deserializing from a JSON string + +internal extension JSONObjectOrArray { + enum DecodingError: Swift.Error { + case incompatibleJSONValue(JSONValue) + } + + /// Deserializes a JSON string into a `JSONObjectOrArray`. Throws an error if not given a valid JSON string. + init(jsonString: String) throws(InternalError) { + let data = Data(jsonString.utf8) + let jsonSerializationOutput: Any + do { + jsonSerializationOutput = try JSONSerialization.jsonObject(with: data) + } catch { + throw error.toInternalError() + } + + 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 + } +} From 2715aebbd5700f8fc7d7b57fc8c4783dd8c09d9d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 18 Jun 2025 11:32:21 -0300 Subject: [PATCH 020/225] Encode and decode data of WireObject Based on [1] at 2e975cb. This was implemented by updating the code to reflect the internal API that I wanted to exist, and then asking Cursor to implement the rules of the spec and to write tests. I then edited the generated code to simplify it a bit and add things like @spec annotations and other explanatory comments. I wanted some round-trip tests that go through the Realtime backend but decided to leave them until later once I have a bit more knowledge of the LiveObjects protocol; have created #17. [1] https://github.com/ably/specification/pull/335 --- .../Internal/DefaultInternalPlugin.swift | 9 +- .../Protocol/ObjectMessage.swift | 319 ++++++++-- .../Protocol/WireObjectMessage.swift | 49 +- .../Utility/Data+Extensions.swift | 19 + Tests/AblyLiveObjectsTests/.swiftformat | 2 + Tests/AblyLiveObjectsTests/.swiftlint.yml | 3 + .../ObjectMessageTests.swift | 568 ++++++++++++++++++ 7 files changed, 902 insertions(+), 67 deletions(-) create mode 100644 Sources/AblyLiveObjects/Utility/Data+Extensions.swift create mode 100644 Tests/AblyLiveObjectsTests/.swiftformat create mode 100644 Tests/AblyLiveObjectsTests/.swiftlint.yml create mode 100644 Tests/AblyLiveObjectsTests/ObjectMessageTests.swift diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index e9eeb8dc1..21b59fa1b 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -60,7 +60,6 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte internal func decodeObjectMessage( _ serialized: [String: Any], context: DecodingContextProtocol, - // TODO: use format: EncodingFormat, error errorPtr: AutoreleasingUnsafeMutablePointer?, ) -> (any ObjectMessageProtocol)? { @@ -71,7 +70,10 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte wireObject: wireObject, decodingContext: context, ) - let objectMessage = InboundObjectMessage(wireObjectMessage: wireObjectMessage) + let objectMessage = try InboundObjectMessage( + wireObjectMessage: wireObjectMessage, + format: format, + ) return ObjectMessageBox(objectMessage: objectMessage) } catch { errorPtr?.pointee = error.toARTErrorInfo() @@ -81,14 +83,13 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte internal func encodeObjectMessage( _ publicObjectMessage: any AblyPlugin.ObjectMessageProtocol, - // TODO: use 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() + let wireObjectMessage = outboundObjectMessageBox.objectMessage.toWire(format: format) return wireObjectMessage.toWireObject.toAblyPluginDataDictionary } diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index d5bd3671b..a98dc0493 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -1,3 +1,4 @@ +internal import AblyPlugin 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. @@ -36,19 +37,23 @@ internal struct ObjectOperation { internal var map: Map? // OOP3e internal var counter: WireCounter? // OOP3f internal var nonce: String? // OOP3g - // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 internal var initialValue: Data? // OOP3h internal var initialValueEncoding: String? // OOP3i } internal struct ObjectData { + /// The values that the `string` property might hold, before being encoded per OD4 or after being decoded per OD5. + internal enum StringPropertyContent { + case string(String) + case json(JSONObjectOrArray) + } + internal var objectId: String? // OD2a internal var encoding: String? // OD2b internal var boolean: Bool? // OD2c - // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 internal var bytes: Data? // OD2d internal var number: NSNumber? // OD2e - internal var string: String? // OD2f + internal var string: StringPropertyContent? // OD2f } internal struct MapOp { @@ -77,31 +82,45 @@ internal struct ObjectState { } internal extension InboundObjectMessage { - /// Initializes an `InboundObjectMessage` from an `InboundWireObjectMessage`. - init(wireObjectMessage: InboundWireObjectMessage) { + /// 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: `InternalError` if JSON or Base64 decoding fails. + init( + wireObjectMessage: InboundWireObjectMessage, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { id = wireObjectMessage.id clientId = wireObjectMessage.clientId connectionId = wireObjectMessage.connectionId extras = wireObjectMessage.extras timestamp = wireObjectMessage.timestamp - operation = wireObjectMessage.operation.map { .init(wireObjectOperation: $0) } - object = wireObjectMessage.object.map { .init(wireObjectState: $0) } + operation = try wireObjectMessage.operation.map { wireObjectOperation throws(InternalError) in + try .init(wireObjectOperation: wireObjectOperation, format: format) + } + object = try wireObjectMessage.object.map { wireObjectState throws(InternalError) in + try .init(wireObjectState: wireObjectState, format: format) + } serial = wireObjectMessage.serial siteCode = wireObjectMessage.siteCode } } internal extension OutboundObjectMessage { - /// Converts this `OutboundObjectMessage` to an `OutboundWireObjectMessage`. - func toWire() -> OutboundWireObjectMessage { + /// 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: AblyPlugin.EncodingFormat) -> OutboundWireObjectMessage { .init( id: id, clientId: clientId, connectionId: connectionId, extras: extras, timestamp: timestamp, - operation: operation?.toWire(), - object: object?.toWire(), + operation: operation?.toWire(format: format), + object: object?.toWire(format: format), serial: serial, siteCode: siteCode, ) @@ -109,13 +128,24 @@ internal extension OutboundObjectMessage { } internal extension ObjectOperation { - /// Initializes an `ObjectOperation` from a `WireObjectOperation`. - init(wireObjectOperation: WireObjectOperation) { + /// 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: `InternalError` if JSON or Base64 decoding fails. + init( + wireObjectOperation: WireObjectOperation, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { action = wireObjectOperation.action objectId = wireObjectOperation.objectId - mapOp = wireObjectOperation.mapOp.map { .init(wireMapOp: $0) } + mapOp = try wireObjectOperation.mapOp.map { wireMapOp throws(InternalError) in + try .init(wireMapOp: wireMapOp, format: format) + } counterOp = wireObjectOperation.counterOp - map = wireObjectOperation.map.map { .init(wireMap: $0) } + map = try wireObjectOperation.map.map { wireMap throws(InternalError) in + try .init(wireMap: wireMap, format: format) + } counter = wireObjectOperation.counter // Do not access on inbound data, per OOP3g @@ -126,115 +156,286 @@ internal extension ObjectOperation { initialValueEncoding = nil } - /// Converts this `ObjectOperation` to a `WireObjectOperation`. - func toWire() -> WireObjectOperation { - .init( + /// Converts this `ObjectOperation` to a `WireObjectOperation`, applying the data encoding rules of OD4 and OOP5. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4 and OOP5. + func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectOperation { + // OOP5: Encode initialValue based on format + let wireInitialValue: StringOrData? + let wireInitialValueEncoding: String? + + if let initialValue { + switch format { + case .messagePack: + // OOP5a: When the MessagePack protocol is used + // OOP5a1: A binary ObjectOperation.initialValue is encoded as a MessagePack binary type + wireInitialValue = .data(initialValue) + // OOP5a2: Set ObjectOperation.initialValueEncoding to msgpack + wireInitialValueEncoding = "msgpack" + + case .json: + // OOP5b: When the JSON protocol is used + // OOP5b1: A binary ObjectOperation.initialValue is Base64-encoded and represented as a JSON string + wireInitialValue = .string(initialValue.base64EncodedString()) + // OOP5b2: Set ObjectOperation.initialValueEncoding to json + wireInitialValueEncoding = "json" + } + } else { + wireInitialValue = nil + wireInitialValueEncoding = nil + } + + return .init( action: action, objectId: objectId, - mapOp: mapOp?.toWire(), + mapOp: mapOp?.toWire(format: format), counterOp: counterOp, - map: map?.toWire(), + map: map?.toWire(format: format), counter: counter, nonce: nonce, - initialValue: initialValue, - initialValueEncoding: initialValueEncoding, + initialValue: wireInitialValue, + initialValueEncoding: wireInitialValueEncoding, ) } } internal extension ObjectData { - /// Initializes an `ObjectData` from a `WireObjectData`. - init(wireObjectData: WireObjectData) { + /// 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: `InternalError` if JSON or Base64 decoding fails. + init( + wireObjectData: WireObjectData, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { objectId = wireObjectData.objectId encoding = wireObjectData.encoding boolean = wireObjectData.boolean - bytes = wireObjectData.bytes 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 + } + } + + if let wireString = wireObjectData.string { + // OD5a2, OD5b3: If ObjectData.encoding is set to "json", the ObjectData.string content is decoded by parsing the string as JSON + if wireObjectData.encoding == "json" { + let jsonValue = try JSONObjectOrArray(jsonString: wireString) + string = .json(jsonValue) + } else { + string = .string(wireString) + } + } else { + string = nil + } } - /// Converts this `ObjectData` to a `WireObjectData`. - func toWire() -> WireObjectData { - .init( + /// 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: AblyPlugin.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 + } + + // 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 + let (wireString, wireEncoding): (String?, String?) = if let stringContent = string { + switch stringContent { + case let .string(str): + (str, nil) + case let .json(jsonValue): + // OD4c5, OD4d5: A payload consisting of a JSON-encodable object or array is stringified as a JSON object or array, represented as a JSON string and the result is set on the ObjectData.string attribute. The ObjectData.encoding attribute is then set to "json" + (jsonValue.toJSONString, "json") + } + } else { + (nil, 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, - encoding: encoding, + encoding: wireEncoding, boolean: boolean, - bytes: bytes, - number: number, - string: string, + bytes: wireBytes, + number: wireNumber, + string: wireString, ) } } internal extension MapOp { - /// Initializes a `MapOp` from a `WireMapOp`. - init(wireMapOp: WireMapOp) { + /// Initializes a `MapOp` from a `WireMapOp`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `InternalError` if JSON or Base64 decoding fails. + init( + wireMapOp: WireMapOp, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { key = wireMapOp.key - data = wireMapOp.data.map { .init(wireObjectData: $0) } + data = try wireMapOp.data.map { wireObjectData throws(InternalError) in + try .init(wireObjectData: wireObjectData, format: format) + } } - /// Converts this `MapOp` to a `WireMapOp`. - func toWire() -> WireMapOp { + /// Converts this `MapOp` to a `WireMapOp`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: AblyPlugin.EncodingFormat) -> WireMapOp { .init( key: key, - data: data?.toWire(), + data: data?.toWire(format: format), ) } } internal extension MapEntry { - /// Initializes a `MapEntry` from a `WireMapEntry`. - init(wireMapEntry: WireMapEntry) { + /// Initializes a `MapEntry` from a `WireMapEntry`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `InternalError` if JSON or Base64 decoding fails. + init( + wireMapEntry: WireMapEntry, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { tombstone = wireMapEntry.tombstone timeserial = wireMapEntry.timeserial - data = .init(wireObjectData: wireMapEntry.data) + data = try .init(wireObjectData: wireMapEntry.data, format: format) } - /// Converts this `MapEntry` to a `WireMapEntry`. - func toWire() -> WireMapEntry { + /// Converts this `MapEntry` to a `WireMapEntry`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: AblyPlugin.EncodingFormat) -> WireMapEntry { .init( tombstone: tombstone, timeserial: timeserial, - data: data.toWire(), + data: data.toWire(format: format), ) } } internal extension Map { - /// Initializes a `Map` from a `WireMap`. - init(wireMap: WireMap) { + /// Initializes a `Map` from a `WireMap`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `InternalError` if JSON or Base64 decoding fails. + init( + wireMap: WireMap, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { semantics = wireMap.semantics - entries = wireMap.entries?.mapValues { .init(wireMapEntry: $0) } + entries = try wireMap.entries?.ablyLiveObjects_mapValuesWithTypedThrow { wireMapEntry throws(InternalError) in + try .init(wireMapEntry: wireMapEntry, format: format) + } } - /// Converts this `Map` to a `WireMap`. - func toWire() -> WireMap { + /// Converts this `Map` to a `WireMap`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: AblyPlugin.EncodingFormat) -> WireMap { .init( semantics: semantics, - entries: entries?.mapValues { $0.toWire() }, + entries: entries?.mapValues { $0.toWire(format: format) }, ) } } internal extension ObjectState { - /// Initializes an `ObjectState` from a `WireObjectState`. - init(wireObjectState: WireObjectState) { + /// 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: `InternalError` if JSON or Base64 decoding fails. + init( + wireObjectState: WireObjectState, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { objectId = wireObjectState.objectId siteTimeserials = wireObjectState.siteTimeserials tombstone = wireObjectState.tombstone - createOp = wireObjectState.createOp.map { .init(wireObjectOperation: $0) } - map = wireObjectState.map.map { .init(wireMap: $0) } + createOp = try wireObjectState.createOp.map { wireObjectOperation throws(InternalError) in + try .init(wireObjectOperation: wireObjectOperation, format: format) + } + map = try wireObjectState.map.map { wireMap throws(InternalError) in + try .init(wireMap: wireMap, format: format) + } counter = wireObjectState.counter } - /// Converts this `ObjectState` to a `WireObjectState`. - func toWire() -> WireObjectState { + /// 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: AblyPlugin.EncodingFormat) -> WireObjectState { .init( objectId: objectId, siteTimeserials: siteTimeserials, tombstone: tombstone, - createOp: createOp?.toWire(), - map: map?.toWire(), + createOp: createOp?.toWire(format: format), + map: map?.toWire(format: format), counter: counter, ) } diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index b53228f6b..482c0357e 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -158,8 +158,7 @@ internal struct WireObjectOperation { internal var map: WireMap? // OOP3e internal var counter: WireCounter? // OOP3f internal var nonce: String? // OOP3g - // TODO: Not yet clear how to encode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 - internal var initialValue: Data? // OOP3h + internal var initialValue: StringOrData? // OOP3h internal var initialValueEncoding: String? // OOP3i } @@ -213,6 +212,9 @@ extension WireObjectOperation: WireObjectCodable { if let nonce { result[WireKey.nonce.rawValue] = .string(nonce) } + if let initialValue { + result[WireKey.initialValue.rawValue] = initialValue.toWireValue + } if let initialValueEncoding { result[WireKey.initialValueEncoding.rawValue] = .string(initialValueEncoding) } @@ -419,8 +421,7 @@ internal struct WireObjectData { internal var objectId: String? // OD2a internal var encoding: String? // OD2b internal var boolean: Bool? // OD2c - // TODO: Not yet clear how to encode / decode this property; I assume it will be properly specified later. Do in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/12 - internal var bytes: Data? // OD2d + internal var bytes: StringOrData? // OD2d internal var number: NSNumber? // OD2e internal var string: String? // OD2f } @@ -439,6 +440,7 @@ extension WireObjectData: WireObjectCodable { objectId = try wireObject.optionalStringValueForKey(WireKey.objectId.rawValue) encoding = try wireObject.optionalStringValueForKey(WireKey.encoding.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) } @@ -455,6 +457,9 @@ extension WireObjectData: WireObjectCodable { 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) } @@ -465,3 +470,39 @@ extension WireObjectData: WireObjectCodable { 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 +/// - the values that `WireObjectOperation.initialValue` might hold, after being encoded per OOP5 +internal enum StringOrData: 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(InternalError) { + self = switch wireValue { + case let .string(string): + .string(string) + case let .data(data): + .data(data) + default: + throw DecodingError.unsupportedValue(wireValue).toInternalError() + } + } + + internal var toWireValue: WireValue { + switch self { + case let .string(string): + .string(string) + case let .data(data): + .data(data) + } + } +} diff --git a/Sources/AblyLiveObjects/Utility/Data+Extensions.swift b/Sources/AblyLiveObjects/Utility/Data+Extensions.swift new file mode 100644 index 000000000..7143a329d --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/Data+Extensions.swift @@ -0,0 +1,19 @@ +import Ably +import Foundation + +/// Errors that can occur during decoding operations. +internal enum DecodingError: Error, Equatable { + case invalidBase64String(String) +} + +internal extension Data { + /// Initialize Data from a Base64-encoded string, throwing an error if decoding fails. + /// - Parameter base64String: The Base64-encoded string to decode + /// - Throws: `DecodingError.invalidBase64String` if the string cannot be decoded as Base64 + static func fromBase64Throwing(_ base64String: String) throws(InternalError) -> Data { + guard let data = Data(base64Encoded: base64String) else { + throw DecodingError.invalidBase64String(base64String).toInternalError() + } + return data + } +} diff --git a/Tests/AblyLiveObjectsTests/.swiftformat b/Tests/AblyLiveObjectsTests/.swiftformat new file mode 100644 index 000000000..b7ca949e2 --- /dev/null +++ b/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/Tests/AblyLiveObjectsTests/.swiftlint.yml b/Tests/AblyLiveObjectsTests/.swiftlint.yml new file mode 100644 index 000000000..d7c5fc369 --- /dev/null +++ b/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/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift new file mode 100644 index 000000000..7a614f470 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift @@ -0,0 +1,568 @@ +@testable import AblyLiveObjects +import AblyPlugin +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 + // @specOneOf(1/8) OD4b + @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) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4c2 + // @specOneOf(2/8) OD4b + @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) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4c3 + // @specOneOf(3/8) OD4b + @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) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4c4 + // @specOneOf(4/8) OD4b + @Test + func string() { + let testString = "hello world" + let objectData = ObjectData(string: .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) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4c5 + @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(string: .json(jsonObjectOrArray)) + let wireData = objectData.toWire(format: .messagePack) + + // OD4c5: A payload consisting of a JSON-encodable object or array is stringified as a JSON object or array, represented as a JSON string and the result is set on the ObjectData.string attribute. The ObjectData.encoding attribute is then set to "json" + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == expectedJSONString) + #expect(wireData.encoding == "json") + } + } + + struct JSONTests { + // @spec OD4d1 + // @specOneOf(5/8) OD4b + @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) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4d2 + // @specOneOf(6/8) OD4b + @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) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4d3 + // @specOneOf(7/8) OD4b + @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) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4d4 + // @specOneOf(8/8) OD4b + @Test + func string() { + let testString = "hello world" + let objectData = ObjectData(string: .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) + // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d + #expect(wireData.encoding == nil) + } + + // @spec OD4d5 + @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(string: .json(jsonObjectOrArray)) + let wireData = objectData.toWire(format: .json) + + // OD4d5: A payload consisting of a JSON-encodable object or array is stringified as a JSON object or array, represented as a JSON string and the result is set on the ObjectData.string attribute. The ObjectData.encoding attribute is then set to "json" + #expect(wireData.boolean == nil) + #expect(wireData.bytes == nil) + #expect(wireData.number == nil) + #expect(wireData.string == expectedJSONString) + #expect(wireData.encoding == "json") + } + } + } + + 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) + } + + // @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) + } + + // @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) + } + + // @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) + } + + // @specOneOf(5/5) OD5a1 - This spec point is a bit weirdly worded, but here we're testing the case where `encoding` is not set and hence OD5a2 does not apply to the `string` property + @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) + switch objectData.string { + case let .string(str): + #expect(str == testString) + default: + Issue.record("Expected .string case") + } + } + + // @specOneOf(1/3) OD5a2 + @Test + func json() throws { + let jsonString = "{\"key\":\"value\",\"number\":123}" + let wireData = WireObjectData(encoding: "json", string: jsonString) + let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) + + // OD5a2: If ObjectData.encoding is set to "json", the ObjectData.string content is decoded by parsing the string as JSON + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + switch objectData.string { + case let .json(jsonValue): + #expect(jsonValue == ["key": "value", "number": 123]) + default: + Issue.record("Expected .json case") + } + } + + // @specOneOf(2/3) OD5a2 - 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(encoding: "json", string: invalidJsonString) + + // Should throw when JSON parsing fails, even in MessagePack format + #expect(throws: InternalError.self) { + _ = try ObjectData(wireObjectData: wireData, format: .messagePack) + } + } + + // @specOneOf(3/3) OD5a2 - 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(encoding: "json", string: jsonString) + + // Should throw when JSON is valid but not an object or array + #expect(throws: InternalError.self) { + _ = try ObjectData(wireObjectData: wireData, format: .messagePack) + } + } + } + + struct JSONTests { + // @specOneOf(1/3) OD5b1 - This spec point is a bit weirdly worded, but here we're testing the case where `encoding` is not set and hence OD5a2 does not apply to the `string` property + @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) + } + + // @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) + } + + // @specOneOf(3/3) OD5b1 - This spec point is a bit weirdly worded, but here we're testing the case where `encoding` is not set and hence OD5a3 does not apply to the `string` property + @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) + switch objectData.string { + case let .string(str): + #expect(str == testString) + default: + Issue.record("Expected .string case") + } + } + + // @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) + } + + // @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: InternalError.self) { + _ = try ObjectData(wireObjectData: wireData, format: .json) + } + } + + // @specOneOf(1/3) OD5b3 + @Test + func json() throws { + let jsonString = "{\"key\":\"value\",\"number\":123}" + let wireData = WireObjectData(encoding: "json", string: jsonString) + let objectData = try ObjectData(wireObjectData: wireData, format: .json) + + // OD5b3: If ObjectData.encoding is set to "json", the ObjectData.string content is decoded by parsing the string as JSON + #expect(objectData.boolean == nil) + #expect(objectData.bytes == nil) + #expect(objectData.number == nil) + switch objectData.string { + case let .json(jsonValue): + #expect(jsonValue == ["key": "value", "number": 123]) + default: + Issue.record("Expected .json case") + } + } + + // @specOneOf(2/3) OD5b3 - 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(encoding: "json", string: invalidJsonString) + + // Should throw when JSON parsing fails + #expect(throws: InternalError.self) { + _ = try ObjectData(wireObjectData: wireData, format: .json) + } + } + + // @specOneOf(3/3) OD5b3 - 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(encoding: "json", string: jsonString) + + // Should throw when JSON is valid but not an object or array + #expect(throws: InternalError.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: .string("hello world")), + ObjectData(string: .json(["key": "value", "number": 123])), + ObjectData(string: .json([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, handling both .string and .json cases + switch (decodedData.string, originalData.string) { + case (.none, .none): + break + case let (.string(decoded), .string(original)): + #expect(decoded == original) + case let (.json(decoded), .json(original)): + #expect(decoded == original) + default: + Issue.record("String cases did not match") + } + } + } + + struct ObjectOperationTests { + struct EncodingTests { + @Test(arguments: [EncodingFormat.json.rawValue, EncodingFormat.messagePack.rawValue]) + func noInitialValue(formatRawValue: EncodingFormat.RawValue) throws { + let format = try #require(EncodingFormat(rawValue: formatRawValue)) + let operation = ObjectOperation( + action: .known(.mapSet), + objectId: "test-id", + ) + let wireOperation = operation.toWire(format: format) + + #expect(wireOperation.initialValue == nil) + #expect(wireOperation.initialValueEncoding == nil) + } + + struct MessagePackTests { + // @spec OOP5a1 + // @spec OOP5a2 + @Test + func initialValue() { + let testData = Data([1, 2, 3, 4]) + let operation = ObjectOperation( + action: .known(.mapSet), + objectId: "test-id", + initialValue: testData, + ) + let wireOperation = operation.toWire(format: .messagePack) + + // OOP5a1: A binary ObjectOperation.initialValue is encoded as a MessagePack binary type + switch wireOperation.initialValue { + case let .data(data): + #expect(data == testData) + default: + Issue.record("Expected .data case") + } + // OOP5a2: Set ObjectOperation.initialValueEncoding to msgpack + #expect(wireOperation.initialValueEncoding == "msgpack") + } + } + + struct JSONTests { + // @spec OOP5b1 + // @spec OOP5b2 + @Test + func initialValue() { + let testData = Data([1, 2, 3, 4]) + let operation = ObjectOperation( + action: .known(.mapSet), + objectId: "test-id", + initialValue: testData, + ) + let wireOperation = operation.toWire(format: .json) + + // OOP5b1: A binary ObjectOperation.initialValue is Base64-encoded and represented as a JSON string + switch wireOperation.initialValue { + case let .string(base64String): + #expect(base64String == testData.base64EncodedString()) + default: + Issue.record("Expected .string case") + } + // OOP5b2: Set ObjectOperation.initialValueEncoding to json + #expect(wireOperation.initialValueEncoding == "json") + } + } + } + } +} From 5259c23d1465d92dd2b88068c99ea301c5793d92 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 24 Jun 2025 10:56:17 -0300 Subject: [PATCH 021/225] Rename DefaultLiveObjects to DefaultObjects To align it with the name of the protocol. Mistake in 4128844. --- .../{DefaultLiveObjects.swift => DefaultObjects.swift} | 2 +- .../AblyLiveObjects/Internal/DefaultInternalPlugin.swift | 8 ++++---- .../Public/ARTRealtimeChannel+Objects.swift | 6 +++--- Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) rename Sources/AblyLiveObjects/{DefaultLiveObjects.swift => DefaultObjects.swift} (98%) diff --git a/Sources/AblyLiveObjects/DefaultLiveObjects.swift b/Sources/AblyLiveObjects/DefaultObjects.swift similarity index 98% rename from Sources/AblyLiveObjects/DefaultLiveObjects.swift rename to Sources/AblyLiveObjects/DefaultObjects.swift index 117ce6787..6302bfee4 100644 --- a/Sources/AblyLiveObjects/DefaultLiveObjects.swift +++ b/Sources/AblyLiveObjects/DefaultObjects.swift @@ -2,7 +2,7 @@ import Ably internal import AblyPlugin /// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. -internal class DefaultLiveObjects: Objects { +internal class DefaultObjects: Objects { private weak var channel: ARTRealtimeChannel? private let logger: AblyPlugin.Logger private let pluginAPI: AblyPlugin.PluginAPIProtocol diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 21b59fa1b..67dc5af87 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -20,14 +20,14 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte /// Retrieves the value that should be returned by `ARTRealtimeChannel.objects`. /// /// We expect this value to have been previously set by ``prepare(_:)``. - internal static func objectsProperty(for channel: ARTRealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultLiveObjects { + internal static func objectsProperty(for channel: ARTRealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultObjects { guard let pluginData = pluginAPI.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! DefaultLiveObjects + return pluginData as! DefaultObjects } // MARK: - LiveObjectsInternalPluginProtocol @@ -37,12 +37,12 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte let logger = pluginAPI.logger(for: channel) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) - let liveObjects = DefaultLiveObjects(channel: channel, logger: logger, pluginAPI: pluginAPI) + let liveObjects = DefaultObjects(channel: channel, logger: logger, pluginAPI: pluginAPI) pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } /// Retrieves the internally-typed `objects` property for the channel. - private func objectsProperty(for channel: ARTRealtimeChannel) -> DefaultLiveObjects { + private func objectsProperty(for channel: ARTRealtimeChannel) -> DefaultObjects { Self.objectsProperty(for: channel, pluginAPI: pluginAPI) } diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index 155290e65..f45b151bf 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -7,12 +7,12 @@ public extension ARTRealtimeChannel { internallyTypedObjects } - private var internallyTypedObjects: DefaultLiveObjects { + private var internallyTypedObjects: DefaultObjects { DefaultInternalPlugin.objectsProperty(for: self, pluginAPI: AblyPlugin.PluginAPI.sharedInstance()) } - /// For tests to access the non-public API of `DefaultLiveObjects`. - internal var testsOnly_internallyTypedObjects: DefaultLiveObjects { + /// For tests to access the non-public API of `DefaultObjects`. + internal var testsOnly_internallyTypedObjects: DefaultObjects { internallyTypedObjects } } diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index c999a22d9..6ef0db20d 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -20,7 +20,7 @@ struct AblyLiveObjectsTests { // Then // Check that the `channel.objects` property works and gives the internal type we expect - #expect(channel.objects is DefaultLiveObjects) + #expect(channel.objects is DefaultObjects) } /// A basic test of the core interactions between this plugin and ably-cocoa. From 3bb38f34aa9f6d2dd6d78e7f4871d2ad6da34038 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 24 Jun 2025 10:55:00 -0300 Subject: [PATCH 022/225] Rename Objects to RealtimeObjects Per [1] and [2]. Resolves #15. [1] https://github.com/ably/specification/pull/332 [2] https://github.com/ably/ably-js/pull/2043 --- ...faultObjects.swift => DefaultRealtimeObjects.swift} | 4 ++-- .../Internal/DefaultInternalPlugin.swift | 8 ++++---- .../Public/ARTRealtimeChannel+Objects.swift | 10 +++++----- Sources/AblyLiveObjects/Public/PublicTypes.swift | 10 +++++----- Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift | 2 +- 5 files changed, 17 insertions(+), 17 deletions(-) rename Sources/AblyLiveObjects/{DefaultObjects.swift => DefaultRealtimeObjects.swift} (97%) diff --git a/Sources/AblyLiveObjects/DefaultObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift similarity index 97% rename from Sources/AblyLiveObjects/DefaultObjects.swift rename to Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 6302bfee4..f96cb0e61 100644 --- a/Sources/AblyLiveObjects/DefaultObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -2,7 +2,7 @@ import Ably internal import AblyPlugin /// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. -internal class DefaultObjects: Objects { +internal class DefaultRealtimeObjects: RealtimeObjects { private weak var channel: ARTRealtimeChannel? private let logger: AblyPlugin.Logger private let pluginAPI: AblyPlugin.PluginAPIProtocol @@ -21,7 +21,7 @@ internal class DefaultObjects: Objects { (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() } - // MARK: `Objects` protocol + // MARK: `RealtimeObjects` protocol internal func getRoot() async throws(ARTErrorInfo) -> any LiveMap { notYetImplemented() diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 67dc5af87..61b316a43 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -20,14 +20,14 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte /// Retrieves the value that should be returned by `ARTRealtimeChannel.objects`. /// /// We expect this value to have been previously set by ``prepare(_:)``. - internal static func objectsProperty(for channel: ARTRealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultObjects { + internal static func objectsProperty(for channel: ARTRealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultRealtimeObjects { guard let pluginData = pluginAPI.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! DefaultObjects + return pluginData as! DefaultRealtimeObjects } // MARK: - LiveObjectsInternalPluginProtocol @@ -37,12 +37,12 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte let logger = pluginAPI.logger(for: channel) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) - let liveObjects = DefaultObjects(channel: channel, logger: logger, pluginAPI: pluginAPI) + let liveObjects = DefaultRealtimeObjects(channel: channel, logger: logger, pluginAPI: pluginAPI) pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } /// Retrieves the internally-typed `objects` property for the channel. - private func objectsProperty(for channel: ARTRealtimeChannel) -> DefaultObjects { + private func objectsProperty(for channel: ARTRealtimeChannel) -> DefaultRealtimeObjects { Self.objectsProperty(for: channel, pluginAPI: pluginAPI) } diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index f45b151bf..2df0f68e1 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -2,17 +2,17 @@ import Ably internal import AblyPlugin public extension ARTRealtimeChannel { - /// An ``Objects`` object. - var objects: Objects { + /// A ``RealtimeObjects`` object. + var objects: RealtimeObjects { internallyTypedObjects } - private var internallyTypedObjects: DefaultObjects { + private var internallyTypedObjects: DefaultRealtimeObjects { DefaultInternalPlugin.objectsProperty(for: self, pluginAPI: AblyPlugin.PluginAPI.sharedInstance()) } - /// For tests to access the non-public API of `DefaultObjects`. - internal var testsOnly_internallyTypedObjects: DefaultObjects { + /// For tests to access the non-public API of `DefaultRealtimeObjects`. + internal var testsOnly_internallyTypedObjects: DefaultRealtimeObjects { internallyTypedObjects } } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 74054837d..149536b24 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -5,18 +5,18 @@ import Ably /// - Parameter update: The update object describing the changes made to the object. public typealias LiveObjectUpdateCallback = (_ update: T) -> Void -/// The callback used for the events emitted by ``Objects``. +/// The callback used for the events emitted by ``RealtimeObjects``. public typealias ObjectsEventCallback = () -> Void /// The callback used for the lifecycle events emitted by ``LiveObject``. public typealias LiveObjectLifecycleEventCallback = () -> Void -/// A function passed to ``Objects/batch(callback:)`` to group multiple Objects operations into a single channel message. +/// A function passed to ``RealtimeObjects/batch(callback:)`` to group multiple Objects operations into a single channel message. /// /// - Parameter batchContext: A ``BatchContext`` object that allows grouping Objects operations for this batch. public typealias BatchCallback = (_ batchContext: BatchContext) -> Void -/// Describes the events emitted by an ``Objects`` object. +/// Describes the events emitted by an ``RealtimeObjects`` object. public enum ObjectsEvent { /// The local copy of Objects on a channel is currently being synchronized with the Ably service. case syncing @@ -31,7 +31,7 @@ public enum LiveObjectLifecycleEvent { } /// Enables the Objects to be read, modified and subscribed to for a channel. -public protocol Objects { +public protocol RealtimeObjects { /// Retrieves the root ``LiveMap`` object for Objects on a channel. func getRoot() async throws(ARTErrorInfo) -> any LiveMap @@ -91,7 +91,7 @@ public protocol OnObjectsEventResponse { /// Enables grouping multiple Objects operations together by providing `BatchContext*` wrapper objects. public protocol BatchContext { - /// Mirrors the ``Objects/getRoot()`` method and returns a ``BatchContextLiveMap`` wrapper for the root object on a channel. + /// Mirrors the ``RealtimeObjects/getRoot()`` method and returns a ``BatchContextLiveMap`` wrapper for the root object on a channel. /// /// - Returns: A ``BatchContextLiveMap`` object. func getRoot() -> BatchContextLiveMap diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index 6ef0db20d..6b89d54b5 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -20,7 +20,7 @@ struct AblyLiveObjectsTests { // Then // Check that the `channel.objects` property works and gives the internal type we expect - #expect(channel.objects is DefaultObjects) + #expect(channel.objects is DefaultRealtimeObjects) } /// A basic test of the core interactions between this plugin and ably-cocoa. From 080be1e56aeb40905e40d30c0530ad0a93d13a5c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 24 Jun 2025 11:18:45 -0300 Subject: [PATCH 023/225] Update internal types to have Object[s] prefix Per [1]. Resolves #16. [1] https://github.com/ably/specification/pull/328. --- .../Protocol/ObjectMessage.swift | 88 +++++++++---------- .../Protocol/WireObjectMessage.swift | 56 ++++++------ .../WireObjectMessageTests.swift | 58 ++++++------ 3 files changed, 101 insertions(+), 101 deletions(-) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index a98dc0493..a15ea0db9 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -32,10 +32,10 @@ internal struct OutboundObjectMessage { internal struct ObjectOperation { internal var action: WireEnum // OOP3a internal var objectId: String // OOP3b - internal var mapOp: MapOp? // OOP3c - internal var counterOp: WireCounterOp? // OOP3d - internal var map: Map? // OOP3e - internal var counter: WireCounter? // OOP3f + internal var mapOp: ObjectsMapOp? // OOP3c + internal var counterOp: WireObjectsCounterOp? // OOP3d + internal var map: ObjectsMap? // OOP3e + internal var counter: WireObjectsCounter? // OOP3f internal var nonce: String? // OOP3g internal var initialValue: Data? // OOP3h internal var initialValueEncoding: String? // OOP3i @@ -56,20 +56,20 @@ internal struct ObjectData { internal var string: StringPropertyContent? // OD2f } -internal struct MapOp { - internal var key: String // MOP2a - internal var data: ObjectData? // MOP2b +internal struct ObjectsMapOp { + internal var key: String // OMO2a + internal var data: ObjectData? // OMO2b } -internal struct MapEntry { - internal var tombstone: Bool? // ME2a - internal var timeserial: String? // ME2b - internal var data: ObjectData // ME2c +internal struct ObjectsMapEntry { + internal var tombstone: Bool? // OME2a + internal var timeserial: String? // OME2b + internal var data: ObjectData // OME2c } -internal struct Map { - internal var semantics: WireEnum // MAP3a - internal var entries: [String: MapEntry]? // MAP3b +internal struct ObjectsMap { + internal var semantics: WireEnum // OMP3a + internal var entries: [String: ObjectsMapEntry]? // OMP3b } internal struct ObjectState { @@ -77,8 +77,8 @@ internal struct ObjectState { internal var siteTimeserials: [String: String] // OST2b internal var tombstone: Bool // OST2c internal var createOp: ObjectOperation? // OST2d - internal var map: Map? // OST2e - internal var counter: WireCounter? // OST2f + internal var map: ObjectsMap? // OST2e + internal var counter: WireObjectsCounter? // OST2f } internal extension InboundObjectMessage { @@ -139,12 +139,12 @@ internal extension ObjectOperation { ) throws(InternalError) { action = wireObjectOperation.action objectId = wireObjectOperation.objectId - mapOp = try wireObjectOperation.mapOp.map { wireMapOp throws(InternalError) in - try .init(wireMapOp: wireMapOp, format: format) + mapOp = try wireObjectOperation.mapOp.map { wireObjectsMapOp throws(InternalError) in + try .init(wireObjectsMapOp: wireObjectsMapOp, format: format) } counterOp = wireObjectOperation.counterOp map = try wireObjectOperation.map.map { wireMap throws(InternalError) in - try .init(wireMap: wireMap, format: format) + try .init(wireObjectsMap: wireMap, format: format) } counter = wireObjectOperation.counter @@ -319,27 +319,27 @@ internal extension ObjectData { } } -internal extension MapOp { - /// Initializes a `MapOp` from a `WireMapOp`, applying the data decoding rules of OD5. +internal extension ObjectsMapOp { + /// Initializes a `ObjectsMapOp` from a `WireObjectsMapOp`, applying the data decoding rules of OD5. /// /// - Parameters: /// - format: The format to use when applying the decoding rules of OD5. /// - Throws: `InternalError` if JSON or Base64 decoding fails. init( - wireMapOp: WireMapOp, + wireObjectsMapOp: WireObjectsMapOp, format: AblyPlugin.EncodingFormat ) throws(InternalError) { - key = wireMapOp.key - data = try wireMapOp.data.map { wireObjectData throws(InternalError) in + key = wireObjectsMapOp.key + data = try wireObjectsMapOp.data.map { wireObjectData throws(InternalError) in try .init(wireObjectData: wireObjectData, format: format) } } - /// Converts this `MapOp` to a `WireMapOp`, applying the data encoding rules of OD4. + /// Converts this `ObjectsMapOp` to a `WireObjectsMapOp`, applying the data encoding rules of OD4. /// /// - Parameters: /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: AblyPlugin.EncodingFormat) -> WireMapOp { + func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectsMapOp { .init( key: key, data: data?.toWire(format: format), @@ -347,26 +347,26 @@ internal extension MapOp { } } -internal extension MapEntry { - /// Initializes a `MapEntry` from a `WireMapEntry`, applying the data decoding rules of OD5. +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: `InternalError` if JSON or Base64 decoding fails. init( - wireMapEntry: WireMapEntry, + wireObjectsMapEntry: WireObjectsMapEntry, format: AblyPlugin.EncodingFormat ) throws(InternalError) { - tombstone = wireMapEntry.tombstone - timeserial = wireMapEntry.timeserial - data = try .init(wireObjectData: wireMapEntry.data, format: format) + tombstone = wireObjectsMapEntry.tombstone + timeserial = wireObjectsMapEntry.timeserial + data = try .init(wireObjectData: wireObjectsMapEntry.data, format: format) } - /// Converts this `MapEntry` to a `WireMapEntry`, applying the data encoding rules of OD4. + /// 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: AblyPlugin.EncodingFormat) -> WireMapEntry { + func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectsMapEntry { .init( tombstone: tombstone, timeserial: timeserial, @@ -375,27 +375,27 @@ internal extension MapEntry { } } -internal extension Map { - /// Initializes a `Map` from a `WireMap`, applying the data decoding rules of OD5. +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: `InternalError` if JSON or Base64 decoding fails. init( - wireMap: WireMap, + wireObjectsMap: WireObjectsMap, format: AblyPlugin.EncodingFormat ) throws(InternalError) { - semantics = wireMap.semantics - entries = try wireMap.entries?.ablyLiveObjects_mapValuesWithTypedThrow { wireMapEntry throws(InternalError) in - try .init(wireMapEntry: wireMapEntry, format: format) + semantics = wireObjectsMap.semantics + entries = try wireObjectsMap.entries?.ablyLiveObjects_mapValuesWithTypedThrow { wireMapEntry throws(InternalError) in + try .init(wireObjectsMapEntry: wireMapEntry, format: format) } } - /// Converts this `Map` to a `WireMap`, applying the data encoding rules of OD4. + /// 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: AblyPlugin.EncodingFormat) -> WireMap { + func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectsMap { .init( semantics: semantics, entries: entries?.mapValues { $0.toWire(format: format) }, @@ -419,8 +419,8 @@ internal extension ObjectState { createOp = try wireObjectState.createOp.map { wireObjectOperation throws(InternalError) in try .init(wireObjectOperation: wireObjectOperation, format: format) } - map = try wireObjectState.map.map { wireMap throws(InternalError) in - try .init(wireMap: wireMap, format: format) + map = try wireObjectState.map.map { wireObjectsMap throws(InternalError) in + try .init(wireObjectsMap: wireObjectsMap, format: format) } counter = wireObjectState.counter } diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 482c0357e..348cc4836 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -145,18 +145,18 @@ internal enum ObjectOperationAction: Int { case objectDelete = 5 } -// MAP2 -internal enum MapSemantics: Int { +// OMP2 +internal enum ObjectsMapSemantics: Int { case lww = 0 } internal struct WireObjectOperation { internal var action: WireEnum // OOP3a internal var objectId: String // OOP3b - internal var mapOp: WireMapOp? // OOP3c - internal var counterOp: WireCounterOp? // OOP3d - internal var map: WireMap? // OOP3e - internal var counter: WireCounter? // OOP3f + internal var mapOp: WireObjectsMapOp? // OOP3c + internal var counterOp: WireObjectsCounterOp? // OOP3d + internal var map: WireObjectsMap? // OOP3e + internal var counter: WireObjectsCounter? // OOP3f internal var nonce: String? // OOP3g internal var initialValue: StringOrData? // OOP3h internal var initialValueEncoding: String? // OOP3i @@ -228,8 +228,8 @@ internal struct WireObjectState { internal var siteTimeserials: [String: String] // OST2b internal var tombstone: Bool // OST2c internal var createOp: WireObjectOperation? // OST2d - internal var map: WireMap? // OST2e - internal var counter: WireCounter? // OST2f + internal var map: WireObjectsMap? // OST2e + internal var counter: WireObjectsCounter? // OST2f } extension WireObjectState: WireObjectCodable { @@ -277,12 +277,12 @@ extension WireObjectState: WireObjectCodable { } } -internal struct WireMapOp { - internal var key: String // MOP2a - internal var data: WireObjectData? // MOP2b +internal struct WireObjectsMapOp { + internal var key: String // OMO2a + internal var data: WireObjectData? // OMO2b } -extension WireMapOp: WireObjectCodable { +extension WireObjectsMapOp: WireObjectCodable { internal enum WireKey: String { case key case data @@ -306,11 +306,11 @@ extension WireMapOp: WireObjectCodable { } } -internal struct WireCounterOp { - internal var amount: NSNumber // COP2a +internal struct WireObjectsCounterOp { + internal var amount: NSNumber // OCO2a } -extension WireCounterOp: WireObjectCodable { +extension WireObjectsCounterOp: WireObjectCodable { internal enum WireKey: String { case amount } @@ -326,12 +326,12 @@ extension WireCounterOp: WireObjectCodable { } } -internal struct WireMap { - internal var semantics: WireEnum // MAP3a - internal var entries: [String: WireMapEntry]? // MAP3b +internal struct WireObjectsMap { + internal var semantics: WireEnum // OMP3a + internal var entries: [String: WireObjectsMapEntry]? // OMP3b } -extension WireMap: WireObjectCodable { +extension WireObjectsMap: WireObjectCodable { internal enum WireKey: String { case semantics case entries @@ -343,7 +343,7 @@ extension WireMap: WireObjectCodable { guard case let .object(object) = value else { throw WireValueDecodingError.wrongTypeForKey(WireKey.entries.rawValue, actualValue: value).toInternalError() } - return try WireMapEntry(wireObject: object) + return try WireObjectsMapEntry(wireObject: object) } } @@ -360,11 +360,11 @@ extension WireMap: WireObjectCodable { } } -internal struct WireCounter { - internal var count: NSNumber? // CNT2a +internal struct WireObjectsCounter { + internal var count: NSNumber? // OCN2a } -extension WireCounter: WireObjectCodable { +extension WireObjectsCounter: WireObjectCodable { internal enum WireKey: String { case count } @@ -382,13 +382,13 @@ extension WireCounter: WireObjectCodable { } } -internal struct WireMapEntry { - internal var tombstone: Bool? // ME2a - internal var timeserial: String? // ME2b - internal var data: WireObjectData // ME2c +internal struct WireObjectsMapEntry { + internal var tombstone: Bool? // OME2a + internal var timeserial: String? // OME2b + internal var data: WireObjectData // OME2c } -extension WireMapEntry: WireObjectCodable { +extension WireObjectsMapEntry: WireObjectCodable { internal enum WireKey: String { case tombstone case timeserial diff --git a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift index bf2081683..4b77924bd 100644 --- a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -227,13 +227,13 @@ enum WireObjectMessageTests { let op = WireObjectOperation( action: .known(.mapCreate), objectId: "obj1", - mapOp: WireMapOp(key: "key1", data: WireObjectData(string: "value1")), - counterOp: WireCounterOp(amount: 42), - map: WireMap( + mapOp: WireObjectsMapOp(key: "key1", data: WireObjectData(string: "value1")), + counterOp: WireObjectsCounterOp(amount: 42), + map: WireObjectsMap( semantics: .known(.lww), - entries: ["key1": WireMapEntry(tombstone: false, timeserial: nil, data: WireObjectData(string: "value1"))], + entries: ["key1": WireObjectsMapEntry(tombstone: false, timeserial: nil, data: WireObjectData(string: "value1"))], ), - counter: WireCounter(count: 42), + counter: WireObjectsCounter(count: 42), nonce: "nonce1", initialValue: nil, initialValueEncoding: "utf8", @@ -328,11 +328,11 @@ enum WireObjectMessageTests { initialValue: nil, initialValueEncoding: nil, ), - map: WireMap( + map: WireObjectsMap( semantics: .known(.lww), - entries: ["key1": WireMapEntry(tombstone: false, timeserial: nil, data: WireObjectData(string: "value1"))], + entries: ["key1": WireObjectsMapEntry(tombstone: false, timeserial: nil, data: WireObjectData(string: "value1"))], ), - counter: WireCounter(count: 42), + counter: WireObjectsCounter(count: 42), ) let wire = state.toWireObject #expect(wire == [ @@ -436,7 +436,7 @@ enum WireObjectMessageTests { "key": "key1", "data": ["string": "value1"], ] - let op = try WireMapOp(wireObject: json) + let op = try WireObjectsMapOp(wireObject: json) #expect(op.key == "key1") #expect(op.data?.string == "value1") } @@ -444,14 +444,14 @@ enum WireObjectMessageTests { @Test func decodesWithOptionalFieldsAbsent() throws { let json: [String: WireValue] = ["key": "key1"] - let op = try WireMapOp(wireObject: json) + let op = try WireObjectsMapOp(wireObject: json) #expect(op.key == "key1") #expect(op.data == nil) } @Test func encodesAllFields() { - let op = WireMapOp( + let op = WireObjectsMapOp( key: "key1", data: WireObjectData(string: "value1"), ) @@ -464,7 +464,7 @@ enum WireObjectMessageTests { @Test func encodesWithOptionalFieldsNil() { - let op = WireMapOp( + let op = WireObjectsMapOp( key: "key1", data: nil, ) @@ -479,13 +479,13 @@ enum WireObjectMessageTests { @Test func decodesAllFields() throws { let json: [String: WireValue] = ["amount": 42] - let op = try WireCounterOp(wireObject: json) + let op = try WireObjectsCounterOp(wireObject: json) #expect(op.amount == 42) } @Test func encodesAllFields() { - let op = WireCounterOp(amount: 42) + let op = WireObjectsCounterOp(amount: 42) let wire = op.toWireObject #expect(wire == ["amount": 42]) } @@ -501,7 +501,7 @@ enum WireObjectMessageTests { "key2": ["data": ["string": "value2"], "tombstone": true], ], ] - let map = try WireMap(wireObject: json) + let map = try WireObjectsMap(wireObject: json) #expect(map.semantics == .known(.lww)) #expect(map.entries?["key1"]?.data.string == "value1") #expect(map.entries?["key1"]?.tombstone == false) @@ -514,7 +514,7 @@ enum WireObjectMessageTests { @Test func decodesWithOptionalFieldsAbsent() throws { let json: [String: WireValue] = ["semantics": 0] - let map = try WireMap(wireObject: json) + let map = try WireObjectsMap(wireObject: json) #expect(map.semantics == .known(.lww)) #expect(map.entries == nil) } @@ -524,17 +524,17 @@ enum WireObjectMessageTests { let json: [String: WireValue] = [ "semantics": 999, // Unknown MapSemantics ] - let map = try WireMap(wireObject: json) + let map = try WireObjectsMap(wireObject: json) #expect(map.semantics == .unknown(999)) } @Test func encodesAllFields() { - let map = WireMap( + let map = WireObjectsMap( semantics: .known(.lww), entries: [ - "key1": WireMapEntry(tombstone: false, timeserial: "ts1", data: WireObjectData(string: "value1")), - "key2": WireMapEntry(tombstone: true, timeserial: nil, data: WireObjectData(string: "value2")), + "key1": WireObjectsMapEntry(tombstone: false, timeserial: "ts1", data: WireObjectData(string: "value1")), + "key2": WireObjectsMapEntry(tombstone: true, timeserial: nil, data: WireObjectData(string: "value2")), ], ) let wire = map.toWireObject @@ -549,7 +549,7 @@ enum WireObjectMessageTests { @Test func encodesWithOptionalFieldsNil() { - let map = WireMap( + let map = WireObjectsMap( semantics: .known(.lww), entries: nil, ) @@ -564,27 +564,27 @@ enum WireObjectMessageTests { @Test func decodesAllFields() throws { let json: [String: WireValue] = ["count": 42] - let counter = try WireCounter(wireObject: json) + let counter = try WireObjectsCounter(wireObject: json) #expect(counter.count == 42) } @Test func decodesWithOptionalFieldsAbsent() throws { let json: [String: WireValue] = [:] - let counter = try WireCounter(wireObject: json) + let counter = try WireObjectsCounter(wireObject: json) #expect(counter.count == nil) } @Test func encodesAllFields() { - let counter = WireCounter(count: 42) + let counter = WireObjectsCounter(count: 42) let wire = counter.toWireObject #expect(wire == ["count": 42]) } @Test func encodesWithOptionalFieldsNil() { - let counter = WireCounter(count: nil) + let counter = WireObjectsCounter(count: nil) let wire = counter.toWireObject #expect(wire.isEmpty) } @@ -598,7 +598,7 @@ enum WireObjectMessageTests { "tombstone": true, "timeserial": "ts1", ] - let entry = try WireMapEntry(wireObject: json) + let entry = try WireObjectsMapEntry(wireObject: json) #expect(entry.data.string == "value1") #expect(entry.tombstone == true) #expect(entry.timeserial == "ts1") @@ -607,7 +607,7 @@ enum WireObjectMessageTests { @Test func decodesWithOptionalFieldsAbsent() throws { let json: [String: WireValue] = ["data": ["string": "value1"]] - let entry = try WireMapEntry(wireObject: json) + let entry = try WireObjectsMapEntry(wireObject: json) #expect(entry.data.string == "value1") #expect(entry.tombstone == nil) #expect(entry.timeserial == nil) @@ -615,7 +615,7 @@ enum WireObjectMessageTests { @Test func encodesAllFields() { - let entry = WireMapEntry( + let entry = WireObjectsMapEntry( tombstone: true, timeserial: "ts1", data: WireObjectData(string: "value1"), @@ -630,7 +630,7 @@ enum WireObjectMessageTests { @Test func encodesWithOptionalFieldsNil() { - let entry = WireMapEntry( + let entry = WireObjectsMapEntry( tombstone: nil, timeserial: nil, data: WireObjectData(string: "value1"), From 5fdb9e1c92283aae15577417404cee85ea202884 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 25 Jun 2025 16:43:32 -0300 Subject: [PATCH 024/225] Update signature of handleObjectSyncProtocolMessage The accompanying ably-cocoa change requires it. --- Sources/AblyLiveObjects/DefaultRealtimeObjects.swift | 2 +- Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift | 2 +- ably-cocoa | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index f96cb0e61..03ef9981d 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -74,7 +74,7 @@ internal class DefaultRealtimeObjects: RealtimeObjects { receivedObjectSyncProtocolMessages } - internal func handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial _: String) { + internal func handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial _: String?) { receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) } diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 61b316a43..fcf975a92 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -109,7 +109,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte ) } - internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], protocolMessageChannelSerial: String, channel: ARTRealtimeChannel) { + internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], protocolMessageChannelSerial: String?, channel: ARTRealtimeChannel) { guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") } diff --git a/ably-cocoa b/ably-cocoa index 9b2bfe48b..4a21e87f9 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 9b2bfe48b4c8c070404957758869b8918e8eade3 +Subproject commit 4a21e87f988be4741f19d74f13cc4c040eb92121 From c53d880e23f87d3cbaffa8f3c49fafce70d1323d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 26 Jun 2025 11:38:02 -0300 Subject: [PATCH 025/225] Add a logger to use in tests --- .../Helpers/TestLogger.swift | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift b/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift new file mode 100644 index 000000000..59c51e17f --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift @@ -0,0 +1,40 @@ +import AblyPlugin +import os + +/// An implementation of `AblyPlugin.Logger` to use when testing internal components of the LiveObjects plugin. +final class TestLogger: NSObject, AblyPlugin.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, with level: ARTLogLevel, file fileName: UnsafePointer, line: Int) { + guard Self.loggingEnabled else { + return + } + + underlyingLogger.log(level: level.toOSLogType, "(\(String(cString: fileName)):\(line)): \(message)") + } +} + +private extension ARTLogLevel { + 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 + } + } +} From 7d7a5632a3e53abf3a49adcd4d1377fcb884d84a Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 27 Jun 2025 11:13:17 -0300 Subject: [PATCH 026/225] Add concurrency annotations to the public API Mark most of its types as Sendable, and callbacks as `sending`. I was going to defer this until later but when I started writing integration tests I realised that it's quite hard to actually use the SDK without any sort of concurrency annotations (many basic things that you try and do with it trigger a compiler error). I haven't added annotations to the BatchContext things yet (because they never leave the callback so may not need it); will address that once we need to test those APIs. Marking things as Sendable is consistent with the ably-cocoa public API, which I think is going to the approach that we take threading-wise (but will revisit in #3). --- .../DefaultRealtimeObjects.swift | 25 ++++++++--- .../AblyLiveObjects/Public/PublicTypes.swift | 44 +++++++++---------- Sources/AblyLiveObjects/Utility/WeakRef.swift | 8 ++++ 3 files changed, 48 insertions(+), 29 deletions(-) create mode 100644 Sources/AblyLiveObjects/Utility/WeakRef.swift diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 03ef9981d..bcf1aacc1 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -2,8 +2,11 @@ import Ably internal import AblyPlugin /// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. -internal class DefaultRealtimeObjects: RealtimeObjects { - private weak var channel: ARTRealtimeChannel? +internal final class DefaultRealtimeObjects: RealtimeObjects { + // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. + private let mutex = NSLock() + + private let channel: WeakRef private let logger: AblyPlugin.Logger private let pluginAPI: AblyPlugin.PluginAPIProtocol @@ -14,7 +17,7 @@ internal class DefaultRealtimeObjects: RealtimeObjects { private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation internal init(channel: ARTRealtimeChannel, logger: AblyPlugin.Logger, pluginAPI: AblyPlugin.PluginAPIProtocol) { - self.channel = channel + self.channel = .init(referenced: channel) self.logger = logger self.pluginAPI = pluginAPI (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() @@ -43,7 +46,7 @@ internal class DefaultRealtimeObjects: RealtimeObjects { notYetImplemented() } - internal func batch(callback _: (any BatchContext) -> Void) async throws { + internal func batch(callback _: sending (sending any BatchContext) -> Void) async throws { notYetImplemented() } @@ -57,9 +60,17 @@ internal class DefaultRealtimeObjects: RealtimeObjects { // MARK: Handling channel events - internal private(set) var testsOnly_onChannelAttachedHasObjects: Bool? + private nonisolated(unsafe) var onChannelAttachedHasObjects: Bool? + internal var testsOnly_onChannelAttachedHasObjects: Bool? { + mutex.withLock { + onChannelAttachedHasObjects + } + } + internal func onChannelAttached(hasObjects: Bool) { - testsOnly_onChannelAttachedHasObjects = hasObjects + mutex.withLock { + onChannelAttachedHasObjects = hasObjects + } } internal var testsOnly_receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> { @@ -82,7 +93,7 @@ internal class DefaultRealtimeObjects: RealtimeObjects { // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { - guard let channel else { + guard let channel = channel.referenced else { return } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 149536b24..15690e7d9 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -3,7 +3,7 @@ import Ably /// A callback used in ``LiveObject`` to listen for updates to the object. /// /// - Parameter update: The update object describing the changes made to the object. -public typealias LiveObjectUpdateCallback = (_ update: T) -> Void +public typealias LiveObjectUpdateCallback = (_ update: sending T) -> Void /// The callback used for the events emitted by ``RealtimeObjects``. public typealias ObjectsEventCallback = () -> Void @@ -14,10 +14,10 @@ public typealias LiveObjectLifecycleEventCallback = () -> Void /// A function passed to ``RealtimeObjects/batch(callback:)`` to group multiple Objects operations into a single channel message. /// /// - Parameter batchContext: A ``BatchContext`` object that allows grouping Objects operations for this batch. -public typealias BatchCallback = (_ batchContext: BatchContext) -> Void +public typealias BatchCallback = (_ batchContext: sending BatchContext) -> Void /// Describes the events emitted by an ``RealtimeObjects`` object. -public enum ObjectsEvent { +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. @@ -25,13 +25,13 @@ public enum ObjectsEvent { } /// Describes the events emitted by a ``LiveObject`` object. -public enum LiveObjectLifecycleEvent { +public enum LiveObjectLifecycleEvent: Sendable { /// Indicates that the object has been deleted from the Objects pool and should no longer be interacted with. case deleted } /// Enables the Objects to be read, modified and subscribed to for a channel. -public protocol RealtimeObjects { +public protocol RealtimeObjects: Sendable { /// Retrieves the root ``LiveMap`` object for Objects on a channel. func getRoot() async throws(ARTErrorInfo) -> any LiveMap @@ -61,7 +61,7 @@ public protocol RealtimeObjects { /// when the batched operations are applied by the Ably service and echoed back to the client. /// /// - Parameter callback: A batch callback function used to group operations together. - func batch(callback: BatchCallback) async throws + func batch(callback: sending BatchCallback) async throws /// 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. /// @@ -69,7 +69,7 @@ public protocol RealtimeObjects { /// - 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. - func on(event: ObjectsEvent, callback: ObjectsEventCallback) -> OnObjectsEventResponse + func on(event: ObjectsEvent, callback: sending ObjectsEventCallback) -> OnObjectsEventResponse /// Deregisters all registrations, for all events and listeners. func offAll() @@ -77,20 +77,20 @@ public protocol RealtimeObjects { /// Represents the type of data stored for a given key in a ``LiveMap``. /// It may be a primitive value (``PrimitiveObjectValue``), or another ``LiveObject``. -public enum LiveMapValue { +public enum LiveMapValue: Sendable { case primitive(PrimitiveObjectValue) case liveMap(any LiveMap) case liveCounter(any LiveCounter) } /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. -public protocol OnObjectsEventResponse { +public protocol OnObjectsEventResponse: Sendable { /// Deregisters the listener passed to the `on` call. func off() } /// Enables grouping multiple Objects operations together by providing `BatchContext*` wrapper objects. -public protocol BatchContext { +public protocol BatchContext: Sendable { /// Mirrors the ``RealtimeObjects/getRoot()`` method and returns a ``BatchContextLiveMap`` wrapper for the root object on a channel. /// /// - Returns: A ``BatchContextLiveMap`` object. @@ -98,7 +98,7 @@ public protocol BatchContext { } /// A wrapper around the ``LiveMap`` object that enables batching operations inside a ``BatchCallback``. -public protocol BatchContextLiveMap: AnyObject { +public protocol BatchContextLiveMap: AnyObject, Sendable { /// Mirrors the ``LiveMap/get(key:)`` method and returns the value associated with a key in the map. /// /// - Parameter key: The key to retrieve the value for. @@ -130,7 +130,7 @@ public protocol BatchContextLiveMap: AnyObject { } /// A wrapper around the ``LiveCounter`` object that enables batching operations inside a ``BatchCallback``. -public protocol BatchContextLiveCounter: AnyObject { +public protocol BatchContextLiveCounter: AnyObject, Sendable { /// Returns the current value of the counter. var value: Int { get } @@ -197,7 +197,7 @@ public protocol LiveMap: LiveObject where Update == LiveMapUpdate { } /// Describes whether an entry in ``LiveMapUpdate/update`` represents an update or a removal. -public enum LiveMapUpdateAction { +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. @@ -205,7 +205,7 @@ public enum LiveMapUpdateAction { } /// Represents an update to a ``LiveMap`` object, describing the keys that were updated or removed. -public protocol LiveMapUpdate { +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. @@ -213,7 +213,7 @@ public protocol LiveMapUpdate { } /// Represents a primitive value that can be stored in a ``LiveMap``. -public enum PrimitiveObjectValue { +public enum PrimitiveObjectValue: Sendable { case string(String) case number(Double) case bool(Bool) @@ -241,13 +241,13 @@ public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { } /// Represents an update to a ``LiveCounter`` object. -public protocol LiveCounterUpdate { +public protocol LiveCounterUpdate: Sendable { /// Holds the numerical change to the counter value. var amount: Int { get } } /// Describes the common interface for all conflict-free data structures supported by the Objects. -public protocol LiveObject: AnyObject { +public protocol LiveObject: AnyObject, Sendable { /// The type of update event that this object emits. associatedtype Update @@ -255,7 +255,7 @@ public protocol LiveObject: AnyObject { /// /// - 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. - func subscribe(listener: LiveObjectUpdateCallback) -> SubscribeResponse + func subscribe(listener: sending LiveObjectUpdateCallback) -> SubscribeResponse /// Deregisters all listeners from updates for this LiveObject. func unsubscribeAll() @@ -266,27 +266,27 @@ public protocol LiveObject: AnyObject { /// - 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. - func on(event: LiveObjectLifecycleEvent, callback: LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse + func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse /// Removes all registrations that match both the specified listener and the specified event. /// /// - Parameters: /// - event: The named event. /// - callback: The event listener. - func off(event: LiveObjectLifecycleEvent, callback: LiveObjectLifecycleEventCallback) + func off(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) /// Deregisters all registrations, for all events and listeners. func offAll() } /// Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. -public protocol SubscribeResponse { +public protocol SubscribeResponse: Sendable { /// Deregisters the listener passed to the `subscribe` call. func unsubscribe() } /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. -public protocol OnLiveObjectLifecycleEventResponse { +public protocol OnLiveObjectLifecycleEventResponse: Sendable { /// Deregisters the listener passed to the `on` call. func off() } diff --git a/Sources/AblyLiveObjects/Utility/WeakRef.swift b/Sources/AblyLiveObjects/Utility/WeakRef.swift new file mode 100644 index 000000000..c7175a7ee --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/WeakRef.swift @@ -0,0 +1,8 @@ +/// A struct that holds a weak reference to an object. +/// +/// This allows us to store a weak reference inside a Sendable object. The pattern comes from the [`weak let` proposal](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0481-weak-let.md). (We can get rid of this type and use `weak let` once Swift 6.2 is out.) +internal struct WeakRef { + internal weak var referenced: Referenced? +} + +extension WeakRef: Sendable where Referenced: Sendable {} From 4db5deb266fcec0767b73655c08501674959ab76 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 1 Jul 2025 14:40:21 -0300 Subject: [PATCH 027/225] Remove LiveObject.off This was a mistake in ce8c022, in which I did not intend to add methods that unsubscribe a given callback (since you can't compare closures by reference in Swift). The equivalent functionality already exists via OnLiveObjectLifecycleEventResponse.off(). --- Sources/AblyLiveObjects/Public/PublicTypes.swift | 7 ------- 1 file changed, 7 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 15690e7d9..224fa09c0 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -268,13 +268,6 @@ public protocol LiveObject: AnyObject, Sendable { /// - Returns: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to be deregistered from future updates. func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse - /// Removes all registrations that match both the specified listener and the specified event. - /// - /// - Parameters: - /// - event: The named event. - /// - callback: The event listener. - func off(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) - /// Deregisters all registrations, for all events and listeners. func offAll() } From 836f108b795a3947baf099461ae55897e7827c72 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 1 Jul 2025 14:43:00 -0300 Subject: [PATCH 028/225] Mark subscription methods as @discardableResult I _think_ that there are enough times that you want to subscribe to events without worrying about subsequently unsubscribing (in tests, but I think also in real life, especially when just playing around with the SDK) that we shouldn't make users think about the return value of these methods. --- Sources/AblyLiveObjects/Public/PublicTypes.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 224fa09c0..edc3c3b24 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -69,6 +69,7 @@ public protocol RealtimeObjects: Sendable { /// - 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: sending ObjectsEventCallback) -> OnObjectsEventResponse /// Deregisters all registrations, for all events and listeners. @@ -255,6 +256,7 @@ public protocol LiveObject: AnyObject, Sendable { /// /// - 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: sending LiveObjectUpdateCallback) -> SubscribeResponse /// Deregisters all listeners from updates for this LiveObject. @@ -266,6 +268,7 @@ public protocol LiveObject: AnyObject, Sendable { /// - 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: sending LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse /// Deregisters all registrations, for all events and listeners. From acb0be8386bce42864da740b5e14587a9d2b6029 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 27 Jun 2025 13:45:02 -0300 Subject: [PATCH 029/225] Change type of `entries` accepted by RealtimeObjects.createMap I didn't copy the type across from JS correctly in ce8c022; their LiveMapType is really just a dictionary with typed values. --- Sources/AblyLiveObjects/DefaultRealtimeObjects.swift | 2 +- Sources/AblyLiveObjects/Public/PublicTypes.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index bcf1aacc1..4581a1ccb 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -30,7 +30,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { notYetImplemented() } - internal func createMap(entries _: any LiveMap) async throws(ARTErrorInfo) -> any LiveMap { + internal func createMap(entries _: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index edc3c3b24..0b1ea4bde 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -38,7 +38,7 @@ public protocol RealtimeObjects: Sendable { /// Creates a new ``LiveMap`` object instance with the provided entries. /// /// - Parameter entries: The initial entries for the new ``LiveMap`` object. - func createMap(entries: any LiveMap) async throws(ARTErrorInfo) -> any LiveMap + func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap /// Creates a new empty ``LiveMap`` object instance. func createMap() async throws(ARTErrorInfo) -> any LiveMap From 0541332b87cf4c792cd93d767af851007b2b7c18 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 24 Jun 2025 12:54:47 -0300 Subject: [PATCH 030/225] Change LiveCounter to use Double instead of Int When I copied the public API from JS in ce8c022 I didn't yet have enough context to know how to translate the use of JS's `number` type. Now from RTLc5 it's clear that counters are floating-point. --- .../AblyLiveObjects/DefaultRealtimeObjects.swift | 2 +- Sources/AblyLiveObjects/Public/PublicTypes.swift | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 4581a1ccb..67ed5d947 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -38,7 +38,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { notYetImplemented() } - internal func createCounter(count _: Int) async throws(ARTErrorInfo) -> any LiveCounter { + internal func createCounter(count _: Double) async throws(ARTErrorInfo) -> any LiveCounter { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 0b1ea4bde..ed77e33e7 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -46,7 +46,7 @@ public protocol RealtimeObjects: Sendable { /// Creates a new ``LiveCounter`` object instance with the provided `count` value. /// /// - Parameter count: The initial value for the new ``LiveCounter`` object. - func createCounter(count: Int) async throws(ARTErrorInfo) -> any LiveCounter + 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 @@ -133,7 +133,7 @@ public protocol BatchContextLiveMap: AnyObject, Sendable { /// A wrapper around the ``LiveCounter`` object that enables batching operations inside a ``BatchCallback``. public protocol BatchContextLiveCounter: AnyObject, Sendable { /// Returns the current value of the counter. - var value: Int { get } + var value: Double { get } /// Similar to the ``LiveCounter/increment(amount:)`` method, but instead, it adds an operation to increment the counter value to the current batch, to be sent in a single message to the Ably service. /// @@ -142,12 +142,12 @@ public protocol BatchContextLiveCounter: AnyObject, Sendable { /// 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: Int) + func increment(amount: Double) /// An alias for calling [`increment(-amount)`](doc:BatchContextLiveCounter/increment(amount:)). /// /// - Parameter amount: The amount by which to decrease the counter value. - func decrement(amount: Int) + func decrement(amount: Double) } /// The `LiveMap` class represents a key-value map data structure, similar to a Swift `Dictionary`, where all changes are synchronized across clients in realtime. @@ -224,7 +224,7 @@ public enum PrimitiveObjectValue: Sendable { /// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { /// Returns the current value of the counter. - var value: Int { get } + var value: Double { get } /// Sends an operation to the Ably system to increment the value of this `LiveCounter` object. /// @@ -233,18 +233,18 @@ public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { /// 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: Int) async throws(ARTErrorInfo) + 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: Int) async throws(ARTErrorInfo) + func decrement(amount: Double) async throws(ARTErrorInfo) } /// Represents an update to a ``LiveCounter`` object. public protocol LiveCounterUpdate: Sendable { /// Holds the numerical change to the counter value. - var amount: Int { get } + var amount: Double { get } } /// Describes the common interface for all conflict-free data structures supported by the Objects. From 51f82dc2e5f0abecf3115cb73aa220258f3b2474 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 3 Jul 2025 00:21:42 -0300 Subject: [PATCH 031/225] Put ably-cocoa behind a protocol For mocking in some upcoming tests. I've also decided to make the internals of the SDK not need to worry about our current memory management confusion, which will be revisited in #9. --- .../DefaultRealtimeObjects.swift | 18 ++------ .../AblyLiveObjects/Internal/CoreSDK.swift | 44 +++++++++++++++++++ .../Internal/DefaultInternalPlugin.swift | 3 +- 3 files changed, 50 insertions(+), 15 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/CoreSDK.swift diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 67ed5d947..68ded6a65 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -6,9 +6,8 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. private let mutex = NSLock() - private let channel: WeakRef + private let coreSDK: CoreSDK private let logger: AblyPlugin.Logger - private let pluginAPI: AblyPlugin.PluginAPIProtocol // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. private let receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> @@ -16,10 +15,9 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation - internal init(channel: ARTRealtimeChannel, logger: AblyPlugin.Logger, pluginAPI: AblyPlugin.PluginAPIProtocol) { - self.channel = .init(referenced: channel) + internal init(coreSDK: CoreSDK, logger: AblyPlugin.Logger) { + self.coreSDK = coreSDK self.logger = logger - self.pluginAPI = pluginAPI (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() } @@ -93,14 +91,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { - guard let channel = channel.referenced else { - return - } - - try await DefaultInternalPlugin.sendObject( - objectMessages: objectMessages, - channel: channel, - pluginAPI: pluginAPI, - ) + try await coreSDK.sendObject(objectMessages: objectMessages) } } diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift new file mode 100644 index 000000000..8a63d3c09 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -0,0 +1,44 @@ +import Ably +internal import AblyPlugin + +/// 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 AblyPlugin's Objective-C API (i.e. boxing). +internal protocol CoreSDK: AnyObject, Sendable { + func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) +} + +internal final class DefaultCoreSDK: CoreSDK { + // We hold a weak reference to the channel so that `DefaultLiveObjects` can hold a strong reference to us without causing a strong reference cycle. We'll revisit this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9. + private let weakChannel: WeakRef + private let pluginAPI: PluginAPIProtocol + + internal init( + channel: ARTRealtimeChannel, + pluginAPI: PluginAPIProtocol + ) { + weakChannel = .init(referenced: channel) + self.pluginAPI = pluginAPI + } + + // MARK: - Fetching channel + + private var channel: ARTRealtimeChannel { + guard let channel = weakChannel.referenced else { + // It's currently completely possible that the channel _does_ become deallocated during the usage of the LiveObjects SDK; in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 we'll figure out how to prevent this. + preconditionFailure("Expected channel to not become deallocated during usage of LiveObjects SDK") + } + + return channel + } + + // MARK: - CoreSDK conformance + + internal func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + try await DefaultInternalPlugin.sendObject( + objectMessages: objectMessages, + channel: channel, + pluginAPI: pluginAPI, + ) + } +} diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index fcf975a92..5cfd46164 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -37,7 +37,8 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte let logger = pluginAPI.logger(for: channel) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) - let liveObjects = DefaultRealtimeObjects(channel: channel, logger: logger, pluginAPI: pluginAPI) + let coreSDK = DefaultCoreSDK(channel: channel, pluginAPI: pluginAPI) + let liveObjects = DefaultRealtimeObjects(coreSDK: coreSDK, logger: logger) pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } From 2deebd5d23041eeb130641925a19ab3a7d1ef448 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 24 Jun 2025 16:15:31 -0300 Subject: [PATCH 032/225] Tweak some Cursor rules The one about array literals is because on multiple occasions it emitted code that upset our linters' trailing_comma rules. Would be worth investigating in the future how to get it to run linting automatically. --- .cursor/rules/swift.mdc | 14 ++++++++++++++ .cursor/rules/testing.mdc | 11 ++++++++++- 2 files changed, 24 insertions(+), 1 deletion(-) diff --git a/.cursor/rules/swift.mdc b/.cursor/rules/swift.mdc index 0af35c323..0ebf73704 100644 --- a/.cursor/rules/swift.mdc +++ b/.cursor/rules/swift.mdc @@ -11,3 +11,17 @@ When writing Swift: - When writing initializer expressions, when the type that is being initialized can be inferred, favour using the implicit `.init(…)` form instead of explicitly writing the type name. - When writing enum value expressions, when the type that is being initialized can be inferred, favour using the implicit `.caseName` form instead of explicitly writing the type name. - When writing JSONValue or WireValue types, favour using the literal syntax enabled by their conformance to the `ExpressibleBy*Literal` protocols where possible. +- When you need to import the following modules inside the AblyLiveObjects library code (that is, in non-test code), do so in the following way: + - Ably: use `import Ably` + - AblyPlugin: use `internal import AblyPlugin` +- When writing an array literal that starts with an initializer expression, start the initializer expression on the line after the opening square bracket of the array literal. That is, instead of writing: + ```swift + objectMessages: [InboundObjectMessage( + id: nil, + ``` + write: + ```swift + objectMessages: [ + InboundObjectMessage( + id: nil, + ``` diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc index 6497a5a9f..073629399 100644 --- a/.cursor/rules/testing.mdc +++ b/.cursor/rules/testing.mdc @@ -8,4 +8,13 @@ When writing tests: - Use the Swift Testing framework (`import Testing`), not XCTest. - Do not use `fatalError` in response to a test expectation failure. Favour the usage of Swift Testing's `#require` macro. - Only add labels to test cases or suites when the label is different to the name of the suite `struct` or test method. -- When writing tests, follow the guidelines given under "Attributing tests to a spec point" in the file `CONTRIBUTING.md` in order to tag the unit tests with the relevant specification points. Pay particular attention to the difference between the meaning of `@spec` and `@specPartial` and be sure not to write `@spec` multiple times for the same specification point. +- When writing tests, follow the guidelines given under "Attributing tests to a spec point" in the file `CONTRIBUTING.md` in order to tag the unit tests with the relevant specification points. Make sure to follow the exact format of the comments as described in that file. Pay particular attention to the difference between the meaning of `@spec` and `@specPartial` and be sure not to write `@spec` multiple times for the same specification point. +- When writing tests, make sure to add comments that explain when some piece of test data is not important for the scenario being tested. +- When writing tests, run the tests to check they pass. +- When you need to import the following modules in the tests, do so in the following way: + - Ably: use `import Ably` + - AblyLiveObjects: use `@testable import AblyLiveObjects` + - AblyPlugin: use `import AblyPlugin`; _do not_ do `internal import` +- When you need to pass a logger to internal components in the tests, pass `TestLogger()`. +- When you need to unwrap an optional value in the tests, favour using `#require` instead of `guard let`. +- When creating `testsOnly_` property declarations, do not write generic comments of the form "Test-only access to the private createOperationIsMerged property"; the meaning of these properties is already well understood. From 498b6feb4abcec83bdf393287afbd38f94bbe93d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 2 Jul 2025 10:22:28 -0300 Subject: [PATCH 033/225] Add convenience getters for LiveMap values These will be useful in some upcoming tests, and also I think generally useful. (Cursor generated this code at my instruction.) --- .../AblyLiveObjects/Public/PublicTypes.swift | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index ed77e33e7..b482c8fb6 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -82,6 +82,52 @@ public enum LiveMapValue: Sendable { case primitive(PrimitiveObjectValue) case liveMap(any LiveMap) case liveCounter(any LiveCounter) + + // MARK: - Convenience getters for associated values + + /// If this `LiveMapValue` has case `primitive`, this returns the associated value. Else, it returns `nil`. + public var primitiveValue: PrimitiveObjectValue? { + if case let .primitive(value) = self { + return value + } + return nil + } + + /// If this `LiveMapValue` has case `liveMap`, this returns the associated value. Else, it returns `nil`. + public var liveMapValue: (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 `primitive` with a string value, this returns that value. Else, it returns `nil`. + public var stringValue: String? { + primitiveValue?.stringValue + } + + /// If this `LiveMapValue` has case `primitive` with a number value, this returns that value. Else, it returns `nil`. + public var numberValue: Double? { + primitiveValue?.numberValue + } + + /// If this `LiveMapValue` has case `primitive` with a boolean value, this returns that value. Else, it returns `nil`. + public var boolValue: Bool? { + primitiveValue?.boolValue + } + + /// If this `LiveMapValue` has case `primitive` with a data value, this returns that value. Else, it returns `nil`. + public var dataValue: Data? { + primitiveValue?.dataValue + } } /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. @@ -219,6 +265,40 @@ public enum PrimitiveObjectValue: Sendable { case number(Double) case bool(Bool) case data(Data) + + // MARK: - Convenience getters for associated values + + /// If this `PrimitiveObjectValue` 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 `PrimitiveObjectValue` 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 `PrimitiveObjectValue` 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 `PrimitiveObjectValue` 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 + } } /// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. From 9c5aadaf80370e18df35b18e274e49675c579c5c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 2 Jul 2025 10:28:04 -0300 Subject: [PATCH 034/225] Add empty implementations of LiveCounter and LiveMap --- .../Internal/DefaultLiveCounter.swift | 37 +++++++++++++ .../Internal/DefaultLiveMap.swift | 52 +++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift create mode 100644 Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift new file mode 100644 index 000000000..842960034 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -0,0 +1,37 @@ +import Ably +import Foundation + +/// Our default implementation of ``LiveCounter``. +internal final class DefaultLiveCounter: LiveCounter { + internal init() {} + + // MARK: - LiveCounter conformance + + internal var value: Double { + notYetImplemented() + } + + internal func increment(amount _: Double) async throws(ARTErrorInfo) { + notYetImplemented() + } + + internal func decrement(amount _: Double) async throws(ARTErrorInfo) { + notYetImplemented() + } + + internal func subscribe(listener _: (sending any LiveCounterUpdate) -> Void) -> any SubscribeResponse { + notYetImplemented() + } + + internal func unsubscribeAll() { + notYetImplemented() + } + + internal func on(event _: LiveObjectLifecycleEvent, callback _: () -> Void) -> any OnLiveObjectLifecycleEventResponse { + notYetImplemented() + } + + internal func offAll() { + notYetImplemented() + } +} diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift new file mode 100644 index 000000000..3a76f4a79 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -0,0 +1,52 @@ +import Ably + +/// Our default implementation of ``LiveMap``. +internal final class DefaultLiveMap: LiveMap { + internal init() {} + + // MARK: - LiveMap conformance + + internal func get(key _: String) -> LiveMapValue? { + notYetImplemented() + } + + internal var size: Int { + notYetImplemented() + } + + internal var entries: [(key: String, value: LiveMapValue)] { + notYetImplemented() + } + + internal var keys: [String] { + notYetImplemented() + } + + internal var values: [LiveMapValue] { + notYetImplemented() + } + + internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { + notYetImplemented() + } + + internal func remove(key _: String) async throws(ARTErrorInfo) { + notYetImplemented() + } + + internal func subscribe(listener _: (sending any LiveMapUpdate) -> Void) -> any SubscribeResponse { + notYetImplemented() + } + + internal func unsubscribeAll() { + notYetImplemented() + } + + internal func on(event _: LiveObjectLifecycleEvent, callback _: () -> Void) -> any OnLiveObjectLifecycleEventResponse { + notYetImplemented() + } + + internal func offAll() { + notYetImplemented() + } +} From 3f6de868a3749cb4988b5627610ce22f6958a5cb Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 3 Jul 2025 01:36:43 -0300 Subject: [PATCH 035/225] Mark LiveObject.get and LiveCounter.value as throwing I didn't do this in ce8c022 because the JS documentation doesn't mention that they can throw. But per specification points RTLC5b and RTLM5c in [1] at dd25dca they can. [1] https://github.com/ably/specification/pull/333 --- Sources/AblyLiveObjects/Public/PublicTypes.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index b482c8fb6..425953b75 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -208,7 +208,7 @@ public protocol LiveMap: LiveObject where Update == LiveMapUpdate { /// /// - Parameter key: The key to retrieve the value for. /// - Returns: A ``LiveObject``, a primitive type (string, number, boolean, 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) -> LiveMapValue? + func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? /// Returns the number of key-value pairs in the map. var size: Int { get } @@ -304,7 +304,7 @@ public enum PrimitiveObjectValue: Sendable { /// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { /// Returns the current value of the counter. - var value: Double { get } + var value: Double { get throws(ARTErrorInfo) } /// Sends an operation to the Ably system to increment the value of this `LiveCounter` object. /// From cb427d85805bc4e5b6c45ae7608f2ba4db8115e5 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 24 Jun 2025 12:39:13 -0300 Subject: [PATCH 036/225] Implement the OBJECT_SYNC specification MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on [1] at dd25dca. The development approach here was roughly the following: I decided the internal types and interfaces that I wanted to exist, and some of the implementation details (e.g. the mutations that DefaultRealtimeObjects performs during a sync sequence). I then asked Cursor to fill in the most of the implementation and tests (providing it with the relevant spec text). I have done some tweaking of the code that Cursor generated, but that doesn't mean that all of the code that's here is exactly the same as code that I would write, and I'm going to leave it that way. I have looked through all (and changed some) of the tests that Cursor generated, and in doing so also fixed the spec conformance tags (because it got those very wrong), so I am _fairly_ confident that the tests are testing what they claim to test. But I have not looked at them in minute detail (in particular, the exact data created by the canned data factories that it created). I also think that tests are one of those things where there's not always one right way to write them, so I'm not going to get hung up on "could it have done this test differently?" or "are the tests consistent with each other?" at the moment. There are some outstanding questions on the spec PR at the moment; have implemented to best of my understanding, and will update later once those are answered. Not yet implemented: - Checking of channel modes before performing operations (RTO2 and the points that refer to it); there's an outstanding question about this at [2] A note re the MutableState pattern that I've used here — done this so that the class can essentially have mutating instance methods which can call each other without having to worry about whose responsibility it is to acquire the mutex. [1] https://github.com/ably/specification/pull/333 [2] https://github.com/ably/specification/pull/333/files#r2152297442 --- .../DefaultRealtimeObjects.swift | 238 ++++++- .../AblyLiveObjects/Internal/CoreSDK.swift | 7 + .../Internal/DefaultLiveCounter.swift | 119 +++- .../Internal/DefaultLiveMap.swift | 395 ++++++++++- .../Internal/ObjectsPool.swift | 197 ++++++ .../AblyLiveObjects/Protocol/SyncCursor.swift | 38 + Sources/AblyLiveObjects/Utility/WeakRef.swift | 8 + .../DefaultLiveCounterTests.swift | 130 ++++ .../DefaultLiveMapTests.swift | 629 +++++++++++++++++ .../DefaultRealtimeObjectsTests.swift | 667 ++++++++++++++++++ .../Helpers/Assertions.swift | 7 + .../Helpers/TestFactories.swift | 554 +++++++++++++++ .../Mocks/MockCoreSDK.swift | 30 + .../Mocks/MockLiveMapObjectPoolDelegate.swift | 25 + .../ObjectsPoolTests.swift | 346 +++++++++ .../SyncCursorTests.swift | 106 +++ 16 files changed, 3484 insertions(+), 12 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/ObjectsPool.swift create mode 100644 Sources/AblyLiveObjects/Protocol/SyncCursor.swift create mode 100644 Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift create mode 100644 Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift create mode 100644 Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift create mode 100644 Tests/AblyLiveObjectsTests/Helpers/Assertions.swift create mode 100644 Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift create mode 100644 Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift create mode 100644 Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift create mode 100644 Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift create mode 100644 Tests/AblyLiveObjectsTests/SyncCursorTests.swift diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 68ded6a65..fa12ed9b4 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -2,10 +2,12 @@ import Ably internal import AblyPlugin /// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. -internal final class DefaultRealtimeObjects: RealtimeObjects { +internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolDelegate { // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. private let mutex = NSLock() + private nonisolated(unsafe) var mutableState: MutableState! + private let coreSDK: CoreSDK private let logger: AblyPlugin.Logger @@ -15,17 +17,98 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { private let receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> private let receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation + internal var testsOnly_objectsPool: ObjectsPool { + mutex.withLock { + mutableState.objectsPool + } + } + + /// If this returns false, it means that there is currently no stored sync sequence ID or SyncObjectsPool + internal var testsOnly_hasSyncSequence: Bool { + mutex.withLock { + mutableState.syncSequence != 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: [ObjectState] + } + + /// Tracks whether an object sync sequence has happened yet. This allows us to wait for a sync before returning from `getRoot()`, per RTO1c. + private struct SyncStatus { + private(set) var isSyncComplete = false + private let syncCompletionEvents: AsyncStream + private let syncCompletionContinuation: AsyncStream.Continuation + + internal init() { + (syncCompletionEvents, syncCompletionContinuation) = AsyncStream.makeStream() + } + + internal mutating func signalSyncComplete() { + isSyncComplete = true + syncCompletionContinuation.yield() + } + + internal func waitForSyncCompletion() async { + await syncCompletionEvents.first { _ in true } + } + } + internal init(coreSDK: CoreSDK, logger: AblyPlugin.Logger) { self.coreSDK = coreSDK self.logger = logger (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() + (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() + mutableState = .init(objectsPool: .init(rootDelegate: self, rootCoreSDK: coreSDK)) + } + + // MARK: - LiveMapObjectPoolDelegate + + internal func getObjectFromPool(id: String) -> ObjectsPool.Entry? { + mutex.withLock { + mutableState.objectsPool.entries[id] + } } // MARK: `RealtimeObjects` protocol internal func getRoot() async throws(ARTErrorInfo) -> any LiveMap { - notYetImplemented() + // RTO1b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "getRoot operation failed (invalid channel state: \(currentChannelState))") + } + + let syncStatus = mutex.withLock { + mutableState.syncStatus + } + + if !syncStatus.isSyncComplete { + // RTO1c + waitingForSyncEventsContinuation.yield() + logger.log("getRoot started waiting for sync sequence to complete", level: .debug) + await syncStatus.waitForSyncCompletion() + logger.log("getRoot completed waiting for sync sequence to complete", level: .debug) + } + + return mutex.withLock { + // RTO1d + mutableState.objectsPool.root + } } internal func createMap(entries _: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { @@ -58,16 +141,20 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { // MARK: Handling channel events - private nonisolated(unsafe) var onChannelAttachedHasObjects: Bool? internal var testsOnly_onChannelAttachedHasObjects: Bool? { mutex.withLock { - onChannelAttachedHasObjects + mutableState.onChannelAttachedHasObjects } } internal func onChannelAttached(hasObjects: Bool) { mutex.withLock { - onChannelAttachedHasObjects = hasObjects + mutableState.onChannelAttached( + hasObjects: hasObjects, + logger: logger, + mapDelegate: self, + coreSDK: coreSDK, + ) } } @@ -83,8 +170,27 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { receivedObjectSyncProtocolMessages } - internal func handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial _: String?) { - receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) + /// Implements the `OBJECT_SYNC` handling of RTO5. + internal func handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial: String?) { + mutex.withLock { + mutableState.handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: protocolMessageChannelSerial, + logger: logger, + receivedObjectSyncProtocolMessagesContinuation: receivedObjectSyncProtocolMessagesContinuation, + mapDelegate: self, + coreSDK: coreSDK, + ) + } + } + + /// 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, coreSDK: CoreSDK) -> ObjectsPool.Entry? { + mutex.withLock { + mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, mapDelegate: self, coreSDK: coreSDK) + } } // MARK: - Sending `OBJECT` ProtocolMessage @@ -93,4 +199,122 @@ internal final class DefaultRealtimeObjects: RealtimeObjects { internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { try await coreSDK.sendObject(objectMessages: objectMessages) } + + // 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 + internal func testsOnly_finishAllTestHelperStreams() { + receivedObjectProtocolMessagesContinuation.finish() + receivedObjectSyncProtocolMessagesContinuation.finish() + waitingForSyncEventsContinuation.finish() + } + + // MARK: - Mutable state and the operations that affect it + + private struct MutableState { + internal var objectsPool: ObjectsPool + internal var syncSequence: SyncSequence? + internal var syncStatus = SyncStatus() + internal var onChannelAttachedHasObjects: Bool? + + internal mutating func onChannelAttached( + hasObjects: Bool, + logger: Logger, + mapDelegate: LiveMapObjectPoolDelegate, + coreSDK: CoreSDK, + ) { + logger.log("onChannelAttached(hasObjects: \(hasObjects)", level: .debug) + + onChannelAttachedHasObjects = hasObjects + + // 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 + // 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 + objectsPool = .init(rootDelegate: mapDelegate, rootCoreSDK: coreSDK) + + // 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 + syncSequence = nil + syncStatus.signalSyncComplete() + } + + /// Implements the `OBJECT_SYNC` handling of RTO5. + internal mutating func handleObjectSyncProtocolMessage( + objectMessages: [InboundObjectMessage], + protocolMessageChannelSerial: String?, + logger: Logger, + receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, + mapDelegate: LiveMapObjectPoolDelegate, + coreSDK: CoreSDK, + ) { + logger.log("handleObjectSyncProtocolMessage(objectMessages: \(objectMessages), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) + + receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) + + // If populated, this contains a full set of sync data for the channel, and should be applied to the ObjectsPool. + let completedSyncObjectsPool: [ObjectState]? + + if let protocolMessageChannelSerial { + let syncCursor: SyncCursor + do { + // RTO5a + syncCursor = try SyncCursor(channelSerial: protocolMessageChannelSerial) + } catch { + logger.log("Failed to parse sync cursor: \(error)", level: .error) + return + } + + // Figure out whether to continue any existing sync sequence or start a new one + var updatedSyncSequence: SyncSequence = if let syncSequence { + if syncCursor.sequenceID == syncSequence.id { + // RTO5a3: Continue existing sync sequence + syncSequence + } else { + // RTO5a2: new sequence started, discard previous + .init(id: syncCursor.sequenceID, syncObjectsPool: []) + } + } else { + // There's no current sync sequence; start one + .init(id: syncCursor.sequenceID, syncObjectsPool: []) + } + + // RTO5b + updatedSyncSequence.syncObjectsPool.append(contentsOf: objectMessages.compactMap(\.object)) + + syncSequence = updatedSyncSequence + + completedSyncObjectsPool = if syncCursor.isEndOfSequence { + updatedSyncSequence.syncObjectsPool + } else { + nil + } + } else { + // RTO5a5: The sync data is contained entirely within this single OBJECT_SYNC + completedSyncObjectsPool = objectMessages.compactMap(\.object) + } + + if let completedSyncObjectsPool { + // RTO5c + objectsPool.applySyncObjectsPool( + completedSyncObjectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + logger: logger, + ) + // RTO5c3, RTO5c4 + syncSequence = nil + + syncStatus.signalSyncComplete() + } + } + } } diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 8a63d3c09..e43410e40 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -6,6 +6,9 @@ internal import AblyPlugin /// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to AblyPlugin's Objective-C API (i.e. boxing). internal protocol CoreSDK: AnyObject, Sendable { func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) + + /// Returns the current state of the Realtime channel that this wraps. + var channelState: ARTRealtimeChannelState { get } } internal final class DefaultCoreSDK: CoreSDK { @@ -41,4 +44,8 @@ internal final class DefaultCoreSDK: CoreSDK { pluginAPI: pluginAPI, ) } + + internal var channelState: ARTRealtimeChannelState { + channel.state + } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift index 842960034..1246d1b24 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -3,12 +3,80 @@ import Foundation /// Our default implementation of ``LiveCounter``. internal final class DefaultLiveCounter: LiveCounter { - internal init() {} + // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. + private let mutex = NSLock() + + private nonisolated(unsafe) var mutableState: MutableState + + internal var testsOnly_siteTimeserials: [String: String]? { + mutex.withLock { + mutableState.siteTimeserials + } + } + + internal var testsOnly_createOperationIsMerged: Bool? { + mutex.withLock { + mutableState.createOperationIsMerged + } + } + + internal var testsOnly_objectID: String? { + mutex.withLock { + mutableState.objectID + } + } + + private let coreSDK: CoreSDK + + // MARK: - Initialization + + internal convenience init( + testsOnly_data data: Double, + objectID: String?, + coreSDK: CoreSDK + ) { + self.init(data: data, objectID: objectID, coreSDK: coreSDK) + } + + private init( + data: Double, + objectID: String?, + coreSDK: CoreSDK + ) { + mutableState = .init(data: data, objectID: objectID) + self.coreSDK = coreSDK + } + + /// Creates a "zero-value LiveCounter", per RTLC4. + /// + /// - Parameters: + /// - objectID: The value for the "private objectId field" of RTO5c1b1a. + internal static func createZeroValued( + objectID: String? = nil, + coreSDK: CoreSDK, + ) -> Self { + .init( + data: 0, + objectID: objectID, + coreSDK: coreSDK, + ) + } // MARK: - LiveCounter conformance internal var value: Double { - notYetImplemented() + get throws(ARTErrorInfo) { + // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "LiveCounter.value operation failed (invalid channel state: \(currentChannelState))") + } + + return mutex.withLock { + // RTLC5c + mutableState.data + } + } } internal func increment(amount _: Double) async throws(ARTErrorInfo) { @@ -34,4 +102,51 @@ internal final class DefaultLiveCounter: LiveCounter { internal func offAll() { notYetImplemented() } + + // MARK: - Data manipulation + + /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. + internal func replaceData(using state: ObjectState) { + mutex.withLock { + mutableState.replaceData(using: state) + } + } + + // MARK: - Mutable state and the operations that affect it + + private struct MutableState { + /// The internal data that this map holds, per RTLC3. + internal var data: Double + + /// The site timeserials for this counter, per RTLC6a. + internal var siteTimeserials: [String: String]? + + /// Whether the create operation has been merged, per RTLC6b and RTLC6d2. + internal var createOperationIsMerged: Bool? + + /// The "private `objectId` field" of RTO5c1b1a. + internal var objectID: String? + + /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. + internal mutating func replaceData(using state: ObjectState) { + // RTLC6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials + siteTimeserials = state.siteTimeserials + + // RTLC6b: Set the private flag createOperationIsMerged to false + 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 + if let createOp = state.createOp { + // RTLC6d1: Add ObjectState.createOp.counter.count to data, if it exists + if let createOpCount = createOp.counter?.count?.doubleValue { + data += createOpCount + } + // RTLC6d2: Set the private flag createOperationIsMerged to true + createOperationIsMerged = true + } + } + } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index 3a76f4a79..8486702da 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -1,13 +1,178 @@ import Ably +/// Protocol for accessing objects from the ObjectsPool. This is used by a LiveMap when it needs to return an object given an object ID. +internal protocol LiveMapObjectPoolDelegate: AnyObject, Sendable { + /// Fetches an object from the pool by its ID + func getObjectFromPool(id: String) -> ObjectsPool.Entry? +} + /// Our default implementation of ``LiveMap``. internal final class DefaultLiveMap: LiveMap { - internal init() {} + // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. + private let mutex = NSLock() + + private nonisolated(unsafe) var mutableState: MutableState + + internal var testsOnly_data: [String: ObjectsMapEntry] { + mutex.withLock { + mutableState.data + } + } + + internal var testsOnly_objectID: String? { + mutex.withLock { + mutableState.objectID + } + } + + internal var testsOnly_semantics: WireEnum? { + mutex.withLock { + mutableState.semantics + } + } + + internal var testsOnly_siteTimeserials: [String: String]? { + mutex.withLock { + mutableState.siteTimeserials + } + } + + internal var testsOnly_createOperationIsMerged: Bool? { + mutex.withLock { + mutableState.createOperationIsMerged + } + } + + /// Delegate for accessing objects from the pool + private let delegate: WeakLiveMapObjectPoolDelegateRef + internal var testsOnly_delegate: LiveMapObjectPoolDelegate? { + delegate.referenced + } + + private let coreSDK: CoreSDK + + // MARK: - Initialization + + internal convenience init( + testsOnly_data data: [String: ObjectsMapEntry], + objectID: String? = nil, + testsOnly_semantics semantics: WireEnum? = nil, + delegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK + ) { + self.init( + data: data, + objectID: objectID, + semantics: semantics, + delegate: delegate, + coreSDK: coreSDK, + ) + } + + private init( + data: [String: ObjectsMapEntry], + objectID: String?, + semantics: WireEnum?, + delegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK + ) { + mutableState = .init(data: data, objectID: objectID, semantics: semantics) + self.delegate = .init(referenced: delegate) + self.coreSDK = coreSDK + } + + /// Creates a "zero-value LiveMap", per RTLM4. + /// + /// - Parameters: + /// - objectID: The value to use for the "private `objectId` field" of RTO5c1b1b. + /// - semantics: The value to use for the "private `semantics` field" of RTO5c1b1b. + internal static func createZeroValued( + objectID: String? = nil, + semantics: WireEnum? = nil, + delegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK, + ) -> Self { + .init( + data: [:], + objectID: objectID, + semantics: semantics, + delegate: delegate, + coreSDK: coreSDK, + ) + } // MARK: - LiveMap conformance - internal func get(key _: String) -> LiveMapValue? { - notYetImplemented() + /// Returns the value associated with a given key, following RTLM5d specification. + internal func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? { + // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "LiveMap.get operation failed (invalid channel state: \(currentChannelState))") + } + + let entry = mutex.withLock { + mutableState.data[key] + } + + // RTLM5d1: If no ObjectsMapEntry exists at the key, return undefined/null + guard let entry else { + return nil + } + + // RTLM5d2: If a ObjectsMapEntry exists at the key + + // 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 .primitive(.bool(boolean)) + } + + // RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it + if let bytes = entry.data.bytes { + return .primitive(.data(bytes)) + } + + // RTLM5d2d: If ObjectsMapEntry.data.number exists, return it + if let number = entry.data.number { + return .primitive(.number(number.doubleValue)) + } + + // RTLM5d2e: If ObjectsMapEntry.data.string exists, return it + if let string = entry.data.string { + switch string { + case let .string(string): + return .primitive(.string(string)) + case .json: + // TODO: Understand how to handle JSON values (https://github.com/ably/specification/pull/333/files#r2164561055) + notYetImplemented() + } + } + + // 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 = delegate.referenced?.getObjectFromPool(id: objectId) else { + return nil + } + + // RTLM5d2f2: If an object with id objectId exists, return it + switch poolEntry { + case let .map(map): + return .liveMap(map) + case let .counter(counter): + return .liveCounter(counter) + } + } + + // RTLM5d2g: Otherwise, return undefined/null + return nil } internal var size: Int { @@ -49,4 +214,228 @@ internal final class DefaultLiveMap: LiveMap { internal func offAll() { notYetImplemented() } + + // 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. + internal func replaceData(using state: ObjectState, objectsPool: inout ObjectsPool) { + mutex.withLock { + mutableState.replaceData( + using: state, + objectsPool: &objectsPool, + mapDelegate: delegate.referenced, + coreSDK: coreSDK, + ) + } + } + + /// 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, + ) { + mutex.withLock { + mutableState.applyMapSetOperation( + key: key, + operationTimeserial: operationTimeserial, + operationData: operationData, + objectsPool: &objectsPool, + mapDelegate: delegate.referenced, + coreSDK: coreSDK, + ) + } + } + + /// 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?) { + mutex.withLock { + mutableState.applyMapRemoveOperation( + key: key, + operationTimeserial: operationTimeserial, + ) + } + } + + // MARK: - Mutable state and the operations that affect it + + private struct MutableState { + /// The internal data that this map holds, per RTLM3. + internal var data: [String: ObjectsMapEntry] + + /// The "private `objectId` field" of RTO5c1b1b. + internal var objectID: String? + + /// The "private `semantics` field" of RTO5c1b1b. + internal var semantics: WireEnum? + + /// The site timeserials for this map, per RTLM6a. + internal var siteTimeserials: [String: String]? + + /// Whether the create operation has been merged, per RTLM6b and RTLM6d2. + internal var createOperationIsMerged: Bool? + + /// 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. + internal mutating func replaceData( + using state: ObjectState, + objectsPool: inout ObjectsPool, + mapDelegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK, + ) { + // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials + siteTimeserials = state.siteTimeserials + + // RTLM6b: Set the private flag createOperationIsMerged to false + createOperationIsMerged = false + + // RTLM6c: Set data to ObjectState.map.entries, or to an empty map if it does not exist + data = state.map?.entries ?? [:] + + // RTLM6d: If ObjectState.createOp is present + if let createOp = state.createOp { + // RTLM6d1: For each key–ObjectsMapEntry pair in ObjectState.createOp.map.entries + if let entries = createOp.map?.entries { + for (key, entry) in entries { + if entry.tombstone == true { + // RTLM6d1b: If ObjectsMapEntry.tombstone is true, apply the MAP_REMOVE operation + // to the specified key using ObjectsMapEntry.timeserial per RTLM8 + applyMapRemoveOperation( + key: key, + operationTimeserial: entry.timeserial, + ) + } else { + // RTLM6d1a: If ObjectsMapEntry.tombstone is false, apply the MAP_SET operation + // to the specified key using ObjectsMapEntry.timeserial and ObjectsMapEntry.data per RTLM7 + applyMapSetOperation( + key: key, + operationTimeserial: entry.timeserial, + operationData: entry.data, + objectsPool: &objectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + ) + } + } + } + // RTLM6d2: Set the private flag createOperationIsMerged to true + createOperationIsMerged = true + } + } + + /// Applies a `MAP_SET` operation to a key, per RTLM7. + internal mutating func applyMapSetOperation( + key: String, + operationTimeserial: String?, + operationData: ObjectData, + objectsPool: inout ObjectsPool, + mapDelegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK, + ) { + // 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 + } + // RTLM7a2: Otherwise, apply the operation + // RTLM7a2a: Set ObjectsMapEntry.data to the ObjectData from the operation + // RTLM7a2b: Set ObjectsMapEntry.timeserial to the operation's serial + // RTLM7a2c: Set ObjectsMapEntry.tombstone to false + var updatedEntry = existingEntry + updatedEntry.data = operationData + updatedEntry.timeserial = operationTimeserial + updatedEntry.tombstone = false + data[key] = updatedEntry + } else { + // RTLM7b: If an entry does not exist in the private data for the specified key + // RTLM7b1: Create a new entry in data for the specified key with the provided ObjectData and the operation's serial + // RTLM7b2: Set ObjectsMapEntry.tombstone for the new entry to false + data[key] = ObjectsMapEntry(tombstone: false, timeserial: operationTimeserial, data: operationData) + } + + // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute + if let objectId = operationData.objectId, !objectId.isEmpty { + // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 + _ = objectsPool.createZeroValueObject(forObjectID: objectId, mapDelegate: mapDelegate, coreSDK: coreSDK) + } + } + + /// Applies a `MAP_REMOVE` operation to a key, per RTLM8. + internal mutating func applyMapRemoveOperation(key: String, operationTimeserial: String?) { + // (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) + + // 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 + } + // 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 + var updatedEntry = existingEntry + updatedEntry.data = ObjectData() + updatedEntry.timeserial = operationTimeserial + updatedEntry.tombstone = true + 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 + data[key] = ObjectsMapEntry(tombstone: true, timeserial: operationTimeserial, data: ObjectData()) + } + } + + /// 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 + } + } + } } diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift new file mode 100644 index 000000000..11d8d0017 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -0,0 +1,197 @@ +internal import AblyPlugin + +/// Maintains the list of objects present on a channel, as described by RTO3. +/// +/// Note that this is a value type. +internal struct ObjectsPool { + /// The possible `ObjectsPool` entries, as described by RTO3a. + internal enum Entry { + case map(DefaultLiveMap) + case counter(DefaultLiveCounter) + + /// Convenience getter for accessing the map value if this entry is a map + internal var mapValue: DefaultLiveMap? { + 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: DefaultLiveCounter? { + switch self { + case .map: + nil + case let .counter(counter): + counter + } + } + } + + /// 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( + rootDelegate: LiveMapObjectPoolDelegate?, + rootCoreSDK: CoreSDK, + testsOnly_otherEntries otherEntries: [String: Entry]? = nil, + ) { + self.init( + rootDelegate: rootDelegate, + rootCoreSDK: rootCoreSDK, + otherEntries: otherEntries, + ) + } + + private init( + rootDelegate: LiveMapObjectPoolDelegate?, + rootCoreSDK: CoreSDK, + 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(delegate: rootDelegate, coreSDK: rootCoreSDK)) + } + + // MARK: - Typed root + + /// Fetches the root object. + internal var root: DefaultLiveMap { + 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 + /// - mapDelegate: The delegate to use for any created LiveMap + /// - coreSDK: The CoreSDK to use for any created LiveObject + /// - Returns: The existing or newly created object + internal mutating func createZeroValueObject(forObjectID objectID: String, mapDelegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK) -> 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, delegate: mapDelegate, coreSDK: coreSDK)) + case "counter": + entry = .counter(.createZeroValued(objectID: objectID, coreSDK: coreSDK)) + 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. + /// + /// - Parameters: + /// - mapDelegate: The delegate to use for any created LiveMap + /// - coreSDK: The CoreSDK to use for any created LiveObject + internal mutating func applySyncObjectsPool( + _ syncObjectsPool: [ObjectState], + mapDelegate: LiveMapObjectPoolDelegate, + coreSDK: CoreSDK, + logger: AblyPlugin.Logger, + ) { + 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() + + // RTO5c1: For each ObjectState member in the SyncObjectsPool list + for objectState in syncObjectsPool { + receivedObjectIds.insert(objectState.objectId) + + // RTO5c1a: If an object with ObjectState.objectId exists in the internal ObjectsPool + if let existingEntry = entries[objectState.objectId] { + logger.log("Updating existing object with ID: \(objectState.objectId)", level: .debug) + + // RTO5c1a1: Override the internal data for the object as per RTLC6, RTLM6 + switch existingEntry { + case let .map(map): + map.replaceData(using: objectState, objectsPool: &self) + case let .counter(counter): + counter.replaceData(using: objectState) + } + } else { + // RTO5c1b: If an object with ObjectState.objectId does not exist in the internal ObjectsPool + logger.log("Creating new object with ID: \(objectState.objectId)", level: .debug) + + // RTO5c1b1: Create a new LiveObject using the data from ObjectState and add it to the internal ObjectsPool: + let newEntry: Entry? + + if objectState.counter != nil { + // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, + // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 + let counter = DefaultLiveCounter.createZeroValued(objectID: objectState.objectId, coreSDK: coreSDK) + counter.replaceData(using: objectState) + newEntry = .counter(counter) + } else if let objectsMap = objectState.map { + // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, + // set its private objectId equal to ObjectState.objectId, set its private semantics + // equal to ObjectState.map.semantics and override its internal data per RTLM6 + let map = DefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, delegate: mapDelegate, coreSDK: coreSDK) + map.replaceData(using: objectState, objectsPool: &self) + newEntry = .map(map) + } else { + // RTO5c1b1c: Otherwise, log a warning that an unsupported object state message has been received, and discard the current ObjectState without taking any action + logger.log("Unsupported object state message received for objectId: \(objectState.objectId)", level: .warn) + newEntry = nil + } + + if let newEntry { + // Note that we will never replace the root object here, and thus never break the RTO3b invariant that the root object is always a map. This is because the pool always contains a root object and thus we always go through the RTO5c1a branch of the `if` above. + entries[objectState.objectId] = newEntry + } + } + } + + // 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) + } + } + + logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) + } +} diff --git a/Sources/AblyLiveObjects/Protocol/SyncCursor.swift b/Sources/AblyLiveObjects/Protocol/SyncCursor.swift new file mode 100644 index 000000000..d3d92ebcf --- /dev/null +++ b/Sources/AblyLiveObjects/Protocol/SyncCursor.swift @@ -0,0 +1,38 @@ +import Foundation + +/// The `OBJECT_SYNC` sync cursor, as extracted from a `channelSerial` per RTO5a1 and RTO5a4. +internal struct SyncCursor { + internal var sequenceID: String + /// `nil` in the case where the objects sync sequence is complete (RTO5a4). + internal var cursorValue: String? + + internal enum Error: Swift.Error { + case channelSerialDoesNotMatchExpectedFormat(String) + } + + /// Creates a `SyncCursor` from the `channelSerial` of an `OBJECT_SYNC` `ProtocolMessage`. + internal init(channelSerial: String) throws(InternalError) { + 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).toInternalError() + } + + // 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/Sources/AblyLiveObjects/Utility/WeakRef.swift b/Sources/AblyLiveObjects/Utility/WeakRef.swift index c7175a7ee..0678728a3 100644 --- a/Sources/AblyLiveObjects/Utility/WeakRef.swift +++ b/Sources/AblyLiveObjects/Utility/WeakRef.swift @@ -6,3 +6,11 @@ internal struct WeakRef { } extension WeakRef: Sendable where Referenced: Sendable {} + +// MARK: - Specialized versions of WeakRef + +// These are protocol-specific versions of ``WeakRef`` that hold an existential type (e.g. `any CoreSDK`). (This is because the compiler complains that an existential of a class-bound protocol doesn't conform to `AnyObject`.) + +internal struct WeakLiveMapObjectPoolDelegateRef: Sendable { + internal weak var referenced: LiveMapObjectPoolDelegate? +} diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift new file mode 100644 index 000000000..973e1e6e7 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift @@ -0,0 +1,130 @@ +@testable import AblyLiveObjects +import AblyPlugin +import Foundation +import Testing + +struct DefaultLiveCounterTests { + /// Tests for the `value` property, covering RTLC5 specification points + struct ValueTests { + // @spec RTLC5b + @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) + func valueThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: channelState)) + + #expect { + _ = try counter.value + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 + } + } + + // @spec RTLC5c + @Test + func valueReturnsCurrentDataWhenChannelIsValid() throws { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attached)) + + // Set some test data + counter.replaceData(using: TestFactories.counterObjectState(count: 42)) + + #expect(try counter.value == 42) + } + } + + /// Tests for the `replaceData` method, covering RTLC6 specification points + struct ReplaceDataTests { + // @spec RTLC6a + @Test + func replacesSiteTimeserials() { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let state = TestFactories.counterObjectState( + siteTimeserials: ["site1": "ts1"], // Test value + ) + counter.replaceData(using: state) + #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 RTLC6d2 takes precedence when createOp exists + @Test + func setsCreateOperationIsMergedToFalse() { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let state = TestFactories.counterObjectState( + createOp: nil, // Test value - must be nil to test RTLC6b + ) + counter.replaceData(using: state) + #expect(counter.testsOnly_createOperationIsMerged == false) + } + + // @specOneOf(1/4) RTLC6c - count but no createOp + @Test + func setsDataToCounterCount() throws { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let state = TestFactories.counterObjectState( + count: 42, // Test value + ) + counter.replaceData(using: state) + #expect(try counter.value == 42) + } + + // @specOneOf(2/4) RTLC6c - no count, no createOp + @Test + func setsDataToZeroWhenCounterCountDoesNotExist() throws { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + counter.replaceData(using: TestFactories.counterObjectState( + count: nil, // Test value - must be nil + )) + #expect(try counter.value == 0) + } + } + + /// Tests for RTLC6d (with createOp present) + struct WithCreateOpTests { + // @specOneOf(1/2) RTLC6d1 - with count + // @specOneOf(3/4) RTLC6c - count and createOp + @Test + func setsDataToCounterCountThenAddsCreateOpCounterCount() throws { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let state = TestFactories.counterObjectState( + createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist + count: 5, // Test value - must exist + ) + counter.replaceData(using: state) + #expect(try counter.value == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC6d1) + } + + // @specOneOf(2/2) RTLC6d1 - no count + // @specOneOf(4/4) RTLC6c - no count but createOp + @Test + func doesNotModifyDataWhenCreateOpCounterCountDoesNotExist() throws { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let state = TestFactories.counterObjectState( + createOp: TestFactories.objectOperation( + action: .known(.counterCreate), + counter: nil, // Test value - must be nil + ), + count: 5, // Test value + ) + counter.replaceData(using: state) + #expect(try counter.value == 5) // Only the base counter.count value + } + + // @spec RTLC6d2 + @Test + func setsCreateOperationIsMergedToTrue() { + let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let state = TestFactories.counterObjectState( + createOp: TestFactories.objectOperation( // Test value - must be non-nil + action: .known(.counterCreate), + ), + ) + counter.replaceData(using: state) + #expect(counter.testsOnly_createOperationIsMerged == true) + } + } + } +} diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift new file mode 100644 index 000000000..c0d3725bc --- /dev/null +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -0,0 +1,629 @@ +@testable import AblyLiveObjects +import AblyPlugin +import Foundation +import Testing + +struct DefaultLiveMapTests { + /// Tests for the `get` method, covering RTLM5 specification points + struct GetTests { + // @spec RTLM5c + @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) + func getThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + let map = DefaultLiveMap.createZeroValued(delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) + + #expect { + _ = try map.get(key: "test") + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 + } + } + + // MARK: - RTLM5d Tests + + // @spec RTLM5d1 + @Test + func returnsNilWhenNoEntryExists() throws { + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: MockLiveMapObjectPoolDelegate(), coreSDK: coreSDK) + #expect(try map.get(key: "nonexistent") == nil) + } + + // @spec RTLM5d2a + @Test + func returnsNilWhenEntryIsTombstoned() throws { + let entry = TestFactories.mapEntry( + tombstone: true, + data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned + ) + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + #expect(try map.get(key: "key") == nil) + } + + // @spec RTLM5d2b + @Test + func returnsBooleanValue() throws { + let entry = TestFactories.mapEntry(data: ObjectData(boolean: true)) + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let result = try map.get(key: "key") + #expect(result?.boolValue == true) + } + + // @spec RTLM5d2c + @Test + func returnsBytesValue() throws { + let bytes = Data([0x01, 0x02, 0x03]) + let entry = TestFactories.mapEntry(data: ObjectData(bytes: bytes)) + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let result = try map.get(key: "key") + #expect(result?.dataValue == bytes) + } + + // @spec RTLM5d2d + @Test + func returnsNumberValue() throws { + let entry = TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 123.456))) + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let result = try map.get(key: "key") + #expect(result?.numberValue == 123.456) + } + + // @spec RTLM5d2e + @Test + func returnsStringValue() throws { + let entry = TestFactories.mapEntry(data: ObjectData(string: .string("test"))) + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let result = try map.get(key: "key") + #expect(result?.stringValue == "test") + } + + // @spec RTLM5d2f1 + @Test + func returnsNilWhenReferencedObjectDoesNotExist() throws { + let entry = TestFactories.mapEntry(data: ObjectData(objectId: "missing")) + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + #expect(try map.get(key: "key") == nil) + } + + // @specOneOf(1/2) RTLM5d2f2 - Returns referenced map when it exists in pool + @Test + func returnsReferencedMap() throws { + let objectId = "map1" + let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let referencedMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + delegate.objects[objectId] = .map(referencedMap) + + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + let result = try map.get(key: "key") + 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 objectId = "counter1" + let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let referencedCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + delegate.objects[objectId] = .counter(referencedCounter) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + let result = try map.get(key: "key") + let returnedCounter = result?.liveCounterValue + #expect(returnedCounter as AnyObject === referencedCounter as AnyObject) + } + + // @spec RTLM5d2g + @Test + func returnsNullOtherwise() throws { + let entry = TestFactories.mapEntry(data: ObjectData()) + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + + let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + #expect(try map.get(key: "key") == nil) + } + } + + /// Tests for the `replaceData` method, covering RTLM6 specification points + struct ReplaceDataTests { + // @spec RTLM6a + @Test + func replacesSiteTimeserials() { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let state = TestFactories.objectState( + objectId: "arbitrary-id", + siteTimeserials: ["site1": "ts1", "site2": "ts2"], + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + map.replaceData(using: state, objectsPool: &pool) + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) + } + + // @spec RTLM6b + @Test + func setsCreateOperationIsMergedToFalseWhenCreateOpAbsent() { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let state = TestFactories.objectState(objectId: "arbitrary-id", createOp: nil) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + map.replaceData(using: state, objectsPool: &pool) + #expect(map.testsOnly_createOperationIsMerged == false) + } + + // @specOneOf(1/2) RTLM6c + @Test + func setsDataToMapEntries() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "test") + let state = TestFactories.mapObjectState( + objectId: "arbitrary-id", + entries: [key: entry], + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + map.replaceData(using: state, objectsPool: &pool) + let newData = map.testsOnly_data + #expect(newData.count == 1) + #expect(Set(newData.keys) == ["key1"]) + #expect(try map.get(key: "key1")?.stringValue == "test") + } + + // @specOneOf(2/2) RTLM6c - Tests that the map entries get combined with the createOp + // @spec RTLM6d1a + @Test + func appliesMapSetOperationFromCreateOp() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + 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(rootDelegate: delegate, rootCoreSDK: coreSDK) + map.replaceData(using: state, 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 entries (per RTLM6c) and also the createOp (per RTLM6d1a) + #expect(try map.get(key: "keyFromMapEntries")?.stringValue == "valueFromMapEntries") + #expect(try map.get(key: "keyFromCreateOp")?.stringValue == "valueFromCreateOp") + } + + // @spec RTLM6d1b + @Test + func appliesMapRemoveOperationFromCreateOp() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], + delegate: delegate, + coreSDK: coreSDK, + ) + // Confirm that the initial data is there + #expect(try map.get(key: "key1") != nil) + + let entry = TestFactories.mapEntry( + tombstone: true, + data: ObjectData(), + ) + let state = TestFactories.objectState( + objectId: "arbitrary-id", + createOp: TestFactories.mapCreateOperation( + objectId: "arbitrary-id", + entries: ["key1": entry], + ), + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + map.replaceData(using: state, objectsPool: &pool) + // Note that we just check for some basic expected side effects of applying MAP_REMOVE; RTLM8 is tested in more detail elsewhere + // Check that MAP_REMOVE removed the initial data + #expect(try map.get(key: "key1") == nil) + } + + // @spec RTLM6d2 + @Test + func setsCreateOperationIsMergedToTrueWhenCreateOpPresent() { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let state = TestFactories.objectState( + objectId: "arbitrary-id", + createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + map.replaceData(using: state, objectsPool: &pool) + #expect(map.testsOnly_createOperationIsMerged == true) + } + } + + /// Tests for `MAP_SET` operations, covering RTLM7 specification points + struct MapSetOperationTests { + // MARK: - RTLM7a Tests (Existing Entry) + + struct ExistingEntryTests { + // @spec RTLM7a1 + @Test + func discardsOperationWhenCannotBeApplied() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + delegate: delegate, + coreSDK: coreSDK, + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + // Try to apply operation with lower timeserial (ts1 < ts2) + 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")?.stringValue == "existing") + // Verify that RTLM7c1 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"]) + } + + // @spec RTLM7a2 + // @specOneOf(1/2) RTLM7c1 + @Test(arguments: [ + // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectPool per RTLM7c) + (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 ObjectPool per RTLM7c) + (operationData: ObjectData(objectId: ""), expectedCreatedObjectID: nil), + // Case 3: ObjectData refers to an object value (should modify the ObjectPool per RTLM7c and RTLM7c1) + (operationData: ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), + ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) + func appliesOperationWhenCanBeApplied(operationData: ObjectData, expectedCreatedObjectID: String?) throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + delegate: delegate, + coreSDK: coreSDK, + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + 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 RTLM7b1 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") + if let numberValue = operationData.number { + #expect(result?.numberValue == numberValue.doubleValue) + } else if expectedCreatedObjectID != nil { + #expect(result?.liveMapValue != nil) + } + + // RTLM7a2a: 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) + + // RTLM7c/RTLM7c1: 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"]) + } + } + } + + // MARK: - RTLM7b Tests (No Existing Entry) + + struct NoExistingEntryTests { + // @spec RTLM7b1 + // @spec RTLM7b2 + // @specOneOf(2/2) RTLM7c1 + @Test(arguments: [ + // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectPool per RTLM7c) + (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 ObjectPool per RTLM7c) + (operationData: ObjectData(objectId: ""), expectedCreatedObjectID: nil), + // Case 3: ObjectData refers to an object value (should modify the ObjectPool per RTLM7c and RTLM7c1) + (operationData: ObjectData(objectId: "map:referenced@123"), expectedCreatedObjectID: "map:referenced@123"), + ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) + func createsNewEntryWhenNoExistingEntry(operationData: ObjectData, expectedCreatedObjectID: String?) throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + 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 RTLM7b1 using map.get it can return a referenced object) + if let expectedCreatedObjectID { + delegate.objects[expectedCreatedObjectID] = pool.entries[expectedCreatedObjectID] + } + + // Verify new entry was created + // RTLM7b1 + let result = try map.get(key: "newKey") + 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) + + // RTLM7c/RTLM7c1: 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"]) + } + } + } + + // MARK: - RTLM7c1 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 RTLM7c1 is applied. + @Test + func doesNotReplaceExistingObjectWhenReferencedByMapSet() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + + // Create an existing object in the pool with some data + let existingObjectId = "map:existing@123" + let existingObject = DefaultLiveMap( + testsOnly_data: [:], + delegate: delegate, + coreSDK: coreSDK, + ) + var pool = ObjectsPool( + rootDelegate: delegate, + rootCoreSDK: coreSDK, + 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") + #expect(referenceValue?.liveMapValue != nil) + } + } + + /// Tests for `MAP_REMOVE` operations, covering RTLM8 specification points + struct MapRemoveOperationTests { + // MARK: - RTLM8a Tests (Existing Entry) + + struct ExistingEntryTests { + // @spec RTLM8a1 + @Test + func discardsOperationWhenCannotBeApplied() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + delegate: delegate, + coreSDK: coreSDK, + ) + + // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 + map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts1") + + // Verify the operation was discarded - existing data unchanged + #expect(try map.get(key: "key1")?.stringValue == "existing") + } + + // @spec RTLM8a2a + // @spec RTLM8a2b + // @spec RTLM8a2c + @Test + func appliesOperationWhenCanBeApplied() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + delegate: delegate, + coreSDK: coreSDK, + ) + + // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 + map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts2") + + // Verify the operation was applied + #expect(try map.get(key: "key1") == nil) + + // RTLM8a2a: Set ObjectsMapEntry.data to undefined/null + let entry = map.testsOnly_data["key1"] + #expect(entry?.data.string == nil) + #expect(entry?.data.number == nil) + #expect(entry?.data.boolean == nil) + #expect(entry?.data.bytes == nil) + #expect(entry?.data.objectId == 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) + } + } + + // MARK: - RTLM8b Tests (No Existing Entry) + + struct NoExistingEntryTests { + // @spec RTLM8b1 - Create new entry with ObjectsMapEntry.data set to undefined/null and operation's serial + @Test + func createsNewEntryWhenNoExistingEntry() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + + map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") + + // Verify new entry was created + let entry = map.testsOnly_data["newKey"] + #expect(entry != nil) + #expect(entry?.timeserial == "ts1") + #expect(entry?.data.string == nil) + #expect(entry?.data.number == nil) + #expect(entry?.data.boolean == nil) + #expect(entry?.data.bytes == nil) + #expect(entry?.data.objectId == nil) + } + + // @spec RTLM8b2 - Set ObjectsMapEntry.tombstone for new entry to true + @Test + func setsNewEntryTombstoneToTrue() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + + map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") + + // 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 delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], + delegate: delegate, + coreSDK: coreSDK, + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + map.testsOnly_applyMapSetOperation( + key: "key1", + operationTimeserial: operationSerial, + operationData: ObjectData(string: .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")?.stringValue == "new") + } else { + // Verify operation was discarded + #expect(try map.get(key: "key1")?.stringValue == "existing") + } + } + } +} diff --git a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift new file mode 100644 index 000000000..b70dffd19 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift @@ -0,0 +1,667 @@ +import Ably +@testable import AblyLiveObjects +import AblyPlugin +import Testing + +/// Tests for `DefaultRealtimeObjects`. +struct DefaultRealtimeObjectsTests { + // MARK: - Test Helpers + + /// Creates a DefaultRealtimeObjects instance for testing + static func createDefaultRealtimeObjects(channelState: ARTRealtimeChannelState = .attached) -> DefaultRealtimeObjects { + let coreSDK = MockCoreSDK(channelState: channelState) + let logger = TestLogger() + return DefaultRealtimeObjects(coreSDK: coreSDK, logger: logger) + } + + /// Tests for `DefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. + struct HandleObjectSyncProtocolMessageTests { + // MARK: - RTO5a5: Single ProtocolMessage Sync Tests + + // @spec RTO5a5 + @Test + func handlesSingleProtocolMessageSync() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + 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) + realtimeObjects.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 RTO5b + // @spec RTO5c3 + // @spec RTO5c4 + @Test + func handlesMultiProtocolMessageSync() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let sequenceId = "seq123" + + // First message in sequence + let firstMessages = [TestFactories.simpleMapMessage(objectId: "map:1@123")] + realtimeObjects.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")] + realtimeObjects.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")] + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: finalMessages, + protocolMessageChannelSerial: "\(sequenceId):", // Empty cursor indicates end + ) + + // Verify sync sequence is cleared and there is no SyncObjectsPool (RTO5c3, RTO5c4) + #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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let firstSequenceId = "seq1" + let secondSequenceId = "seq2" + + // Start first sequence + let firstMessages = [TestFactories.simpleMapMessage(objectId: "map:1@123")] + realtimeObjects.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")] + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: secondMessages, + protocolMessageChannelSerial: "\(secondSequenceId):cursor1", + ) + + // Verify sync sequence is still active but with new ID + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Complete the new sequence + realtimeObjects.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 + + // @spec(RTO5c2, RTO5c2a) Objects not in sync are removed, except root + @Test + func removesObjectsNotInSyncButPreservesRoot() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Perform sync with only one object (RTO5a5 case) + let syncMessages = [TestFactories.mapObjectMessage(objectId: "map:synced@1")] + realtimeObjects.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 + + // Note: We rely on applySyncObjectsPool being tested separately for RTO5c2 removal behavior + // as the side effect of removing pre-existing objects is tested in ObjectsPoolTests + } + + // MARK: - Error Handling Tests + + /// Test handling of invalid channelSerial format + @Test + func handlesInvalidChannelSerialFormat() async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectMessages = [TestFactories.mapObjectMessage(objectId: "map:1@123")] + + // Call with invalid channelSerial (missing colon) + realtimeObjects.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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectMessages = [TestFactories.mapObjectMessage(objectId: "map:1@123")] + + // Start sequence with empty sequence ID + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: ":cursor1", + ) + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // End sequence with empty sequence ID + realtimeObjects.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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + let mixedMessages = [ + TestFactories.mapObjectMessage(objectId: "map:1@123"), + TestFactories.counterObjectMessage(objectId: "counter:1@456"), + TestFactories.mapObjectMessage(objectId: "map:2@789"), + ] + + realtimeObjects.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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Start first sequence + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [TestFactories.mapObjectMessage(objectId: "map:old@1")], + protocolMessageChannelSerial: "oldSeq:cursor1", + ) + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Interrupt with new sequence + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [TestFactories.mapObjectMessage(objectId: "map:new@1")], + protocolMessageChannelSerial: "newSeq:cursor1", + ) + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // Continue new sequence + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [TestFactories.mapObjectMessage(objectId: "map:new@2")], + protocolMessageChannelSerial: "newSeq:cursor2", + ) + + // Complete new sequence + realtimeObjects.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 `DefaultRealtimeObjects.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 any internal state + @Test + func doesNotModifyStateWhenHasObjectsIsTrue() { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // 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", coreSDK: MockCoreSDK(channelState: .attaching)) + + // Set up an in-progress sync sequence + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:sync@456"), + ], + protocolMessageChannelSerial: "seq1:cursor1", + ) + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // When: onChannelAttached is called with hasObjects = true + realtimeObjects.onChannelAttached(hasObjects: true) + + // Then: Nothing should be modified + #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) + } + + // MARK: - RTO4b Tests + + // @spec RTO4b1 + // @spec RTO4b2 + // @spec RTO4b3 + // @spec RTO4b4 + @Test + func handlesHasObjectsFalse() { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Set up initial state with additional objects in the pool using sync + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:existing@123"), + TestFactories.counterObjectMessage(objectId: "counter:existing@456"), + ], + protocolMessageChannelSerial: nil, // Complete sync immediately + ) + + let originalPool = realtimeObjects.testsOnly_objectsPool + + // Set up an in-progress sync sequence + realtimeObjects.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 + realtimeObjects.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 root is a new zero-valued map (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 + let newRoot = newPool.root + #expect(newRoot as AnyObject !== originalPool.root as AnyObject) // Should be a new instance + #expect(newRoot.testsOnly_data.isEmpty) // Should be zero-valued (empty) + + // RTO4b3, RTO4b4: SyncObjectsPool must be cleared, sync sequence cleared + #expect(!realtimeObjects.testsOnly_hasSyncSequence) + } + + // MARK: - Edge Cases and Integration Tests + + /// Test that multiple calls to onChannelAttached work correctly + @Test + func handlesMultipleCallsCorrectly() { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // First call with hasObjects = true (should do nothing) + realtimeObjects.onChannelAttached(hasObjects: true) + #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == true) + let originalPool = realtimeObjects.testsOnly_objectsPool + let originalRoot = originalPool.root + + // Second call with hasObjects = false (should reset) + realtimeObjects.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) + let secondResetRoot = newPool.root + realtimeObjects.onChannelAttached(hasObjects: true) + #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == true) + let finalPool = realtimeObjects.testsOnly_objectsPool + #expect(finalPool.root as AnyObject === secondResetRoot as AnyObject) // Should be unchanged + } + + /// Test that sync sequence is properly discarded even with complex sync state + @Test + func discardsComplexSyncSequence() { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Create a complex sync sequence using OBJECT_SYNC messages + // (This simulates realistic multi-message sync scenarios) + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:sync1@123"), + ], + protocolMessageChannelSerial: "seq1:cursor1", + ) + + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.counterObjectMessage(objectId: "counter:sync1@456"), + ], + protocolMessageChannelSerial: "seq1:cursor2", + ) + + #expect(realtimeObjects.testsOnly_hasSyncSequence) + + // When: onChannelAttached is called with hasObjects = false + realtimeObjects.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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Add some objects to the pool using OBJECT_SYNC messages + // (This is the realistic way objects enter the pool, not through direct manipulation) + realtimeObjects.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 + realtimeObjects.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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // When: onChannelAttached is called with hasObjects = false + realtimeObjects.onChannelAttached(hasObjects: false) + + // Then: The new root should have the correct delegate + let newRoot = realtimeObjects.testsOnly_objectsPool.root + #expect(newRoot.testsOnly_delegate as AnyObject === realtimeObjects as AnyObject) + } + } + + /// Tests for `DefaultRealtimeObjects.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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Start getRoot call - it should wait for sync completion + async let getRootTask = realtimeObjects.getRoot() + + // 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) + realtimeObjects.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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Start getRoot call - it should wait for sync completion + async let getRootTask = realtimeObjects.getRoot() + + // 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") + realtimeObjects.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")?.stringValue + #expect(testValue == "testValue") + + // Verify the root object contains a reference to the other LiveObject + let referencedObject = try root.get(key: "referencedObject") + #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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let sequenceId = "seq123" + + // Start getRoot call - it should wait for sync completion + async let getRootTask = realtimeObjects.getRoot() + + // 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") + realtimeObjects.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 + realtimeObjects.handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:second@456"), + ], + protocolMessageChannelSerial: "\(sequenceId):cursor2", + ) + + // Complete sync sequence (RTO5a4) - add final object + realtimeObjects.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")?.stringValue + let firstObject = try root.get(key: "firstObject") + let secondObject = try root.get(key: "secondObject") + let finalObject = try root.get(key: "finalObject") + #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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Complete sync first + realtimeObjects.onChannelAttached(hasObjects: false) + + // getRoot should return + _ = try await realtimeObjects.getRoot() + + // 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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + + // Complete sync first + realtimeObjects.onChannelAttached(hasObjects: false) + + // Call getRoot + let root = try await realtimeObjects.getRoot() + + // 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 [ARTRealtimeChannelState]) + func getRootThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects(channelState: channelState) + + await #expect { + _ = try await realtimeObjects.getRoot() + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 90001 + } + } + } +} diff --git a/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift b/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift new file mode 100644 index 000000000..876416449 --- /dev/null +++ b/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/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift new file mode 100644 index 000000000..b61414c01 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -0,0 +1,554 @@ +@testable import AblyLiveObjects +import AblyPlugin +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 +/// /// - delegate: The delegate for the map (default: MockLiveMapObjectPoolDelegate()) +/// /// - Returns: A configured DefaultLiveMap instance +/// static func liveMap( +/// objectId: String = "map:test@123", +/// entries: [String: String] = [:], +/// delegate: LiveMapObjectPoolDelegate = MockLiveMapObjectPoolDelegate() +/// ) -> DefaultLiveMap { +/// let map = DefaultLiveMap.createZeroValued(delegate: delegate) +/// // 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, + ) -> InboundObjectMessage { + InboundObjectMessage( + id: id, + clientId: clientId, + connectionId: connectionId, + extras: extras, + timestamp: timestamp, + operation: operation, + object: object, + serial: serial, + siteCode: siteCode, + ) + } + + /// 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", + mapOp: ObjectsMapOp? = nil, + counterOp: WireObjectsCounterOp? = nil, + map: ObjectsMap? = nil, + counter: WireObjectsCounter? = nil, + nonce: String? = nil, + initialValue: Data? = nil, + initialValueEncoding: String? = nil, + ) -> ObjectOperation { + ObjectOperation( + action: action, + objectId: objectId, + mapOp: mapOp, + counterOp: counterOp, + map: map, + counter: counter, + nonce: nonce, + initialValue: initialValue, + initialValueEncoding: initialValueEncoding, + ) + } + + /// Creates a map create operation + static func mapCreateOperation( + objectId: String = "map:test@123", + entries: [String: ObjectsMapEntry]? = nil, + ) -> ObjectOperation { + objectOperation( + action: .known(.mapCreate), + objectId: objectId, + map: ObjectsMap( + 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, + counter: WireObjectsCounter(count: count.map { NSNumber(value: $0) }), + ) + } + + // 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 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: .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, + ) -> ObjectsMap { + ObjectsMap( + semantics: semantics, + entries: entries, + ) + } + + /// 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: .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: - 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: .string(value))) + } + return rootObjectMessage(entries: mapEntries) + } +} diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift new file mode 100644 index 000000000..6893a5f75 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -0,0 +1,30 @@ +import Ably +@testable import AblyLiveObjects + +final class MockCoreSDK: CoreSDK { + /// Synchronizes access to all of this instance's mutable state. + private let mutex = NSLock() + + private nonisolated(unsafe) var _channelState: ARTRealtimeChannelState + + init(channelState: ARTRealtimeChannelState) { + _channelState = channelState + } + + func sendObject(objectMessages _: [AblyLiveObjects.OutboundObjectMessage]) async throws(AblyLiveObjects.InternalError) { + protocolRequirementNotImplemented() + } + + var channelState: ARTRealtimeChannelState { + get { + mutex.withLock { + _channelState + } + } + set { + mutex.withLock { + _channelState = newValue + } + } + } +} diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift b/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift new file mode 100644 index 000000000..b094f2e97 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift @@ -0,0 +1,25 @@ +@testable import AblyLiveObjects +import Foundation + +/// A mock delegate that can return predefined objects +final class MockLiveMapObjectPoolDelegate: LiveMapObjectPoolDelegate { + private let mutex = NSLock() + private nonisolated(unsafe) var _objects: [String: ObjectsPool.Entry] = [:] + var objects: [String: ObjectsPool.Entry] { + get { + mutex.withLock { + _objects + } + } + + set { + mutex.withLock { + _objects = newValue + } + } + } + + func getObjectFromPool(id: String) -> ObjectsPool.Entry? { + objects[id] + } +} diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift new file mode 100644 index 000000000..2495cf3d5 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -0,0 +1,346 @@ +@testable import AblyLiveObjects +import AblyPlugin +import Testing + +struct ObjectsPoolTests { + /// Tests for the `createZeroValueObject` method, covering RTO6 specification points + struct CreateZeroValueObjectTests { + // @spec RTO6a + @Test + func returnsExistingObject() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) + + let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let map = try #require(result?.mapValue) + #expect(map as AnyObject === existingMap as AnyObject) + } + + // @spec RTO6b2 + @Test + func createsZeroValueMap() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let map = try #require(result?.mapValue) + + // Verify it was added to the pool + #expect(pool.entries["map:123@456"]?.mapValue != nil) + + // Verify the map has the delegate set + #expect(map.testsOnly_delegate as AnyObject === delegate as AnyObject) + // Verify the objectID is correctly set + #expect(map.testsOnly_objectID == "map:123@456") + } + + // @spec RTO6b3 + @Test + func createsZeroValueCounter() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + let result = pool.createZeroValueObject(forObjectID: "counter:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let counter = try #require(result?.counterValue) + #expect(try counter.value == 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 delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + let result = pool.createZeroValueObject(forObjectID: "invalid", mapDelegate: delegate, coreSDK: coreSDK) + #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 delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + + let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", mapDelegate: delegate, coreSDK: coreSDK) + #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 + @Test + func updatesExistingMapObject() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) + let logger = TestLogger() + + let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "updated_value") + let objectState = TestFactories.mapObjectState( + objectId: "map:hash@123", + siteTimeserials: ["site1": "ts1"], + entries: [key: entry], + ) + + pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // Verify the existing map was updated by checking side effects of DefaultLiveMap.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")?.stringValue == "updated_value") + // Checking site timeserials to verify they were updated by replaceData + #expect(updatedMap.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @specOneOf(2/2) RTO5c1a1 - Override the internal data for existing counter objects + @Test + func updatesExistingCounterObject() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let existingCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) + let logger = TestLogger() + + let objectState = TestFactories.counterObjectState( + objectId: "counter:hash@123", + siteTimeserials: ["site1": "ts1"], + count: 42, + ) + + pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // Verify the existing counter was updated by checking side effects of DefaultLiveCounter.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 == 42) + // Checking site timeserials to verify they were updated by replaceData + #expect(updatedCounter.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @spec RTO5c1b1a + @Test + func createsNewCounterObject() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + let logger = TestLogger() + + let objectState = TestFactories.counterObjectState( + objectId: "counter:hash@456", + siteTimeserials: ["site2": "ts2"], + count: 100, + ) + + pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // Verify a new counter was created and data was set by checking side effects of DefaultLiveCounter.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 == 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 delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + let logger = TestLogger() + + let (key, entry) = TestFactories.stringMapEntry(key: "key2", value: "new_value") + let objectState = TestFactories.mapObjectState( + objectId: "map:hash@789", + siteTimeserials: ["site3": "ts3"], + entries: [key: entry], + ) + + pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // Verify a new map was created and data was set by checking side effects of DefaultLiveMap.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")?.stringValue == "new_value") + // Checking site timeserials to verify they were set by replaceData + #expect(newMap.testsOnly_siteTimeserials == ["site3": "ts3"]) + // Verify delegate was set on the new map + #expect(newMap.testsOnly_delegate as AnyObject === delegate as AnyObject) + // Verify the objectID and semantics are correctly set per RTO5c1b1b + #expect(newMap.testsOnly_objectID == "map:hash@789") + #expect(newMap.testsOnly_semantics == .known(.lww)) + } + + // @spec RTO5c1b1c + @Test + func ignoresNonMapOrCounterObject() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + let logger = TestLogger() + + let validObjectState = TestFactories.counterObjectState( + objectId: "counter:hash@456", + siteTimeserials: ["site2": "ts2"], + count: 100, + ) + + let invalidObjectState = TestFactories.objectState(objectId: "invalid") + + pool.applySyncObjectsPool([invalidObjectState, validObjectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // Check that there's no entry for the key that we don't know how to handle, and that it didn't interfere with the insertion of the we one that we do know how to handle + #expect(Set(pool.entries.keys) == ["root", "counter:hash@456"]) + } + + // MARK: - RTO5c2 Tests + + // @spec(RTO5c2) Remove objects not received during sync + @Test + func removesObjectsNotInSync() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let existingMap1 = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingMap2 = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: [ + "map:hash@1": .map(existingMap1), + "map:hash@2": .map(existingMap2), + "counter:hash@1": .counter(existingCounter), + ]) + let logger = TestLogger() + + // Only sync one of the existing objects + let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") + + pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // 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) Root object must not be removed + @Test + func doesNotRemoveRootObject() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) + let logger = TestLogger() + + // Sync with empty list (no objects) + pool.applySyncObjectsPool([], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // 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 + } + + // @spec(RTO5c1, RTO5c2) Complete sync scenario with mixed operations + @Test + func handlesComplexSyncScenario() throws { + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + + let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + let toBeRemovedMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: [ + "map:existing@1": .map(existingMap), + "counter:existing@1": .counter(existingCounter), + "map:toremove@1": .map(toBeRemovedMap), + ]) + let logger = TestLogger() + + let syncObjects = [ + // Update existing map + TestFactories.mapObjectState( + objectId: "map:existing@1", + siteTimeserials: ["site1": "ts1"], + entries: ["updated": TestFactories.mapEntry(data: ObjectData(string: .string("updated")))], + ), + // Update existing counter + TestFactories.counterObjectState( + objectId: "counter:existing@1", + siteTimeserials: ["site2": "ts2"], + count: 100, + ), + // Create new map + TestFactories.mapObjectState( + objectId: "map:new@1", + siteTimeserials: ["site3": "ts3"], + entries: ["new": TestFactories.mapEntry(data: ObjectData(string: .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 + ] + + pool.applySyncObjectsPool(syncObjects, mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + + // 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")?.stringValue == "updated") + + let updatedCounter = try #require(pool.entries["counter:existing@1"]?.counterValue) + // Checking counter value to verify replaceData was called successfully + #expect(try updatedCounter.value == 100) + + // 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")?.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 == 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/Tests/AblyLiveObjectsTests/SyncCursorTests.swift b/Tests/AblyLiveObjectsTests/SyncCursorTests.swift new file mode 100644 index 000000000..83f1e3734 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/SyncCursorTests.swift @@ -0,0 +1,106 @@ +@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 case let .other(.generic(underlyingError)) = error, + let syncError = underlyingError as? SyncCursor.Error, + case .channelSerialDoesNotMatchExpectedFormat = syncError + 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 case let .other(.generic(underlyingError)) = error, + let syncError = underlyingError as? SyncCursor.Error, + case .channelSerialDoesNotMatchExpectedFormat = syncError + 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) + } +} From 2fc37373fc41eca6702acc8b5d5a04fb8d79e13d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 30 Jun 2025 12:39:27 -0300 Subject: [PATCH 037/225] Add TestProxyTransport from ably-cocoa I'm going to port the JS integration tests to Swift, and need a way to intercept and inject ProtocolMessages. I didn't want to come up with something new, so have just copied this from ably-cocoa at 5fc038c and made the minimum amount of edits needed to get it to compile (mainly suppressing various concurrency warnings with @preconcurrency and @unchecked Sendable and nonisolated(unsafe)). --- .../TestProxyTransport.swift | 456 ++++++++++++++++++ 1 file changed, 456 insertions(+) create mode 100644 Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift new file mode 100644 index 000000000..154285cff --- /dev/null +++ b/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) + 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) + } + } +} From fa255c1e98f61aa65d40d7a66edcfb9b71a798ca Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 1 Jul 2025 12:16:07 -0300 Subject: [PATCH 038/225] Start porting the ably-js integration tests to Swift This is based on ably-js at 39e0677. I think that these tests will prove to be very useful and should help reduce the testing burden when implementing this plugin. I wrote an example of how to map the parameterised tests into Swift Testing, and wrote some of the Realtime-related helpers. Most of the laborious conversion of the tests and of ObjectsHelper was then done by Cursor, with some manual corrections. I have not looked in detail at its output and am taking an attitude of "something is better than nothing" towards this particular piece of work. I will implement the remaining test cases as we implement the relevant features. --- .../Internal/DefaultLiveMap.swift | 5 +- .../Helpers/Ably+Concurrency.swift | 12 + .../JS Integration Tests/ObjectsHelper.swift | 540 ++++++++++++ .../ObjectsIntegrationTests.swift | 787 ++++++++++++++++++ 4 files changed, 1343 insertions(+), 1 deletion(-) create mode 100644 Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift create mode 100644 Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index 8486702da..2711663a5 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -176,7 +176,10 @@ internal final class DefaultLiveMap: LiveMap { } internal var size: Int { - notYetImplemented() + mutex.withLock { + // TODO: this is not yet specified, but it seems like the obvious right thing and it unlocks some integration tests; add spec point once specified + mutableState.data.count + } } internal var entries: [(key: String, value: LiveMapValue)] { diff --git a/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift b/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift index 4a0bab3f4..b554a80d1 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift @@ -14,6 +14,18 @@ extension ARTRealtimeChannelProtocol { } }.get() } + + func detachAsync() async throws(ARTErrorInfo) { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + detach { error in + if let error { + continuation.resume(returning: .failure(error)) + } else { + continuation.resume(returning: .success(())) + } + } + }.get() + } } extension ARTRestProtocol { diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift new file mode 100644 index 000000000..bd81079ab --- /dev/null +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift @@ -0,0 +1,540 @@ +import Ably +@testable import AblyLiveObjects +import AblyPlugin +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 + + 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" + } + } + } + + // 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: JSONValue]? = nil) -> [String: JSONValue] { + var operation: [String: JSONValue] = [ + "action": .number(NSNumber(value: Actions.mapCreate.rawValue)), + "nonce": .string(nonce()), + "map": .object(["semantics": .number(NSNumber(value: 0))]), + ] + + if let objectId { + operation["objectId"] = .string(objectId) + } + + if let entries { + var mapValue = operation["map"]!.objectValue! + mapValue["entries"] = .object(entries) + operation["map"] = .object(mapValue) + } + + return ["operation": .object(operation)] + } + + /// Creates a map set operation + func mapSetOp(objectId: String, key: String, data: JSONValue) -> [String: JSONValue] { + [ + "operation": .object([ + "action": .number(NSNumber(value: Actions.mapSet.rawValue)), + "objectId": .string(objectId), + "mapOp": .object([ + "key": .string(key), + "data": data, + ]), + ]), + ] + } + + /// Creates a map remove operation + func mapRemoveOp(objectId: String, key: String) -> [String: JSONValue] { + [ + "operation": .object([ + "action": .number(NSNumber(value: Actions.mapRemove.rawValue)), + "objectId": .string(objectId), + "mapOp": .object([ + "key": .string(key), + ]), + ]), + ] + } + + /// Creates a counter create operation + func counterCreateOp(objectId: String? = nil, count: Int? = nil) -> [String: JSONValue] { + var operation: [String: JSONValue] = [ + "action": .number(NSNumber(value: Actions.counterCreate.rawValue)), + "nonce": .string(nonce()), + ] + + if let objectId { + operation["objectId"] = .string(objectId) + } + + if let count { + operation["counter"] = .object(["count": .number(NSNumber(value: count))]) + } + + return ["operation": .object(operation)] + } + + /// Creates a counter increment operation + func counterIncOp(objectId: String, amount: Int) -> [String: JSONValue] { + [ + "operation": .object([ + "action": .number(NSNumber(value: Actions.counterInc.rawValue)), + "objectId": .string(objectId), + "counterOp": .object([ + "amount": .number(NSNumber(value: amount)), + ]), + ]), + ] + } + + /// Creates an object delete operation + func objectDeleteOp(objectId: String) -> [String: JSONValue] { + [ + "operation": .object([ + "action": .number(NSNumber(value: Actions.objectDelete.rawValue)), + "objectId": .string(objectId), + ]), + ] + } + + /// Creates a map object structure + func mapObject( + objectId: String, + siteTimeserials: [String: String], + initialEntries: [String: JSONValue]? = nil, + materialisedEntries: [String: JSONValue]? = nil, + tombstone: Bool = false, + ) -> [String: JSONValue] { + var object: [String: JSONValue] = [ + "objectId": .string(objectId), + "siteTimeserials": .object(siteTimeserials.mapValues { .string($0) }), + "tombstone": .bool(tombstone), + "map": .object([ + "semantics": .number(NSNumber(value: 0)), + "entries": .object(materialisedEntries ?? [:]), + ]), + ] + + 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: JSONValue] { + let materialisedCountValue: JSONValue = if let materialisedCount { + .number(NSNumber(value: materialisedCount)) + } else { + .null + } + + var object: [String: JSONValue] = [ + "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: JSONValue]]? = nil, + ) -> [String: JSONValue] { + 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 JSONValue.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: JSONValue]]? = nil, + ) -> [String: JSONValue] { + let stateArray = state?.map { dict in JSONValue.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: JSONValue], + channel: ARTRealtimeChannel, + ) { + let jsonEncoder = ARTJsonEncoder() + let encoder = ARTJsonLikeEncoder( + rest: channel.internal.realtime!.rest, + delegate: jsonEncoder, + logger: channel.internal.logger, + ) + + let foundationObject = deserialized.toJSONSerializationInput + let protocolMessage = withExtendedLifetime(jsonEncoder) { + encoder.protocolMessage(from: foundationObject)! + } + + channel.internal.onChannelMessage(protocolMessage) + } + + /// Processes an object operation message on a channel + func processObjectOperationMessageOnChannel( + channel: ARTRealtimeChannel, + serial: String, + siteCode: String, + state: [[String: JSONValue]]? = nil, + ) async throws { + 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: JSONValue]]? = nil, + ) async throws { + 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 opBody: [String: JSONValue] = [ + "operation": .string(Actions.mapCreate.stringValue), + ] + + if let data { + opBody["data"] = .object(data) + } + + 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] { + [ + "operation": .string(Actions.mapSet.stringValue), + "objectId": .string(objectId), + "data": .object([ + "key": .string(key), + "value": .object(value), + ]), + ] + } + + /// Creates a map remove REST operation + func mapRemoveRestOp(objectId: String, key: String) -> [String: JSONValue] { + [ + "operation": .string(Actions.mapRemove.stringValue), + "objectId": .string(objectId), + "data": .object([ + "key": .string(key), + ]), + ] + } + + /// Creates a counter create REST operation + func counterCreateRestOp(objectId: String? = nil, nonce: String? = nil, number: Int? = nil) -> [String: JSONValue] { + var opBody: [String: JSONValue] = [ + "operation": .string(Actions.counterCreate.stringValue), + ] + + if let number { + opBody["data"] = .object(["number": .number(NSNumber(value: number))]) + } + + if let objectId { + opBody["objectId"] = .string(objectId) + opBody["nonce"] = .string(nonce ?? "") + } + + return opBody + } + + /// Creates a counter increment REST operation + func counterIncRestOp(objectId: String, number: Int) -> [String: JSONValue] { + [ + "operation": .string(Actions.counterInc.stringValue), + "objectId": .string(objectId), + "data": .object(["number": .number(NSNumber(value: number))]), + ] + } + + /// Sends an operation request to the REST API + private 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/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift new file mode 100644 index 000000000..9931a54ee --- /dev/null +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -0,0 +1,787 @@ +import Ably +@testable import AblyLiveObjects +import Testing + +// This file is copied from the file objects.test.js in ably-js. + +// Disable trailing_closure so that we can pass `action:` to the TestScenario initializer, consistent with the JS code +// swiftlint:disable trailing_closure + +// MARK: - Top-level helpers + +private func realtimeWithObjects(options: PartialClientOptions?) 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 options { + clientOptions.useBinaryProtocol = options.useBinaryProtocol + } + + return ARTRealtime(options: clientOptions) +} + +private func channelOptionsWithObjects() -> ARTRealtimeChannelOptions { + let options = ARTRealtimeChannelOptions() + options.modes = [.objectSubscribe, .objectPublish] + return options +} + +// Swift version of the JS lexicoTimeserial function +// +// Example: +// +// 01726585978590-001@abcdefghij:001 +// |____________| |_| |________| |_| +// | | | | +// timestamp counter seriesId idx +private func lexicoTimeserial(seriesId: String, timestamp: Int64, counter: Int, index: Int? = nil) -> String { + let paddedTimestamp = String(format: "%014d", timestamp) + let paddedCounter = String(format: "%03d", counter) + + var result = "\(paddedTimestamp)-\(paddedCounter)@\(seriesId)" + + if let index { + let paddedIndex = String(format: "%03d", index) + result += ":\(paddedIndex)" + } + + return result +} + +func monitorConnectionThenCloseAndFinishAsync(_ realtime: ARTRealtime, action: @escaping @Sendable () async throws -> Void) async throws { + defer { realtime.connection.close() } + + try await withThrowingTaskGroup { group in + // Monitor connection state + for state in [ARTRealtimeConnectionEvent.failed, .suspended] { + group.addTask { + let (stream, continuation) = AsyncThrowingStream.makeStream() + + let subscription = realtime.connection.on(state) { _ in + realtime.close() + + let error = NSError( + domain: "IntegrationTestsError", + code: 1, + userInfo: [ + NSLocalizedDescriptionKey: "Connection monitoring: state changed to \(state), aborting test", + ], + ) + continuation.finish(throwing: error) + } + continuation.onTermination = { _ in + realtime.connection.off(subscription) + } + + try await stream.first { _ in true } + } + } + + // Perform the action + group.addTask { + try await action() + } + + // Wait for either connection monitoring to throw an error or for the action to complete + guard let result = await group.nextResult() else { + return + } + + group.cancelAll() + try result.get() + } +} + +func waitFixtureChannelIsReady(_: ARTRealtime) async throws { + // TODO: Implement this using the subscription APIs once we've got a spec for those, but this should be fine for now + try await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC) +} + +func waitForMapKeyUpdate(_ map: any LiveMap, _ key: String) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + var subscription: SubscribeResponse! + subscription = map.subscribe { update in + if update.update[key] != nil { + subscription.unsubscribe() + continuation.resume() + } + } + } +} + +func waitForCounterUpdate(_ counter: any LiveCounter) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + var subscription: SubscribeResponse! + subscription = counter.subscribe { _ in + subscription.unsubscribe() + continuation.resume() + } + } +} + +// 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: - Constants + +private let objectsFixturesChannel = "objects_fixtures" + +// MARK: - Support for parameterised tests + +/// The output of `forScenarios`. One element of the one-dimensional arguments array that is passed to a Swift Testing test. +private struct TestCase: Identifiable, CustomStringConvertible { + var disabled: Bool + var scenario: TestScenario + var options: 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 options { + result += " (\(options.useBinaryProtocol ? "binary" : "text"))" + } + + return result + } +} + +/// Enables `TestCase`'s conformance to `Identifiable`. +private struct TestCaseID: Encodable, Hashable { + var description: String + var options: PartialClientOptions? +} + +private struct PartialClientOptions: Encodable, Hashable { + var useBinaryProtocol: Bool +} + +/// The input to `forScenarios`. +private struct TestScenario { + var disabled: Bool + var allTransportsAndProtocols: Bool + var description: String + var action: @Sendable (Context) async throws -> Void +} + +private func forScenarios(_ scenarios: [TestScenario]) -> [TestCase] { + scenarios.map { scenario -> [TestCase] in + if scenario.allTransportsAndProtocols { + [ + PartialClientOptions(useBinaryProtocol: true), + PartialClientOptions(useBinaryProtocol: false), + ].map { options -> TestCase in + .init( + disabled: scenario.disabled, + scenario: scenario, + options: options, + channelName: "\(scenario.description) \(options.useBinaryProtocol ? "binary" : "text")", + ) + } + } else { + [.init(disabled: scenario.disabled, scenario: scenario, options: nil, channelName: scenario.description)] + } + } + .flatMap(\.self) +} + +private protocol Scenarios { + associatedtype Context + static var scenarios: [TestScenario] { get } +} + +private extension Scenarios { + static var testCases: [TestCase] { + forScenarios(scenarios) + } +} + +// MARK: - Test lifecycle + +/// Creates the fixtures on ``objectsFixturesChannel`` if not yet created. +/// +/// This fulfils the role of JS's `before` hook. +private actor ObjectsFixturesTrait: SuiteTrait, TestScoping { + private actor SetupManager { + private var setupTask: Task? + + func setUpFixtures() async throws { + let setupTask: Task = if let existingSetupTask = self.setupTask { + existingSetupTask + } else { + Task { + let helper = try await ObjectsHelper() + try await helper.initForChannel(objectsFixturesChannel) + } + } + + try await setupTask.value + } + } + + private static let setupManager = SetupManager() + + func provideScope(for _: Test, testCase _: Test.Case?, performing function: () async throws -> Void) async throws { + try await Self.setupManager.setUpFixtures() + try await function() + } +} + +extension Trait where Self == ObjectsFixturesTrait { + static var objectsFixtures: Self { Self() } +} + +// MARK: - Test suite + +@Suite(.objectsFixtures) +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: PartialClientOptions? + } + + static let scenarios: [TestScenario] = { + let objectSyncSequenceScenarios: [TestScenario] = [ + .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(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(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") + } + + // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 + withExtendedLifetime(channel) {} + }, + ), + .init( + disabled: true, // Uses LiveMap.set which we haven't implemented yet + allTransportsAndProtocols: true, + description: "OBJECT_SYNC sequence builds object tree with all operations applied", + action: { ctx in + let root = ctx.root + let objects = ctx.objects + + // Create the promise first, before the operations that will trigger it + async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(root, "counter") + } + group.addTask { + await waitForMapKeyUpdate(root, "map") + } + while try await group.next() != nil {} + } + + // MAP_CREATE + let map = try await objects.createMap(entries: ["shouldStay": .primitive(.string("foo")), "shouldDelete": .primitive(.string("bar"))]) + // COUNTER_CREATE + let counter = try await objects.createCounter(count: 1) + + // Set the values and await the promise + 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, objectsCreatedPromise) + + // Create the promise first, before the operations that will trigger it + async let operationsAppliedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(map, "anotherKey") + } + group.addTask { + await waitForMapKeyUpdate(map, "shouldDelete") + } + group.addTask { + await waitForCounterUpdate(counter) + } + while try await group.next() != nil {} + } + + // Perform the operations and await the promise + async let setAnotherKeyPromise: Void = map.set(key: "anotherKey", value: .primitive(.string("baz"))) + async let removeKeyPromise: Void = map.remove(key: "shouldDelete") + async let incrementPromise: Void = counter.increment(amount: 10) + _ = try await (setAnotherKeyPromise, removeKeyPromise, incrementPromise, operationsAppliedPromise) + + // 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(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") + + // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 + withExtendedLifetime(channel2) {} + } + }, + ), + .init( + disabled: true, // Uses LiveMap.set which we haven't implemented yet + 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 + + // Create the promise first, before the operations that will trigger it + async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(root, "counter") + } + group.addTask { + await waitForMapKeyUpdate(root, "map") + } + while try await group.next() != nil {} + } + + let map = try await objects.createMap() + let counter = try await objects.createCounter() + + // Set the values and await the promise + 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, objectsCreatedPromise) + + 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: 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") + } + + // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 + withExtendedLifetime(channel) {} + }, + ), + .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(emptyMap.size == 0, "Check empty map in root has no keys") + + let referencedMap = try #require(root.get(key: "referencedMap")?.liveMapValue) + #expect(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(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(mapFromValuesMap.size == 1, "Check nested map has correct number of keys") + + // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 + withExtendedLifetime(channel) {} + }, + ), + .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(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") + + // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 + withExtendedLifetime(channel) {} + }, + ), + .init( + disabled: true, // This relies on the LiveMap.get returning `nil` when the referenced object's internal `tombstone` flag is true; this is not yet specified, have asked in https://ably-real-time.slack.com/archives/D067YAXGYQ5/p1751376526929339 + allTransportsAndProtocols: false, + description: "OBJECT_SYNC sequence with object state \"tombstone\" property creates tombstoned object", + action: { ctx in + let root = ctx.root + let objectsHelper = ctx.objectsHelper + let channel = ctx.channel + + let mapId = objectsHelper.fakeMapObjectId() + let counterId = objectsHelper.fakeCounterObjectId() + + try 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: true, // Uses LiveMap.subscribe (through waitForMapKeyUpdate) which we haven't implemented yet. It also seems to rely on the same internal `tombstone` flag as the previous test. + 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 + + async let counterCreatedPromise: Void = waitForMapKeyUpdate(root, "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 + try 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: true, // Uses LiveMap.subscribe (through waitForMapKeyUpdate) which we haven't implemented yet + 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 + + async let counterCreatedPromise: Void = waitForMapKeyUpdate(root, "counter") + let counterResult = try await objectsHelper.createAndSetOnMap( + channelName: channelName, + mapObjectId: "root", + key: "counter", + createOp: objectsHelper.counterCreateRestOp(number: 1), + ) + _ = await counterCreatedPromise + + async let counterSubPromise: Void = withCheckedThrowingContinuation { continuation in + do { + try #require(root.get(key: "counter")?.liveCounterValue).subscribe { update in + #expect(update.amount == -1, "Check counter subscription callback is called with an expected update object after OBJECT_SYNC sequence with \"tombstone=true\"") + continuation.resume() + } + } catch { + continuation.resume(throwing: error) + } + } + + // inject an OBJECT_SYNC message where a counter is now tombstoned + try 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 + }, + ), + ] + + let applyOperationsScenarios: [TestScenario] = [ + // TODO: Implement these scenarios + ] + + let applyOperationsDuringSyncScenarios: [TestScenario] = [ + // TODO: Implement these scenarios + ] + + let writeApiScenarios: [TestScenario] = [ + // TODO: Implement these scenarios + ] + + 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, + ), + ) + + // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 + withExtendedLifetime(channel) {} + } + } + + // TODO: Implement the remaining scenarios +} + +// swiftlint:enable trailing_closure From 200a9ca3be50ac64b3148cfe9dc7dc5671c0924c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 4 Jul 2025 14:40:20 -0300 Subject: [PATCH 039/225] Mark remaining LiveMap accessors as throwing Motivation as in 3f6de86; the new spec points in [1] tell us these can throw. [1] https://github.com/ably/specification/pull/341 --- Sources/AblyLiveObjects/Public/PublicTypes.swift | 8 ++++---- .../ObjectsIntegrationTests.swift | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 425953b75..88d3bd1e5 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -211,16 +211,16 @@ public protocol LiveMap: LiveObject where Update == LiveMapUpdate { func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? /// Returns the number of key-value pairs in the map. - var size: Int { get } + 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 } + var entries: [(key: String, value: LiveMapValue)] { get throws(ARTErrorInfo) } /// Returns an array of keys in the map. - var keys: [String] { get } + var keys: [String] { get throws(ARTErrorInfo) } /// Returns an iterable of values in the map. - var values: [LiveMapValue] { get } + 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. /// diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 9931a54ee..502d0b91d 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -295,7 +295,7 @@ private struct ObjectsIntegrationTests { let mapKeys = ["emptyMap", "referencedMap", "valuesMap"] let rootKeysCount = counterKeys.count + mapKeys.count - #expect(root.size == rootKeysCount, "Check root has correct number of keys") + #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)) @@ -319,7 +319,7 @@ private struct ObjectsIntegrationTests { "falseKey", "mapKey", ] - #expect(valuesMap.size == valueMapKeys.count, "Check nested map has correct number of keys") + #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") } @@ -391,7 +391,7 @@ private struct ObjectsIntegrationTests { #expect(try counter2.value == 11, "Check counter has correct value") let map2 = try #require(root2.get(key: "map")?.liveMapValue) - #expect(map2.size == 2, "Check map has correct number of keys") + #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") @@ -492,16 +492,16 @@ private struct ObjectsIntegrationTests { let root = try await objects.getRoot() let emptyMap = try #require(root.get(key: "emptyMap")?.liveMapValue) - #expect(emptyMap.size == 0, "Check empty map in root has no keys") + #expect(try emptyMap.size == 0, "Check empty map in root has no keys") let referencedMap = try #require(root.get(key: "referencedMap")?.liveMapValue) - #expect(referencedMap.size == 1, "Check referenced map in root has correct number of keys") + #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(valuesMap.size == 9, "Check values map in root has correct number of keys") + #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") @@ -513,7 +513,7 @@ private struct ObjectsIntegrationTests { #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(mapFromValuesMap.size == 1, "Check nested map has correct number of keys") + #expect(try mapFromValuesMap.size == 1, "Check nested map has correct number of keys") // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 withExtendedLifetime(channel) {} @@ -543,7 +543,7 @@ private struct ObjectsIntegrationTests { #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(mapFromValuesMap.size == 1, "Check nested map has correct number of keys") + #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") // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 From d82d90dfad6dd7a1e9686014c8c867698ab58482 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 4 Jul 2025 16:16:55 -0300 Subject: [PATCH 040/225] Introduce status code into invalid channel state error I didn't do this in cb427d8 because the specification hadn't yet specified the status code (it was an outstanding question on the PR at time of implementing), but the newly-written spec [1] for other LiveMap getter methods _does_ specify the status code as being 400. So DRY up the creation of these errors, and supply a status code (assuming that the spec will be updated to specify 400 for these existing ones too). [1] https://github.com/ably/specification/pull/341 --- .../DefaultRealtimeObjects.swift | 6 ++- .../Internal/DefaultLiveCounter.swift | 6 ++- .../Internal/DefaultLiveMap.swift | 6 ++- Sources/AblyLiveObjects/Utility/Errors.swift | 41 +++++++++++++++++++ .../DefaultLiveCounterTests.swift | 2 +- .../DefaultLiveMapTests.swift | 2 +- .../DefaultRealtimeObjectsTests.swift | 2 +- 7 files changed, 59 insertions(+), 6 deletions(-) create mode 100644 Sources/AblyLiveObjects/Utility/Errors.swift diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index fa12ed9b4..2e6ea38e6 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -90,7 +90,11 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD // RTO1b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 let currentChannelState = coreSDK.channelState if currentChannelState == .detached || currentChannelState == .failed { - throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "getRoot operation failed (invalid channel state: \(currentChannelState))") + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "getRoot", + channelState: currentChannelState, + ) + .toARTErrorInfo() } let syncStatus = mutex.withLock { diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift index 1246d1b24..020bd5357 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -69,7 +69,11 @@ internal final class DefaultLiveCounter: LiveCounter { // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 let currentChannelState = coreSDK.channelState if currentChannelState == .detached || currentChannelState == .failed { - throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "LiveCounter.value operation failed (invalid channel state: \(currentChannelState))") + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveCounter.value", + channelState: currentChannelState, + ) + .toARTErrorInfo() } return mutex.withLock { diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index 2711663a5..3d781f1ce 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -108,7 +108,11 @@ internal final class DefaultLiveMap: LiveMap { // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 let currentChannelState = coreSDK.channelState if currentChannelState == .detached || currentChannelState == .failed { - throw ARTErrorInfo.create(withCode: Int(ARTErrorCode.channelOperationFailedInvalidState.rawValue), message: "LiveMap.get operation failed (invalid channel state: \(currentChannelState))") + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveMap.get", + channelState: currentChannelState, + ) + .toARTErrorInfo() } let entry = mutex.withLock { diff --git a/Sources/AblyLiveObjects/Utility/Errors.swift b/Sources/AblyLiveObjects/Utility/Errors.swift new file mode 100644 index 000000000..e44d47f98 --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/Errors.swift @@ -0,0 +1,41 @@ +import Ably + +/** + Describes the errors that can be thrown by the LiveObjects SDK. Use ``toARTErrorInfo()`` to convert to an `ARTErrorInfo` that you can throw. + */ +internal enum LiveObjectsError { + // operationDescription should be a description of a method like "LiveCounter.value"; it will be interpolated into an error message + case objectsOperationFailedInvalidChannelState(operationDescription: String, channelState: ARTRealtimeChannelState) + + /// The ``ARTErrorInfo/code`` that should be returned for this error. + internal var code: ARTErrorCode { + switch self { + case .objectsOperationFailedInvalidChannelState: + .channelOperationFailedInvalidState + } + } + + /// The ``ARTErrorInfo/statusCode`` that should be returned for this error. + internal var statusCode: Int { + switch self { + case .objectsOperationFailedInvalidChannelState: + 400 + } + } + + /// 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))" + } + } + + internal func toARTErrorInfo() -> ARTErrorInfo { + ARTErrorInfo.create( + withCode: Int(code.rawValue), + status: statusCode, + message: localizedDescription, + ) + } +} diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift index 973e1e6e7..17839c95c 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift @@ -18,7 +18,7 @@ struct DefaultLiveCounterTests { return false } - return errorInfo.code == 90001 + return errorInfo.code == 90001 && errorInfo.statusCode == 400 } } diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift index c0d3725bc..05c493fef 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -18,7 +18,7 @@ struct DefaultLiveMapTests { return false } - return errorInfo.code == 90001 + return errorInfo.code == 90001 && errorInfo.statusCode == 400 } } diff --git a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift index b70dffd19..f6e9d69ff 100644 --- a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift @@ -660,7 +660,7 @@ struct DefaultRealtimeObjectsTests { return false } - return errorInfo.code == 90001 + return errorInfo.code == 90001 && errorInfo.statusCode == 400 } } } From 8d881e23eaceb7fa9d3b18c11f4a5532a256e4b6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 4 Jul 2025 14:42:19 -0300 Subject: [PATCH 041/225] Implement remaining LiveMap access API properties Based on [1] at 7d4c215. A few outstanding questions on the PR; have implemented based on my current understanding of what's there. Development approach similar to that described in cb427d8. Also, have not implemented the specification points related to RTO2's channel mode checking for same reasons as mentioned there. [1] https://github.com/ably/specification/pull/341 --- .../Internal/DefaultLiveMap.swift | 167 +++++++++++------ .../DefaultLiveMapTests.swift | 168 ++++++++++++++++++ 2 files changed, 279 insertions(+), 56 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index 3d781f1ce..a8ed390f4 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -124,78 +124,74 @@ internal final class DefaultLiveMap: LiveMap { return nil } - // RTLM5d2: If a ObjectsMapEntry exists at the key - - // 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 .primitive(.bool(boolean)) - } - - // RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it - if let bytes = entry.data.bytes { - return .primitive(.data(bytes)) - } + // RTLM5d2: If a ObjectsMapEntry exists at the key, convert it using the shared logic + return convertEntryToLiveMapValue(entry) + } - // RTLM5d2d: If ObjectsMapEntry.data.number exists, return it - if let number = entry.data.number { - return .primitive(.number(number.doubleValue)) - } + internal var size: Int { + get throws(ARTErrorInfo) { + // RTLM10c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveMap.size", + channelState: currentChannelState, + ) + .toARTErrorInfo() + } - // RTLM5d2e: If ObjectsMapEntry.data.string exists, return it - if let string = entry.data.string { - switch string { - case let .string(string): - return .primitive(.string(string)) - case .json: - // TODO: Understand how to handle JSON values (https://github.com/ably/specification/pull/333/files#r2164561055) - notYetImplemented() + return mutex.withLock { + // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map + mutableState.data.values.count { entry in + // RTLM14a: The method returns true if ObjectsMapEntry.tombstone is true + // RTLM14b: Otherwise, it returns false + entry.tombstone != true + } } } + } - // 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 = delegate.referenced?.getObjectFromPool(id: objectId) else { - return nil + internal var entries: [(key: String, value: LiveMapValue)] { + get throws(ARTErrorInfo) { + // RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveMap.entries", + channelState: currentChannelState, + ) + .toARTErrorInfo() } - // RTLM5d2f2: If an object with id objectId exists, return it - switch poolEntry { - case let .map(map): - return .liveMap(map) - case let .counter(counter): - return .liveCounter(counter) - } - } + return mutex.withLock { + // RTLM11d: Returns key-value pairs from the internal data map + // RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned + var result: [(key: String, value: LiveMapValue)] = [] - // RTLM5d2g: Otherwise, return undefined/null - return nil - } + for (key, entry) in mutableState.data { + // Convert entry to LiveMapValue using the same logic as get(key:) + if let value = convertEntryToLiveMapValue(entry) { + result.append((key: key, value: value)) + } + } - internal var size: Int { - mutex.withLock { - // TODO: this is not yet specified, but it seems like the obvious right thing and it unlocks some integration tests; add spec point once specified - mutableState.data.count + return result + } } } - internal var entries: [(key: String, value: LiveMapValue)] { - notYetImplemented() - } - internal var keys: [String] { - notYetImplemented() + get throws(ARTErrorInfo) { + // RTLM12b: Identical to LiveMap#entries, except that it returns only the keys from the internal data map + try entries.map(\.key) + } } internal var values: [LiveMapValue] { - notYetImplemented() + get throws(ARTErrorInfo) { + // RTLM13b: Identical to LiveMap#entries, except that it returns only the values from the internal data map + try entries.map(\.value) + } } internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { @@ -445,4 +441,63 @@ internal final class DefaultLiveMap: LiveMap { } } } + + // MARK: - Helper Methods + + /// Converts an ObjectsMapEntry to LiveMapValue using the same logic as get(key:) + /// This is used by entries to ensure consistent value conversion + private func convertEntryToLiveMapValue(_ entry: ObjectsMapEntry) -> LiveMapValue? { + // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null + // This is also equivalent to the RTLM14 check + 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 .primitive(.bool(boolean)) + } + + // RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it + if let bytes = entry.data.bytes { + return .primitive(.data(bytes)) + } + + // RTLM5d2d: If ObjectsMapEntry.data.number exists, return it + if let number = entry.data.number { + return .primitive(.number(number.doubleValue)) + } + + // RTLM5d2e: If ObjectsMapEntry.data.string exists, return it + if let string = entry.data.string { + switch string { + case let .string(string): + return .primitive(.string(string)) + case .json: + // TODO: Understand how to handle JSON values (https://github.com/ably/specification/pull/333/files#r2164561055) + notYetImplemented() + } + } + + // 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 = delegate.referenced?.getObjectFromPool(id: objectId) else { + return nil + } + + // RTLM5d2f2: If an object with id objectId exists, return it + 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/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift index 05c493fef..97a8c10c7 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -263,6 +263,174 @@ struct DefaultLiveMapTests { } } + /// 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 [ARTRealtimeChannelState]) + func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + let map = DefaultLiveMap.createZeroValued(delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) + + // Define actions to test + let actions: [(String, () throws -> Any)] = [ + ("size", { try map.size }), + ("entries", { try map.entries }), + ("keys", { try map.keys }), + ("values", { try map.values }), + ] + + // 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 coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: [ + // tombstone is nil, so not considered tombstoned + "active1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))), + // tombstone is false, so not considered tombstoned[ + "active2": TestFactories.mapEntry(tombstone: false, data: ObjectData(string: .string("value2"))), + "tombstoned": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned"))), + "tombstoned2": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned2"))), + ], + delegate: nil, + coreSDK: coreSDK, + ) + + // Test size - should only count non-tombstoned entries + let size = try map.size + #expect(size == 2) + + // Test entries - should only return non-tombstoned entries + let entries = try map.entries + #expect(entries.count == 2) + #expect(Set(entries.map(\.key)) == ["active1", "active2"]) + #expect(entries.first { $0.key == "active1" }?.value.stringValue == "value1") + #expect(entries.first { $0.key == "active2" }?.value.stringValue == "value2") + + // Test keys - should only return keys from non-tombstoned entries + let keys = try map.keys + #expect(keys.count == 2) + #expect(Set(keys) == ["active1", "active2"]) + + // Test values - should only return values from non-tombstoned entries + let values = try map.values + #expect(values.count == 2) + #expect(Set(values.compactMap(\.stringValue)) == Set(["value1", "value2"])) + } + + // MARK: - Consistency Tests + + // @specOneOf(2/2) RTLM10d + // @specOneOf(2/2) RTLM12b + // @specOneOf(2/2) RTLM13b + @Test + func allAccessPropertiesReturnExpectedValuesAndAreConsistentWithEachOther() throws { + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: [ + "key1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))), + "key2": TestFactories.mapEntry(data: ObjectData(string: .string("value2"))), + "key3": TestFactories.mapEntry(data: ObjectData(string: .string("value3"))), + ], + delegate: nil, + coreSDK: coreSDK, + ) + + let size = try map.size + let entries = try map.entries + let keys = try map.keys + let values = try map.values + + // 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 delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + + // Create referenced objects for testing + let referencedMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let referencedCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + delegate.objects["map:ref@123"] = .map(referencedMap) + delegate.objects["counter:ref@456"] = .counter(referencedCounter) + + let map = DefaultLiveMap( + testsOnly_data: [ + "boolean": TestFactories.mapEntry(data: ObjectData(boolean: true)), // RTLM5d2b + "bytes": TestFactories.mapEntry(data: ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c + "number": TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 42))), // RTLM5d2d + "string": TestFactories.mapEntry(data: ObjectData(string: .string("hello"))), // RTLM5d2e + "mapRef": TestFactories.mapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 + "counterRef": TestFactories.mapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 + ], + delegate: delegate, + coreSDK: coreSDK, + ) + + let size = try map.size + let entries = try map.entries + let keys = try map.keys + let values = try map.values + + #expect(size == 6) + #expect(entries.count == 6) + #expect(keys.count == 6) + #expect(values.count == 6) + + // 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 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(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: - RTLM7a Tests (Existing Entry) From fd0db2301bf24a116763516c2961ae87640be001 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 7 Jul 2025 14:50:58 -0300 Subject: [PATCH 042/225] Fix nullability and default values of common LiveObject properties This is based on the values stated in RTOL3 of [1] at 29276a5. (We'll formalise the fact that these are common LiveObject properties in an upcoming commit.) [1] https://github.com/ably/specification/pull/343 --- .../Internal/DefaultLiveCounter.swift | 18 ++-- .../Internal/DefaultLiveMap.swift | 18 ++-- .../Internal/ObjectsPool.swift | 2 +- .../DefaultLiveCounterTests.swift | 39 ++++++--- .../DefaultLiveMapTests.swift | 82 ++++++++++++------- .../ObjectsPoolTests.swift | 20 ++--- 6 files changed, 111 insertions(+), 68 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift index 020bd5357..4f1453ba7 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -8,19 +8,19 @@ internal final class DefaultLiveCounter: LiveCounter { private nonisolated(unsafe) var mutableState: MutableState - internal var testsOnly_siteTimeserials: [String: String]? { + internal var testsOnly_siteTimeserials: [String: String] { mutex.withLock { mutableState.siteTimeserials } } - internal var testsOnly_createOperationIsMerged: Bool? { + internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { mutableState.createOperationIsMerged } } - internal var testsOnly_objectID: String? { + internal var testsOnly_objectID: String { mutex.withLock { mutableState.objectID } @@ -32,7 +32,7 @@ internal final class DefaultLiveCounter: LiveCounter { internal convenience init( testsOnly_data data: Double, - objectID: String?, + objectID: String, coreSDK: CoreSDK ) { self.init(data: data, objectID: objectID, coreSDK: coreSDK) @@ -40,7 +40,7 @@ internal final class DefaultLiveCounter: LiveCounter { private init( data: Double, - objectID: String?, + objectID: String, coreSDK: CoreSDK ) { mutableState = .init(data: data, objectID: objectID) @@ -52,7 +52,7 @@ internal final class DefaultLiveCounter: LiveCounter { /// - Parameters: /// - objectID: The value for the "private objectId field" of RTO5c1b1a. internal static func createZeroValued( - objectID: String? = nil, + objectID: String, coreSDK: CoreSDK, ) -> Self { .init( @@ -123,13 +123,13 @@ internal final class DefaultLiveCounter: LiveCounter { internal var data: Double /// The site timeserials for this counter, per RTLC6a. - internal var siteTimeserials: [String: String]? + internal var siteTimeserials: [String: String] = [:] /// Whether the create operation has been merged, per RTLC6b and RTLC6d2. - internal var createOperationIsMerged: Bool? + internal var createOperationIsMerged = false /// The "private `objectId` field" of RTO5c1b1a. - internal var objectID: String? + internal var objectID: String /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. internal mutating func replaceData(using state: ObjectState) { diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index a8ed390f4..c432b5ec1 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -19,7 +19,7 @@ internal final class DefaultLiveMap: LiveMap { } } - internal var testsOnly_objectID: String? { + internal var testsOnly_objectID: String { mutex.withLock { mutableState.objectID } @@ -31,13 +31,13 @@ internal final class DefaultLiveMap: LiveMap { } } - internal var testsOnly_siteTimeserials: [String: String]? { + internal var testsOnly_siteTimeserials: [String: String] { mutex.withLock { mutableState.siteTimeserials } } - internal var testsOnly_createOperationIsMerged: Bool? { + internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { mutableState.createOperationIsMerged } @@ -55,7 +55,7 @@ internal final class DefaultLiveMap: LiveMap { internal convenience init( testsOnly_data data: [String: ObjectsMapEntry], - objectID: String? = nil, + objectID: String, testsOnly_semantics semantics: WireEnum? = nil, delegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK @@ -71,7 +71,7 @@ internal final class DefaultLiveMap: LiveMap { private init( data: [String: ObjectsMapEntry], - objectID: String?, + objectID: String, semantics: WireEnum?, delegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK @@ -87,7 +87,7 @@ internal final class DefaultLiveMap: LiveMap { /// - objectID: The value to use for the "private `objectId` field" of RTO5c1b1b. /// - semantics: The value to use for the "private `semantics` field" of RTO5c1b1b. internal static func createZeroValued( - objectID: String? = nil, + objectID: String, semantics: WireEnum? = nil, delegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK, @@ -275,16 +275,16 @@ internal final class DefaultLiveMap: LiveMap { internal var data: [String: ObjectsMapEntry] /// The "private `objectId` field" of RTO5c1b1b. - internal var objectID: String? + internal var objectID: String /// The "private `semantics` field" of RTO5c1b1b. internal var semantics: WireEnum? /// The site timeserials for this map, per RTLM6a. - internal var siteTimeserials: [String: String]? + internal var siteTimeserials: [String: String] = [:] /// Whether the create operation has been merged, per RTLM6b and RTLM6d2. - internal var createOperationIsMerged: Bool? + internal var createOperationIsMerged = false /// Replaces the internal data of this map with the provided ObjectState, per RTLM6. /// diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 11d8d0017..7fb7ce0fb 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -60,7 +60,7 @@ internal struct ObjectsPool { ) { entries = otherEntries ?? [:] // TODO: What initial root entry to use? https://github.com/ably/specification/pull/333/files#r2152312933 - entries[Self.rootKey] = .map(.createZeroValued(delegate: rootDelegate, coreSDK: rootCoreSDK)) + entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, delegate: rootDelegate, coreSDK: rootCoreSDK)) } // MARK: - Typed root diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift index 17839c95c..2c8df459f 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift @@ -9,7 +9,7 @@ struct DefaultLiveCounterTests { // @spec RTLC5b @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func valueThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: channelState)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: channelState)) #expect { _ = try counter.value @@ -25,7 +25,7 @@ struct DefaultLiveCounterTests { // @spec RTLC5c @Test func valueReturnsCurrentDataWhenChannelIsValid() throws { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attached)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attached)) // Set some test data counter.replaceData(using: TestFactories.counterObjectState(count: 42)) @@ -39,7 +39,7 @@ struct DefaultLiveCounterTests { // @spec RTLC6a @Test func replacesSiteTimeserials() { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) @@ -52,18 +52,35 @@ struct DefaultLiveCounterTests { // @spec RTLC6b - Tests the case without createOp, as RTLC6d2 takes precedence when createOp exists @Test func setsCreateOperationIsMergedToFalse() { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + // Given: A counter whose createOperationIsMerged is true + let counter = { + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + // Test setup: Manipulate counter so that its createOperationIsMerged gets set to true (we need to do this since we want to later assert that it gets set to false, but the default is false). + let state = TestFactories.counterObjectState( + createOp: TestFactories.objectOperation( + action: .known(.counterCreate), + ), + ) + counter.replaceData(using: state) + #expect(counter.testsOnly_createOperationIsMerged) + + return counter + }() + + // When: let state = TestFactories.counterObjectState( createOp: nil, // Test value - must be nil to test RTLC6b ) counter.replaceData(using: state) - #expect(counter.testsOnly_createOperationIsMerged == false) + + // Then: + #expect(!counter.testsOnly_createOperationIsMerged) } // @specOneOf(1/4) RTLC6c - count but no createOp @Test func setsDataToCounterCount() throws { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) let state = TestFactories.counterObjectState( count: 42, // Test value ) @@ -74,7 +91,7 @@ struct DefaultLiveCounterTests { // @specOneOf(2/4) RTLC6c - no count, no createOp @Test func setsDataToZeroWhenCounterCountDoesNotExist() throws { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) counter.replaceData(using: TestFactories.counterObjectState( count: nil, // Test value - must be nil )) @@ -88,7 +105,7 @@ struct DefaultLiveCounterTests { // @specOneOf(3/4) RTLC6c - count and createOp @Test func setsDataToCounterCountThenAddsCreateOpCounterCount() throws { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) let state = TestFactories.counterObjectState( createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist count: 5, // Test value - must exist @@ -101,7 +118,7 @@ struct DefaultLiveCounterTests { // @specOneOf(4/4) RTLC6c - no count but createOp @Test func doesNotModifyDataWhenCreateOpCounterCountDoesNotExist() throws { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) let state = TestFactories.counterObjectState( createOp: TestFactories.objectOperation( action: .known(.counterCreate), @@ -116,14 +133,14 @@ struct DefaultLiveCounterTests { // @spec RTLC6d2 @Test func setsCreateOperationIsMergedToTrue() { - let counter = DefaultLiveCounter.createZeroValued(coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) let state = TestFactories.counterObjectState( createOp: TestFactories.objectOperation( // Test value - must be non-nil action: .known(.counterCreate), ), ) counter.replaceData(using: state) - #expect(counter.testsOnly_createOperationIsMerged == true) + #expect(counter.testsOnly_createOperationIsMerged) } } } diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift index 97a8c10c7..9e0ab1507 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -9,7 +9,7 @@ struct DefaultLiveMapTests { // @spec RTLM5c @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func getThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let map = DefaultLiveMap.createZeroValued(delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) #expect { _ = try map.get(key: "test") @@ -28,7 +28,7 @@ struct DefaultLiveMapTests { @Test func returnsNilWhenNoEntryExists() throws { let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: MockLiveMapObjectPoolDelegate(), coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: coreSDK) #expect(try map.get(key: "nonexistent") == nil) } @@ -40,7 +40,7 @@ struct DefaultLiveMapTests { data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) #expect(try map.get(key: "key") == nil) } @@ -49,7 +49,7 @@ struct DefaultLiveMapTests { func returnsBooleanValue() throws { let entry = TestFactories.mapEntry(data: ObjectData(boolean: true)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) let result = try map.get(key: "key") #expect(result?.boolValue == true) } @@ -60,7 +60,7 @@ struct DefaultLiveMapTests { let bytes = Data([0x01, 0x02, 0x03]) let entry = TestFactories.mapEntry(data: ObjectData(bytes: bytes)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) let result = try map.get(key: "key") #expect(result?.dataValue == bytes) } @@ -70,7 +70,7 @@ struct DefaultLiveMapTests { func returnsNumberValue() throws { let entry = TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 123.456))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) let result = try map.get(key: "key") #expect(result?.numberValue == 123.456) } @@ -80,7 +80,7 @@ struct DefaultLiveMapTests { func returnsStringValue() throws { let entry = TestFactories.mapEntry(data: ObjectData(string: .string("test"))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) let result = try map.get(key: "key") #expect(result?.stringValue == "test") } @@ -91,7 +91,7 @@ struct DefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: "missing")) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) #expect(try map.get(key: "key") == nil) } @@ -102,10 +102,10 @@ struct DefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) delegate.objects[objectId] = .map(referencedMap) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) let result = try map.get(key: "key") let returnedMap = result?.liveMapValue #expect(returnedMap as AnyObject === referencedMap as AnyObject) @@ -118,9 +118,9 @@ struct DefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) delegate.objects[objectId] = .counter(referencedCounter) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) let result = try map.get(key: "key") let returnedCounter = result?.liveCounterValue #expect(returnedCounter as AnyObject === referencedCounter as AnyObject) @@ -133,7 +133,7 @@ struct DefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) #expect(try map.get(key: "key") == nil) } } @@ -145,7 +145,7 @@ struct DefaultLiveMapTests { func replacesSiteTimeserials() { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) let state = TestFactories.objectState( objectId: "arbitrary-id", siteTimeserials: ["site1": "ts1", "site2": "ts2"], @@ -158,13 +158,29 @@ struct DefaultLiveMapTests { // @spec RTLM6b @Test func setsCreateOperationIsMergedToFalseWhenCreateOpAbsent() { + // Given: let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) - let state = TestFactories.objectState(objectId: "arbitrary-id", createOp: nil) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + let map = { + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + + // Test setup: Manipulate map so that its createOperationIsMerged gets set to true (we need to do this since we want to later assert that it gets set to false, but the default is false). + let state = TestFactories.objectState( + createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), + ) + map.replaceData(using: state, objectsPool: &pool) + #expect(map.testsOnly_createOperationIsMerged) + + return map + }() + + // When: + let state = TestFactories.objectState(objectId: "arbitrary-id", createOp: nil) map.replaceData(using: state, objectsPool: &pool) - #expect(map.testsOnly_createOperationIsMerged == false) + + // Then: + #expect(!map.testsOnly_createOperationIsMerged) } // @specOneOf(1/2) RTLM6c @@ -172,7 +188,7 @@ struct DefaultLiveMapTests { func setsDataToMapEntries() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "test") let state = TestFactories.mapObjectState( objectId: "arbitrary-id", @@ -192,7 +208,7 @@ struct DefaultLiveMapTests { func appliesMapSetOperationFromCreateOp() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation( @@ -223,6 +239,7 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -252,14 +269,14 @@ struct DefaultLiveMapTests { func setsCreateOperationIsMergedToTrueWhenCreateOpPresent() { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), ) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) map.replaceData(using: state, objectsPool: &pool) - #expect(map.testsOnly_createOperationIsMerged == true) + #expect(map.testsOnly_createOperationIsMerged) } } @@ -273,7 +290,7 @@ struct DefaultLiveMapTests { // @spec RTLM13b @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let map = DefaultLiveMap.createZeroValued(delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) // Define actions to test let actions: [(String, () throws -> Any)] = [ @@ -315,6 +332,7 @@ struct DefaultLiveMapTests { "tombstoned": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned"))), "tombstoned2": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned2"))), ], + objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, ) @@ -355,6 +373,7 @@ struct DefaultLiveMapTests { "key2": TestFactories.mapEntry(data: ObjectData(string: .string("value2"))), "key3": TestFactories.mapEntry(data: ObjectData(string: .string("value3"))), ], + objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, ) @@ -386,8 +405,8 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Create referenced objects for testing - let referencedMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) - let referencedCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) delegate.objects["map:ref@123"] = .map(referencedMap) delegate.objects["counter:ref@456"] = .counter(referencedCounter) @@ -400,6 +419,7 @@ struct DefaultLiveMapTests { "mapRef": TestFactories.mapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 "counterRef": TestFactories.mapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 ], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -443,6 +463,7 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -477,6 +498,7 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -541,7 +563,7 @@ struct DefaultLiveMapTests { func createsNewEntryWhenNoExistingEntry(operationData: ObjectData, expectedCreatedObjectID: String?) throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) map.testsOnly_applyMapSetOperation( @@ -587,12 +609,13 @@ struct DefaultLiveMapTests { func doesNotReplaceExistingObjectWhenReferencedByMapSet() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) // Create an existing object in the pool with some data let existingObjectId = "map:existing@123" let existingObject = DefaultLiveMap( testsOnly_data: [:], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -634,6 +657,7 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -654,6 +678,7 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) @@ -688,7 +713,7 @@ struct DefaultLiveMapTests { func createsNewEntryWhenNoExistingEntry() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -708,7 +733,7 @@ struct DefaultLiveMapTests { func setsNewEntryTombstoneToTrue() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -771,6 +796,7 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], + objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, ) diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 2495cf3d5..40752e8e5 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -10,7 +10,7 @@ struct ObjectsPoolTests { func returnsExistingObject() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK) @@ -87,7 +87,7 @@ struct ObjectsPoolTests { func updatesExistingMapObject() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) let logger = TestLogger() @@ -114,7 +114,7 @@ struct ObjectsPoolTests { func updatesExistingCounterObject() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) let logger = TestLogger() @@ -221,9 +221,9 @@ struct ObjectsPoolTests { func removesObjectsNotInSync() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap1 = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) - let existingMap2 = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) - let existingCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) + let existingMap1 = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let existingMap2 = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: [ "map:hash@1": .map(existingMap1), @@ -250,7 +250,7 @@ struct ObjectsPoolTests { func doesNotRemoveRootObject() throws { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) let logger = TestLogger() @@ -269,9 +269,9 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) - let existingCounter = DefaultLiveCounter.createZeroValued(coreSDK: coreSDK) - let toBeRemovedMap = DefaultLiveMap.createZeroValued(delegate: delegate, coreSDK: coreSDK) + let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) + let toBeRemovedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: [ "map:existing@1": .map(existingMap), From 337c415b3632d235e8871c1df4e097e4f31fc04f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 7 Jul 2025 15:33:32 -0300 Subject: [PATCH 043/225] Extract LiveObject common mutable state into a new type Based on [1] at 29276a5. [1] https://github.com/ably/specification/pull/343 --- .../Internal/DefaultLiveCounter.swift | 26 +++++++---------- .../Internal/DefaultLiveMap.swift | 28 ++++++++----------- .../Internal/LiveObjectMutableState.swift | 11 ++++++++ 3 files changed, 32 insertions(+), 33 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift index 4f1453ba7..708eef208 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -10,19 +10,19 @@ internal final class DefaultLiveCounter: LiveCounter { internal var testsOnly_siteTimeserials: [String: String] { mutex.withLock { - mutableState.siteTimeserials + mutableState.liveObject.siteTimeserials } } internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { - mutableState.createOperationIsMerged + mutableState.liveObject.createOperationIsMerged } } internal var testsOnly_objectID: String { mutex.withLock { - mutableState.objectID + mutableState.liveObject.objectID } } @@ -43,7 +43,7 @@ internal final class DefaultLiveCounter: LiveCounter { objectID: String, coreSDK: CoreSDK ) { - mutableState = .init(data: data, objectID: objectID) + mutableState = .init(liveObject: .init(objectID: objectID), data: data) self.coreSDK = coreSDK } @@ -119,25 +119,19 @@ internal final class DefaultLiveCounter: LiveCounter { // MARK: - Mutable state and the operations that affect it private struct MutableState { + /// The mutable state common to all LiveObjects. + internal var liveObject: LiveObjectMutableState + /// The internal data that this map holds, per RTLC3. internal var data: Double - /// The site timeserials for this counter, per RTLC6a. - internal var siteTimeserials: [String: String] = [:] - - /// Whether the create operation has been merged, per RTLC6b and RTLC6d2. - internal var createOperationIsMerged = false - - /// The "private `objectId` field" of RTO5c1b1a. - internal var objectID: String - /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. internal mutating func replaceData(using state: ObjectState) { // RTLC6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials - siteTimeserials = state.siteTimeserials + liveObject.siteTimeserials = state.siteTimeserials // RTLC6b: Set the private flag createOperationIsMerged to false - createOperationIsMerged = false + liveObject.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 @@ -149,7 +143,7 @@ internal final class DefaultLiveCounter: LiveCounter { data += createOpCount } // RTLC6d2: Set the private flag createOperationIsMerged to true - createOperationIsMerged = true + liveObject.createOperationIsMerged = true } } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index c432b5ec1..1a5535049 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -21,7 +21,7 @@ internal final class DefaultLiveMap: LiveMap { internal var testsOnly_objectID: String { mutex.withLock { - mutableState.objectID + mutableState.liveObject.objectID } } @@ -33,13 +33,13 @@ internal final class DefaultLiveMap: LiveMap { internal var testsOnly_siteTimeserials: [String: String] { mutex.withLock { - mutableState.siteTimeserials + mutableState.liveObject.siteTimeserials } } internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { - mutableState.createOperationIsMerged + mutableState.liveObject.createOperationIsMerged } } @@ -76,7 +76,7 @@ internal final class DefaultLiveMap: LiveMap { delegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK ) { - mutableState = .init(data: data, objectID: objectID, semantics: semantics) + mutableState = .init(liveObject: .init(objectID: objectID), data: data, semantics: semantics) self.delegate = .init(referenced: delegate) self.coreSDK = coreSDK } @@ -84,7 +84,7 @@ internal final class DefaultLiveMap: LiveMap { /// Creates a "zero-value LiveMap", per RTLM4. /// /// - Parameters: - /// - objectID: The value to use for the "private `objectId` field" of RTO5c1b1b. + /// - 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, @@ -271,21 +271,15 @@ internal final class DefaultLiveMap: LiveMap { // MARK: - Mutable state and the operations that affect it private struct MutableState { + /// The mutable state common to all LiveObjects. + internal var liveObject: LiveObjectMutableState + /// The internal data that this map holds, per RTLM3. internal var data: [String: ObjectsMapEntry] - /// The "private `objectId` field" of RTO5c1b1b. - internal var objectID: String - /// The "private `semantics` field" of RTO5c1b1b. internal var semantics: WireEnum? - /// The site timeserials for this map, per RTLM6a. - internal var siteTimeserials: [String: String] = [:] - - /// Whether the create operation has been merged, per RTLM6b and RTLM6d2. - internal var createOperationIsMerged = false - /// Replaces the internal data of this map with the provided ObjectState, per RTLM6. /// /// - Parameters: @@ -297,10 +291,10 @@ internal final class DefaultLiveMap: LiveMap { coreSDK: CoreSDK, ) { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials - siteTimeserials = state.siteTimeserials + liveObject.siteTimeserials = state.siteTimeserials // RTLM6b: Set the private flag createOperationIsMerged to false - createOperationIsMerged = false + liveObject.createOperationIsMerged = false // RTLM6c: Set data to ObjectState.map.entries, or to an empty map if it does not exist data = state.map?.entries ?? [:] @@ -332,7 +326,7 @@ internal final class DefaultLiveMap: LiveMap { } } // RTLM6d2: Set the private flag createOperationIsMerged to true - createOperationIsMerged = true + liveObject.createOperationIsMerged = true } } diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift new file mode 100644 index 000000000..d0043c8ab --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -0,0 +1,11 @@ +/// This is the equivalent of the `LiveObject` abstract class described in RTLO. +/// +/// ``DefaultLiveCounter`` and ``DefaultLiveMap`` include it by composition. +internal struct LiveObjectMutableState { + // RTLO3a + internal var objectID: String + // RTLO3b + internal var siteTimeserials: [String: String] = [:] + // RTLO3c + internal var createOperationIsMerged = false +} From 0a2e72a5a8fc2c51b04c7e4624174d25a70d8169 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 7 Jul 2025 17:03:32 -0300 Subject: [PATCH 044/225] Add logger to DefaultLiveCounter and DefaultLiveMap All done by Cursor at my instruction. --- .../DefaultRealtimeObjects.swift | 6 +- .../Internal/DefaultLiveCounter.swift | 13 +- .../Internal/DefaultLiveMap.swift | 19 ++- .../Internal/ObjectsPool.swift | 16 ++- .../DefaultLiveCounterTests.swift | 27 +++-- .../DefaultLiveMapTests.swift | 111 ++++++++++++------ .../ObjectsPoolTests.swift | 77 ++++++------ 7 files changed, 174 insertions(+), 95 deletions(-) diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 2e6ea38e6..0e01a02de 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -73,7 +73,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() - mutableState = .init(objectsPool: .init(rootDelegate: self, rootCoreSDK: coreSDK)) + mutableState = .init(objectsPool: .init(rootDelegate: self, rootCoreSDK: coreSDK, logger: logger)) } // MARK: - LiveMapObjectPoolDelegate @@ -193,7 +193,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD /// Intended as a way for tests to populate the object pool. internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String, coreSDK: CoreSDK) -> ObjectsPool.Entry? { mutex.withLock { - mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, mapDelegate: self, coreSDK: coreSDK) + mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, mapDelegate: self, coreSDK: coreSDK, logger: logger) } } @@ -242,7 +242,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD // RTO4b1, RTO4b2: Reset the ObjectsPool to have a single empty root object // 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 - objectsPool = .init(rootDelegate: mapDelegate, rootCoreSDK: coreSDK) + objectsPool = .init(rootDelegate: mapDelegate, rootCoreSDK: coreSDK, logger: logger) // 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. diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift index 708eef208..a07088318 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -1,4 +1,5 @@ import Ably +internal import AblyPlugin import Foundation /// Our default implementation of ``LiveCounter``. @@ -27,24 +28,28 @@ internal final class DefaultLiveCounter: LiveCounter { } private let coreSDK: CoreSDK + private let logger: AblyPlugin.Logger // MARK: - Initialization internal convenience init( testsOnly_data data: Double, objectID: String, - coreSDK: CoreSDK + coreSDK: CoreSDK, + logger: AblyPlugin.Logger ) { - self.init(data: data, objectID: objectID, coreSDK: coreSDK) + self.init(data: data, objectID: objectID, coreSDK: coreSDK, logger: logger) } private init( data: Double, objectID: String, - coreSDK: CoreSDK + coreSDK: CoreSDK, + logger: AblyPlugin.Logger ) { mutableState = .init(liveObject: .init(objectID: objectID), data: data) self.coreSDK = coreSDK + self.logger = logger } /// Creates a "zero-value LiveCounter", per RTLC4. @@ -54,11 +59,13 @@ internal final class DefaultLiveCounter: LiveCounter { internal static func createZeroValued( objectID: String, coreSDK: CoreSDK, + logger: AblyPlugin.Logger, ) -> Self { .init( data: 0, objectID: objectID, coreSDK: coreSDK, + logger: logger, ) } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index 1a5535049..83bdf160d 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -1,4 +1,5 @@ import Ably +internal import AblyPlugin /// Protocol for accessing objects from the ObjectsPool. This is used by a LiveMap when it needs to return an object given an object ID. internal protocol LiveMapObjectPoolDelegate: AnyObject, Sendable { @@ -50,6 +51,7 @@ internal final class DefaultLiveMap: LiveMap { } private let coreSDK: CoreSDK + private let logger: AblyPlugin.Logger // MARK: - Initialization @@ -58,7 +60,8 @@ internal final class DefaultLiveMap: LiveMap { objectID: String, testsOnly_semantics semantics: WireEnum? = nil, delegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK + coreSDK: CoreSDK, + logger: AblyPlugin.Logger ) { self.init( data: data, @@ -66,6 +69,7 @@ internal final class DefaultLiveMap: LiveMap { semantics: semantics, delegate: delegate, coreSDK: coreSDK, + logger: logger, ) } @@ -74,11 +78,13 @@ internal final class DefaultLiveMap: LiveMap { objectID: String, semantics: WireEnum?, delegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK + coreSDK: CoreSDK, + logger: AblyPlugin.Logger ) { mutableState = .init(liveObject: .init(objectID: objectID), data: data, semantics: semantics) self.delegate = .init(referenced: delegate) self.coreSDK = coreSDK + self.logger = logger } /// Creates a "zero-value LiveMap", per RTLM4. @@ -91,6 +97,7 @@ internal final class DefaultLiveMap: LiveMap { semantics: WireEnum? = nil, delegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK, + logger: AblyPlugin.Logger, ) -> Self { .init( data: [:], @@ -98,6 +105,7 @@ internal final class DefaultLiveMap: LiveMap { semantics: semantics, delegate: delegate, coreSDK: coreSDK, + logger: logger, ) } @@ -231,6 +239,7 @@ internal final class DefaultLiveMap: LiveMap { objectsPool: &objectsPool, mapDelegate: delegate.referenced, coreSDK: coreSDK, + logger: logger, ) } } @@ -252,6 +261,7 @@ internal final class DefaultLiveMap: LiveMap { objectsPool: &objectsPool, mapDelegate: delegate.referenced, coreSDK: coreSDK, + logger: logger, ) } } @@ -289,6 +299,7 @@ internal final class DefaultLiveMap: LiveMap { objectsPool: inout ObjectsPool, mapDelegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK, + logger: AblyPlugin.Logger, ) { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials liveObject.siteTimeserials = state.siteTimeserials @@ -321,6 +332,7 @@ internal final class DefaultLiveMap: LiveMap { objectsPool: &objectsPool, mapDelegate: mapDelegate, coreSDK: coreSDK, + logger: logger, ) } } @@ -338,6 +350,7 @@ internal final class DefaultLiveMap: LiveMap { objectsPool: inout ObjectsPool, mapDelegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK, + logger: AblyPlugin.Logger, ) { // RTLM7a: If an entry exists in the private data for the specified key if let existingEntry = data[key] { @@ -364,7 +377,7 @@ internal final class DefaultLiveMap: LiveMap { // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute if let objectId = operationData.objectId, !objectId.isEmpty { // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 - _ = objectsPool.createZeroValueObject(forObjectID: objectId, mapDelegate: mapDelegate, coreSDK: coreSDK) + _ = objectsPool.createZeroValueObject(forObjectID: objectId, mapDelegate: mapDelegate, coreSDK: coreSDK, logger: logger) } } diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 7fb7ce0fb..41b6ab382 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -44,11 +44,13 @@ internal struct ObjectsPool { internal init( rootDelegate: LiveMapObjectPoolDelegate?, rootCoreSDK: CoreSDK, + logger: AblyPlugin.Logger, testsOnly_otherEntries otherEntries: [String: Entry]? = nil, ) { self.init( rootDelegate: rootDelegate, rootCoreSDK: rootCoreSDK, + logger: logger, otherEntries: otherEntries, ) } @@ -56,11 +58,12 @@ internal struct ObjectsPool { private init( rootDelegate: LiveMapObjectPoolDelegate?, rootCoreSDK: CoreSDK, + logger: AblyPlugin.Logger, 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, delegate: rootDelegate, coreSDK: rootCoreSDK)) + entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, delegate: rootDelegate, coreSDK: rootCoreSDK, logger: logger)) } // MARK: - Typed root @@ -87,8 +90,9 @@ internal struct ObjectsPool { /// - objectID: The ID of the object to create /// - mapDelegate: The delegate to use for any created LiveMap /// - coreSDK: The CoreSDK to use for any created LiveObject + /// - logger: The logger to use for any created LiveObject /// - Returns: The existing or newly created object - internal mutating func createZeroValueObject(forObjectID objectID: String, mapDelegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK) -> Entry? { + internal mutating func createZeroValueObject(forObjectID objectID: String, mapDelegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK, logger: AblyPlugin.Logger) -> Entry? { // RTO6a: If an object with objectId exists in ObjectsPool, do not create a new object if let existingEntry = entries[objectID] { return existingEntry @@ -106,9 +110,9 @@ internal struct ObjectsPool { let entry: Entry switch typeString { case "map": - entry = .map(.createZeroValued(objectID: objectID, delegate: mapDelegate, coreSDK: coreSDK)) + entry = .map(.createZeroValued(objectID: objectID, delegate: mapDelegate, coreSDK: coreSDK, logger: logger)) case "counter": - entry = .counter(.createZeroValued(objectID: objectID, coreSDK: coreSDK)) + entry = .counter(.createZeroValued(objectID: objectID, coreSDK: coreSDK, logger: logger)) default: return nil } @@ -159,14 +163,14 @@ internal struct ObjectsPool { if objectState.counter != nil { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 - let counter = DefaultLiveCounter.createZeroValued(objectID: objectState.objectId, coreSDK: coreSDK) + let counter = DefaultLiveCounter.createZeroValued(objectID: objectState.objectId, coreSDK: coreSDK, logger: logger) counter.replaceData(using: objectState) newEntry = .counter(counter) } else if let objectsMap = objectState.map { // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, // set its private objectId equal to ObjectState.objectId, set its private semantics // equal to ObjectState.map.semantics and override its internal data per RTLM6 - let map = DefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, delegate: mapDelegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, delegate: mapDelegate, coreSDK: coreSDK, logger: logger) map.replaceData(using: objectState, objectsPool: &self) newEntry = .map(map) } else { diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift index 2c8df459f..746bde7f0 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift @@ -9,7 +9,8 @@ struct DefaultLiveCounterTests { // @spec RTLC5b @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func valueThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: channelState)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: channelState), logger: logger) #expect { _ = try counter.value @@ -25,7 +26,8 @@ struct DefaultLiveCounterTests { // @spec RTLC5c @Test func valueReturnsCurrentDataWhenChannelIsValid() throws { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attached)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attached), logger: logger) // Set some test data counter.replaceData(using: TestFactories.counterObjectState(count: 42)) @@ -39,7 +41,8 @@ struct DefaultLiveCounterTests { // @spec RTLC6a @Test func replacesSiteTimeserials() { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) @@ -53,8 +56,9 @@ struct DefaultLiveCounterTests { @Test func setsCreateOperationIsMergedToFalse() { // Given: A counter whose createOperationIsMerged is true + let logger = TestLogger() let counter = { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) // 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( @@ -80,7 +84,8 @@ struct DefaultLiveCounterTests { // @specOneOf(1/4) RTLC6c - count but no createOp @Test func setsDataToCounterCount() throws { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) let state = TestFactories.counterObjectState( count: 42, // Test value ) @@ -91,7 +96,8 @@ struct DefaultLiveCounterTests { // @specOneOf(2/4) RTLC6c - no count, no createOp @Test func setsDataToZeroWhenCounterCountDoesNotExist() throws { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) counter.replaceData(using: TestFactories.counterObjectState( count: nil, // Test value - must be nil )) @@ -105,7 +111,8 @@ struct DefaultLiveCounterTests { // @specOneOf(3/4) RTLC6c - count and createOp @Test func setsDataToCounterCountThenAddsCreateOpCounterCount() throws { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) let state = TestFactories.counterObjectState( createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist count: 5, // Test value - must exist @@ -118,7 +125,8 @@ struct DefaultLiveCounterTests { // @specOneOf(4/4) RTLC6c - no count but createOp @Test func doesNotModifyDataWhenCreateOpCounterCountDoesNotExist() throws { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) let state = TestFactories.counterObjectState( createOp: TestFactories.objectOperation( action: .known(.counterCreate), @@ -133,7 +141,8 @@ struct DefaultLiveCounterTests { // @spec RTLC6d2 @Test func setsCreateOperationIsMergedToTrue() { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching)) + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) let state = TestFactories.counterObjectState( createOp: TestFactories.objectOperation( // Test value - must be non-nil action: .known(.counterCreate), diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift index 9e0ab1507..ce991d27d 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -9,7 +9,8 @@ struct DefaultLiveMapTests { // @spec RTLM5c @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func getThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) + let logger = TestLogger() + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState), logger: logger) #expect { _ = try map.get(key: "test") @@ -27,29 +28,32 @@ struct DefaultLiveMapTests { // @spec RTLM5d1 @Test func returnsNilWhenNoEntryExists() throws { + let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: coreSDK, logger: logger) #expect(try map.get(key: "nonexistent") == nil) } // @spec RTLM5d2a @Test func returnsNilWhenEntryIsTombstoned() throws { + let logger = TestLogger() let entry = TestFactories.mapEntry( tombstone: true, data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) #expect(try map.get(key: "key") == nil) } // @spec RTLM5d2b @Test func returnsBooleanValue() throws { + let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(boolean: true)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) let result = try map.get(key: "key") #expect(result?.boolValue == true) } @@ -57,10 +61,11 @@ struct DefaultLiveMapTests { // @spec RTLM5d2c @Test func returnsBytesValue() throws { + let logger = TestLogger() let bytes = Data([0x01, 0x02, 0x03]) let entry = TestFactories.mapEntry(data: ObjectData(bytes: bytes)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) let result = try map.get(key: "key") #expect(result?.dataValue == bytes) } @@ -68,9 +73,10 @@ struct DefaultLiveMapTests { // @spec RTLM5d2d @Test func returnsNumberValue() throws { + let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 123.456))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) let result = try map.get(key: "key") #expect(result?.numberValue == 123.456) } @@ -78,9 +84,10 @@ struct DefaultLiveMapTests { // @spec RTLM5d2e @Test func returnsStringValue() throws { + let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(string: .string("test"))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) let result = try map.get(key: "key") #expect(result?.stringValue == "test") } @@ -88,24 +95,26 @@ struct DefaultLiveMapTests { // @spec RTLM5d2f1 @Test func returnsNilWhenReferencedObjectDoesNotExist() throws { + let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(objectId: "missing")) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) #expect(try map.get(key: "key") == nil) } // @specOneOf(1/2) RTLM5d2f2 - Returns referenced map when it exists in pool @Test func returnsReferencedMap() throws { + let logger = TestLogger() let objectId = "map1" let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) delegate.objects[objectId] = .map(referencedMap) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) let result = try map.get(key: "key") let returnedMap = result?.liveMapValue #expect(returnedMap as AnyObject === referencedMap as AnyObject) @@ -114,13 +123,14 @@ struct DefaultLiveMapTests { // @specOneOf(2/2) RTLM5d2f2 - Returns referenced counter when it exists in pool @Test func returnsReferencedCounter() throws { + let logger = TestLogger() let objectId = "counter1" let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) + let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) delegate.objects[objectId] = .counter(referencedCounter) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) let result = try map.get(key: "key") let returnedCounter = result?.liveCounterValue #expect(returnedCounter as AnyObject === referencedCounter as AnyObject) @@ -129,11 +139,12 @@ struct DefaultLiveMapTests { // @spec RTLM5d2g @Test func returnsNullOtherwise() throws { + let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData()) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) #expect(try map.get(key: "key") == nil) } } @@ -143,14 +154,15 @@ struct DefaultLiveMapTests { // @spec RTLM6a @Test func replacesSiteTimeserials() { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) let state = TestFactories.objectState( objectId: "arbitrary-id", siteTimeserials: ["site1": "ts1", "site2": "ts2"], ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) } @@ -161,9 +173,10 @@ struct DefaultLiveMapTests { // Given: let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + let logger = TestLogger() + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) let map = { - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) // 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( @@ -186,15 +199,16 @@ struct DefaultLiveMapTests { // @specOneOf(1/2) RTLM6c @Test func setsDataToMapEntries() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "test") let state = TestFactories.mapObjectState( objectId: "arbitrary-id", entries: [key: entry], ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.replaceData(using: state, objectsPool: &pool) let newData = map.testsOnly_data #expect(newData.count == 1) @@ -206,9 +220,10 @@ struct DefaultLiveMapTests { // @spec RTLM6d1a @Test func appliesMapSetOperationFromCreateOp() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation( @@ -224,7 +239,7 @@ struct DefaultLiveMapTests { ], ), ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.replaceData(using: state, 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 entries (per RTLM6c) and also the createOp (per RTLM6d1a) @@ -235,6 +250,7 @@ struct DefaultLiveMapTests { // @spec RTLM6d1b @Test func appliesMapRemoveOperationFromCreateOp() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( @@ -242,6 +258,7 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) // Confirm that the initial data is there #expect(try map.get(key: "key1") != nil) @@ -257,7 +274,7 @@ struct DefaultLiveMapTests { entries: ["key1": entry], ), ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.replaceData(using: state, objectsPool: &pool) // Note that we just check for some basic expected side effects of applying MAP_REMOVE; RTLM8 is tested in more detail elsewhere // Check that MAP_REMOVE removed the initial data @@ -267,14 +284,15 @@ struct DefaultLiveMapTests { // @spec RTLM6d2 @Test func setsCreateOperationIsMergedToTrueWhenCreateOpPresent() { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) } @@ -290,7 +308,8 @@ struct DefaultLiveMapTests { // @spec RTLM13b @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState)) + let logger = TestLogger() + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState), logger: logger) // Define actions to test let actions: [(String, () throws -> Any)] = [ @@ -322,6 +341,7 @@ struct DefaultLiveMapTests { // @spec RTLM14 @Test func allPropertiesFilterOutTombstonedEntries() throws { + let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: [ @@ -335,6 +355,7 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, + logger: logger, ) // Test size - should only count non-tombstoned entries @@ -366,6 +387,7 @@ struct DefaultLiveMapTests { // @specOneOf(2/2) RTLM13b @Test func allAccessPropertiesReturnExpectedValuesAndAreConsistentWithEachOther() throws { + let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( testsOnly_data: [ @@ -376,6 +398,7 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, + logger: logger, ) let size = try map.size @@ -401,12 +424,13 @@ struct DefaultLiveMapTests { // @spec RTLM11d @Test func entriesHandlesAllValueTypes() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) // Create referenced objects for testing - let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) - let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) + let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) delegate.objects["map:ref@123"] = .map(referencedMap) delegate.objects["counter:ref@456"] = .counter(referencedCounter) @@ -422,6 +446,7 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) let size = try map.size @@ -459,6 +484,7 @@ struct DefaultLiveMapTests { // @spec RTLM7a1 @Test func discardsOperationWhenCannotBeApplied() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( @@ -466,8 +492,9 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) // Try to apply operation with lower timeserial (ts1 < ts2) map.testsOnly_applyMapSetOperation( @@ -494,6 +521,7 @@ struct DefaultLiveMapTests { (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 delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( @@ -501,8 +529,9 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.testsOnly_applyMapSetOperation( key: "key1", @@ -561,10 +590,11 @@ struct DefaultLiveMapTests { (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 delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.testsOnly_applyMapSetOperation( key: "newKey", @@ -607,9 +637,10 @@ struct DefaultLiveMapTests { // 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 RTLM7c1 is applied. @Test func doesNotReplaceExistingObjectWhenReferencedByMapSet() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) // Create an existing object in the pool with some data let existingObjectId = "map:existing@123" @@ -618,10 +649,12 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) var pool = ObjectsPool( rootDelegate: delegate, rootCoreSDK: coreSDK, + logger: logger, 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 @@ -653,6 +686,7 @@ struct DefaultLiveMapTests { // @spec RTLM8a1 @Test func discardsOperationWhenCannotBeApplied() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( @@ -660,6 +694,7 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 @@ -674,6 +709,7 @@ struct DefaultLiveMapTests { // @spec RTLM8a2c @Test func appliesOperationWhenCanBeApplied() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( @@ -681,6 +717,7 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 @@ -711,9 +748,10 @@ struct DefaultLiveMapTests { // @spec RTLM8b1 - Create new entry with ObjectsMapEntry.data set to undefined/null and operation's serial @Test func createsNewEntryWhenNoExistingEntry() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -731,9 +769,10 @@ struct DefaultLiveMapTests { // @spec RTLM8b2 - Set ObjectsMapEntry.tombstone for new entry to true @Test func setsNewEntryTombstoneToTrue() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -792,6 +831,7 @@ struct DefaultLiveMapTests { (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 delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = DefaultLiveMap( @@ -799,8 +839,9 @@ struct DefaultLiveMapTests { objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, + logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.testsOnly_applyMapSetOperation( key: "key1", diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 40752e8e5..985354a1f 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -8,12 +8,13 @@ struct ObjectsPoolTests { // @spec RTO6a @Test func returnsExistingObject() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) + let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) let map = try #require(result?.mapValue) #expect(map as AnyObject === existingMap as AnyObject) } @@ -21,11 +22,12 @@ struct ObjectsPoolTests { // @spec RTO6b2 @Test func createsZeroValueMap() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) let map = try #require(result?.mapValue) // Verify it was added to the pool @@ -40,11 +42,12 @@ struct ObjectsPoolTests { // @spec RTO6b3 @Test func createsZeroValueCounter() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) - let result = pool.createZeroValueObject(forObjectID: "counter:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let result = pool.createZeroValueObject(forObjectID: "counter:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) let counter = try #require(result?.counterValue) #expect(try counter.value == 0) @@ -57,22 +60,24 @@ struct ObjectsPoolTests { // 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 delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) - let result = pool.createZeroValueObject(forObjectID: "invalid", mapDelegate: delegate, coreSDK: coreSDK) + let result = pool.createZeroValueObject(forObjectID: "invalid", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) #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 delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) - let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", mapDelegate: delegate, coreSDK: coreSDK) + let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) #expect(result == nil) #expect(pool.entries["unknown:123@456"] == nil) } @@ -85,11 +90,11 @@ struct ObjectsPoolTests { // @specOneOf(1/2) RTO5c1a1 - Override the internal data for existing map objects @Test func updatesExistingMapObject() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) - let logger = TestLogger() + let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "updated_value") let objectState = TestFactories.mapObjectState( @@ -112,11 +117,11 @@ struct ObjectsPoolTests { // @specOneOf(2/2) RTO5c1a1 - Override the internal data for existing counter objects @Test func updatesExistingCounterObject() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) - let logger = TestLogger() + let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@123", @@ -138,10 +143,10 @@ struct ObjectsPoolTests { // @spec RTO5c1b1a @Test func createsNewCounterObject() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) - let logger = TestLogger() + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -164,11 +169,11 @@ struct ObjectsPoolTests { // @spec RTO5c1b1b @Test func createsNewMapObject() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) - let logger = TestLogger() + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) let (key, entry) = TestFactories.stringMapEntry(key: "key2", value: "new_value") let objectState = TestFactories.mapObjectState( @@ -195,10 +200,10 @@ struct ObjectsPoolTests { // @spec RTO5c1b1c @Test func ignoresNonMapOrCounterObject() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK) - let logger = TestLogger() + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) let validObjectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -219,18 +224,18 @@ struct ObjectsPoolTests { // @spec(RTO5c2) Remove objects not received during sync @Test func removesObjectsNotInSync() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap1 = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) - let existingMap2 = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) - let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) + let existingMap1 = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let existingMap2 = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: [ + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: [ "map:hash@1": .map(existingMap1), "map:hash@2": .map(existingMap2), "counter:hash@1": .counter(existingCounter), ]) - let logger = TestLogger() // Only sync one of the existing objects let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") @@ -248,11 +253,11 @@ struct ObjectsPoolTests { // @spec(RTO5c2a) Root object must not be removed @Test func doesNotRemoveRootObject() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) - let logger = TestLogger() + let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) // Sync with empty list (no objects) pool.applySyncObjectsPool([], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) @@ -266,19 +271,19 @@ struct ObjectsPoolTests { // @spec(RTO5c1, RTO5c2) Complete sync scenario with mixed operations @Test func handlesComplexSyncScenario() throws { + let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) - let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK) - let toBeRemovedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK) + let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) + let toBeRemovedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, testsOnly_otherEntries: [ + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: [ "map:existing@1": .map(existingMap), "counter:existing@1": .counter(existingCounter), "map:toremove@1": .map(toBeRemovedMap), ]) - let logger = TestLogger() let syncObjects = [ // Update existing map From d795f9e2948e054a5430d875bb91777f09b6409f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 7 Jul 2025 15:35:38 -0300 Subject: [PATCH 045/225] Implement the LiveObject.canApplyOperation method Based on [1] at 29276a5. Development approach as described in cb427d8. [1] https://github.com/ably/specification/pull/343 --- .../Internal/LiveObjectMutableState.swift | 39 ++++++ .../LiveObjectMutableStateTests.swift | 116 ++++++++++++++++++ 2 files changed, 155 insertions(+) create mode 100644 Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index d0043c8ab..c81bec58f 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -1,3 +1,5 @@ +internal import AblyPlugin + /// This is the equivalent of the `LiveObject` abstract class described in RTLO. /// /// ``DefaultLiveCounter`` and ``DefaultLiveMap`` include it by composition. @@ -8,4 +10,41 @@ internal struct LiveObjectMutableState { internal var siteTimeserials: [String: String] = [:] // RTLO3c internal var createOperationIsMerged = false + + /// 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 + } } diff --git a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift new file mode 100644 index 000000000..47c03cbba --- /dev/null +++ b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift @@ -0,0 +1,116 @@ +import Ably +@testable import AblyLiveObjects +import AblyPlugin +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", + 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)") + } + } +} From c315352683dbb461bbd5055e25616c3e1de6aee7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 8 Jul 2025 10:54:33 -0300 Subject: [PATCH 046/225] Pull "apply initial value" into a separate operation, per the spec This applies the changes from [1] at 29276a5. Development approach as described in cb427d8. [1] https://github.com/ably/specification/pull/343 --- .../Internal/DefaultLiveCounter.swift | 26 +++- .../Internal/DefaultLiveMap.swift | 82 +++++++---- .../DefaultLiveCounterTests.swift | 92 +++++++----- .../DefaultLiveMapTests.swift | 133 +++++++++++------- 4 files changed, 212 insertions(+), 121 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift index a07088318..98e4bbfe3 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -123,6 +123,13 @@ internal final class DefaultLiveCounter: LiveCounter { } } + /// Test-only method to merge initial value from an ObjectOperation, per RTLC10. + internal func testsOnly_mergeInitialValue(from operation: ObjectOperation) { + mutex.withLock { + mutableState.mergeInitialValue(from: operation) + } + } + // MARK: - Mutable state and the operations that affect it private struct MutableState { @@ -143,15 +150,20 @@ internal final class DefaultLiveCounter: LiveCounter { // 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 + // RTLC6d: If ObjectState.createOp is present, merge the initial value into the LiveCounter as described in RTLC10 if let createOp = state.createOp { - // RTLC6d1: Add ObjectState.createOp.counter.count to data, if it exists - if let createOpCount = createOp.counter?.count?.doubleValue { - data += createOpCount - } - // RTLC6d2: Set the private flag createOperationIsMerged to true - liveObject.createOperationIsMerged = true + mergeInitialValue(from: createOp) + } + } + + /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC10. + internal mutating func mergeInitialValue(from operation: ObjectOperation) { + // RTLC10a: Add ObjectOperation.counter.count to data, if it exists + if let operationCount = operation.counter?.count?.doubleValue { + data += operationCount } + // RTLC10b: Set the private flag createOperationIsMerged to true + liveObject.createOperationIsMerged = true } } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index 83bdf160d..806417351 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -244,6 +244,19 @@ internal final class DefaultLiveMap: LiveMap { } } + /// Test-only method to merge initial value from an ObjectOperation, per RTLM17. + internal func testsOnly_mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) { + mutex.withLock { + mutableState.mergeInitialValue( + from: operation, + objectsPool: &objectsPool, + mapDelegate: delegate.referenced, + coreSDK: coreSDK, + logger: logger, + ) + } + } + /// 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. @@ -310,36 +323,53 @@ internal final class DefaultLiveMap: LiveMap { // RTLM6c: Set data to ObjectState.map.entries, or to an empty map if it does not exist data = state.map?.entries ?? [:] - // RTLM6d: If ObjectState.createOp is present + // RTLM6d: If ObjectState.createOp is present, merge the initial value into the LiveMap as described in RTLM17 if let createOp = state.createOp { - // RTLM6d1: For each key–ObjectsMapEntry pair in ObjectState.createOp.map.entries - if let entries = createOp.map?.entries { - for (key, entry) in entries { - if entry.tombstone == true { - // RTLM6d1b: If ObjectsMapEntry.tombstone is true, apply the MAP_REMOVE operation - // to the specified key using ObjectsMapEntry.timeserial per RTLM8 - applyMapRemoveOperation( - key: key, - operationTimeserial: entry.timeserial, - ) - } else { - // RTLM6d1a: If ObjectsMapEntry.tombstone is false, apply the MAP_SET operation - // to the specified key using ObjectsMapEntry.timeserial and ObjectsMapEntry.data per RTLM7 - applyMapSetOperation( - key: key, - operationTimeserial: entry.timeserial, - operationData: entry.data, - objectsPool: &objectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, - logger: logger, - ) - } + mergeInitialValue( + from: createOp, + objectsPool: &objectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + logger: logger, + ) + } + } + + /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM17. + internal mutating func mergeInitialValue( + from operation: ObjectOperation, + objectsPool: inout ObjectsPool, + mapDelegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK, + logger: AblyPlugin.Logger, + ) { + // RTLM17a: For each key–ObjectsMapEntry pair in ObjectOperation.map.entries + if let entries = operation.map?.entries { + for (key, entry) in entries { + if entry.tombstone == true { + // RTLM17a2: If ObjectsMapEntry.tombstone is true, apply the MAP_REMOVE operation + // as described in RTLM8, passing in the current key as ObjectsMapOp, and ObjectsMapEntry.timeserial as the operation's serial + applyMapRemoveOperation( + key: key, + operationTimeserial: entry.timeserial, + ) + } else { + // RTLM17a1: If ObjectsMapEntry.tombstone is false, apply the MAP_SET operation + // as described in RTLM7, passing in ObjectsMapEntry.data and the current key as ObjectsMapOp, and ObjectsMapEntry.timeserial as the operation's serial + applyMapSetOperation( + key: key, + operationTimeserial: entry.timeserial, + operationData: entry.data, + objectsPool: &objectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + logger: logger, + ) } } - // RTLM6d2: Set the private flag createOperationIsMerged to true - liveObject.createOperationIsMerged = true } + // RTLM17b: Set the private flag createOperationIsMerged to true + liveObject.createOperationIsMerged = true } /// Applies a `MAP_SET` operation to a key, per RTLM7. diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift index 746bde7f0..494345066 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift @@ -52,7 +52,7 @@ struct DefaultLiveCounterTests { /// Tests for the case where createOp is not present struct WithoutCreateOpTests { - // @spec RTLC6b - Tests the case without createOp, as RTLC6d2 takes precedence when createOp exists + // @spec RTLC6b - Tests the case without createOp, as RTLC10b takes precedence when createOp exists @Test func setsCreateOperationIsMergedToFalse() { // Given: A counter whose createOperationIsMerged is true @@ -105,12 +105,11 @@ struct DefaultLiveCounterTests { } } - /// Tests for RTLC6d (with createOp present) + /// Tests for RTLC10 (merge initial value from createOp) struct WithCreateOpTests { - // @specOneOf(1/2) RTLC6d1 - with count - // @specOneOf(3/4) RTLC6c - count and createOp + // @spec RTLC10 - Tests that replaceData merges initial value when createOp is present @Test - func setsDataToCounterCountThenAddsCreateOpCounterCount() throws { + func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) let state = TestFactories.counterObjectState( @@ -118,39 +117,62 @@ struct DefaultLiveCounterTests { count: 5, // Test value - must exist ) counter.replaceData(using: state) - #expect(try counter.value == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC6d1) + #expect(try counter.value == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC10a) + #expect(counter.testsOnly_createOperationIsMerged) } + } + } - // @specOneOf(2/2) RTLC6d1 - no count - // @specOneOf(4/4) RTLC6c - no count but createOp - @Test - func doesNotModifyDataWhenCreateOpCounterCountDoesNotExist() throws { - let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) - let state = TestFactories.counterObjectState( - createOp: TestFactories.objectOperation( - action: .known(.counterCreate), - counter: nil, // Test value - must be nil - ), - count: 5, // Test value - ) - counter.replaceData(using: state) - #expect(try counter.value == 5) // Only the base counter.count value - } + /// Tests for the `testsOnly_mergeInitialValue` method, covering RTLC10 specification points + struct MergeInitialValueTests { + // @specOneOf(1/2) RTLC10a - with count + @Test + func addsCounterCountToData() throws { + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) - // @spec RTLC6d2 - @Test - func setsCreateOperationIsMergedToTrue() { - let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) - let state = TestFactories.counterObjectState( - createOp: TestFactories.objectOperation( // Test value - must be non-nil - action: .known(.counterCreate), - ), - ) - counter.replaceData(using: state) - #expect(counter.testsOnly_createOperationIsMerged) - } + // Set initial data + counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + #expect(try counter.value == 5) + + // Apply merge operation + let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist + counter.testsOnly_mergeInitialValue(from: operation) + + #expect(try counter.value == 15) // 5 + 10 + } + + // @specOneOf(2/2) RTLC10a - no count + @Test + func doesNotModifyDataWhenCounterCountDoesNotExist() throws { + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Set initial data + counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + #expect(try counter.value == 5) + + // Apply merge operation with no count + let operation = TestFactories.objectOperation( + action: .known(.counterCreate), + counter: nil, // Test value - must be nil + ) + counter.testsOnly_mergeInitialValue(from: operation) + + #expect(try counter.value == 5) // Unchanged + } + + // @spec RTLC10b + @Test + func setsCreateOperationIsMergedToTrue() { + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Apply merge operation + let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist + counter.testsOnly_mergeInitialValue(from: operation) + + #expect(counter.testsOnly_createOperationIsMerged) } } } diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift index ce991d27d..66d7b01d3 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -217,9 +217,9 @@ struct DefaultLiveMapTests { } // @specOneOf(2/2) RTLM6c - Tests that the map entries get combined with the createOp - // @spec RTLM6d1a + // @spec RTLM6d @Test - func appliesMapSetOperationFromCreateOp() throws { + func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) @@ -241,59 +241,10 @@ struct DefaultLiveMapTests { ) var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) map.replaceData(using: state, 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 entries (per RTLM6c) and also the createOp (per RTLM6d1a) + // Note that we just check for some basic expected side effects of merging the initial value; RTLM17 is tested in more detail elsewhere + // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d) #expect(try map.get(key: "keyFromMapEntries")?.stringValue == "valueFromMapEntries") #expect(try map.get(key: "keyFromCreateOp")?.stringValue == "valueFromCreateOp") - } - - // @spec RTLM6d1b - @Test - func appliesMapRemoveOperationFromCreateOp() throws { - let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( - testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], - objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, - logger: logger, - ) - // Confirm that the initial data is there - #expect(try map.get(key: "key1") != nil) - - let entry = TestFactories.mapEntry( - tombstone: true, - data: ObjectData(), - ) - let state = TestFactories.objectState( - objectId: "arbitrary-id", - createOp: TestFactories.mapCreateOperation( - objectId: "arbitrary-id", - entries: ["key1": entry], - ), - ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) - map.replaceData(using: state, objectsPool: &pool) - // Note that we just check for some basic expected side effects of applying MAP_REMOVE; RTLM8 is tested in more detail elsewhere - // Check that MAP_REMOVE removed the initial data - #expect(try map.get(key: "key1") == nil) - } - - // @spec RTLM6d2 - @Test - func setsCreateOperationIsMergedToTrueWhenCreateOpPresent() { - let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let state = TestFactories.objectState( - objectId: "arbitrary-id", - createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), - ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) - map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) } } @@ -861,4 +812,80 @@ struct DefaultLiveMapTests { } } } + + /// Tests for the `testsOnly_mergeInitialValue` method, covering RTLM17 specification points + struct MergeInitialValueTests { + // @spec RTLM17a1 + @Test + func appliesMapSetOperationsFromOperation() throws { + let logger = TestLogger() + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + + // Apply merge operation with MAP_SET entries + let operation = TestFactories.mapCreateOperation( + objectId: "arbitrary-id", + entries: [ + "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, + ], + ) + map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + + // Note that we just check for some basic expected side effects of applying MAP_SET; RTLM7 is tested in more detail elsewhere + // Check that it contains the data from the operation (per RTLM17a1) + #expect(try map.get(key: "keyFromCreateOp")?.stringValue == "valueFromCreateOp") + } + + // @spec RTLM17a2 + @Test + func appliesMapRemoveOperationsFromOperation() throws { + let logger = TestLogger() + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap( + testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], + objectID: "arbitrary", + delegate: delegate, + coreSDK: coreSDK, + logger: logger, + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + + // Confirm that the initial data is there + #expect(try map.get(key: "key1") != 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], + ) + map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + + // Verify the MAP_REMOVE operation was applied + #expect(try map.get(key: "key1") == nil) + } + + // @spec RTLM17b + @Test + func setsCreateOperationIsMergedToTrue() { + let logger = TestLogger() + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + + // Apply merge operation + let operation = TestFactories.mapCreateOperation(objectId: "arbitrary-id") + map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + + #expect(map.testsOnly_createOperationIsMerged) + } + } } From 643035886242daaacd4b398529e8b5b285fead74 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 7 Jul 2025 10:46:44 -0300 Subject: [PATCH 047/225] Apply OBJECT ProtocolMessages Based on [1] at 29276a5. I wrote the implementation, and for the tests followed the development approach described in cb427d8. We have a separate issue for applying RTO8a's buffering during a sync, so I haven't done that here. [1] https://github.com/ably/specification/pull/343 --- .../DefaultRealtimeObjects.swift | 86 +++++- .../Internal/DefaultLiveCounter.swift | 90 ++++++ .../Internal/DefaultLiveMap.swift | 124 +++++++++ .../Internal/ObjectsPool.swift | 25 ++ .../DefaultLiveCounterTests.swift | 158 +++++++++++ .../DefaultLiveMapTests.swift | 194 +++++++++++++ .../DefaultRealtimeObjectsTests.swift | 259 ++++++++++++++++++ .../Helpers/TestFactories.swift | 99 +++++++ 8 files changed, 1034 insertions(+), 1 deletion(-) diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index 0e01a02de..b96c82b01 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -166,8 +166,17 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD receivedObjectProtocolMessages } + /// Implements the `OBJECT` handling of RTO8. internal func handleObjectProtocolMessage(objectMessages: [InboundObjectMessage]) { - receivedObjectProtocolMessagesContinuation.yield(objectMessages) + mutex.withLock { + mutableState.handleObjectProtocolMessage( + objectMessages: objectMessages, + logger: logger, + receivedObjectProtocolMessagesContinuation: receivedObjectProtocolMessagesContinuation, + mapDelegate: self, + coreSDK: coreSDK, + ) + } } internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { @@ -320,5 +329,80 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD syncStatus.signalSyncComplete() } } + + /// Implements the `OBJECT` handling of RTO8. + internal mutating func handleObjectProtocolMessage( + objectMessages: [InboundObjectMessage], + logger: Logger, + receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, + mapDelegate: LiveMapObjectPoolDelegate, + coreSDK: CoreSDK, + ) { + receivedObjectProtocolMessagesContinuation.yield(objectMessages) + + logger.log("handleObjectProtocolMessage(objectMessages: \(objectMessages))", level: .debug) + + // TODO: RTO8a's buffering + + // RTO8b + for objectMessage in objectMessages { + applyObjectProtocolMessageObjectMessage( + objectMessage, + logger: logger, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + ) + } + } + + /// Implements the `OBJECT` application of RTO9. + private mutating func applyObjectProtocolMessageObjectMessage( + _ objectMessage: InboundObjectMessage, + logger: Logger, + mapDelegate: LiveMapObjectPoolDelegate, + coreSDK: CoreSDK, + ) { + guard let operation = objectMessage.operation else { + // RTO9a1 + logger.log("Unsupported OBJECT message received (no operation); \(objectMessage)", level: .warn) + 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, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + logger: logger, + ) 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: + // RTO9a2a3 + entry.apply( + operation, + objectMessageSerial: objectMessage.serial, + objectMessageSiteCode: objectMessage.siteCode, + objectsPool: &objectsPool, + ) + } + case let .unknown(rawValue): + // RTO9a2b + logger.log("Unsupported OBJECT operation action \(rawValue) received", level: .warn) + return + } + } } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift index 98e4bbfe3..b4c9603a7 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift @@ -130,6 +130,38 @@ internal final class DefaultLiveCounter: LiveCounter { } } + /// Test-only method to apply a COUNTER_CREATE operation, per RTLC8. + internal func testsOnly_applyCounterCreateOperation(_ operation: ObjectOperation) { + mutex.withLock { + mutableState.applyCounterCreateOperation(operation, logger: logger) + } + } + + /// Test-only method to apply a COUNTER_INC operation, per RTLC9. + internal func testsOnly_applyCounterIncOperation(_ operation: WireObjectsCounterOp?) { + mutex.withLock { + mutableState.applyCounterIncOperation(operation) + } + } + + /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. + internal func apply( + _ operation: ObjectOperation, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectsPool: inout ObjectsPool, + ) { + mutex.withLock { + mutableState.apply( + operation, + objectMessageSerial: objectMessageSerial, + objectMessageSiteCode: objectMessageSiteCode, + objectsPool: &objectsPool, + logger: logger, + ) + } + } + // MARK: - Mutable state and the operations that affect it private struct MutableState { @@ -165,5 +197,63 @@ internal final class DefaultLiveCounter: LiveCounter { // RTLC10b: Set the private flag createOperationIsMerged to true liveObject.createOperationIsMerged = true } + + /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. + internal mutating func apply( + _ operation: ObjectOperation, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectsPool: inout ObjectsPool, + logger: Logger, + ) { + guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { + // RTLC7b + logger.log("Operation \(operation) (serial: \(String(describing: objectMessageSerial)), siteCode: \(String(describing: objectMessageSiteCode))) should not be applied; discarding", level: .debug) + return + } + + // RTLC7c + liveObject.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial + + switch operation.action { + case .known(.counterCreate): + // RTLC7d1 + applyCounterCreateOperation( + operation, + logger: logger, + ) + case .known(.counterInc): + // RTLC7d2 + applyCounterIncOperation(operation.counterOp) + default: + // RTLC7d3 + logger.log("Operation \(operation) has unsupported action for LiveCounter; discarding", level: .warn) + } + } + + /// Applies a `COUNTER_CREATE` operation, per RTLC8. + internal mutating func applyCounterCreateOperation( + _ operation: ObjectOperation, + logger: Logger, + ) { + if liveObject.createOperationIsMerged { + // RTLC8b + logger.log("Not applying COUNTER_CREATE because a COUNTER_CREATE has already been applied", level: .warn) + return + } + + // RTLC8c + mergeInitialValue(from: operation) + } + + /// Applies a `COUNTER_INC` operation, per RTLC9. + internal mutating func applyCounterIncOperation(_ operation: WireObjectsCounterOp?) { + guard let operation else { + return + } + + // RTLC9b + data += operation.amount.doubleValue + } } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift index 806417351..d9a5af6b3 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift @@ -257,6 +257,39 @@ internal final class DefaultLiveMap: LiveMap { } } + /// Test-only method to apply a MAP_CREATE operation, per RTLM16. + internal func testsOnly_applyMapCreateOperation(_ operation: ObjectOperation, objectsPool: inout ObjectsPool) { + mutex.withLock { + mutableState.applyMapCreateOperation( + operation, + objectsPool: &objectsPool, + mapDelegate: delegate.referenced, + coreSDK: coreSDK, + logger: logger, + ) + } + } + + /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLM15. + internal func apply( + _ operation: ObjectOperation, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectsPool: inout ObjectsPool, + ) { + mutex.withLock { + mutableState.apply( + operation, + objectMessageSerial: objectMessageSerial, + objectMessageSiteCode: objectMessageSiteCode, + objectsPool: &objectsPool, + mapDelegate: delegate.referenced, + coreSDK: coreSDK, + logger: logger, + ) + } + } + /// 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. @@ -372,6 +405,71 @@ internal final class DefaultLiveMap: LiveMap { liveObject.createOperationIsMerged = true } + /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLM15. + internal mutating func apply( + _ operation: ObjectOperation, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectsPool: inout ObjectsPool, + mapDelegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK, + logger: Logger, + ) { + guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { + // RTLM15b + logger.log("Operation \(operation) (serial: \(String(describing: objectMessageSerial)), siteCode: \(String(describing: objectMessageSiteCode))) should not be applied; discarding", level: .debug) + return + } + + // RTLM15c + liveObject.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial + + switch operation.action { + case .known(.mapCreate): + // RTLM15d1 + applyMapCreateOperation( + operation, + objectsPool: &objectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + logger: logger, + ) + case .known(.mapSet): + guard let mapOp = operation.mapOp else { + logger.log("Could not apply MAP_SET since operation.mapOp is missing", level: .warn) + return + } + guard let data = mapOp.data else { + logger.log("Could not apply MAP_SET since operation.data is missing", level: .warn) + return + } + + // RTLM15d2 + applyMapSetOperation( + key: mapOp.key, + operationTimeserial: applicableOperation.objectMessageSerial, + operationData: data, + objectsPool: &objectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + logger: logger, + ) + case .known(.mapRemove): + guard let mapOp = operation.mapOp else { + return + } + + // RTLM15d3 + applyMapRemoveOperation( + key: mapOp.key, + operationTimeserial: applicableOperation.objectMessageSerial, + ) + default: + // RTLM15d4 + logger.log("Operation \(operation) has unsupported action for LiveMap; discarding", level: .warn) + } + } + /// Applies a `MAP_SET` operation to a key, per RTLM7. internal mutating func applyMapSetOperation( key: String, @@ -477,6 +575,32 @@ internal final class DefaultLiveMap: LiveMap { false } } + + /// Applies a `MAP_CREATE` operation, per RTLM16. + internal mutating func applyMapCreateOperation( + _ operation: ObjectOperation, + objectsPool: inout ObjectsPool, + mapDelegate: LiveMapObjectPoolDelegate?, + coreSDK: CoreSDK, + logger: AblyPlugin.Logger, + ) { + if liveObject.createOperationIsMerged { + // RTLM16b + logger.log("Not applying MAP_CREATE because a MAP_CREATE has already been applied", level: .warn) + return + } + + // TODO: RTLM16c `semantics` comparison; outstanding question in https://github.com/ably/specification/pull/343/files#r2192784482 + + // RTLM16d + mergeInitialValue( + from: operation, + objectsPool: &objectsPool, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + logger: logger, + ) + } } // MARK: - Helper Methods diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 41b6ab382..4e2efc4b4 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -28,6 +28,31 @@ internal struct ObjectsPool { counter } } + + /// Applies an operation to a LiveObject, per RTO9a2a3. + internal func apply( + _ operation: ObjectOperation, + objectMessageSerial: String?, + objectMessageSiteCode: String?, + objectsPool: inout ObjectsPool, + ) { + switch self { + case let .map(map): + map.apply( + operation, + objectMessageSerial: objectMessageSerial, + objectMessageSiteCode: objectMessageSiteCode, + objectsPool: &objectsPool, + ) + case let .counter(counter): + counter.apply( + operation, + objectMessageSerial: objectMessageSerial, + objectMessageSiteCode: objectMessageSiteCode, + objectsPool: &objectsPool, + ) + } + } } /// Keyed by `objectId`. diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift index 494345066..4b8ce9c32 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift @@ -175,4 +175,162 @@ struct DefaultLiveCounterTests { #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 counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Set initial data and mark create operation as merged + counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + counter.testsOnly_mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) + #expect(counter.testsOnly_createOperationIsMerged) + + // Try to apply another COUNTER_CREATE operation + let operation = TestFactories.counterCreateOperation(count: 20) + counter.testsOnly_applyCounterCreateOperation(operation) + + // Verify the operation was discarded - data unchanged + #expect(try counter.value == 15) // 5 + 10, not 5 + 10 + 20 + } + + // @spec RTLC8c + @Test + func mergesInitialValue() throws { + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Set initial data but don't mark create operation as merged + counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + #expect(!counter.testsOnly_createOperationIsMerged) + + // Apply COUNTER_CREATE operation + let operation = TestFactories.counterCreateOperation(count: 10) + counter.testsOnly_applyCounterCreateOperation(operation) + + // Verify the operation was applied - initial value merged. (The full logic of RTLC10 is tested elsewhere; we just check for some of its side effects here.) + #expect(try counter.value == 15) // 5 + 10 + #expect(counter.testsOnly_createOperationIsMerged) + } + } + + /// Tests for `COUNTER_INC` operations, covering RTLC9 specification points + struct CounterIncOperationTests { + // @spec RTLC9b + @Test(arguments: [ + (operation: TestFactories.counterOp(amount: 10), expectedValue: 15.0), // 5 + 10 + (operation: nil as WireObjectsCounterOp?, expectedValue: 5.0), // unchanged + ] as [(operation: WireObjectsCounterOp?, expectedValue: Double)]) + func addsAmountToData(operation: WireObjectsCounterOp?, expectedValue: Double) throws { + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Set initial data + counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + #expect(try counter.value == 5) + + // Apply COUNTER_INC operation + counter.testsOnly_applyCounterIncOperation(operation) + + // Verify the operation was applied correctly + #expect(try counter.value == expectedValue) + } + } + + /// 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 counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Set up the counter with an existing site timeserial that will cause the operation to be discarded + counter.replaceData(using: TestFactories.counterObjectState( + siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" + count: 5, + )) + + let operation = TestFactories.objectOperation( + action: .known(.counterInc), + counterOp: TestFactories.counterOp(amount: 10), + ) + var pool = ObjectsPool(rootDelegate: MockLiveMapObjectPoolDelegate(), rootCoreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Apply operation with serial "ts1" which is lexicographically less than existing "ts2" and thus will be applied per RTLO4a (this is a non-pathological case of RTOL4a, that spec point being fully tested elsewhere) + counter.apply( + operation, + objectMessageSerial: "ts1", // Less than existing "ts2" + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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 == 5) + // Verify site timeserials unchanged + #expect(counter.testsOnly_siteTimeserials == ["site1": "ts2"]) + } + + // @specOneOf(1/2) RTLC7c - We test this spec point for each possible operation + // @spec RTLC7d1 - Tests COUNTER_CREATE operation application + @Test + func appliesCounterCreateOperation() throws { + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + let operation = TestFactories.counterCreateOperation(count: 15) + var pool = ObjectsPool(rootDelegate: MockLiveMapObjectPoolDelegate(), rootCoreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Apply COUNTER_CREATE operation + counter.apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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 == 15) + #expect(counter.testsOnly_createOperationIsMerged) + // Verify RTLC7c side-effect: site timeserial was updated + #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @specOneOf(2/2) RTLC7c - We test this spec point for each possible operation + // @spec RTLC7d2 - Tests COUNTER_INC operation application + @Test + func appliesCounterIncOperation() throws { + let logger = TestLogger() + let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Set initial data + counter.replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5)) + #expect(try counter.value == 5) + + let operation = TestFactories.objectOperation( + action: .known(.counterInc), + counterOp: TestFactories.counterOp(amount: 10), + ) + var pool = ObjectsPool(rootDelegate: MockLiveMapObjectPoolDelegate(), rootCoreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + + // Apply COUNTER_INC operation + counter.apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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 == 15) // 5 + 10 + // Verify RTLC7c side-effect: site timeserial was updated + #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @specUntested RTLC7e3 - 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 + } } diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift index 66d7b01d3..f9bb1838b 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift @@ -888,4 +888,198 @@ struct DefaultLiveMapTests { #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 delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + + // Set initial data and mark create operation as merged + map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) + map.testsOnly_mergeInitialValue(from: TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]), objectsPool: &pool) + #expect(map.testsOnly_createOperationIsMerged) + + // Try to apply another MAP_CREATE operation + let operation = TestFactories.mapCreateOperation(entries: ["key3": TestFactories.stringMapEntry(key: "key3", value: "value3").entry]) + map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) + + // Verify the operation was discarded - data unchanged + #expect(try map.get(key: "key1")?.stringValue == "testValue") // Original data + #expect(try map.get(key: "key2")?.stringValue == "value2") // From first merge + #expect(try map.get(key: "key3") == nil) // Not added by second operation + } + + // @spec RTLM16d + @Test + func mergesInitialValue() throws { + let logger = TestLogger() + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + + // Set initial data but don't mark create operation as merged + map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) + #expect(!map.testsOnly_createOperationIsMerged) + + // Apply MAP_CREATE operation + let operation = TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]) + map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) + + // Verify the operation was applied - initial value merged. (The full logic of RTLM17 is tested elsewhere; we just check for some of its side effects here.) + #expect(try map.get(key: "key1")?.stringValue == "testValue") // Original data + #expect(try map.get(key: "key2")?.stringValue == "value2") // From merge + #expect(map.testsOnly_createOperationIsMerged) + } + } + + /// 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 delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + + // Set up the map with an existing site timeserial that will cause the operation to be discarded + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) + map.replaceData(using: TestFactories.mapObjectState( + siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" + entries: [key1: entry1], + ), objectsPool: &pool) + + let operation = TestFactories.objectOperation( + action: .known(.mapSet), + mapOp: ObjectsMapOp(key: "key1", data: ObjectData(string: .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) + map.apply( + operation, + objectMessageSerial: "ts1", // Less than existing "ts2" + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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")?.stringValue == "existing") + // Verify site timeserials unchanged + #expect(map.testsOnly_siteTimeserials == ["site1": "ts2"]) + } + + // @specOneOf(1/3) RTLM15c - We test this spec point for each possible operation + // @spec RTLM15d1 - Tests MAP_CREATE operation application + @Test + func appliesMapCreateOperation() throws { + let logger = TestLogger() + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + + let operation = TestFactories.mapCreateOperation( + entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "value1").entry], + ) + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + + // Apply MAP_CREATE operation + map.apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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")?.stringValue == "value1") + #expect(map.testsOnly_createOperationIsMerged) + // Verify RTLM15c side-effect: site timeserial was updated + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @specOneOf(2/3) RTLM15c - We test this spec point for each possible operation + // @spec RTLM15d2 - Tests MAP_SET operation application + @Test + func appliesMapSetOperation() throws { + let logger = TestLogger() + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + + // Set initial data + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) + map.replaceData(using: TestFactories.mapObjectState( + siteTimeserials: [:], + entries: [key1: entry1], + ), objectsPool: &pool) + #expect(try map.get(key: "key1")?.stringValue == "existing") + + let operation = TestFactories.objectOperation( + action: .known(.mapSet), + mapOp: ObjectsMapOp(key: "key1", data: ObjectData(string: .string("new"))), + ) + + // Apply MAP_SET operation + map.apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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")?.stringValue == "new") + // Verify RTLM15c side-effect: site timeserial was updated + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @specOneOf(3/3) RTLM15c - We test this spec point for each possible operation + // @spec RTLM15d3 - Tests MAP_REMOVE operation application + @Test + func appliesMapRemoveOperation() throws { + let logger = TestLogger() + let delegate = MockLiveMapObjectPoolDelegate() + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + + // Set initial data + var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) + map.replaceData(using: TestFactories.mapObjectState( + siteTimeserials: [:], + entries: [key1: entry1], + ), objectsPool: &pool) + #expect(try map.get(key: "key1")?.stringValue == "existing") + + let operation = TestFactories.objectOperation( + action: .known(.mapRemove), + mapOp: ObjectsMapOp(key: "key1", data: ObjectData()), + ) + + // Apply MAP_REMOVE operation + map.apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // 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") == nil) + // Verify RTLM15c side-effect: site timeserial was updated + #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) + } + + // @specUntested RTLM15d4 - 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 + } } diff --git a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift index f6e9d69ff..35804f78f 100644 --- a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift @@ -664,4 +664,263 @@ struct DefaultRealtimeObjectsTests { } } } + + /// Tests for `DefaultRealtimeObjects.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: - RTO9a2a1 Tests + + // @spec RTO9a2a1 - Tests that if necessary it creates an object in the ObjectsPool + @Test + func createsObjectInObjectsPoolWhenNecessary() { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + 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 + realtimeObjects.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/5) RTO9a2a3 - Tests MAP_CREATE operation application + @Test + func appliesMapCreateOperation() throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectId = "map:test@123" + + // Create a map object in the pool first + let (entryKey, entry) = TestFactories.stringMapEntry(key: "existingKey", value: "existingValue") + realtimeObjects.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 initialValue = try #require(map.get(key: "existingKey")?.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 + realtimeObjects.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")?.stringValue) + #expect(finalValue == "createValue") + #expect(map.testsOnly_createOperationIsMerged) + #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") + } + + // MARK: - RTO9a2a3 Tests for MAP_SET + + // @specOneOf(2/5) RTO9a2a3 - Tests MAP_SET operation application + @Test + func appliesMapSetOperation() throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectId = "map:test@123" + + // Create a map object in the pool first + let (entryKey, entry) = TestFactories.stringMapEntry(key: "existingKey", value: "existingValue") + realtimeObjects.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 initialValue = try #require(map.get(key: "existingKey")?.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 + realtimeObjects.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")?.stringValue) + #expect(finalValue == "newValue") + #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") + } + + // MARK: - RTO9a2a3 Tests for MAP_REMOVE + + // @specOneOf(3/5) RTO9a2a3 - Tests MAP_REMOVE operation application + @Test + func appliesMapRemoveOperation() throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectId = "map:test@123" + + // Create a map object in the pool first + let (entryKey, entry) = TestFactories.stringMapEntry(key: "existingKey", value: "existingValue") + realtimeObjects.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 initialValue = try #require(map.get(key: "existingKey")?.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 + realtimeObjects.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") + #expect(finalValue == nil) // Key should be removed/tombstoned + #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") + } + + // MARK: - RTO9a2a3 Tests for COUNTER_CREATE + + // @specOneOf(4/5) RTO9a2a3 - Tests COUNTER_CREATE operation application + @Test + func appliesCounterCreateOperation() throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectId = "counter:test@123" + + // Create a counter object in the pool first + realtimeObjects.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 initialValue = try counter.value + #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 + realtimeObjects.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 + #expect(finalValue == 15) // 5 + 10 (initial value merged) + #expect(counter.testsOnly_siteTimeserials["site1"] == "ts2") + } + + // MARK: - RTO9a2a3 Tests for COUNTER_INC + + // @specOneOf(5/5) RTO9a2a3 - Tests COUNTER_INC operation application + @Test + func appliesCounterIncOperation() throws { + let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let objectId = "counter:test@123" + + // Create a counter object in the pool first + realtimeObjects.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 initialValue = try counter.value + #expect(initialValue == 5) + + // Create a COUNTER_INC operation message + let operationMessage = TestFactories.counterIncOperationMessage( + objectId: objectId, + amount: 10, + serial: "ts2", // Higher than existing "ts1" + siteCode: "site1", + ) + + // Handle the object protocol message + realtimeObjects.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 + #expect(finalValue == 15) // 5 + 10 + #expect(counter.testsOnly_siteTimeserials["site1"] == "ts2") + } + } + } } diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index b61414c01..cf94ccfca 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -386,6 +386,11 @@ struct TestFactories { ) } + /// Creates a WireObjectsCounterOp + static func counterOp(amount: Int = 10) -> WireObjectsCounterOp { + WireObjectsCounterOp(amount: NSNumber(value: amount)) + } + // MARK: - ObjectsMapEntry Factory /// Creates an ObjectsMapEntry with sensible defaults @@ -516,6 +521,100 @@ struct TestFactories { 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, + mapOp: ObjectsMapOp( + key: key, + data: ObjectData(string: .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, + mapOp: ObjectsMapOp(key: key), + ), + 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", + amount: Int = 10, + serial: String = "ts1", + siteCode: String = "site1", + ) -> InboundObjectMessage { + inboundObjectMessage( + operation: objectOperation( + action: .known(.counterInc), + objectId: objectId, + counterOp: counterOp(amount: amount), + ), + serial: serial, + siteCode: siteCode, + ) + } + // MARK: - Common Test Scenarios /// Creates a simple map object message with one string entry From 22c71cf6dbaceb70e86737d1b16514024c763007 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 9 Jul 2025 09:41:13 -0300 Subject: [PATCH 048/225] Buffer OBJECT ProtocolMessages during a sync Based on [1] at 29276a5. I wrote the implementation, and for the tests followed the development approach described in cb427d8. [1] https://github.com/ably/specification/pull/343 --- .../DefaultRealtimeObjects.swift | 65 ++++++++---- .../DefaultRealtimeObjectsTests.swift | 100 +++++++++++++++++- 2 files changed, 144 insertions(+), 21 deletions(-) diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift index b96c82b01..730f519d8 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift @@ -23,7 +23,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD } } - /// If this returns false, it means that there is currently no stored sync sequence ID or SyncObjectsPool + /// If this returns false, it means that there is currently no stored sync sequence ID, SyncObjectsPool, or BufferedObjectOperations. internal var testsOnly_hasSyncSequence: Bool { mutex.withLock { mutableState.syncSequence != nil @@ -45,6 +45,9 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD /// The `ObjectMessage`s gathered during this sync sequence. internal var syncObjectsPool: [ObjectState] + + /// `OBJECT` ProtocolMessages that were received during this sync sequence, to be applied once the sync sequence is complete, per RTO7a. + internal var bufferedObjectOperations: [InboundObjectMessage] } /// Tracks whether an object sync sequence has happened yet. This allows us to wait for a sync before returning from `getRoot()`, per RTO1c. @@ -230,6 +233,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD private struct MutableState { internal var objectsPool: ObjectsPool + /// 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. internal var syncSequence: SyncSequence? internal var syncStatus = SyncStatus() internal var onChannelAttachedHasObjects: Bool? @@ -255,7 +259,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD // 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 + // RTO4b3, RTO4b4, RTO4b5, RTO5c3, RTO5c4, RTO5c5 syncSequence = nil syncStatus.signalSyncComplete() } @@ -275,6 +279,8 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD // If populated, this contains a full set of sync data for the channel, and should be applied to the ObjectsPool. let completedSyncObjectsPool: [ObjectState]? + // If populated, this contains a set of buffered inbound OBJECT messages that should be applied. + let completedSyncBufferedObjectOperations: [InboundObjectMessage]? if let protocolMessageChannelSerial { let syncCursor: SyncCursor @@ -292,12 +298,12 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD // RTO5a3: Continue existing sync sequence syncSequence } else { - // RTO5a2: new sequence started, discard previous - .init(id: syncCursor.sequenceID, syncObjectsPool: []) + // RTO5a2a, RTO5a2b: new sequence started, discard previous + .init(id: syncCursor.sequenceID, syncObjectsPool: [], bufferedObjectOperations: []) } } else { // There's no current sync sequence; start one - .init(id: syncCursor.sequenceID, syncObjectsPool: []) + .init(id: syncCursor.sequenceID, syncObjectsPool: [], bufferedObjectOperations: []) } // RTO5b @@ -305,14 +311,15 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD syncSequence = updatedSyncSequence - completedSyncObjectsPool = if syncCursor.isEndOfSequence { - updatedSyncSequence.syncObjectsPool + (completedSyncObjectsPool, completedSyncBufferedObjectOperations) = if syncCursor.isEndOfSequence { + (updatedSyncSequence.syncObjectsPool, updatedSyncSequence.bufferedObjectOperations) } else { - nil + (nil, nil) } } else { // RTO5a5: The sync data is contained entirely within this single OBJECT_SYNC completedSyncObjectsPool = objectMessages.compactMap(\.object) + completedSyncBufferedObjectOperations = nil } if let completedSyncObjectsPool { @@ -323,7 +330,21 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD coreSDK: coreSDK, logger: logger, ) - // RTO5c3, RTO5c4 + + // RTO5c6 + if let completedSyncBufferedObjectOperations, !completedSyncBufferedObjectOperations.isEmpty { + logger.log("Applying \(completedSyncBufferedObjectOperations.count) buffered OBJECT ObjectMessages", level: .debug) + for objectMessage in completedSyncBufferedObjectOperations { + applyObjectProtocolMessageObjectMessage( + objectMessage, + logger: logger, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + ) + } + } + + // RTO5c3, RTO5c4, RTO5c5 syncSequence = nil syncStatus.signalSyncComplete() @@ -342,16 +363,22 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD logger.log("handleObjectProtocolMessage(objectMessages: \(objectMessages))", level: .debug) - // TODO: RTO8a's buffering - - // RTO8b - for objectMessage in objectMessages { - applyObjectProtocolMessageObjectMessage( - objectMessage, - logger: logger, - mapDelegate: mapDelegate, - coreSDK: coreSDK, - ) + if let existingSyncSequence = syncSequence { + // RTO8a: Buffer the OBJECT message, to be handled once the sync completes + logger.log("Buffering OBJECT message due to in-progress sync", level: .debug) + var newSyncSequence = existingSyncSequence + newSyncSequence.bufferedObjectOperations.append(contentsOf: objectMessages) + syncSequence = newSyncSequence + } else { + // RTO8b: Handle the OBJECT message immediately + for objectMessage in objectMessages { + applyObjectProtocolMessageObjectMessage( + objectMessage, + logger: logger, + mapDelegate: mapDelegate, + coreSDK: coreSDK, + ) + } } } diff --git a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift index 35804f78f..a7b6a5cd7 100644 --- a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift @@ -53,6 +53,7 @@ struct DefaultRealtimeObjectsTests { // @spec RTO5b // @spec RTO5c3 // @spec RTO5c4 + // @spec RTO5c5 @Test func handlesMultiProtocolMessageSync() async throws { let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() @@ -94,7 +95,7 @@ struct DefaultRealtimeObjectsTests { protocolMessageChannelSerial: "\(sequenceId):", // Empty cursor indicates end ) - // Verify sync sequence is cleared and there is no SyncObjectsPool (RTO5c3, RTO5c4) + // 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) @@ -108,6 +109,7 @@ struct DefaultRealtimeObjectsTests { // @spec RTO5a2 // @spec RTO5a2a + // @spec RTO5a2b @Test func newSequenceIdDiscardsInFlightSync() async throws { let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() @@ -123,6 +125,11 @@ struct DefaultRealtimeObjectsTests { #expect(realtimeObjects.testsOnly_hasSyncSequence) + // Inject an OBJECT; it will get buffered per RTO8a and subsequently discarded per RTO5a2b + realtimeObjects.handleObjectProtocolMessage(objectMessages: [ + TestFactories.mapCreateOperationMessage(objectId: "map:3@789"), + ]) + // Start new sequence with different ID (RTO5a2) let secondMessages = [TestFactories.simpleMapMessage(objectId: "map:2@456")] realtimeObjects.handleObjectSyncProtocolMessage( @@ -142,6 +149,7 @@ struct DefaultRealtimeObjectsTests { // 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:3@789"] == nil) // Check we discarded the OBJECT that was buffered during discarded first sequence (RTO5a2b) #expect(pool.entries["map:2@456"] != nil) // From completed second sequence #expect(!realtimeObjects.testsOnly_hasSyncSequence) } @@ -336,6 +344,7 @@ struct DefaultRealtimeObjectsTests { // @spec RTO4b2 // @spec RTO4b3 // @spec RTO4b4 + // @spec RTO4b5 @Test func handlesHasObjectsFalse() { let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() @@ -381,7 +390,7 @@ struct DefaultRealtimeObjectsTests { #expect(newRoot as AnyObject !== originalPool.root as AnyObject) // Should be a new instance #expect(newRoot.testsOnly_data.isEmpty) // Should be zero-valued (empty) - // RTO4b3, RTO4b4: SyncObjectsPool must be cleared, sync sequence cleared + // RTO4b3, RTO4b4, RTO4b5: SyncObjectsPool must be cleared, sync sequence cleared, BufferedObjectOperations cleared #expect(!realtimeObjects.testsOnly_hasSyncSequence) } @@ -922,5 +931,92 @@ struct DefaultRealtimeObjectsTests { #expect(counter.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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + 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], + ), + ] + realtimeObjects.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", + ) + realtimeObjects.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", + amount: 10, + serial: "ts4", // Higher than sync data "ts2" + siteCode: "site1", + ) + realtimeObjects.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, + ), + ] + realtimeObjects.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) + + // Verify the buffered operations were applied after sync completion (RTO5c6) + // Check that MAP_SET operation was applied to the map + let mapValue = try #require(map.get(key: "key1")?.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 + #expect(counterValue == 15) // 5 (from sync) + 10 (from buffered operation) + #expect(counter.testsOnly_siteTimeserials["site1"] == "ts4") + } + } } } From 836f502c976f259760f27f88bfc4d1cecc9257f2 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 10 Jul 2025 10:43:02 -0300 Subject: [PATCH 049/225] Use an abstract channel This brings us in line with the AblyPlugin API changes that come in the accompanying ably-cocoa submodule update. --- .../AblyLiveObjects/Internal/CoreSDK.swift | 6 ++-- .../Internal/DefaultInternalPlugin.swift | 28 ++++++++++++------- ably-cocoa | 2 +- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index e43410e40..9f4f623ac 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -13,11 +13,11 @@ internal protocol CoreSDK: AnyObject, Sendable { internal final class DefaultCoreSDK: CoreSDK { // We hold a weak reference to the channel so that `DefaultLiveObjects` can hold a strong reference to us without causing a strong reference cycle. We'll revisit this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9. - private let weakChannel: WeakRef + private let weakChannel: WeakRef private let pluginAPI: PluginAPIProtocol internal init( - channel: ARTRealtimeChannel, + channel: AblyPlugin.RealtimeChannel, pluginAPI: PluginAPIProtocol ) { weakChannel = .init(referenced: channel) @@ -26,7 +26,7 @@ internal final class DefaultCoreSDK: CoreSDK { // MARK: - Fetching channel - private var channel: ARTRealtimeChannel { + private var channel: AblyPlugin.RealtimeChannel { guard let channel = weakChannel.referenced else { // It's currently completely possible that the channel _does_ become deallocated during the usage of the LiveObjects SDK; in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 we'll figure out how to prevent this. preconditionFailure("Expected channel to not become deallocated during usage of LiveObjects SDK") diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 5cfd46164..caeaf2162 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -21,6 +21,14 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte /// /// We expect this value to have been previously set by ``prepare(_:)``. internal static func objectsProperty(for channel: ARTRealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultRealtimeObjects { + let pluginChannel = pluginAPI.channel(forPublicRealtimeChannel: channel) + return realtimeObjects(for: pluginChannel, pluginAPI: pluginAPI) + } + + /// Retrieves the `RealtimeObjects` for this channel. + /// + /// We expect this value to have been previously set by ``prepare(_:)``. + private static func realtimeObjects(for channel: AblyPlugin.RealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultRealtimeObjects { guard let pluginData = pluginAPI.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]`") @@ -33,7 +41,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte // MARK: - LiveObjectsInternalPluginProtocol // Populates the channel's `objects` property. - internal func prepare(_ channel: ARTRealtimeChannel) { + internal func prepare(_ channel: AblyPlugin.RealtimeChannel) { let logger = pluginAPI.logger(for: channel) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) @@ -43,8 +51,8 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } /// Retrieves the internally-typed `objects` property for the channel. - private func objectsProperty(for channel: ARTRealtimeChannel) -> DefaultRealtimeObjects { - Self.objectsProperty(for: channel, pluginAPI: pluginAPI) + private func realtimeObjects(for channel: AblyPlugin.RealtimeChannel) -> DefaultRealtimeObjects { + Self.realtimeObjects(for: channel, pluginAPI: pluginAPI) } /// A class that wraps an object message. @@ -94,30 +102,30 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte return wireObjectMessage.toWireObject.toAblyPluginDataDictionary } - internal func onChannelAttached(_ channel: ARTRealtimeChannel, hasObjects: Bool) { - objectsProperty(for: channel).onChannelAttached(hasObjects: hasObjects) + internal func onChannelAttached(_ channel: AblyPlugin.RealtimeChannel, hasObjects: Bool) { + realtimeObjects(for: channel).onChannelAttached(hasObjects: hasObjects) } - internal func handleObjectProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], channel: ARTRealtimeChannel) { + internal func handleObjectProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], channel: AblyPlugin.RealtimeChannel) { guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") } let objectMessages = inboundObjectMessageBoxes.map(\.objectMessage) - objectsProperty(for: channel).handleObjectProtocolMessage( + realtimeObjects(for: channel).handleObjectProtocolMessage( objectMessages: objectMessages, ) } - internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], protocolMessageChannelSerial: String?, channel: ARTRealtimeChannel) { + internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], protocolMessageChannelSerial: String?, channel: AblyPlugin.RealtimeChannel) { guard let inboundObjectMessageBoxes = publicObjectMessages as? [ObjectMessageBox] else { preconditionFailure("Expected to receive the same InboundObjectMessage type as we emit") } let objectMessages = inboundObjectMessageBoxes.map(\.objectMessage) - objectsProperty(for: channel).handleObjectSyncProtocolMessage( + realtimeObjects(for: channel).handleObjectSyncProtocolMessage( objectMessages: objectMessages, protocolMessageChannelSerial: protocolMessageChannelSerial, ) @@ -127,7 +135,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte internal static func sendObject( objectMessages: [OutboundObjectMessage], - channel: ARTRealtimeChannel, + channel: AblyPlugin.RealtimeChannel, pluginAPI: PluginAPIProtocol, ) async throws(InternalError) { let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } diff --git a/ably-cocoa b/ably-cocoa index 4a21e87f9..291758b84 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 4a21e87f988be4741f19d74f13cc4c040eb92121 +Subproject commit 291758b843f80b088136a985f0ef8dd0c5627d05 From 05d8f9dbb1ee6a29041fe220b7612c7a7e190ee0 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 14 Jul 2025 10:24:24 -0300 Subject: [PATCH 050/225] Update API used for fetching underlying objects from AblyPlugin See accompanying ably-cocoa change. The additional test dependencies in Package.swift are because I was getting "Undefined symbol: _OBJC_CLASS_$_APDefaultPublicRealtimeChannelUnderlyingObjects" when compiling. Now, not entirely sure _what_ was causing this error, but given that the tests do indeed import these two packages it seems reasonable enough to declare them as dependencies. --- Package.swift | 8 ++++++++ .../AblyLiveObjects/Internal/DefaultInternalPlugin.swift | 6 +++--- ably-cocoa | 2 +- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/Package.swift b/Package.swift index fef2737be..25fd4fce0 100644 --- a/Package.swift +++ b/Package.swift @@ -56,6 +56,14 @@ let package = Package( name: "AblyLiveObjectsTests", dependencies: [ "AblyLiveObjects", + .product( + name: "Ably", + package: "ably-cocoa", + ), + .product( + name: "AblyPlugin", + package: "ably-cocoa", + ), ], resources: [ .copy("ably-common"), diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index caeaf2162..4859c08a9 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -21,8 +21,8 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte /// /// We expect this value to have been previously set by ``prepare(_:)``. internal static func objectsProperty(for channel: ARTRealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultRealtimeObjects { - let pluginChannel = pluginAPI.channel(forPublicRealtimeChannel: channel) - return realtimeObjects(for: pluginChannel, pluginAPI: pluginAPI) + let underlyingObjects = pluginAPI.underlyingObjects(forPublicRealtimeChannel: channel) + return realtimeObjects(for: underlyingObjects.channel, pluginAPI: pluginAPI) } /// Retrieves the `RealtimeObjects` for this channel. @@ -41,7 +41,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte // MARK: - LiveObjectsInternalPluginProtocol // Populates the channel's `objects` property. - internal func prepare(_ channel: AblyPlugin.RealtimeChannel) { + internal func prepare(_ channel: AblyPlugin.RealtimeChannel, client _: AblyPlugin.RealtimeClient) { let logger = pluginAPI.logger(for: channel) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) diff --git a/ably-cocoa b/ably-cocoa index 291758b84..4e0601a45 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 291758b843f80b088136a985f0ef8dd0c5627d05 +Subproject commit 4e0601a4567235a2e5afd36eb19d363057b642da From 2a0b3a216cc823089fb7db1d74582b58177c73b0 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 16 Jul 2025 14:52:51 -0300 Subject: [PATCH 051/225] Make individual options optional in client options passed to realtimeWithObjects helper Preparation for pulling this out to be used more widely. --- .../Helpers/ClientHelper.swift | 36 +++++++++++++++++++ .../ObjectsIntegrationTests.swift | 27 +++++++------- 2 files changed, 48 insertions(+), 15 deletions(-) create mode 100644 Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift diff --git a/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift b/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift new file mode 100644 index 000000000..8c6f9a0ad --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift @@ -0,0 +1,36 @@ +import Ably +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 + } + + return ARTRealtime(options: clientOptions) + } + + /// 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? + } +} diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 502d0b91d..b2a3d8703 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -9,7 +9,7 @@ import Testing // MARK: - Top-level helpers -private func realtimeWithObjects(options: PartialClientOptions?) async throws -> ARTRealtime { +private 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] @@ -21,8 +21,8 @@ private func realtimeWithObjects(options: PartialClientOptions?) async throws -> clientOptions.logLevel = .verbose } - if let options { - clientOptions.useBinaryProtocol = options.useBinaryProtocol + if let useBinaryProtocol = options.useBinaryProtocol { + clientOptions.useBinaryProtocol = useBinaryProtocol } return ARTRealtime(options: clientOptions) @@ -152,7 +152,7 @@ private let objectsFixturesChannel = "objects_fixtures" private struct TestCase: Identifiable, CustomStringConvertible { var disabled: Bool var scenario: TestScenario - var options: PartialClientOptions? + var options: 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) @@ -164,8 +164,8 @@ private struct TestCase: Identifiable, CustomStringConvertible { var description: String { var result = scenario.description - if let options { - result += " (\(options.useBinaryProtocol ? "binary" : "text"))" + if let useBinaryProtocol = options.useBinaryProtocol { + result += " (\(useBinaryProtocol ? "binary" : "text"))" } return result @@ -179,7 +179,7 @@ private struct TestCaseID: Encodable, Hashable { } private struct PartialClientOptions: Encodable, Hashable { - var useBinaryProtocol: Bool + var useBinaryProtocol: Bool? } /// The input to `forScenarios`. @@ -193,19 +193,16 @@ private struct TestScenario { private func forScenarios(_ scenarios: [TestScenario]) -> [TestCase] { scenarios.map { scenario -> [TestCase] in if scenario.allTransportsAndProtocols { - [ - PartialClientOptions(useBinaryProtocol: true), - PartialClientOptions(useBinaryProtocol: false), - ].map { options -> TestCase in + [true, false].map { useBinaryProtocol -> TestCase in .init( disabled: scenario.disabled, scenario: scenario, - options: options, - channelName: "\(scenario.description) \(options.useBinaryProtocol ? "binary" : "text")", + options: .init(useBinaryProtocol: useBinaryProtocol), + channelName: "\(scenario.description) \(useBinaryProtocol ? "binary" : "text")", ) } } else { - [.init(disabled: scenario.disabled, scenario: scenario, options: nil, channelName: scenario.description)] + [.init(disabled: scenario.disabled, scenario: scenario, options: .init(), channelName: scenario.description)] } } .flatMap(\.self) @@ -271,7 +268,7 @@ private struct ObjectsIntegrationTests { var channelName: String var channel: ARTRealtimeChannel var client: ARTRealtime - var clientOptions: PartialClientOptions? + var clientOptions: PartialClientOptions } static let scenarios: [TestScenario] = { From ed082236aa9f03bf654684e57175806b2d462462 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 16 Jul 2025 14:59:20 -0300 Subject: [PATCH 052/225] Extract integration tests helper Will use in some upcoming tests for object lifetimes. --- .../ObjectsIntegrationTests.swift | 33 ++++--------------- 1 file changed, 6 insertions(+), 27 deletions(-) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index b2a3d8703..9b14499ab 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -9,29 +9,12 @@ import Testing // MARK: - Top-level helpers -private 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 - } - - return ARTRealtime(options: clientOptions) +private func realtimeWithObjects(options: ClientHelper.PartialClientOptions) async throws -> ARTRealtime { + try await ClientHelper.realtimeWithObjects(options: options) } private func channelOptionsWithObjects() -> ARTRealtimeChannelOptions { - let options = ARTRealtimeChannelOptions() - options.modes = [.objectSubscribe, .objectPublish] - return options + ClientHelper.channelOptionsWithObjects() } // Swift version of the JS lexicoTimeserial function @@ -152,7 +135,7 @@ private let objectsFixturesChannel = "objects_fixtures" private struct TestCase: Identifiable, CustomStringConvertible { var disabled: Bool var scenario: TestScenario - var options: PartialClientOptions + 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) @@ -175,11 +158,7 @@ private struct TestCase: Identifiable, CustomStringConvertible { /// Enables `TestCase`'s conformance to `Identifiable`. private struct TestCaseID: Encodable, Hashable { var description: String - var options: PartialClientOptions? -} - -private struct PartialClientOptions: Encodable, Hashable { - var useBinaryProtocol: Bool? + var options: ClientHelper.PartialClientOptions? } /// The input to `forScenarios`. @@ -268,7 +247,7 @@ private struct ObjectsIntegrationTests { var channelName: String var channel: ARTRealtimeChannel var client: ARTRealtime - var clientOptions: PartialClientOptions + var clientOptions: ClientHelper.PartialClientOptions } static let scenarios: [TestScenario] = { From dcbe617cf9c154ff5ead1dde7b601f8e79525d50 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 9 Jul 2025 13:06:45 -0300 Subject: [PATCH 053/225] Implement memory management pattern similar to ably-cocoa We adopt a pattern whereby the LiveObjects SDK functions correctly as long as the user is holding a reference to any object vended by the public API of the SDK. We do this by copying the ably-cocoa approach of having separate public and internal versions of objects. All written by me, except for getting Cursor to help with updating the tests in response to the new way of injecting CoreSDK. Resolves #9. --- CONTRIBUTING.md | 22 ++ .../AblyLiveObjects/Internal/CoreSDK.swift | 19 +- .../Internal/DefaultInternalPlugin.swift | 17 +- ...swift => InternalDefaultLiveCounter.swift} | 42 ++- ...Map.swift => InternalDefaultLiveMap.swift} | 157 ++++------- .../InternalDefaultRealtimeObjects.swift} | 44 +-- .../Internal/InternalLiveMapValue.swift | 54 ++++ .../Internal/LiveObjectMutableState.swift | 2 +- .../Internal/ObjectsPool.swift | 36 +-- .../Public/ARTRealtimeChannel+Objects.swift | 27 +- Sources/AblyLiveObjects/Public/Plugin.swift | 5 +- .../InternalLiveMapValue+ToPublic.swift | 38 +++ .../PublicDefaultLiveCounter.swift | 52 ++++ .../PublicDefaultLiveMap.swift | 98 +++++++ .../PublicDefaultRealtimeObjects.swift | 81 ++++++ .../PublicObjectsStore.swift | 129 +++++++++ Sources/AblyLiveObjects/Utility/WeakRef.swift | 8 - .../AblyLiveObjectsTests.swift | 12 +- .../Helpers/ClientHelper.swift | 4 + .../Helpers/TestFactories.swift | 8 +- ... => InternalDefaultLiveCounterTests.swift} | 87 +++--- ...wift => InternalDefaultLiveMapTests.swift} | 260 ++++++++---------- ...InternalDefaultRealtimeObjectsTests.swift} | 139 +++++----- .../ObjectsIntegrationTests.swift | 18 -- .../ObjectLifetimesTests.swift | 237 ++++++++++++++++ .../ObjectsPoolTests.swift | 119 ++++---- 26 files changed, 1137 insertions(+), 578 deletions(-) rename Sources/AblyLiveObjects/Internal/{DefaultLiveCounter.swift => InternalDefaultLiveCounter.swift} (87%) rename Sources/AblyLiveObjects/Internal/{DefaultLiveMap.swift => InternalDefaultLiveMap.swift} (81%) rename Sources/AblyLiveObjects/{DefaultRealtimeObjects.swift => Internal/InternalDefaultRealtimeObjects.swift} (91%) create mode 100644 Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift create mode 100644 Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift create mode 100644 Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift create mode 100644 Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift create mode 100644 Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift create mode 100644 Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift rename Tests/AblyLiveObjectsTests/{DefaultLiveCounterTests.swift => InternalDefaultLiveCounterTests.swift} (75%) rename Tests/AblyLiveObjectsTests/{DefaultLiveMapTests.swift => InternalDefaultLiveMapTests.swift} (80%) rename Tests/AblyLiveObjectsTests/{DefaultRealtimeObjectsTests.swift => InternalDefaultRealtimeObjectsTests.swift} (87%) create mode 100644 Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 75fe28d04..462785bc5 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -45,6 +45,28 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with ` - The public API of the SDK should use typed throws, and the thrown errors should be of type `ARTErrorInfo`. - `Dictionary.mapValues` does not support typed throws. We have our own extension `ablyLiveObjects_mapValuesWithTypedThrow` which does; use this. +### 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. + ### Testing guidelines #### Attributing tests to a spec point diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 9f4f623ac..e7bc5b9e0 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -12,29 +12,20 @@ internal protocol CoreSDK: AnyObject, Sendable { } internal final class DefaultCoreSDK: CoreSDK { - // We hold a weak reference to the channel so that `DefaultLiveObjects` can hold a strong reference to us without causing a strong reference cycle. We'll revisit this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9. - private let weakChannel: WeakRef + private let channel: AblyPlugin.RealtimeChannel + private let client: AblyPlugin.RealtimeClient private let pluginAPI: PluginAPIProtocol internal init( channel: AblyPlugin.RealtimeChannel, + client: AblyPlugin.RealtimeClient, pluginAPI: PluginAPIProtocol ) { - weakChannel = .init(referenced: channel) + self.channel = channel + self.client = client self.pluginAPI = pluginAPI } - // MARK: - Fetching channel - - private var channel: AblyPlugin.RealtimeChannel { - guard let channel = weakChannel.referenced else { - // It's currently completely possible that the channel _does_ become deallocated during the usage of the LiveObjects SDK; in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 we'll figure out how to prevent this. - preconditionFailure("Expected channel to not become deallocated during usage of LiveObjects SDK") - } - - return channel - } - // MARK: - CoreSDK conformance internal func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 4859c08a9..b80a86aa2 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -17,25 +17,17 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte /// The `pluginDataValue(forKey:channel:)` key that we use to store the value of the `ARTRealtimeChannel.objects` property. private static let pluginDataKey = "LiveObjects" - /// Retrieves the value that should be returned by `ARTRealtimeChannel.objects`. - /// - /// We expect this value to have been previously set by ``prepare(_:)``. - internal static func objectsProperty(for channel: ARTRealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultRealtimeObjects { - let underlyingObjects = pluginAPI.underlyingObjects(forPublicRealtimeChannel: channel) - return realtimeObjects(for: underlyingObjects.channel, pluginAPI: pluginAPI) - } - /// Retrieves the `RealtimeObjects` for this channel. /// /// We expect this value to have been previously set by ``prepare(_:)``. - private static func realtimeObjects(for channel: AblyPlugin.RealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> DefaultRealtimeObjects { + internal static func realtimeObjects(for channel: AblyPlugin.RealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> InternalDefaultRealtimeObjects { guard let pluginData = pluginAPI.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! DefaultRealtimeObjects + return pluginData as! InternalDefaultRealtimeObjects } // MARK: - LiveObjectsInternalPluginProtocol @@ -45,13 +37,12 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte let logger = pluginAPI.logger(for: channel) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) - let coreSDK = DefaultCoreSDK(channel: channel, pluginAPI: pluginAPI) - let liveObjects = DefaultRealtimeObjects(coreSDK: coreSDK, logger: logger) + let liveObjects = InternalDefaultRealtimeObjects(logger: logger) pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } /// Retrieves the internally-typed `objects` property for the channel. - private func realtimeObjects(for channel: AblyPlugin.RealtimeChannel) -> DefaultRealtimeObjects { + private func realtimeObjects(for channel: AblyPlugin.RealtimeChannel) -> InternalDefaultRealtimeObjects { Self.realtimeObjects(for: channel, pluginAPI: pluginAPI) } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift similarity index 87% rename from Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift rename to Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index b4c9603a7..7876aa0b2 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -2,8 +2,8 @@ import Ably internal import AblyPlugin import Foundation -/// Our default implementation of ``LiveCounter``. -internal final class DefaultLiveCounter: LiveCounter { +/// This provides the implementation behind ``PublicDefaultLiveCounter``, via internal versions of the ``LiveCounter`` API. +internal final class InternalDefaultLiveCounter: Sendable { // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. private let mutex = NSLock() @@ -27,7 +27,6 @@ internal final class DefaultLiveCounter: LiveCounter { } } - private let coreSDK: CoreSDK private let logger: AblyPlugin.Logger // MARK: - Initialization @@ -35,20 +34,17 @@ internal final class DefaultLiveCounter: LiveCounter { internal convenience init( testsOnly_data data: Double, objectID: String, - coreSDK: CoreSDK, logger: AblyPlugin.Logger ) { - self.init(data: data, objectID: objectID, coreSDK: coreSDK, logger: logger) + self.init(data: data, objectID: objectID, logger: logger) } private init( data: Double, objectID: String, - coreSDK: CoreSDK, logger: AblyPlugin.Logger ) { mutableState = .init(liveObject: .init(objectID: objectID), data: data) - self.coreSDK = coreSDK self.logger = logger } @@ -58,35 +54,31 @@ internal final class DefaultLiveCounter: LiveCounter { /// - objectID: The value for the "private objectId field" of RTO5c1b1a. internal static func createZeroValued( objectID: String, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) -> Self { .init( data: 0, objectID: objectID, - coreSDK: coreSDK, logger: logger, ) } - // MARK: - LiveCounter conformance + // MARK: - Internal methods that back LiveCounter conformance - internal var value: Double { - get throws(ARTErrorInfo) { - // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveCounter.value", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + internal func value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { + // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveCounter.value", + channelState: currentChannelState, + ) + .toARTErrorInfo() + } - return mutex.withLock { - // RTLC5c - mutableState.data - } + return mutex.withLock { + // RTLC5c + mutableState.data } } diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift similarity index 81% rename from Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift rename to Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index d9a5af6b3..56be4a2f9 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -7,8 +7,8 @@ internal protocol LiveMapObjectPoolDelegate: AnyObject, Sendable { func getObjectFromPool(id: String) -> ObjectsPool.Entry? } -/// Our default implementation of ``LiveMap``. -internal final class DefaultLiveMap: LiveMap { +/// This provides the implementation behind ``PublicDefaultLiveMap``, via internal versions of the ``LiveMap`` API. +internal final class InternalDefaultLiveMap: Sendable { // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. private let mutex = NSLock() @@ -44,13 +44,6 @@ internal final class DefaultLiveMap: LiveMap { } } - /// Delegate for accessing objects from the pool - private let delegate: WeakLiveMapObjectPoolDelegateRef - internal var testsOnly_delegate: LiveMapObjectPoolDelegate? { - delegate.referenced - } - - private let coreSDK: CoreSDK private let logger: AblyPlugin.Logger // MARK: - Initialization @@ -59,16 +52,12 @@ internal final class DefaultLiveMap: LiveMap { testsOnly_data data: [String: ObjectsMapEntry], objectID: String, testsOnly_semantics semantics: WireEnum? = nil, - delegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger ) { self.init( data: data, objectID: objectID, semantics: semantics, - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) } @@ -77,13 +66,9 @@ internal final class DefaultLiveMap: LiveMap { data: [String: ObjectsMapEntry], objectID: String, semantics: WireEnum?, - delegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger ) { mutableState = .init(liveObject: .init(objectID: objectID), data: data, semantics: semantics) - self.delegate = .init(referenced: delegate) - self.coreSDK = coreSDK self.logger = logger } @@ -95,24 +80,20 @@ internal final class DefaultLiveMap: LiveMap { internal static func createZeroValued( objectID: String, semantics: WireEnum? = nil, - delegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) -> Self { .init( data: [:], objectID: objectID, semantics: semantics, - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) } - // MARK: - LiveMap conformance + // MARK: - Internal methods that back LiveMap conformance /// Returns the value associated with a given key, following RTLM5d specification. - internal func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? { + internal func get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 let currentChannelState = coreSDK.channelState if currentChannelState == .detached || currentChannelState == .failed { @@ -133,73 +114,65 @@ internal final class DefaultLiveMap: LiveMap { } // RTLM5d2: If a ObjectsMapEntry exists at the key, convert it using the shared logic - return convertEntryToLiveMapValue(entry) + return convertEntryToLiveMapValue(entry, delegate: delegate) } - internal var size: Int { - get throws(ARTErrorInfo) { - // RTLM10c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveMap.size", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + internal func size(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Int { + // RTLM10c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveMap.size", + channelState: currentChannelState, + ) + .toARTErrorInfo() + } - return mutex.withLock { - // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map - mutableState.data.values.count { entry in - // RTLM14a: The method returns true if ObjectsMapEntry.tombstone is true - // RTLM14b: Otherwise, it returns false - entry.tombstone != true - } + return mutex.withLock { + // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map + mutableState.data.values.count { entry in + // RTLM14a: The method returns true if ObjectsMapEntry.tombstone is true + // RTLM14b: Otherwise, it returns false + entry.tombstone != true } } } - internal var entries: [(key: String, value: LiveMapValue)] { - get throws(ARTErrorInfo) { - // RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveMap.entries", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + internal func entries(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { + // RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "LiveMap.entries", + channelState: currentChannelState, + ) + .toARTErrorInfo() + } - return mutex.withLock { - // RTLM11d: Returns key-value pairs from the internal data map - // RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned - var result: [(key: String, value: LiveMapValue)] = [] + return mutex.withLock { + // RTLM11d: Returns key-value pairs from the internal data map + // RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned + var result: [(key: String, value: InternalLiveMapValue)] = [] - for (key, entry) in mutableState.data { - // Convert entry to LiveMapValue using the same logic as get(key:) - if let value = convertEntryToLiveMapValue(entry) { - result.append((key: key, value: value)) - } + for (key, entry) in mutableState.data { + // Convert entry to LiveMapValue using the same logic as get(key:) + if let value = convertEntryToLiveMapValue(entry, delegate: delegate) { + result.append((key: key, value: value)) } - - return result } + + return result } } - internal var keys: [String] { - get throws(ARTErrorInfo) { - // RTLM12b: Identical to LiveMap#entries, except that it returns only the keys from the internal data map - try entries.map(\.key) - } + internal func keys(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) 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 var values: [LiveMapValue] { - get throws(ARTErrorInfo) { - // RTLM13b: Identical to LiveMap#entries, except that it returns only the values from the internal data map - try entries.map(\.value) - } + internal func values(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) 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 _: LiveMapValue) async throws(ARTErrorInfo) { @@ -237,8 +210,6 @@ internal final class DefaultLiveMap: LiveMap { mutableState.replaceData( using: state, objectsPool: &objectsPool, - mapDelegate: delegate.referenced, - coreSDK: coreSDK, logger: logger, ) } @@ -250,8 +221,6 @@ internal final class DefaultLiveMap: LiveMap { mutableState.mergeInitialValue( from: operation, objectsPool: &objectsPool, - mapDelegate: delegate.referenced, - coreSDK: coreSDK, logger: logger, ) } @@ -263,8 +232,6 @@ internal final class DefaultLiveMap: LiveMap { mutableState.applyMapCreateOperation( operation, objectsPool: &objectsPool, - mapDelegate: delegate.referenced, - coreSDK: coreSDK, logger: logger, ) } @@ -283,8 +250,6 @@ internal final class DefaultLiveMap: LiveMap { objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, objectsPool: &objectsPool, - mapDelegate: delegate.referenced, - coreSDK: coreSDK, logger: logger, ) } @@ -305,8 +270,6 @@ internal final class DefaultLiveMap: LiveMap { operationTimeserial: operationTimeserial, operationData: operationData, objectsPool: &objectsPool, - mapDelegate: delegate.referenced, - coreSDK: coreSDK, logger: logger, ) } @@ -343,8 +306,6 @@ internal final class DefaultLiveMap: LiveMap { internal mutating func replaceData( using state: ObjectState, objectsPool: inout ObjectsPool, - mapDelegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials @@ -361,8 +322,6 @@ internal final class DefaultLiveMap: LiveMap { mergeInitialValue( from: createOp, objectsPool: &objectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) } @@ -372,8 +331,6 @@ internal final class DefaultLiveMap: LiveMap { internal mutating func mergeInitialValue( from operation: ObjectOperation, objectsPool: inout ObjectsPool, - mapDelegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) { // RTLM17a: For each key–ObjectsMapEntry pair in ObjectOperation.map.entries @@ -394,8 +351,6 @@ internal final class DefaultLiveMap: LiveMap { operationTimeserial: entry.timeserial, operationData: entry.data, objectsPool: &objectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) } @@ -411,8 +366,6 @@ internal final class DefaultLiveMap: LiveMap { objectMessageSerial: String?, objectMessageSiteCode: String?, objectsPool: inout ObjectsPool, - mapDelegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: Logger, ) { guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { @@ -430,8 +383,6 @@ internal final class DefaultLiveMap: LiveMap { applyMapCreateOperation( operation, objectsPool: &objectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) case .known(.mapSet): @@ -450,8 +401,6 @@ internal final class DefaultLiveMap: LiveMap { operationTimeserial: applicableOperation.objectMessageSerial, operationData: data, objectsPool: &objectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) case .known(.mapRemove): @@ -476,8 +425,6 @@ internal final class DefaultLiveMap: LiveMap { operationTimeserial: String?, operationData: ObjectData, objectsPool: inout ObjectsPool, - mapDelegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) { // RTLM7a: If an entry exists in the private data for the specified key @@ -505,7 +452,7 @@ internal final class DefaultLiveMap: LiveMap { // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute if let objectId = operationData.objectId, !objectId.isEmpty { // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 - _ = objectsPool.createZeroValueObject(forObjectID: objectId, mapDelegate: mapDelegate, coreSDK: coreSDK, logger: logger) + _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger) } } @@ -580,8 +527,6 @@ internal final class DefaultLiveMap: LiveMap { internal mutating func applyMapCreateOperation( _ operation: ObjectOperation, objectsPool: inout ObjectsPool, - mapDelegate: LiveMapObjectPoolDelegate?, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) { if liveObject.createOperationIsMerged { @@ -596,8 +541,6 @@ internal final class DefaultLiveMap: LiveMap { mergeInitialValue( from: operation, objectsPool: &objectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) } @@ -607,7 +550,7 @@ internal final class DefaultLiveMap: LiveMap { /// Converts an ObjectsMapEntry to LiveMapValue using the same logic as get(key:) /// This is used by entries to ensure consistent value conversion - private func convertEntryToLiveMapValue(_ entry: ObjectsMapEntry) -> LiveMapValue? { + private func convertEntryToLiveMapValue(_ entry: ObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> InternalLiveMapValue? { // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null // This is also equivalent to the RTLM14 check if entry.tombstone == true { @@ -645,7 +588,7 @@ internal final class DefaultLiveMap: LiveMap { // 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 = delegate.referenced?.getObjectFromPool(id: objectId) else { + guard let poolEntry = delegate.getObjectFromPool(id: objectId) else { return nil } diff --git a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift similarity index 91% rename from Sources/AblyLiveObjects/DefaultRealtimeObjects.swift rename to Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 730f519d8..ec25db75f 100644 --- a/Sources/AblyLiveObjects/DefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -1,14 +1,13 @@ import Ably internal import AblyPlugin -/// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. -internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolDelegate { +/// This provides the implementation behind ``PublicDefaultRealtimeObjects``, via internal versions of the ``RealtimeObjects`` API. +internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPoolDelegate { // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. private let mutex = NSLock() private nonisolated(unsafe) var mutableState: MutableState! - private let coreSDK: CoreSDK private let logger: AblyPlugin.Logger // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. @@ -70,13 +69,12 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD } } - internal init(coreSDK: CoreSDK, logger: AblyPlugin.Logger) { - self.coreSDK = coreSDK + internal init(logger: AblyPlugin.Logger) { self.logger = logger (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() - mutableState = .init(objectsPool: .init(rootDelegate: self, rootCoreSDK: coreSDK, logger: logger)) + mutableState = .init(objectsPool: .init(logger: logger)) } // MARK: - LiveMapObjectPoolDelegate @@ -87,9 +85,9 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD } } - // MARK: `RealtimeObjects` protocol + // MARK: - Internal methods that power RealtimeObjects conformance - internal func getRoot() async throws(ARTErrorInfo) -> any LiveMap { + internal func getRoot(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { // RTO1b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 let currentChannelState = coreSDK.channelState if currentChannelState == .detached || currentChannelState == .failed { @@ -159,8 +157,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD mutableState.onChannelAttached( hasObjects: hasObjects, logger: logger, - mapDelegate: self, - coreSDK: coreSDK, ) } } @@ -176,8 +172,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD objectMessages: objectMessages, logger: logger, receivedObjectProtocolMessagesContinuation: receivedObjectProtocolMessagesContinuation, - mapDelegate: self, - coreSDK: coreSDK, ) } } @@ -194,8 +188,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD protocolMessageChannelSerial: protocolMessageChannelSerial, logger: logger, receivedObjectSyncProtocolMessagesContinuation: receivedObjectSyncProtocolMessagesContinuation, - mapDelegate: self, - coreSDK: coreSDK, ) } } @@ -203,16 +195,16 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD /// 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, coreSDK: CoreSDK) -> ObjectsPool.Entry? { + internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String) -> ObjectsPool.Entry? { mutex.withLock { - mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, mapDelegate: self, coreSDK: coreSDK, logger: logger) + mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, logger: logger) } } // MARK: - Sending `OBJECT` ProtocolMessage // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. - internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage], coreSDK: CoreSDK) async throws(InternalError) { try await coreSDK.sendObject(objectMessages: objectMessages) } @@ -241,8 +233,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD internal mutating func onChannelAttached( hasObjects: Bool, logger: Logger, - mapDelegate: LiveMapObjectPoolDelegate, - coreSDK: CoreSDK, ) { logger.log("onChannelAttached(hasObjects: \(hasObjects)", level: .debug) @@ -255,7 +245,7 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD // RTO4b1, RTO4b2: Reset the ObjectsPool to have a single empty root object // 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 - objectsPool = .init(rootDelegate: mapDelegate, rootCoreSDK: coreSDK, logger: logger) + objectsPool = .init(logger: logger) // 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. @@ -270,8 +260,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD protocolMessageChannelSerial: String?, logger: Logger, receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, - mapDelegate: LiveMapObjectPoolDelegate, - coreSDK: CoreSDK, ) { logger.log("handleObjectSyncProtocolMessage(objectMessages: \(objectMessages), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) @@ -326,8 +314,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD // RTO5c objectsPool.applySyncObjectsPool( completedSyncObjectsPool, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) @@ -338,8 +324,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD applyObjectProtocolMessageObjectMessage( objectMessage, logger: logger, - mapDelegate: mapDelegate, - coreSDK: coreSDK, ) } } @@ -356,8 +340,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD objectMessages: [InboundObjectMessage], logger: Logger, receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, - mapDelegate: LiveMapObjectPoolDelegate, - coreSDK: CoreSDK, ) { receivedObjectProtocolMessagesContinuation.yield(objectMessages) @@ -375,8 +357,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD applyObjectProtocolMessageObjectMessage( objectMessage, logger: logger, - mapDelegate: mapDelegate, - coreSDK: coreSDK, ) } } @@ -386,8 +366,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD private mutating func applyObjectProtocolMessageObjectMessage( _ objectMessage: InboundObjectMessage, logger: Logger, - mapDelegate: LiveMapObjectPoolDelegate, - coreSDK: CoreSDK, ) { guard let operation = objectMessage.operation else { // RTO9a1 @@ -402,8 +380,6 @@ internal final class DefaultRealtimeObjects: RealtimeObjects, LiveMapObjectPoolD } else { guard let newEntry = objectsPool.createZeroValueObject( forObjectID: operation.objectId, - mapDelegate: mapDelegate, - coreSDK: coreSDK, logger: logger, ) else { logger.log("Unable to create zero-value object for \(operation.objectId) when processing OBJECT message; dropping", level: .warn) diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift new file mode 100644 index 000000000..f63d27133 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Same as the public ``LiveMapValue`` type but with associated values of internal type. +internal enum InternalLiveMapValue: Sendable { + case primitive(PrimitiveObjectValue) + case liveMap(InternalDefaultLiveMap) + case liveCounter(InternalDefaultLiveCounter) + + // MARK: - Convenience getters for associated values + + /// If this `InternalLiveMapValue` has case `primitive`, this returns the associated value. Else, it returns `nil`. + internal var primitiveValue: PrimitiveObjectValue? { + if case let .primitive(value) = self { + return value + } + return nil + } + + /// 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 `primitive` with a string value, this returns that value. Else, it returns `nil`. + internal var stringValue: String? { + primitiveValue?.stringValue + } + + /// If this `InternalLiveMapValue` has case `primitive` with a number value, this returns that value. Else, it returns `nil`. + internal var numberValue: Double? { + primitiveValue?.numberValue + } + + /// If this `InternalLiveMapValue` has case `primitive` with a boolean value, this returns that value. Else, it returns `nil`. + internal var boolValue: Bool? { + primitiveValue?.boolValue + } + + /// If this `InternalLiveMapValue` has case `primitive` with a data value, this returns that value. Else, it returns `nil`. + internal var dataValue: Data? { + primitiveValue?.dataValue + } +} diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index c81bec58f..3454909a5 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -2,7 +2,7 @@ internal import AblyPlugin /// This is the equivalent of the `LiveObject` abstract class described in RTLO. /// -/// ``DefaultLiveCounter`` and ``DefaultLiveMap`` include it by composition. +/// ``InternalDefaultLiveCounter`` and ``InternalDefaultLiveMap`` include it by composition. internal struct LiveObjectMutableState { // RTLO3a internal var objectID: String diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 4e2efc4b4..c2e358555 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -6,11 +6,11 @@ internal import AblyPlugin internal struct ObjectsPool { /// The possible `ObjectsPool` entries, as described by RTO3a. internal enum Entry { - case map(DefaultLiveMap) - case counter(DefaultLiveCounter) + case map(InternalDefaultLiveMap) + case counter(InternalDefaultLiveCounter) /// Convenience getter for accessing the map value if this entry is a map - internal var mapValue: DefaultLiveMap? { + internal var mapValue: InternalDefaultLiveMap? { switch self { case let .map(map): map @@ -20,7 +20,7 @@ internal struct ObjectsPool { } /// Convenience getter for accessing the counter value if this entry is a counter - internal var counterValue: DefaultLiveCounter? { + internal var counterValue: InternalDefaultLiveCounter? { switch self { case .map: nil @@ -67,34 +67,28 @@ internal struct ObjectsPool { /// Creates an `ObjectsPool` whose root is a zero-value `LiveMap`. internal init( - rootDelegate: LiveMapObjectPoolDelegate?, - rootCoreSDK: CoreSDK, logger: AblyPlugin.Logger, testsOnly_otherEntries otherEntries: [String: Entry]? = nil, ) { self.init( - rootDelegate: rootDelegate, - rootCoreSDK: rootCoreSDK, logger: logger, otherEntries: otherEntries, ) } private init( - rootDelegate: LiveMapObjectPoolDelegate?, - rootCoreSDK: CoreSDK, logger: AblyPlugin.Logger, 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, delegate: rootDelegate, coreSDK: rootCoreSDK, logger: logger)) + entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, logger: logger)) } // MARK: - Typed root /// Fetches the root object. - internal var root: DefaultLiveMap { + internal var root: InternalDefaultLiveMap { guard let rootEntry = entries[Self.rootKey] else { preconditionFailure("ObjectsPool should always contain a root object") } @@ -113,11 +107,9 @@ internal struct ObjectsPool { /// /// - Parameters: /// - objectID: The ID of the object to create - /// - mapDelegate: The delegate to use for any created LiveMap - /// - coreSDK: The CoreSDK to use for any created LiveObject /// - logger: The logger to use for any created LiveObject /// - Returns: The existing or newly created object - internal mutating func createZeroValueObject(forObjectID objectID: String, mapDelegate: LiveMapObjectPoolDelegate?, coreSDK: CoreSDK, logger: AblyPlugin.Logger) -> Entry? { + internal mutating func createZeroValueObject(forObjectID objectID: String, logger: AblyPlugin.Logger) -> Entry? { // RTO6a: If an object with objectId exists in ObjectsPool, do not create a new object if let existingEntry = entries[objectID] { return existingEntry @@ -135,9 +127,9 @@ internal struct ObjectsPool { let entry: Entry switch typeString { case "map": - entry = .map(.createZeroValued(objectID: objectID, delegate: mapDelegate, coreSDK: coreSDK, logger: logger)) + entry = .map(.createZeroValued(objectID: objectID, logger: logger)) case "counter": - entry = .counter(.createZeroValued(objectID: objectID, coreSDK: coreSDK, logger: logger)) + entry = .counter(.createZeroValued(objectID: objectID, logger: logger)) default: return nil } @@ -148,14 +140,8 @@ internal struct ObjectsPool { } /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1. - /// - /// - Parameters: - /// - mapDelegate: The delegate to use for any created LiveMap - /// - coreSDK: The CoreSDK to use for any created LiveObject internal mutating func applySyncObjectsPool( _ syncObjectsPool: [ObjectState], - mapDelegate: LiveMapObjectPoolDelegate, - coreSDK: CoreSDK, logger: AblyPlugin.Logger, ) { logger.log("applySyncObjectsPool called with \(syncObjectsPool.count) objects", level: .debug) @@ -188,14 +174,14 @@ internal struct ObjectsPool { if objectState.counter != nil { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 - let counter = DefaultLiveCounter.createZeroValued(objectID: objectState.objectId, coreSDK: coreSDK, logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger) counter.replaceData(using: objectState) newEntry = .counter(counter) } else if let objectsMap = objectState.map { // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, // set its private objectId equal to ObjectState.objectId, set its private semantics // equal to ObjectState.map.semantics and override its internal data per RTLM6 - let map = DefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, delegate: mapDelegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger) map.replaceData(using: objectState, objectsPool: &self) newEntry = .map(map) } else { diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index 2df0f68e1..964baabae 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -4,15 +4,30 @@ internal import AblyPlugin public extension ARTRealtimeChannel { /// A ``RealtimeObjects`` object. var objects: RealtimeObjects { - internallyTypedObjects + nonTypeErasedObjects } - private var internallyTypedObjects: DefaultRealtimeObjects { - DefaultInternalPlugin.objectsProperty(for: self, pluginAPI: AblyPlugin.PluginAPI.sharedInstance()) + private var nonTypeErasedObjects: PublicDefaultRealtimeObjects { + let pluginAPI = Plugin.defaultPluginAPI + let underlyingObjects = pluginAPI.underlyingObjects(forPublicRealtimeChannel: self) + let internalObjects = DefaultInternalPlugin.realtimeObjects(for: underlyingObjects.channel, pluginAPI: pluginAPI) + + let coreSDK = DefaultCoreSDK( + channel: underlyingObjects.channel, + client: underlyingObjects.client, + pluginAPI: Plugin.defaultPluginAPI, + ) + + return PublicObjectsStore.shared.getOrCreateRealtimeObjects( + proxying: internalObjects, + creationArgs: .init( + coreSDK: coreSDK, + ), + ) } - /// For tests to access the non-public API of `DefaultRealtimeObjects`. - internal var testsOnly_internallyTypedObjects: DefaultRealtimeObjects { - internallyTypedObjects + /// For tests to access the non-public API of `PublicDefaultRealtimeObjects`. + internal var testsOnly_nonTypeErasedObjects: PublicDefaultRealtimeObjects { + nonTypeErasedObjects } } diff --git a/Sources/AblyLiveObjects/Public/Plugin.swift b/Sources/AblyLiveObjects/Public/Plugin.swift index 20c709cd1..26a377d45 100644 --- a/Sources/AblyLiveObjects/Public/Plugin.swift +++ b/Sources/AblyLiveObjects/Public/Plugin.swift @@ -22,7 +22,10 @@ import ObjectiveC.NSObject /// ``` @objc public class Plugin: NSObject { + /// The `AblyPlugin.PluginAPIProtocol` that the LiveObjects plugin should use by default (i.e. when one hasn't been injected for test purposes). + internal static let defaultPluginAPI: AblyPlugin.PluginAPIProtocol = AblyPlugin.PluginAPI.sharedInstance() + // MARK: - Informal conformance to AblyPlugin.LiveObjectsPluginProtocol - @objc private static let internalPlugin = DefaultInternalPlugin(pluginAPI: AblyPlugin.PluginAPI.sharedInstance()) + @objc private static let internalPlugin = DefaultInternalPlugin(pluginAPI: defaultPluginAPI) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift new file mode 100644 index 000000000..92acbe1ae --- /dev/null +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift @@ -0,0 +1,38 @@ +internal extension InternalLiveMapValue { + // MARK: - Mapping to public types + + struct PublicValueCreationArgs { + internal var coreSDK: CoreSDK + internal var mapDelegate: LiveMapObjectPoolDelegate + + internal var toCounterCreationArgs: PublicObjectsStore.CounterCreationArgs { + .init(coreSDK: coreSDK) + } + + internal var toMapCreationArgs: PublicObjectsStore.MapCreationArgs { + .init(coreSDK: coreSDK, delegate: mapDelegate) + } + } + + /// 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 .primitive(primitive): + .primitive(primitive) + 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/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift new file mode 100644 index 000000000..1d1e5053f --- /dev/null +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -0,0 +1,52 @@ +import Ably + +/// Our default implementation of ``LiveCounter``. +/// +/// This is largely a wrapper around ``InternalDefaultLiveCounter``. +internal final class PublicDefaultLiveCounter: LiveCounter { + private let proxied: InternalDefaultLiveCounter + internal var testsOnly_proxied: InternalDefaultLiveCounter { + proxied + } + + // MARK: - Dependencies that hold a strong reference to `proxied` + + private let coreSDK: CoreSDK + + internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK) { + self.proxied = proxied + self.coreSDK = coreSDK + } + + // 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) + } + + internal func decrement(amount: Double) async throws(ARTErrorInfo) { + try await proxied.decrement(amount: amount) + } + + internal func subscribe(listener: sending (sending any LiveCounterUpdate) -> Void) -> any SubscribeResponse { + proxied.subscribe(listener: listener) + } + + internal func unsubscribeAll() { + proxied.unsubscribeAll() + } + + internal func on(event: LiveObjectLifecycleEvent, callback: sending () -> Void) -> any OnLiveObjectLifecycleEventResponse { + proxied.on(event: event, callback: callback) + } + + internal func offAll() { + proxied.offAll() + } +} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift new file mode 100644 index 000000000..52edb50cc --- /dev/null +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -0,0 +1,98 @@ +import Ably + +/// Our default implementation of ``LiveMap``. +/// +/// This is largely a wrapper around ``InternalDefaultLiveMap``. +internal final class PublicDefaultLiveMap: LiveMap { + private let proxied: InternalDefaultLiveMap + internal var testsOnly_proxied: InternalDefaultLiveMap { + proxied + } + + // MARK: - Dependencies that hold a strong reference to `proxied` + + private let coreSDK: CoreSDK + private let delegate: LiveMapObjectPoolDelegate + + internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) { + self.proxied = proxied + self.coreSDK = coreSDK + self.delegate = delegate + } + + // MARK: - `LiveMap` protocol + + internal func get(key: String) throws(ARTErrorInfo) -> LiveMapValue? { + try proxied.get(key: key, coreSDK: coreSDK, delegate: delegate)?.toPublic( + creationArgs: .init( + coreSDK: coreSDK, + mapDelegate: delegate, + ), + ) + } + + internal var size: Int { + get throws(ARTErrorInfo) { + try proxied.size(coreSDK: coreSDK) + } + } + + internal var entries: [(key: String, value: LiveMapValue)] { + get throws(ARTErrorInfo) { + try proxied.entries(coreSDK: coreSDK, delegate: delegate).map { entry in + ( + entry.key, + entry.value.toPublic( + creationArgs: .init( + coreSDK: coreSDK, + mapDelegate: delegate, + ), + ) + ) + } + } + } + + internal var keys: [String] { + get throws(ARTErrorInfo) { + try proxied.keys(coreSDK: coreSDK, delegate: delegate) + } + } + + internal var values: [LiveMapValue] { + get throws(ARTErrorInfo) { + try proxied.values(coreSDK: coreSDK, delegate: delegate).map { value in + value.toPublic( + creationArgs: .init( + coreSDK: coreSDK, + mapDelegate: delegate, + ), + ) + } + } + } + + internal func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) { + try await proxied.set(key: key, value: value) + } + + internal func remove(key: String) async throws(ARTErrorInfo) { + try await proxied.remove(key: key) + } + + internal func subscribe(listener: sending (sending any LiveMapUpdate) -> Void) -> any SubscribeResponse { + proxied.subscribe(listener: listener) + } + + internal func unsubscribeAll() { + proxied.unsubscribeAll() + } + + internal func on(event: LiveObjectLifecycleEvent, callback: sending () -> Void) -> any OnLiveObjectLifecycleEventResponse { + proxied.on(event: event, callback: callback) + } + + internal func offAll() { + proxied.offAll() + } +} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift new file mode 100644 index 000000000..c0fd7dcf6 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -0,0 +1,81 @@ +import Ably + +/// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. +/// +/// This is largely a wrapper around ``InternalDefaultRealtimeObjects``. +internal final class PublicDefaultRealtimeObjects: RealtimeObjects { + private let proxied: InternalDefaultRealtimeObjects + internal var testsOnly_proxied: InternalDefaultRealtimeObjects { + proxied + } + + // MARK: - Dependencies that hold a strong reference to `proxied` + + private let coreSDK: CoreSDK + + internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK) { + self.proxied = proxied + self.coreSDK = coreSDK + } + + // 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, + delegate: proxied, + ), + ) + } + + internal func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { + try await proxied.createMap(entries: entries) + } + + internal func createMap() async throws(ARTErrorInfo) -> any LiveMap { + try await proxied.createMap() + } + + internal func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter { + try await proxied.createCounter(count: count) + } + + internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter { + try await proxied.createCounter() + } + + internal func batch(callback: sending (sending any BatchContext) -> Void) async throws { + try await proxied.batch(callback: callback) + } + + internal func on(event: ObjectsEvent, callback: sending () -> Void) -> 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_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + try await proxied.testsOnly_sendObject(objectMessages: objectMessages, coreSDK: coreSDK) + } + + internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { + proxied.testsOnly_receivedObjectSyncProtocolMessages + } +} diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift new file mode 100644 index 000000000..39e7f9078 --- /dev/null +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift @@ -0,0 +1,129 @@ +import Foundation + +/// Stores the public objects that wrap the SDK's internal components. +/// +/// This allows us to provide stable object identity for our public `RealtimeObjects`, `LiveMap`, and `LiveCounter` objects. Concretely, this means that it allows us to, for example, consistently return: +/// +/// - the same `PublicDefaultRealtimeObjects` instance across multiple calls to `ARTRealtimeChannel.objects` +/// - the same `PublicDefaultLiveMap` instance across multiple calls to `PublicDefaultRealtimeObjects.getRoot()` +/// - the same `PublicDefaultLiveMap` and `PublicDefaultLiveCounter` instance across multiple calls to `PublicDefaultLiveMap.get(…)` with the same key (similarly for other `LiveMap` getters) +/// +/// This differs from the approach that we take in ably-cocoa, in which we create a new public object each time we need to return one. Given that the LiveObjects SDK revolves around the concept of various live-updating objects, it seemed like it might be quite a confusing user experience if the pointer identity of, say, a `LiveMap` changed each time it was fetched. +/// +/// - Note: We can only make a best-effort attempt to maintain the pointer identity of the public objects. Since the SDK cannot maintain a strong reference to the public objects (given that the whole reason that these objects exist is for us to know whether the user holds a strong reference to them), if the user releases all of their strong references to a public object then the next time they fetch the public object they will receive a new object. +internal final class PublicObjectsStore: Sendable { + // Used to synchronize access to mutable state + private let mutex = NSLock() + private nonisolated(unsafe) var mutableState = MutableState() + + internal static let shared = PublicObjectsStore() + + internal struct RealtimeObjectsCreationArgs { + internal var coreSDK: CoreSDK + } + + /// 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 + } + + /// 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 delegate: LiveMapObjectPoolDelegate + } + + /// 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, + createProxy: () -> Proxy, + ) -> Proxy { + // Remove any entries that are no longer useful + removeDeallocatedEntries() + + // Do the get-or-create + let proxiedObjectIdentifier = ObjectIdentifier(proxied) + + if let existing = proxiesByProxiedObjectIdentifier[proxiedObjectIdentifier]?.referenced { + return existing + } + + let created = createProxy() + proxiesByProxiedObjectIdentifier[proxiedObjectIdentifier] = .init(referenced: created) + + return created + } + + private mutating func removeDeallocatedEntries() { + proxiesByProxiedObjectIdentifier = proxiesByProxiedObjectIdentifier.filter { entry in + entry.value.referenced != nil + } + } + } + + internal mutating func getOrCreateRealtimeObjects( + proxying proxied: InternalDefaultRealtimeObjects, + creationArgs: RealtimeObjectsCreationArgs, + ) -> PublicDefaultRealtimeObjects { + realtimeObjectsProxies.getOrCreate(proxying: proxied) { + .init( + proxied: proxied, + coreSDK: creationArgs.coreSDK, + ) + } + } + + internal mutating func getOrCreateCounter( + proxying proxied: InternalDefaultLiveCounter, + creationArgs: CounterCreationArgs, + ) -> PublicDefaultLiveCounter { + counterProxies.getOrCreate(proxying: proxied) { + .init( + proxied: proxied, + coreSDK: creationArgs.coreSDK, + ) + } + } + + internal mutating func getOrCreateMap( + proxying proxied: InternalDefaultLiveMap, + creationArgs: MapCreationArgs, + ) -> PublicDefaultLiveMap { + mapProxies.getOrCreate(proxying: proxied) { + .init( + proxied: proxied, + coreSDK: creationArgs.coreSDK, + delegate: creationArgs.delegate, + ) + } + } + } +} diff --git a/Sources/AblyLiveObjects/Utility/WeakRef.swift b/Sources/AblyLiveObjects/Utility/WeakRef.swift index 0678728a3..c7175a7ee 100644 --- a/Sources/AblyLiveObjects/Utility/WeakRef.swift +++ b/Sources/AblyLiveObjects/Utility/WeakRef.swift @@ -6,11 +6,3 @@ internal struct WeakRef { } extension WeakRef: Sendable where Referenced: Sendable {} - -// MARK: - Specialized versions of WeakRef - -// These are protocol-specific versions of ``WeakRef`` that hold an existential type (e.g. `any CoreSDK`). (This is because the compiler complains that an existential of a class-bound protocol doesn't conform to `AnyObject`.) - -internal struct WeakLiveMapObjectPoolDelegateRef: Sendable { - internal weak var referenced: LiveMapObjectPoolDelegate? -} diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index 6b89d54b5..d33ca22d1 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -20,7 +20,7 @@ struct AblyLiveObjectsTests { // Then // Check that the `channel.objects` property works and gives the internal type we expect - #expect(channel.objects is DefaultRealtimeObjects) + #expect(channel.objects is PublicDefaultRealtimeObjects) } /// A basic test of the core interactions between this plugin and ably-cocoa. @@ -79,10 +79,10 @@ struct AblyLiveObjectsTests { try await channel.attachAsync() // 3. Check that ably-cocoa called our onChannelAttached and passed the HAS_OBJECTS flag. - #expect(channel.testsOnly_internallyTypedObjects.testsOnly_onChannelAttachedHasObjects == true) + #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_internallyTypedObjects.testsOnly_receivedObjectSyncProtocolMessages.first { _ in true }) + 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. @@ -90,7 +90,7 @@ struct AblyLiveObjectsTests { // (This objectId comes from copying that which was given in an expected value in an error message from Realtime) let realtimeCreatedMapObjectID = "map:iC4Nq8EbTSEmw-_tDJdVV8HfiBvJGpZmO_WbGbh0_-4@\(currentAblyTimestamp)" - try await channel.testsOnly_internallyTypedObjects.testsOnly_sendObject(objectMessages: [ + try await channel.testsOnly_nonTypeErasedObjects.testsOnly_sendObject(objectMessages: [ OutboundObjectMessage( operation: .init( action: .known(.mapCreate), @@ -101,7 +101,7 @@ struct AblyLiveObjectsTests { ]) // 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_internallyTypedObjects.testsOnly_receivedObjectProtocolMessages.first { _ in true }) + 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) @@ -110,7 +110,7 @@ struct AblyLiveObjectsTests { // 7. Now, send an invalid OBJECT ProtocolMessage to check that ably-cocoa correctly reports on its NACK. let invalidObjectThrownError = try await #require(throws: ARTErrorInfo.self) { do throws(InternalError) { - try await channel.testsOnly_internallyTypedObjects.testsOnly_sendObject(objectMessages: [ + try await channel.testsOnly_nonTypeErasedObjects.testsOnly_sendObject(objectMessages: [ .init(), ]) } catch { diff --git a/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift b/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift index 8c6f9a0ad..ff3dcb715 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift @@ -19,6 +19,9 @@ enum ClientHelper { if let useBinaryProtocol = options.useBinaryProtocol { clientOptions.useBinaryProtocol = useBinaryProtocol } + if let autoConnect = options.autoConnect { + clientOptions.autoConnect = autoConnect + } return ARTRealtime(options: clientOptions) } @@ -32,5 +35,6 @@ enum ClientHelper { struct PartialClientOptions: Encodable, Hashable { var useBinaryProtocol: Bool? + var autoConnect: Bool? } } diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index cf94ccfca..c82df9e96 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -172,14 +172,12 @@ import Foundation /// /// - Parameters: /// /// - objectId: The object ID for the map (default: "map:test@123") /// /// - entries: Dictionary of key-value pairs to populate the map -/// /// - delegate: The delegate for the map (default: MockLiveMapObjectPoolDelegate()) -/// /// - Returns: A configured DefaultLiveMap instance +/// /// - Returns: A configured InternalDefaultLiveMap instance /// static func liveMap( /// objectId: String = "map:test@123", /// entries: [String: String] = [:], -/// delegate: LiveMapObjectPoolDelegate = MockLiveMapObjectPoolDelegate() -/// ) -> DefaultLiveMap { -/// let map = DefaultLiveMap.createZeroValued(delegate: delegate) +/// ) -> InternalDefaultLiveMap { +/// let map = InternalDefaultLiveMap.createZeroValued() /// // Configure map with entries... /// return map /// } diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift similarity index 75% rename from Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift rename to Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 4b8ce9c32..d4cd7b751 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -3,17 +3,18 @@ import AblyPlugin import Foundation import Testing -struct DefaultLiveCounterTests { +struct InternalDefaultLiveCounterTests { /// Tests for the `value` property, covering RTLC5 specification points struct ValueTests { // @spec RTLC5b @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func valueThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: channelState), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: channelState) #expect { - _ = try counter.value + _ = try counter.value(coreSDK: coreSDK) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false @@ -27,12 +28,13 @@ struct DefaultLiveCounterTests { @Test func valueReturnsCurrentDataWhenChannelIsValid() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attached), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attached) // Set some test data counter.replaceData(using: TestFactories.counterObjectState(count: 42)) - #expect(try counter.value == 42) + #expect(try counter.value(coreSDK: coreSDK) == 42) } } @@ -42,7 +44,7 @@ struct DefaultLiveCounterTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) @@ -58,7 +60,7 @@ struct DefaultLiveCounterTests { // Given: A counter whose createOperationIsMerged is true let logger = TestLogger() let counter = { - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) // 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( @@ -85,23 +87,25 @@ struct DefaultLiveCounterTests { @Test func setsDataToCounterCount() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) let state = TestFactories.counterObjectState( count: 42, // Test value ) counter.replaceData(using: state) - #expect(try counter.value == 42) + #expect(try counter.value(coreSDK: coreSDK) == 42) } // @specOneOf(2/4) RTLC6c - no count, no createOp @Test func setsDataToZeroWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) counter.replaceData(using: TestFactories.counterObjectState( count: nil, // Test value - must be nil )) - #expect(try counter.value == 0) + #expect(try counter.value(coreSDK: coreSDK) == 0) } } @@ -111,13 +115,14 @@ struct DefaultLiveCounterTests { @Test func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) let state = TestFactories.counterObjectState( createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist count: 5, // Test value - must exist ) counter.replaceData(using: state) - #expect(try counter.value == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC10a) + #expect(try counter.value(coreSDK: coreSDK) == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC10a) #expect(counter.testsOnly_createOperationIsMerged) } } @@ -129,28 +134,30 @@ struct DefaultLiveCounterTests { @Test func addsCounterCountToData() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data counter.replaceData(using: TestFactories.counterObjectState(count: 5)) - #expect(try counter.value == 5) + #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist counter.testsOnly_mergeInitialValue(from: operation) - #expect(try counter.value == 15) // 5 + 10 + #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 } // @specOneOf(2/2) RTLC10a - no count @Test func doesNotModifyDataWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data counter.replaceData(using: TestFactories.counterObjectState(count: 5)) - #expect(try counter.value == 5) + #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation with no count let operation = TestFactories.objectOperation( @@ -159,14 +166,14 @@ struct DefaultLiveCounterTests { ) counter.testsOnly_mergeInitialValue(from: operation) - #expect(try counter.value == 5) // Unchanged + #expect(try counter.value(coreSDK: coreSDK) == 5) // Unchanged } // @spec RTLC10b @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist @@ -182,7 +189,8 @@ struct DefaultLiveCounterTests { @Test func discardsOperationWhenCreateOperationIsMerged() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data and mark create operation as merged counter.replaceData(using: TestFactories.counterObjectState(count: 5)) @@ -194,14 +202,15 @@ struct DefaultLiveCounterTests { counter.testsOnly_applyCounterCreateOperation(operation) // Verify the operation was discarded - data unchanged - #expect(try counter.value == 15) // 5 + 10, not 5 + 10 + 20 + #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10, not 5 + 10 + 20 } // @spec RTLC8c @Test func mergesInitialValue() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data but don't mark create operation as merged counter.replaceData(using: TestFactories.counterObjectState(count: 5)) @@ -212,7 +221,7 @@ struct DefaultLiveCounterTests { counter.testsOnly_applyCounterCreateOperation(operation) // Verify the operation was applied - initial value merged. (The full logic of RTLC10 is tested elsewhere; we just check for some of its side effects here.) - #expect(try counter.value == 15) // 5 + 10 + #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 #expect(counter.testsOnly_createOperationIsMerged) } } @@ -226,17 +235,18 @@ struct DefaultLiveCounterTests { ] as [(operation: WireObjectsCounterOp?, expectedValue: Double)]) func addsAmountToData(operation: WireObjectsCounterOp?, expectedValue: Double) throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data counter.replaceData(using: TestFactories.counterObjectState(count: 5)) - #expect(try counter.value == 5) + #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply COUNTER_INC operation counter.testsOnly_applyCounterIncOperation(operation) // Verify the operation was applied correctly - #expect(try counter.value == expectedValue) + #expect(try counter.value(coreSDK: coreSDK) == expectedValue) } } @@ -246,7 +256,8 @@ struct DefaultLiveCounterTests { @Test func discardsOperationWhenCannotBeApplied() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set up the counter with an existing site timeserial that will cause the operation to be discarded counter.replaceData(using: TestFactories.counterObjectState( @@ -258,7 +269,7 @@ struct DefaultLiveCounterTests { action: .known(.counterInc), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(rootDelegate: MockLiveMapObjectPoolDelegate(), rootCoreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + var pool = ObjectsPool(logger: logger) // Apply operation with serial "ts1" which is lexicographically less than existing "ts2" and thus will be applied per RTLO4a (this is a non-pathological case of RTOL4a, that spec point being fully tested elsewhere) counter.apply( @@ -270,7 +281,7 @@ struct DefaultLiveCounterTests { // 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 == 5) + #expect(try counter.value(coreSDK: coreSDK) == 5) // Verify site timeserials unchanged #expect(counter.testsOnly_siteTimeserials == ["site1": "ts2"]) } @@ -280,10 +291,11 @@ struct DefaultLiveCounterTests { @Test func appliesCounterCreateOperation() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) let operation = TestFactories.counterCreateOperation(count: 15) - var pool = ObjectsPool(rootDelegate: MockLiveMapObjectPoolDelegate(), rootCoreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + var pool = ObjectsPool(logger: logger) // Apply COUNTER_CREATE operation counter.apply( @@ -294,7 +306,7 @@ struct DefaultLiveCounterTests { ) // 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 == 15) + #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"]) @@ -305,17 +317,18 @@ struct DefaultLiveCounterTests { @Test func appliesCounterIncOperation() throws { let logger = TestLogger() - let counter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data counter.replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5)) - #expect(try counter.value == 5) + #expect(try counter.value(coreSDK: coreSDK) == 5) let operation = TestFactories.objectOperation( action: .known(.counterInc), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(rootDelegate: MockLiveMapObjectPoolDelegate(), rootCoreSDK: MockCoreSDK(channelState: .attaching), logger: logger) + var pool = ObjectsPool(logger: logger) // Apply COUNTER_INC operation counter.apply( @@ -326,7 +339,7 @@ struct DefaultLiveCounterTests { ) // 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 == 15) // 5 + 10 + #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 // Verify RTLC7c side-effect: site timeserial was updated #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) } diff --git a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift similarity index 80% rename from Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift rename to Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index f9bb1838b..3a2f14501 100644 --- a/Tests/AblyLiveObjectsTests/DefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -3,17 +3,17 @@ import AblyPlugin import Foundation import Testing -struct DefaultLiveMapTests { +struct InternalDefaultLiveMapTests { /// Tests for the `get` method, covering RTLM5 specification points struct GetTests { // @spec RTLM5c @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func getThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState), logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) #expect { - _ = try map.get(key: "test") + _ = try map.get(key: "test", coreSDK: MockCoreSDK(channelState: channelState), delegate: MockLiveMapObjectPoolDelegate()) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false @@ -30,8 +30,8 @@ struct DefaultLiveMapTests { func returnsNilWhenNoEntryExists() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: coreSDK, logger: logger) - #expect(try map.get(key: "nonexistent") == nil) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + #expect(try map.get(key: "nonexistent", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) } // @spec RTLM5d2a @@ -43,8 +43,8 @@ struct DefaultLiveMapTests { data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) - #expect(try map.get(key: "key") == nil) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) } // @spec RTLM5d2b @@ -53,8 +53,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(boolean: true)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) - let result = try map.get(key: "key") + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.boolValue == true) } @@ -65,8 +65,8 @@ struct DefaultLiveMapTests { let bytes = Data([0x01, 0x02, 0x03]) let entry = TestFactories.mapEntry(data: ObjectData(bytes: bytes)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) - let result = try map.get(key: "key") + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.dataValue == bytes) } @@ -76,8 +76,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 123.456))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) - let result = try map.get(key: "key") + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.numberValue == 123.456) } @@ -87,8 +87,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(string: .string("test"))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: nil, coreSDK: coreSDK, logger: logger) - let result = try map.get(key: "key") + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.stringValue == "test") } @@ -99,8 +99,8 @@ struct DefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: "missing")) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - #expect(try map.get(key: "key") == nil) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) } // @specOneOf(1/2) RTLM5d2f2 - Returns referenced map when it exists in pool @@ -111,11 +111,11 @@ struct DefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) delegate.objects[objectId] = .map(referencedMap) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let result = try map.get(key: "key") + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) let returnedMap = result?.liveMapValue #expect(returnedMap as AnyObject === referencedMap as AnyObject) } @@ -128,10 +128,10 @@ struct DefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) delegate.objects[objectId] = .counter(referencedCounter) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let result = try map.get(key: "key") + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) let returnedCounter = result?.liveCounterValue #expect(returnedCounter as AnyObject === referencedCounter as AnyObject) } @@ -144,8 +144,8 @@ struct DefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - #expect(try map.get(key: "key") == nil) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) } } @@ -155,14 +155,12 @@ struct DefaultLiveMapTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) let state = TestFactories.objectState( objectId: "arbitrary-id", siteTimeserials: ["site1": "ts1", "site2": "ts2"], ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) } @@ -171,12 +169,10 @@ struct DefaultLiveMapTests { @Test func setsCreateOperationIsMergedToFalseWhenCreateOpAbsent() { // Given: - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) let logger = TestLogger() - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let map = { - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) // 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( @@ -202,18 +198,18 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "test") let state = TestFactories.mapObjectState( objectId: "arbitrary-id", entries: [key: entry], ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) map.replaceData(using: state, objectsPool: &pool) let newData = map.testsOnly_data #expect(newData.count == 1) #expect(Set(newData.keys) == ["key1"]) - #expect(try map.get(key: "key1")?.stringValue == "test") + #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 @@ -223,7 +219,7 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation( @@ -239,12 +235,12 @@ struct DefaultLiveMapTests { ], ), ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) map.replaceData(using: state, objectsPool: &pool) // Note that we just check for some basic expected side effects of merging the initial value; RTLM17 is tested in more detail elsewhere // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d) - #expect(try map.get(key: "keyFromMapEntries")?.stringValue == "valueFromMapEntries") - #expect(try map.get(key: "keyFromCreateOp")?.stringValue == "valueFromCreateOp") + #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) } } @@ -260,14 +256,16 @@ struct DefaultLiveMapTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: MockLiveMapObjectPoolDelegate(), coreSDK: MockCoreSDK(channelState: channelState), logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let coreSDK = MockCoreSDK(channelState: channelState) + let delegate = MockLiveMapObjectPoolDelegate() // Define actions to test let actions: [(String, () throws -> Any)] = [ - ("size", { try map.size }), - ("entries", { try map.entries }), - ("keys", { try map.keys }), - ("values", { try map.values }), + ("size", { try map.size(coreSDK: coreSDK) }), + ("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 @@ -294,7 +292,8 @@ struct DefaultLiveMapTests { func allPropertiesFilterOutTombstonedEntries() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let delegate = MockLiveMapObjectPoolDelegate() + let map = InternalDefaultLiveMap( testsOnly_data: [ // tombstone is nil, so not considered tombstoned "active1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))), @@ -304,29 +303,27 @@ struct DefaultLiveMapTests { "tombstoned2": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned2"))), ], objectID: "arbitrary", - delegate: nil, - coreSDK: coreSDK, logger: logger, ) // Test size - should only count non-tombstoned entries - let size = try map.size + let size = try map.size(coreSDK: coreSDK) #expect(size == 2) // Test entries - should only return non-tombstoned entries - let entries = try map.entries + let entries = try map.entries(coreSDK: coreSDK, delegate: delegate) #expect(entries.count == 2) #expect(Set(entries.map(\.key)) == ["active1", "active2"]) #expect(entries.first { $0.key == "active1" }?.value.stringValue == "value1") #expect(entries.first { $0.key == "active2" }?.value.stringValue == "value2") // Test keys - should only return keys from non-tombstoned entries - let keys = try map.keys + let keys = try map.keys(coreSDK: coreSDK, delegate: delegate) #expect(keys.count == 2) #expect(Set(keys) == ["active1", "active2"]) // Test values - should only return values from non-tombstoned entries - let values = try map.values + let values = try map.values(coreSDK: coreSDK, delegate: delegate) #expect(values.count == 2) #expect(Set(values.compactMap(\.stringValue)) == Set(["value1", "value2"])) } @@ -340,22 +337,21 @@ struct DefaultLiveMapTests { func allAccessPropertiesReturnExpectedValuesAndAreConsistentWithEachOther() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let delegate = MockLiveMapObjectPoolDelegate() + let map = InternalDefaultLiveMap( testsOnly_data: [ "key1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))), "key2": TestFactories.mapEntry(data: ObjectData(string: .string("value2"))), "key3": TestFactories.mapEntry(data: ObjectData(string: .string("value3"))), ], objectID: "arbitrary", - delegate: nil, - coreSDK: coreSDK, logger: logger, ) - let size = try map.size - let entries = try map.entries - let keys = try map.keys - let values = try map.values + let size = try map.size(coreSDK: coreSDK) + 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) @@ -380,12 +376,12 @@ struct DefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Create referenced objects for testing - let referencedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let referencedCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) delegate.objects["map:ref@123"] = .map(referencedMap) delegate.objects["counter:ref@456"] = .counter(referencedCounter) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: [ "boolean": TestFactories.mapEntry(data: ObjectData(boolean: true)), // RTLM5d2b "bytes": TestFactories.mapEntry(data: ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c @@ -395,15 +391,13 @@ struct DefaultLiveMapTests { "counterRef": TestFactories.mapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 ], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) - let size = try map.size - let entries = try map.entries - let keys = try map.keys - let values = try map.values + let size = try map.size(coreSDK: coreSDK) + 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 == 6) #expect(entries.count == 6) @@ -438,14 +432,12 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) // Try to apply operation with lower timeserial (ts1 < ts2) map.testsOnly_applyMapSetOperation( @@ -456,7 +448,7 @@ struct DefaultLiveMapTests { ) // Verify the operation was discarded - existing data unchanged - #expect(try map.get(key: "key1")?.stringValue == "existing") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") // Verify that RTLM7c1 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"]) } @@ -475,14 +467,12 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) map.testsOnly_applyMapSetOperation( key: "key1", @@ -497,7 +487,7 @@ struct DefaultLiveMapTests { } // Verify the operation was applied - let result = try map.get(key: "key1") + 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 { @@ -544,8 +534,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger) map.testsOnly_applyMapSetOperation( key: "newKey", @@ -561,7 +551,7 @@ struct DefaultLiveMapTests { // Verify new entry was created // RTLM7b1 - let result = try map.get(key: "newKey") + 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 { @@ -591,20 +581,16 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) // Create an existing object in the pool with some data let existingObjectId = "map:existing@123" - let existingObject = DefaultLiveMap( + let existingObject = InternalDefaultLiveMap( testsOnly_data: [:], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) var pool = ObjectsPool( - rootDelegate: delegate, - rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: [existingObjectId: .map(existingObject)], ) @@ -624,7 +610,7 @@ struct DefaultLiveMapTests { #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") + let referenceValue = try map.get(key: "referenceKey", coreSDK: coreSDK, delegate: delegate) #expect(referenceValue?.liveMapValue != nil) } } @@ -640,11 +626,9 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) @@ -652,7 +636,7 @@ struct DefaultLiveMapTests { map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts1") // Verify the operation was discarded - existing data unchanged - #expect(try map.get(key: "key1")?.stringValue == "existing") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") } // @spec RTLM8a2a @@ -663,11 +647,9 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) @@ -675,7 +657,7 @@ struct DefaultLiveMapTests { map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts2") // Verify the operation was applied - #expect(try map.get(key: "key1") == nil) + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) // RTLM8a2a: Set ObjectsMapEntry.data to undefined/null let entry = map.testsOnly_data["key1"] @@ -700,9 +682,7 @@ struct DefaultLiveMapTests { @Test func createsNewEntryWhenNoExistingEntry() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -721,9 +701,7 @@ struct DefaultLiveMapTests { @Test func setsNewEntryTombstoneToTrue() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -785,14 +763,12 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) map.testsOnly_applyMapSetOperation( key: "key1", @@ -805,10 +781,10 @@ struct DefaultLiveMapTests { if shouldApply { // Verify operation was applied - #expect(try map.get(key: "key1")?.stringValue == "new") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") } else { // Verify operation was discarded - #expect(try map.get(key: "key1")?.stringValue == "existing") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") } } } @@ -821,8 +797,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger) // Apply merge operation with MAP_SET entries let operation = TestFactories.mapCreateOperation( @@ -835,7 +811,7 @@ struct DefaultLiveMapTests { // Note that we just check for some basic expected side effects of applying MAP_SET; RTLM7 is tested in more detail elsewhere // Check that it contains the data from the operation (per RTLM17a1) - #expect(try map.get(key: "keyFromCreateOp")?.stringValue == "valueFromCreateOp") + #expect(try map.get(key: "keyFromCreateOp", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromCreateOp") } // @spec RTLM17a2 @@ -844,17 +820,15 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap( + let map = InternalDefaultLiveMap( testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], objectID: "arbitrary", - delegate: delegate, - coreSDK: coreSDK, logger: logger, ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) // Confirm that the initial data is there - #expect(try map.get(key: "key1") != nil) + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) != nil) // Apply merge operation with MAP_REMOVE entry let entry = TestFactories.mapEntry( @@ -869,17 +843,15 @@ struct DefaultLiveMapTests { map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) // Verify the MAP_REMOVE operation was applied - #expect(try map.get(key: "key1") == nil) + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) } // @spec RTLM17b @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger) // Apply merge operation let operation = TestFactories.mapCreateOperation(objectId: "arbitrary-id") @@ -897,8 +869,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger) // Set initial data and mark create operation as merged map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) @@ -910,9 +882,9 @@ struct DefaultLiveMapTests { map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) // Verify the operation was discarded - data unchanged - #expect(try map.get(key: "key1")?.stringValue == "testValue") // Original data - #expect(try map.get(key: "key2")?.stringValue == "value2") // From first merge - #expect(try map.get(key: "key3") == nil) // Not added by second operation + #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 } // @spec RTLM16d @@ -921,8 +893,8 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger) // Set initial data but don't mark create operation as merged map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) @@ -933,8 +905,8 @@ struct DefaultLiveMapTests { map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) // Verify the operation was applied - initial value merged. (The full logic of RTLM17 is tested elsewhere; we just check for some of its side effects here.) - #expect(try map.get(key: "key1")?.stringValue == "testValue") // Original data - #expect(try map.get(key: "key2")?.stringValue == "value2") // From merge + #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) } } @@ -947,10 +919,10 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) // Set up the map with an existing site timeserial that will cause the operation to be discarded - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" @@ -972,7 +944,7 @@ struct DefaultLiveMapTests { // 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")?.stringValue == "existing") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") // Verify site timeserials unchanged #expect(map.testsOnly_siteTimeserials == ["site1": "ts2"]) } @@ -984,12 +956,12 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) let operation = TestFactories.mapCreateOperation( entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "value1").entry], ) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) // Apply MAP_CREATE operation map.apply( @@ -1000,7 +972,7 @@ struct DefaultLiveMapTests { ) // 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")?.stringValue == "value1") + #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"]) @@ -1013,16 +985,16 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) // Set initial data - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], entries: [key1: entry1], ), objectsPool: &pool) - #expect(try map.get(key: "key1")?.stringValue == "existing") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") let operation = TestFactories.objectOperation( action: .known(.mapSet), @@ -1038,7 +1010,7 @@ struct DefaultLiveMapTests { ) // 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")?.stringValue == "new") + #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"]) } @@ -1050,16 +1022,16 @@ struct DefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) // Set initial data - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], entries: [key1: entry1], ), objectsPool: &pool) - #expect(try map.get(key: "key1")?.stringValue == "existing") + #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") let operation = TestFactories.objectOperation( action: .known(.mapRemove), @@ -1075,7 +1047,7 @@ struct DefaultLiveMapTests { ) // 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") == nil) + #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"]) } diff --git a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift similarity index 87% rename from Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift rename to Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index a7b6a5cd7..017ec7b3d 100644 --- a/Tests/AblyLiveObjectsTests/DefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -3,25 +3,24 @@ import Ably import AblyPlugin import Testing -/// Tests for `DefaultRealtimeObjects`. -struct DefaultRealtimeObjectsTests { +/// Tests for `InternalDefaultRealtimeObjects`. +struct InternalDefaultRealtimeObjectsTests { // MARK: - Test Helpers - /// Creates a DefaultRealtimeObjects instance for testing - static func createDefaultRealtimeObjects(channelState: ARTRealtimeChannelState = .attached) -> DefaultRealtimeObjects { - let coreSDK = MockCoreSDK(channelState: channelState) + /// Creates a InternalDefaultRealtimeObjects instance for testing + static func createDefaultRealtimeObjects() -> InternalDefaultRealtimeObjects { let logger = TestLogger() - return DefaultRealtimeObjects(coreSDK: coreSDK, logger: logger) + return InternalDefaultRealtimeObjects(logger: logger) } - /// Tests for `DefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. + /// Tests for `InternalDefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. struct HandleObjectSyncProtocolMessageTests { // MARK: - RTO5a5: Single ProtocolMessage Sync Tests // @spec RTO5a5 @Test func handlesSingleProtocolMessageSync() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectMessages = [ TestFactories.simpleMapMessage(objectId: "map:1@123"), TestFactories.simpleMapMessage(objectId: "map:2@456"), @@ -56,7 +55,7 @@ struct DefaultRealtimeObjectsTests { // @spec RTO5c5 @Test func handlesMultiProtocolMessageSync() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let sequenceId = "seq123" // First message in sequence @@ -112,7 +111,7 @@ struct DefaultRealtimeObjectsTests { // @spec RTO5a2b @Test func newSequenceIdDiscardsInFlightSync() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let firstSequenceId = "seq1" let secondSequenceId = "seq2" @@ -159,7 +158,7 @@ struct DefaultRealtimeObjectsTests { // @spec(RTO5c2, RTO5c2a) Objects not in sync are removed, except root @Test func removesObjectsNotInSyncButPreservesRoot() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Perform sync with only one object (RTO5a5 case) let syncMessages = [TestFactories.mapObjectMessage(objectId: "map:synced@1")] @@ -183,7 +182,7 @@ struct DefaultRealtimeObjectsTests { /// Test handling of invalid channelSerial format @Test func handlesInvalidChannelSerialFormat() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectMessages = [TestFactories.mapObjectMessage(objectId: "map:1@123")] // Call with invalid channelSerial (missing colon) @@ -205,7 +204,7 @@ struct DefaultRealtimeObjectsTests { /// Test with empty sequence ID @Test func handlesEmptySequenceId() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectMessages = [TestFactories.mapObjectMessage(objectId: "map:1@123")] // Start sequence with empty sequence ID @@ -231,7 +230,7 @@ struct DefaultRealtimeObjectsTests { /// Test mixed object types in single sync @Test func handlesMixedObjectTypesInSync() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let mixedMessages = [ TestFactories.mapObjectMessage(objectId: "map:1@123"), @@ -255,7 +254,7 @@ struct DefaultRealtimeObjectsTests { /// Test continuation of sync after interruption by new sequence @Test func handlesSequenceInterruptionCorrectly() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Start first sequence realtimeObjects.handleObjectSyncProtocolMessage( @@ -294,7 +293,7 @@ struct DefaultRealtimeObjectsTests { } } - /// Tests for `DefaultRealtimeObjects.onChannelAttached`, covering RTO4 specification points. + /// 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, @@ -305,12 +304,12 @@ struct DefaultRealtimeObjectsTests { // @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 any internal state @Test func doesNotModifyStateWhenHasObjectsIsTrue() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // 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", coreSDK: MockCoreSDK(channelState: .attaching)) + _ = realtimeObjects.testsOnly_createZeroValueLiveObject(forObjectID: "map:test@123") // Set up an in-progress sync sequence realtimeObjects.handleObjectSyncProtocolMessage( @@ -347,7 +346,7 @@ struct DefaultRealtimeObjectsTests { // @spec RTO4b5 @Test func handlesHasObjectsFalse() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Set up initial state with additional objects in the pool using sync realtimeObjects.handleObjectSyncProtocolMessage( @@ -399,7 +398,7 @@ struct DefaultRealtimeObjectsTests { /// Test that multiple calls to onChannelAttached work correctly @Test func handlesMultipleCallsCorrectly() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // First call with hasObjects = true (should do nothing) realtimeObjects.onChannelAttached(hasObjects: true) @@ -425,7 +424,7 @@ struct DefaultRealtimeObjectsTests { /// Test that sync sequence is properly discarded even with complex sync state @Test func discardsComplexSyncSequence() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Create a complex sync sequence using OBJECT_SYNC messages // (This simulates realistic multi-message sync scenarios) @@ -459,7 +458,7 @@ struct DefaultRealtimeObjectsTests { /// Test behavior when there's no sync sequence in progress @Test func handlesNoSyncSequenceCorrectly() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Add some objects to the pool using OBJECT_SYNC messages // (This is the realistic way objects enter the pool, not through direct manipulation) @@ -488,28 +487,29 @@ struct DefaultRealtimeObjectsTests { /// Test that the root object's delegate is correctly set after reset @Test func setsCorrectDelegateOnNewRoot() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // When: onChannelAttached is called with hasObjects = false realtimeObjects.onChannelAttached(hasObjects: false) - // Then: The new root should have the correct delegate + // Then: The new root should be properly initialized let newRoot = realtimeObjects.testsOnly_objectsPool.root - #expect(newRoot.testsOnly_delegate as AnyObject === realtimeObjects as AnyObject) + #expect(newRoot.testsOnly_data.isEmpty) // Should be zero-valued (empty) } } - /// Tests for `DefaultRealtimeObjects.getRoot`, covering RTO1 specification points + /// 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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) // Start getRoot call - it should wait for sync completion - async let getRootTask = realtimeObjects.getRoot() + async let getRootTask = realtimeObjects.getRoot(coreSDK: coreSDK) // Wait for getRoot to start waiting for sync _ = try #require(await realtimeObjects.testsOnly_waitingForSyncEvents.first { _ in true }) @@ -524,10 +524,11 @@ struct DefaultRealtimeObjectsTests { // @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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) // Start getRoot call - it should wait for sync completion - async let getRootTask = realtimeObjects.getRoot() + async let getRootTask = realtimeObjects.getRoot(coreSDK: coreSDK) // Wait for getRoot to start waiting for sync _ = try #require(await realtimeObjects.testsOnly_waitingForSyncEvents.first { _ in true }) @@ -550,22 +551,23 @@ struct DefaultRealtimeObjectsTests { let root = try await getRootTask // Verify the root object contains the expected entries from the sync - let testValue = try root.get(key: "testKey")?.stringValue + 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") + 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 realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) let sequenceId = "seq123" // Start getRoot call - it should wait for sync completion - async let getRootTask = realtimeObjects.getRoot() + async let getRootTask = realtimeObjects.getRoot(coreSDK: coreSDK) // Wait for getRoot to start waiting for sync _ = try #require(await realtimeObjects.testsOnly_waitingForSyncEvents.first { _ in true }) @@ -608,10 +610,10 @@ struct DefaultRealtimeObjectsTests { let root = try await getRootTask // Verify the root object contains the expected entries from the sync sequence - let firstValue = try root.get(key: "firstKey")?.stringValue - let firstObject = try root.get(key: "firstObject") - let secondObject = try root.get(key: "secondObject") - let finalObject = try root.get(key: "finalObject") + 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) @@ -621,13 +623,14 @@ struct DefaultRealtimeObjectsTests { // @specOneOf(4/4) RTO1c - getRoot returns immediately when sync is already complete @Test func returnsImmediatelyWhenSyncAlreadyComplete() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) // Complete sync first realtimeObjects.onChannelAttached(hasObjects: false) // getRoot should return - _ = try await realtimeObjects.getRoot() + _ = try await realtimeObjects.getRoot(coreSDK: coreSDK) // Verify no waiting events were emitted realtimeObjects.testsOnly_finishAllTestHelperStreams() @@ -642,13 +645,14 @@ struct DefaultRealtimeObjectsTests { // @spec RTO1d @Test func returnsRootObjectFromObjectsPool() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) // Complete sync first realtimeObjects.onChannelAttached(hasObjects: false) // Call getRoot - let root = try await realtimeObjects.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 @@ -660,10 +664,11 @@ struct DefaultRealtimeObjectsTests { // @spec RTO1b @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func getRootThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects(channelState: channelState) + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: channelState) await #expect { - _ = try await realtimeObjects.getRoot() + _ = try await realtimeObjects.getRoot(coreSDK: coreSDK) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false @@ -674,7 +679,7 @@ struct DefaultRealtimeObjectsTests { } } - /// Tests for `DefaultRealtimeObjects.handleObjectProtocolMessage`, covering RTO8 specification points. + /// 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 { @@ -686,7 +691,7 @@ struct DefaultRealtimeObjectsTests { // @spec RTO9a2a1 - Tests that if necessary it creates an object in the ObjectsPool @Test func createsObjectInObjectsPoolWhenNecessary() { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectId = "map:new@123" // Verify the object doesn't exist in the pool initially @@ -715,7 +720,7 @@ struct DefaultRealtimeObjectsTests { // @specOneOf(1/5) RTO9a2a3 - Tests MAP_CREATE operation application @Test func appliesMapCreateOperation() throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectId = "map:test@123" // Create a map object in the pool first @@ -733,7 +738,8 @@ struct DefaultRealtimeObjectsTests { // Verify the object exists and has initial data let map = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.mapValue) - let initialValue = try #require(map.get(key: "existingKey")?.stringValue) + let coreSDK = MockCoreSDK(channelState: .attached) + let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) #expect(initialValue == "existingValue") // Create a MAP_CREATE operation message @@ -750,7 +756,7 @@ struct DefaultRealtimeObjectsTests { // 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")?.stringValue) + 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") @@ -761,7 +767,7 @@ struct DefaultRealtimeObjectsTests { // @specOneOf(2/5) RTO9a2a3 - Tests MAP_SET operation application @Test func appliesMapSetOperation() throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectId = "map:test@123" // Create a map object in the pool first @@ -779,7 +785,8 @@ struct DefaultRealtimeObjectsTests { // Verify the object exists and has initial data let map = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.mapValue) - let initialValue = try #require(map.get(key: "existingKey")?.stringValue) + let coreSDK = MockCoreSDK(channelState: .attached) + let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) #expect(initialValue == "existingValue") // Create a MAP_SET operation message @@ -796,7 +803,7 @@ struct DefaultRealtimeObjectsTests { // 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")?.stringValue) + let finalValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) #expect(finalValue == "newValue") #expect(map.testsOnly_siteTimeserials["site1"] == "ts2") } @@ -806,7 +813,7 @@ struct DefaultRealtimeObjectsTests { // @specOneOf(3/5) RTO9a2a3 - Tests MAP_REMOVE operation application @Test func appliesMapRemoveOperation() throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectId = "map:test@123" // Create a map object in the pool first @@ -824,7 +831,8 @@ struct DefaultRealtimeObjectsTests { // Verify the object exists and has initial data let map = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.mapValue) - let initialValue = try #require(map.get(key: "existingKey")?.stringValue) + let coreSDK = MockCoreSDK(channelState: .attached) + let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) #expect(initialValue == "existingValue") // Create a MAP_REMOVE operation message @@ -840,7 +848,7 @@ struct DefaultRealtimeObjectsTests { // 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") + 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") } @@ -850,7 +858,7 @@ struct DefaultRealtimeObjectsTests { // @specOneOf(4/5) RTO9a2a3 - Tests COUNTER_CREATE operation application @Test func appliesCounterCreateOperation() throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectId = "counter:test@123" // Create a counter object in the pool first @@ -867,7 +875,8 @@ struct DefaultRealtimeObjectsTests { // Verify the object exists and has initial data let counter = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.counterValue) - let initialValue = try counter.value + let coreSDK = MockCoreSDK(channelState: .attached) + let initialValue = try counter.value(coreSDK: coreSDK) #expect(initialValue == 5) // Create a COUNTER_CREATE operation message @@ -883,7 +892,7 @@ struct DefaultRealtimeObjectsTests { // 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 + let finalValue = try counter.value(coreSDK: coreSDK) #expect(finalValue == 15) // 5 + 10 (initial value merged) #expect(counter.testsOnly_siteTimeserials["site1"] == "ts2") } @@ -893,7 +902,7 @@ struct DefaultRealtimeObjectsTests { // @specOneOf(5/5) RTO9a2a3 - Tests COUNTER_INC operation application @Test func appliesCounterIncOperation() throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let objectId = "counter:test@123" // Create a counter object in the pool first @@ -910,7 +919,8 @@ struct DefaultRealtimeObjectsTests { // Verify the object exists and has initial data let counter = try #require(realtimeObjects.testsOnly_objectsPool.entries[objectId]?.counterValue) - let initialValue = try counter.value + let coreSDK = MockCoreSDK(channelState: .attached) + let initialValue = try counter.value(coreSDK: coreSDK) #expect(initialValue == 5) // Create a COUNTER_INC operation message @@ -926,7 +936,7 @@ struct DefaultRealtimeObjectsTests { // 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 + let finalValue = try counter.value(coreSDK: coreSDK) #expect(finalValue == 15) // 5 + 10 #expect(counter.testsOnly_siteTimeserials["site1"] == "ts2") } @@ -938,7 +948,7 @@ struct DefaultRealtimeObjectsTests { // @spec RTO5c6 @Test func buffersObjectOperationsDuringSyncAndAppliesAfterCompletion() async throws { - let realtimeObjects = DefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let sequenceId = "seq123" // Start sync sequence with first OBJECT_SYNC message @@ -1008,12 +1018,13 @@ struct DefaultRealtimeObjectsTests { // Verify the buffered operations were applied after sync completion (RTO5c6) // Check that MAP_SET operation was applied to the map - let mapValue = try #require(map.get(key: "key1")?.stringValue) + let coreSDK = MockCoreSDK(channelState: .attached) + 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 + let counterValue = try counter.value(coreSDK: coreSDK) #expect(counterValue == 15) // 5 (from sync) + 10 (from buffered operation) #expect(counter.testsOnly_siteTimeserials["site1"] == "ts4") } diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 9b14499ab..ec84e3f8e 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -299,9 +299,6 @@ private struct ObjectsIntegrationTests { for key in valueMapKeys { #expect(try valuesMap.get(key: key) != nil, "Check value at key=\"\(key)\" in nested map exists") } - - // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 - withExtendedLifetime(channel) {} }, ), .init( @@ -371,9 +368,6 @@ private struct ObjectsIntegrationTests { #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") - - // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 - withExtendedLifetime(channel2) {} } }, ), @@ -447,9 +441,6 @@ private struct ObjectsIntegrationTests { 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") } - - // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 - withExtendedLifetime(channel) {} }, ), .init( @@ -490,9 +481,6 @@ private struct ObjectsIntegrationTests { let mapFromValuesMap = try #require(valuesMap.get(key: "mapKey")?.liveMapValue) #expect(try mapFromValuesMap.size == 1, "Check nested map has correct number of keys") - - // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 - withExtendedLifetime(channel) {} }, ), .init( @@ -521,9 +509,6 @@ private struct ObjectsIntegrationTests { 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") - - // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 - withExtendedLifetime(channel) {} }, ), .init( @@ -751,9 +736,6 @@ private struct ObjectsIntegrationTests { clientOptions: testCase.options, ), ) - - // TODO: remove (Swift-only) — keep channel alive until we've executed our test case. We'll address this in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/9 - withExtendedLifetime(channel) {} } } diff --git a/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift new file mode 100644 index 000000000..067f2f197 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift @@ -0,0 +1,237 @@ +import Ably.Private +@testable import AblyLiveObjects +import Testing + +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-cocoa-liveobjects-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.testsOnly_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-cocoa-liveobjects-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-cocoa-liveobjects-plugin/issues/30) + } +} diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 985354a1f..dd3db831a 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -9,12 +9,10 @@ struct ObjectsPoolTests { @Test func returnsExistingObject() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger) let map = try #require(result?.mapValue) #expect(map as AnyObject === existingMap as AnyObject) } @@ -23,18 +21,14 @@ struct ObjectsPoolTests { @Test func createsZeroValueMap() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger) let map = try #require(result?.mapValue) // Verify it was added to the pool #expect(pool.entries["map:123@456"]?.mapValue != nil) - // Verify the map has the delegate set - #expect(map.testsOnly_delegate as AnyObject === delegate as AnyObject) // Verify the objectID is correctly set #expect(map.testsOnly_objectID == "map:123@456") } @@ -43,13 +37,12 @@ struct ObjectsPoolTests { @Test func createsZeroValueCounter() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) - let result = pool.createZeroValueObject(forObjectID: "counter:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + let result = pool.createZeroValueObject(forObjectID: "counter:123@456", logger: logger) let counter = try #require(result?.counterValue) - #expect(try counter.value == 0) + #expect(try counter.value(coreSDK: coreSDK) == 0) // Verify it was added to the pool #expect(pool.entries["counter:123@456"]?.counterValue != nil) @@ -61,11 +54,9 @@ struct ObjectsPoolTests { @Test func returnsNilForInvalidObjectId() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) - let result = pool.createZeroValueObject(forObjectID: "invalid", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + let result = pool.createZeroValueObject(forObjectID: "invalid", logger: logger) #expect(result == nil) } @@ -73,11 +64,9 @@ struct ObjectsPoolTests { @Test func returnsNilForUnknownType() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) - let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", logger: logger) #expect(result == nil) #expect(pool.entries["unknown:123@456"] == nil) } @@ -93,8 +82,8 @@ struct ObjectsPoolTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "updated_value") let objectState = TestFactories.mapObjectState( @@ -103,13 +92,13 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger) - // Verify the existing map was updated by checking side effects of DefaultLiveMap.replaceData(using:) + // 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")?.stringValue == "updated_value") + #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"]) } @@ -118,10 +107,9 @@ struct ObjectsPoolTests { @Test func updatesExistingCounterObject() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@123", @@ -129,13 +117,13 @@ struct ObjectsPoolTests { count: 42, ) - pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger) - // Verify the existing counter was updated by checking side effects of DefaultLiveCounter.replaceData(using:) + // 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 == 42) + #expect(try updatedCounter.value(coreSDK: coreSDK) == 42) // Checking site timeserials to verify they were updated by replaceData #expect(updatedCounter.testsOnly_siteTimeserials == ["site1": "ts1"]) } @@ -144,9 +132,8 @@ struct ObjectsPoolTests { @Test func createsNewCounterObject() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -154,12 +141,12 @@ struct ObjectsPoolTests { count: 100, ) - pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger) - // Verify a new counter was created and data was set by checking side effects of DefaultLiveCounter.replaceData(using:) + // 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 == 100) + #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 @@ -173,7 +160,7 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let (key, entry) = TestFactories.stringMapEntry(key: "key2", value: "new_value") let objectState = TestFactories.mapObjectState( @@ -182,16 +169,14 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger) - // Verify a new map was created and data was set by checking side effects of DefaultLiveMap.replaceData(using:) + // 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")?.stringValue == "new_value") + #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 delegate was set on the new map - #expect(newMap.testsOnly_delegate as AnyObject === delegate as AnyObject) // Verify the objectID and semantics are correctly set per RTO5c1b1b #expect(newMap.testsOnly_objectID == "map:hash@789") #expect(newMap.testsOnly_semantics == .known(.lww)) @@ -201,9 +186,7 @@ struct ObjectsPoolTests { @Test func ignoresNonMapOrCounterObject() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger) + var pool = ObjectsPool(logger: logger) let validObjectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -213,7 +196,7 @@ struct ObjectsPoolTests { let invalidObjectState = TestFactories.objectState(objectId: "invalid") - pool.applySyncObjectsPool([invalidObjectState, validObjectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([invalidObjectState, validObjectState], logger: logger) // Check that there's no entry for the key that we don't know how to handle, and that it didn't interfere with the insertion of the we one that we do know how to handle #expect(Set(pool.entries.keys) == ["root", "counter:hash@456"]) @@ -225,13 +208,11 @@ struct ObjectsPoolTests { @Test func removesObjectsNotInSync() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap1 = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let existingMap2 = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) + let existingMap1 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let existingMap2 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: [ + var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: [ "map:hash@1": .map(existingMap1), "map:hash@2": .map(existingMap2), "counter:hash@1": .counter(existingCounter), @@ -240,7 +221,7 @@ struct ObjectsPoolTests { // Only sync one of the existing objects let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") - pool.applySyncObjectsPool([objectState], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger) // Verify only synced object and root remain #expect(pool.entries.count == 2) // root + map:hash@1 @@ -254,13 +235,11 @@ struct ObjectsPoolTests { @Test func doesNotRemoveRootObject() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) // Sync with empty list (no objects) - pool.applySyncObjectsPool([], mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool([], logger: logger) // Verify root is preserved but other objects are removed #expect(pool.entries.count == 1) // Only root @@ -275,11 +254,11 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) - let existingCounter = DefaultLiveCounter.createZeroValued(objectID: "arbitrary", coreSDK: coreSDK, logger: logger) - let toBeRemovedMap = DefaultLiveMap.createZeroValued(objectID: "arbitrary", delegate: delegate, coreSDK: coreSDK, logger: logger) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(rootDelegate: delegate, rootCoreSDK: coreSDK, logger: logger, testsOnly_otherEntries: [ + var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: [ "map:existing@1": .map(existingMap), "counter:existing@1": .counter(existingCounter), "map:toremove@1": .map(toBeRemovedMap), @@ -313,7 +292,7 @@ struct ObjectsPoolTests { // Note: "map:toremove@1" is not in sync, so it should be removed ] - pool.applySyncObjectsPool(syncObjects, mapDelegate: delegate, coreSDK: coreSDK, logger: logger) + pool.applySyncObjectsPool(syncObjects, logger: logger) // Verify final state #expect(pool.entries.count == 5) // root + 4 synced objects @@ -324,23 +303,23 @@ struct ObjectsPoolTests { // 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")?.stringValue == "updated") + #expect(try updatedMap.get(key: "updated", coreSDK: coreSDK, delegate: delegate)?.stringValue == "updated") let updatedCounter = try #require(pool.entries["counter:existing@1"]?.counterValue) // Checking counter value to verify replaceData was called successfully - #expect(try updatedCounter.value == 100) + #expect(try updatedCounter.value(coreSDK: coreSDK) == 100) // 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")?.stringValue == "new") + #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 == 50) + #expect(try newCounter.value(coreSDK: coreSDK) == 50) // Verify the objectID is correctly set per RTO5c1b1a #expect(newCounter.testsOnly_objectID == "counter:new@1") From 51e209ac8d8609e3409f838c543712e51c16e8ed Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 14 Jul 2025 10:50:03 -0300 Subject: [PATCH 054/225] Use callback typealiases in implementations It's a bit annoying that Xcode's autocomplete didn't do this when I created these implementations. --- CONTRIBUTING.md | 1 + .../AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift | 4 ++-- Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift | 4 ++-- .../Internal/InternalDefaultRealtimeObjects.swift | 4 ++-- .../Public Proxy Objects/PublicDefaultLiveCounter.swift | 4 ++-- .../Public/Public Proxy Objects/PublicDefaultLiveMap.swift | 4 ++-- .../Public Proxy Objects/PublicDefaultRealtimeObjects.swift | 4 ++-- 7 files changed, 13 insertions(+), 12 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 462785bc5..a1c78efde 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -39,6 +39,7 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with ` - 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 diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 7876aa0b2..a23a0c552 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -90,7 +90,7 @@ internal final class InternalDefaultLiveCounter: Sendable { notYetImplemented() } - internal func subscribe(listener _: (sending any LiveCounterUpdate) -> Void) -> any SubscribeResponse { + internal func subscribe(listener _: LiveObjectUpdateCallback) -> any SubscribeResponse { notYetImplemented() } @@ -98,7 +98,7 @@ internal final class InternalDefaultLiveCounter: Sendable { notYetImplemented() } - internal func on(event _: LiveObjectLifecycleEvent, callback _: () -> Void) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event _: LiveObjectLifecycleEvent, callback _: LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 56be4a2f9..101e646e1 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -183,7 +183,7 @@ internal final class InternalDefaultLiveMap: Sendable { notYetImplemented() } - internal func subscribe(listener _: (sending any LiveMapUpdate) -> Void) -> any SubscribeResponse { + internal func subscribe(listener _: LiveObjectUpdateCallback) -> any SubscribeResponse { notYetImplemented() } @@ -191,7 +191,7 @@ internal final class InternalDefaultLiveMap: Sendable { notYetImplemented() } - internal func on(event _: LiveObjectLifecycleEvent, callback _: () -> Void) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event _: LiveObjectLifecycleEvent, callback _: LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index ec25db75f..782eed348 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -132,11 +132,11 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool notYetImplemented() } - internal func batch(callback _: sending (sending any BatchContext) -> Void) async throws { + internal func batch(callback _: sending BatchCallback) async throws { notYetImplemented() } - internal func on(event _: ObjectsEvent, callback _: () -> Void) -> any OnObjectsEventResponse { + internal func on(event _: ObjectsEvent, callback _: ObjectsEventCallback) -> any OnObjectsEventResponse { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 1d1e5053f..fdd616dab 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -34,7 +34,7 @@ internal final class PublicDefaultLiveCounter: LiveCounter { try await proxied.decrement(amount: amount) } - internal func subscribe(listener: sending (sending any LiveCounterUpdate) -> Void) -> any SubscribeResponse { + internal func subscribe(listener: sending LiveObjectUpdateCallback) -> any SubscribeResponse { proxied.subscribe(listener: listener) } @@ -42,7 +42,7 @@ internal final class PublicDefaultLiveCounter: LiveCounter { proxied.unsubscribeAll() } - internal func on(event: LiveObjectLifecycleEvent, callback: sending () -> Void) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 52edb50cc..84f4109ca 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -80,7 +80,7 @@ internal final class PublicDefaultLiveMap: LiveMap { try await proxied.remove(key: key) } - internal func subscribe(listener: sending (sending any LiveMapUpdate) -> Void) -> any SubscribeResponse { + internal func subscribe(listener: sending LiveObjectUpdateCallback) -> any SubscribeResponse { proxied.subscribe(listener: listener) } @@ -88,7 +88,7 @@ internal final class PublicDefaultLiveMap: LiveMap { proxied.unsubscribeAll() } - internal func on(event: LiveObjectLifecycleEvent, callback: sending () -> Void) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index c0fd7dcf6..b4a59e522 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -47,11 +47,11 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { try await proxied.createCounter() } - internal func batch(callback: sending (sending any BatchContext) -> Void) async throws { + internal func batch(callback: sending BatchCallback) async throws { try await proxied.batch(callback: callback) } - internal func on(event: ObjectsEvent, callback: sending () -> Void) -> any OnObjectsEventResponse { + internal func on(event: ObjectsEvent, callback: sending ObjectsEventCallback) -> any OnObjectsEventResponse { proxied.on(event: event, callback: callback) } From be7b19055d1bd129bd3eecbc27d21c7f0cbd92e3 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 11 Jul 2025 12:24:50 -0300 Subject: [PATCH 055/225] Make public API listeners @escaping Missed this in ce8c022. --- .../Internal/InternalDefaultLiveCounter.swift | 4 ++-- .../AblyLiveObjects/Internal/InternalDefaultLiveMap.swift | 4 ++-- .../Public Proxy Objects/PublicDefaultLiveCounter.swift | 4 ++-- .../Public/Public Proxy Objects/PublicDefaultLiveMap.swift | 4 ++-- .../Public Proxy Objects/PublicDefaultRealtimeObjects.swift | 2 +- Sources/AblyLiveObjects/Public/PublicTypes.swift | 6 +++--- 6 files changed, 12 insertions(+), 12 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index a23a0c552..acce77f4a 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -90,7 +90,7 @@ internal final class InternalDefaultLiveCounter: Sendable { notYetImplemented() } - internal func subscribe(listener _: LiveObjectUpdateCallback) -> any SubscribeResponse { + internal func subscribe(listener _: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { notYetImplemented() } @@ -98,7 +98,7 @@ internal final class InternalDefaultLiveCounter: Sendable { notYetImplemented() } - internal func on(event _: LiveObjectLifecycleEvent, callback _: LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event _: LiveObjectLifecycleEvent, callback _: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 101e646e1..eb626005a 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -183,7 +183,7 @@ internal final class InternalDefaultLiveMap: Sendable { notYetImplemented() } - internal func subscribe(listener _: LiveObjectUpdateCallback) -> any SubscribeResponse { + internal func subscribe(listener _: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { notYetImplemented() } @@ -191,7 +191,7 @@ internal final class InternalDefaultLiveMap: Sendable { notYetImplemented() } - internal func on(event _: LiveObjectLifecycleEvent, callback _: LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event _: LiveObjectLifecycleEvent, callback _: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index fdd616dab..2f388be05 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -34,7 +34,7 @@ internal final class PublicDefaultLiveCounter: LiveCounter { try await proxied.decrement(amount: amount) } - internal func subscribe(listener: sending LiveObjectUpdateCallback) -> any SubscribeResponse { + internal func subscribe(listener: sending @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { proxied.subscribe(listener: listener) } @@ -42,7 +42,7 @@ internal final class PublicDefaultLiveCounter: LiveCounter { proxied.unsubscribeAll() } - internal func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event: LiveObjectLifecycleEvent, callback: sending @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 84f4109ca..1e40024c0 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -80,7 +80,7 @@ internal final class PublicDefaultLiveMap: LiveMap { try await proxied.remove(key: key) } - internal func subscribe(listener: sending LiveObjectUpdateCallback) -> any SubscribeResponse { + internal func subscribe(listener: sending @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { proxied.subscribe(listener: listener) } @@ -88,7 +88,7 @@ internal final class PublicDefaultLiveMap: LiveMap { proxied.unsubscribeAll() } - internal func on(event: LiveObjectLifecycleEvent, callback: sending LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event: LiveObjectLifecycleEvent, callback: sending @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index b4a59e522..de1f0ab85 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -51,7 +51,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { try await proxied.batch(callback: callback) } - internal func on(event: ObjectsEvent, callback: sending ObjectsEventCallback) -> any OnObjectsEventResponse { + internal func on(event: ObjectsEvent, callback: sending @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 88d3bd1e5..36b85250c 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -70,7 +70,7 @@ public protocol RealtimeObjects: Sendable { /// - 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: sending ObjectsEventCallback) -> OnObjectsEventResponse + func on(event: ObjectsEvent, callback: sending @escaping ObjectsEventCallback) -> OnObjectsEventResponse /// Deregisters all registrations, for all events and listeners. func offAll() @@ -337,7 +337,7 @@ public protocol LiveObject: AnyObject, Sendable { /// - 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: sending LiveObjectUpdateCallback) -> SubscribeResponse + func subscribe(listener: sending @escaping LiveObjectUpdateCallback) -> SubscribeResponse /// Deregisters all listeners from updates for this LiveObject. func unsubscribeAll() @@ -349,7 +349,7 @@ public protocol LiveObject: AnyObject, Sendable { /// - 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: sending LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse + func on(event: LiveObjectLifecycleEvent, callback: sending @escaping LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse /// Deregisters all registrations, for all events and listeners. func offAll() From 89394e816604f400f13826d486c14955485b011a Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 14 Jul 2025 10:40:56 -0300 Subject: [PATCH 056/225] Make callbacks Sendable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Didn't do this in ce8c022 because I didn't have a good idea of our threading approach. But for the initial approach that we'll be taking — namely, calling the callbacks on ably-cocoa's callback queue — I think we'll need it. Haven't done it for batch stuff yet because I don't yet know whether it'll be necessary (will get a better idea when we implement it). Now that the callback can be called on any thread, we can no longer easily use the "capture the return value of `subscribe` pattern so that we can unsubscribe later" pattern. So, I've decided to pass the subscripiton as a second argument to the callback. Might be there's a better pattern we can use (e.g. pass in an object to the `subscribe` call, like JS `fetch`'s AbortController), but this'll do for now. --- .../PublicDefaultLiveCounter.swift | 4 ++-- .../PublicDefaultLiveMap.swift | 4 ++-- .../PublicDefaultRealtimeObjects.swift | 2 +- .../AblyLiveObjects/Public/PublicTypes.swift | 19 ++++++++++++------- .../ObjectsIntegrationTests.swift | 8 +++----- 5 files changed, 20 insertions(+), 17 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 2f388be05..ec770d502 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -34,7 +34,7 @@ internal final class PublicDefaultLiveCounter: LiveCounter { try await proxied.decrement(amount: amount) } - internal func subscribe(listener: sending @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { + internal func subscribe(listener: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { proxied.subscribe(listener: listener) } @@ -42,7 +42,7 @@ internal final class PublicDefaultLiveCounter: LiveCounter { proxied.unsubscribeAll() } - internal func on(event: LiveObjectLifecycleEvent, callback: sending @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 1e40024c0..1ef96b855 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -80,7 +80,7 @@ internal final class PublicDefaultLiveMap: LiveMap { try await proxied.remove(key: key) } - internal func subscribe(listener: sending @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { + internal func subscribe(listener: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { proxied.subscribe(listener: listener) } @@ -88,7 +88,7 @@ internal final class PublicDefaultLiveMap: LiveMap { proxied.unsubscribeAll() } - internal func on(event: LiveObjectLifecycleEvent, callback: sending @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index de1f0ab85..09828035d 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -51,7 +51,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { try await proxied.batch(callback: callback) } - internal func on(event: ObjectsEvent, callback: sending @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { + internal func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 36b85250c..d0da43149 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -2,14 +2,19 @@ import Ably /// A callback used in ``LiveObject`` to listen for updates to the object. /// -/// - Parameter update: The update object describing the changes made to the object. -public typealias LiveObjectUpdateCallback = (_ update: sending T) -> Void +/// - Parameters: +/// - update: The update object describing the changes made to the object. +/// - subscription: A ``SubscribeResponse`` object that allows the provided listener to deregister itself from future updates. +public typealias LiveObjectUpdateCallback = @Sendable (_ update: sending T, _ subscription: SubscribeResponse) -> Void /// The callback used for the events emitted by ``RealtimeObjects``. -public typealias ObjectsEventCallback = () -> Void +/// +/// - Parameter subscription: An ``OnObjectsEventResponse`` object that allows the provided listener to deregister itself from future updates. +public typealias ObjectsEventCallback = @Sendable (_ subscription: OnObjectsEventResponse) -> Void /// The callback used for the lifecycle events emitted by ``LiveObject``. -public typealias LiveObjectLifecycleEventCallback = () -> Void +/// - Parameter subscription: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to deregister itself from future updates. +public typealias LiveObjectLifecycleEventCallback = @Sendable (_ subscription: OnLiveObjectLifecycleEventResponse) -> Void /// A function passed to ``RealtimeObjects/batch(callback:)`` to group multiple Objects operations into a single channel message. /// @@ -70,7 +75,7 @@ public protocol RealtimeObjects: Sendable { /// - 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: sending @escaping ObjectsEventCallback) -> OnObjectsEventResponse + func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> OnObjectsEventResponse /// Deregisters all registrations, for all events and listeners. func offAll() @@ -337,7 +342,7 @@ public protocol LiveObject: AnyObject, Sendable { /// - 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: sending @escaping LiveObjectUpdateCallback) -> SubscribeResponse + func subscribe(listener: @escaping LiveObjectUpdateCallback) -> SubscribeResponse /// Deregisters all listeners from updates for this LiveObject. func unsubscribeAll() @@ -349,7 +354,7 @@ public protocol LiveObject: AnyObject, Sendable { /// - 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: sending @escaping LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse + func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> OnLiveObjectLifecycleEventResponse /// Deregisters all registrations, for all events and listeners. func offAll() diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index ec84e3f8e..ad42e1a8e 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -90,8 +90,7 @@ func waitFixtureChannelIsReady(_: ARTRealtime) async throws { func waitForMapKeyUpdate(_ map: any LiveMap, _ key: String) async { await withCheckedContinuation { (continuation: CheckedContinuation) in - var subscription: SubscribeResponse! - subscription = map.subscribe { update in + map.subscribe { update, subscription in if update.update[key] != nil { subscription.unsubscribe() continuation.resume() @@ -102,8 +101,7 @@ func waitForMapKeyUpdate(_ map: any LiveMap, _ key: String) async { func waitForCounterUpdate(_ counter: any LiveCounter) async { await withCheckedContinuation { (continuation: CheckedContinuation) in - var subscription: SubscribeResponse! - subscription = counter.subscribe { _ in + counter.subscribe { _, subscription in subscription.unsubscribe() continuation.resume() } @@ -642,7 +640,7 @@ private struct ObjectsIntegrationTests { async let counterSubPromise: Void = withCheckedThrowingContinuation { continuation in do { - try #require(root.get(key: "counter")?.liveCounterValue).subscribe { update in + try #require(root.get(key: "counter")?.liveCounterValue).subscribe { update, _ in #expect(update.amount == -1, "Check counter subscription callback is called with an expected update object after OBJECT_SYNC sequence with \"tombstone=true\"") continuation.resume() } From 91598b19aa18919faad01b62c67c4140bc604940 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 11 Jul 2025 15:17:09 -0300 Subject: [PATCH 057/225] Maintain same root object when resetting the ObjectsPool The correct behaviour wasn't clear from the spec when I wrote cb427d8, but new spec PR [1] makes it seem that this is the right thing to do (still needs clarifying though). [1] https://github.com/ably/specification/pull/346/ --- .../Internal/InternalDefaultLiveMap.swift | 13 +++++++++++++ .../Internal/InternalDefaultRealtimeObjects.swift | 3 +-- Sources/AblyLiveObjects/Internal/ObjectsPool.swift | 12 ++++++++++++ .../InternalDefaultRealtimeObjectsTests.swift | 11 +++++------ 4 files changed, 31 insertions(+), 8 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index eb626005a..7a3c35f62 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -287,6 +287,13 @@ internal final class InternalDefaultLiveMap: Sendable { } } + /// 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 resetData() { + mutex.withLock { + mutableState.resetData() + } + } + // MARK: - Mutable state and the operations that affect it private struct MutableState { @@ -544,6 +551,12 @@ internal final class InternalDefaultLiveMap: Sendable { logger: logger, ) } + + /// 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 mutating func resetData() { + // RTO4b2 + data = [:] + } } // MARK: - Helper Methods diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 782eed348..bd6697da7 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -244,8 +244,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } // RTO4b1, RTO4b2: Reset the ObjectsPool to have a single empty root object - // 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 - objectsPool = .init(logger: logger) + objectsPool.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. diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index c2e358555..a9928d362 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -209,4 +209,16 @@ internal struct ObjectsPool { logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) } + + /// 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 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.resetData() + } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 017ec7b3d..cc7abe184 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -383,10 +383,10 @@ struct InternalDefaultRealtimeObjectsTests { #expect(newPool.entries["map:existing@123"] == nil) // Should be removed #expect(newPool.entries["counter:existing@456"] == nil) // Should be removed - // Verify root is a new zero-valued map (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 + // 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 a new instance + #expect(newRoot as AnyObject === originalPool.root as AnyObject) // Should be same instance #expect(newRoot.testsOnly_data.isEmpty) // Should be zero-valued (empty) // RTO4b3, RTO4b4, RTO4b5: SyncObjectsPool must be cleared, sync sequence cleared, BufferedObjectOperations cleared @@ -410,15 +410,14 @@ struct InternalDefaultRealtimeObjectsTests { realtimeObjects.onChannelAttached(hasObjects: false) #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == false) let newPool = realtimeObjects.testsOnly_objectsPool - #expect(newPool.root as AnyObject !== originalRoot as AnyObject) + #expect(newPool.root as AnyObject === originalRoot as AnyObject) #expect(newPool.entries.count == 1) // Third call with hasObjects = true again (should do nothing) - let secondResetRoot = newPool.root realtimeObjects.onChannelAttached(hasObjects: true) #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == true) let finalPool = realtimeObjects.testsOnly_objectsPool - #expect(finalPool.root as AnyObject === secondResetRoot as AnyObject) // Should be unchanged + #expect(finalPool.root as AnyObject === originalRoot as AnyObject) // Should be unchanged } /// Test that sync sequence is properly discarded even with complex sync state From a30f635d6853493fd19bf143fa64edc0c1bd4318 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 15 Jul 2025 15:04:06 -0300 Subject: [PATCH 058/225] Fix spec point reference Mistake in 6430358. --- .../AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index d4cd7b751..fb98727cc 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -344,6 +344,6 @@ struct InternalDefaultLiveCounterTests { #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) } - // @specUntested RTLC7e3 - 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 RTLC7d3 - 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 } } From cd325c19cd5e4859695d1e8918d3d1cbd152b711 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 14 Jul 2025 11:33:47 -0300 Subject: [PATCH 059/225] Inject callback queue into map and counter This is preparation for implementing subscriptions. Cursor updated the tests. --- .../Internal/DefaultInternalPlugin.swift | 5 +- .../Internal/InternalDefaultLiveCounter.swift | 12 +- .../Internal/InternalDefaultLiveMap.swift | 28 ++++- .../InternalDefaultRealtimeObjects.swift | 17 ++- .../Internal/ObjectsPool.swift | 17 ++- .../InternalDefaultLiveCounterTests.swift | 38 +++--- .../InternalDefaultLiveMapTests.swift | 111 ++++++++++-------- .../InternalDefaultRealtimeObjectsTests.swift | 2 +- .../ObjectsPoolTests.swift | 72 ++++++------ 9 files changed, 178 insertions(+), 124 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index b80a86aa2..695e92cca 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -33,11 +33,12 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte // MARK: - LiveObjectsInternalPluginProtocol // Populates the channel's `objects` property. - internal func prepare(_ channel: AblyPlugin.RealtimeChannel, client _: AblyPlugin.RealtimeClient) { + internal func prepare(_ channel: AblyPlugin.RealtimeChannel, client: AblyPlugin.RealtimeClient) { let logger = pluginAPI.logger(for: channel) + let callbackQueue = pluginAPI.callbackQueue(for: client) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) - let liveObjects = InternalDefaultRealtimeObjects(logger: logger) + let liveObjects = InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: callbackQueue) pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index acce77f4a..f1287b6ba 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -28,24 +28,28 @@ internal final class InternalDefaultLiveCounter: Sendable { } private let logger: AblyPlugin.Logger + private let userCallbackQueue: DispatchQueue // MARK: - Initialization internal convenience init( testsOnly_data data: Double, objectID: String, - logger: AblyPlugin.Logger + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue ) { - self.init(data: data, objectID: objectID, logger: logger) + self.init(data: data, objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue) } private init( data: Double, objectID: String, - logger: AblyPlugin.Logger + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue ) { mutableState = .init(liveObject: .init(objectID: objectID), data: data) self.logger = logger + self.userCallbackQueue = userCallbackQueue } /// Creates a "zero-value LiveCounter", per RTLC4. @@ -55,11 +59,13 @@ internal final class InternalDefaultLiveCounter: Sendable { internal static func createZeroValued( objectID: String, logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) -> Self { .init( data: 0, objectID: objectID, logger: logger, + userCallbackQueue: userCallbackQueue, ) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 7a3c35f62..f49ec748e 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -45,6 +45,7 @@ internal final class InternalDefaultLiveMap: Sendable { } private let logger: AblyPlugin.Logger + private let userCallbackQueue: DispatchQueue // MARK: - Initialization @@ -52,13 +53,15 @@ internal final class InternalDefaultLiveMap: Sendable { testsOnly_data data: [String: ObjectsMapEntry], objectID: String, testsOnly_semantics semantics: WireEnum? = nil, - logger: AblyPlugin.Logger + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { self.init( data: data, objectID: objectID, semantics: semantics, logger: logger, + userCallbackQueue: userCallbackQueue, ) } @@ -66,10 +69,12 @@ internal final class InternalDefaultLiveMap: Sendable { data: [String: ObjectsMapEntry], objectID: String, semantics: WireEnum?, - logger: AblyPlugin.Logger + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { mutableState = .init(liveObject: .init(objectID: objectID), data: data, semantics: semantics) self.logger = logger + self.userCallbackQueue = userCallbackQueue } /// Creates a "zero-value LiveMap", per RTLM4. @@ -81,12 +86,14 @@ internal final class InternalDefaultLiveMap: Sendable { objectID: String, semantics: WireEnum? = nil, logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) -> Self { .init( data: [:], objectID: objectID, semantics: semantics, logger: logger, + userCallbackQueue: userCallbackQueue, ) } @@ -211,6 +218,7 @@ internal final class InternalDefaultLiveMap: Sendable { using: state, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -222,6 +230,7 @@ internal final class InternalDefaultLiveMap: Sendable { from: operation, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -233,6 +242,7 @@ internal final class InternalDefaultLiveMap: Sendable { operation, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -251,6 +261,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectMessageSiteCode: objectMessageSiteCode, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -271,6 +282,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationData: operationData, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -314,6 +326,7 @@ internal final class InternalDefaultLiveMap: Sendable { using state: ObjectState, objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials liveObject.siteTimeserials = state.siteTimeserials @@ -330,6 +343,7 @@ internal final class InternalDefaultLiveMap: Sendable { from: createOp, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -339,6 +353,7 @@ internal final class InternalDefaultLiveMap: Sendable { from operation: ObjectOperation, objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { // RTLM17a: For each key–ObjectsMapEntry pair in ObjectOperation.map.entries if let entries = operation.map?.entries { @@ -359,6 +374,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationData: entry.data, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -374,6 +390,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectMessageSiteCode: String?, objectsPool: inout ObjectsPool, logger: Logger, + userCallbackQueue: DispatchQueue, ) { guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { // RTLM15b @@ -391,6 +408,7 @@ internal final class InternalDefaultLiveMap: Sendable { operation, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) case .known(.mapSet): guard let mapOp = operation.mapOp else { @@ -409,6 +427,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationData: data, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) case .known(.mapRemove): guard let mapOp = operation.mapOp else { @@ -433,6 +452,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationData: ObjectData, objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { // RTLM7a: If an entry exists in the private data for the specified key if let existingEntry = data[key] { @@ -459,7 +479,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute if let objectId = operationData.objectId, !objectId.isEmpty { // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 - _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger) + _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger, userCallbackQueue: userCallbackQueue) } } @@ -535,6 +555,7 @@ internal final class InternalDefaultLiveMap: Sendable { _ operation: ObjectOperation, objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { if liveObject.createOperationIsMerged { // RTLM16b @@ -549,6 +570,7 @@ internal final class InternalDefaultLiveMap: Sendable { from: operation, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index bd6697da7..3d3e672c1 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -9,6 +9,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool private nonisolated(unsafe) var mutableState: MutableState! private let logger: AblyPlugin.Logger + private let userCallbackQueue: DispatchQueue // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. private let receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> @@ -69,12 +70,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } } - internal init(logger: AblyPlugin.Logger) { + internal init(logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue) { self.logger = logger + self.userCallbackQueue = userCallbackQueue (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() - mutableState = .init(objectsPool: .init(logger: logger)) + mutableState = .init(objectsPool: .init(logger: logger, userCallbackQueue: userCallbackQueue)) } // MARK: - LiveMapObjectPoolDelegate @@ -171,6 +173,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool mutableState.handleObjectProtocolMessage( objectMessages: objectMessages, logger: logger, + userCallbackQueue: userCallbackQueue, receivedObjectProtocolMessagesContinuation: receivedObjectProtocolMessagesContinuation, ) } @@ -187,6 +190,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessages: objectMessages, protocolMessageChannelSerial: protocolMessageChannelSerial, logger: logger, + userCallbackQueue: userCallbackQueue, receivedObjectSyncProtocolMessagesContinuation: receivedObjectSyncProtocolMessagesContinuation, ) } @@ -197,7 +201,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool /// Intended as a way for tests to populate the object pool. internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String) -> ObjectsPool.Entry? { mutex.withLock { - mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, logger: logger) + mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue) } } @@ -258,6 +262,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessages: [InboundObjectMessage], protocolMessageChannelSerial: String?, logger: Logger, + userCallbackQueue: DispatchQueue, receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, ) { logger.log("handleObjectSyncProtocolMessage(objectMessages: \(objectMessages), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) @@ -314,6 +319,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectsPool.applySyncObjectsPool( completedSyncObjectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) // RTO5c6 @@ -323,6 +329,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool applyObjectProtocolMessageObjectMessage( objectMessage, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -338,6 +345,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool internal mutating func handleObjectProtocolMessage( objectMessages: [InboundObjectMessage], logger: Logger, + userCallbackQueue: DispatchQueue, receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, ) { receivedObjectProtocolMessagesContinuation.yield(objectMessages) @@ -356,6 +364,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool applyObjectProtocolMessageObjectMessage( objectMessage, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -365,6 +374,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool private mutating func applyObjectProtocolMessageObjectMessage( _ objectMessage: InboundObjectMessage, logger: Logger, + userCallbackQueue: DispatchQueue, ) { guard let operation = objectMessage.operation else { // RTO9a1 @@ -380,6 +390,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool guard let newEntry = objectsPool.createZeroValueObject( forObjectID: operation.objectId, logger: logger, + userCallbackQueue: userCallbackQueue, ) else { logger.log("Unable to create zero-value object for \(operation.objectId) when processing OBJECT message; dropping", level: .warn) return diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index a9928d362..1fdc406ae 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -68,21 +68,24 @@ internal struct ObjectsPool { /// Creates an `ObjectsPool` whose root is a zero-value `LiveMap`. internal init( logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, testsOnly_otherEntries otherEntries: [String: Entry]? = nil, ) { self.init( logger: logger, + userCallbackQueue: userCallbackQueue, otherEntries: otherEntries, ) } private init( logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, 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)) + entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, logger: logger, userCallbackQueue: userCallbackQueue)) } // MARK: - Typed root @@ -108,8 +111,9 @@ internal struct ObjectsPool { /// - 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 /// - Returns: The existing or newly created object - internal mutating func createZeroValueObject(forObjectID objectID: String, logger: AblyPlugin.Logger) -> Entry? { + internal mutating func createZeroValueObject(forObjectID objectID: String, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue) -> Entry? { // RTO6a: If an object with objectId exists in ObjectsPool, do not create a new object if let existingEntry = entries[objectID] { return existingEntry @@ -127,9 +131,9 @@ internal struct ObjectsPool { let entry: Entry switch typeString { case "map": - entry = .map(.createZeroValued(objectID: objectID, logger: logger)) + entry = .map(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue)) case "counter": - entry = .counter(.createZeroValued(objectID: objectID, logger: logger)) + entry = .counter(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue)) default: return nil } @@ -143,6 +147,7 @@ internal struct ObjectsPool { internal mutating func applySyncObjectsPool( _ syncObjectsPool: [ObjectState], logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, ) { logger.log("applySyncObjectsPool called with \(syncObjectsPool.count) objects", level: .debug) @@ -174,14 +179,14 @@ internal struct ObjectsPool { if objectState.counter != nil { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger, userCallbackQueue: userCallbackQueue) counter.replaceData(using: objectState) newEntry = .counter(counter) } else if let objectsMap = objectState.map { // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, // set its private objectId equal to ObjectState.objectId, set its private semantics // equal to ObjectState.map.semantics and override its internal data per RTLM6 - let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue) map.replaceData(using: objectState, objectsPool: &self) newEntry = .map(map) } else { diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index fb98727cc..c7dfb0585 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -10,7 +10,7 @@ struct InternalDefaultLiveCounterTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func valueThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: channelState) #expect { @@ -28,7 +28,7 @@ struct InternalDefaultLiveCounterTests { @Test func valueReturnsCurrentDataWhenChannelIsValid() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attached) // Set some test data @@ -44,7 +44,7 @@ struct InternalDefaultLiveCounterTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) @@ -60,7 +60,7 @@ struct InternalDefaultLiveCounterTests { // Given: A counter whose createOperationIsMerged is true let logger = TestLogger() let counter = { - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // Test setup: Manipulate counter so that its createOperationIsMerged gets set to true (we need to do this since we want to later assert that it gets set to false, but the default is false). let state = TestFactories.counterObjectState( createOp: TestFactories.objectOperation( @@ -87,7 +87,7 @@ struct InternalDefaultLiveCounterTests { @Test func setsDataToCounterCount() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) let state = TestFactories.counterObjectState( count: 42, // Test value @@ -100,7 +100,7 @@ struct InternalDefaultLiveCounterTests { @Test func setsDataToZeroWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) counter.replaceData(using: TestFactories.counterObjectState( count: nil, // Test value - must be nil @@ -115,7 +115,7 @@ struct InternalDefaultLiveCounterTests { @Test func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) let state = TestFactories.counterObjectState( createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist @@ -134,7 +134,7 @@ struct InternalDefaultLiveCounterTests { @Test func addsCounterCountToData() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data @@ -152,7 +152,7 @@ struct InternalDefaultLiveCounterTests { @Test func doesNotModifyDataWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data @@ -173,7 +173,7 @@ struct InternalDefaultLiveCounterTests { @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist @@ -189,7 +189,7 @@ struct InternalDefaultLiveCounterTests { @Test func discardsOperationWhenCreateOperationIsMerged() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data and mark create operation as merged @@ -209,7 +209,7 @@ struct InternalDefaultLiveCounterTests { @Test func mergesInitialValue() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data but don't mark create operation as merged @@ -235,7 +235,7 @@ struct InternalDefaultLiveCounterTests { ] as [(operation: WireObjectsCounterOp?, expectedValue: Double)]) func addsAmountToData(operation: WireObjectsCounterOp?, expectedValue: Double) throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data @@ -256,7 +256,7 @@ struct InternalDefaultLiveCounterTests { @Test func discardsOperationWhenCannotBeApplied() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set up the counter with an existing site timeserial that will cause the operation to be discarded @@ -269,7 +269,7 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterInc), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Apply operation with serial "ts1" which is lexicographically less than existing "ts2" and thus will be applied per RTLO4a (this is a non-pathological case of RTOL4a, that spec point being fully tested elsewhere) counter.apply( @@ -291,11 +291,11 @@ struct InternalDefaultLiveCounterTests { @Test func appliesCounterCreateOperation() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) let operation = TestFactories.counterCreateOperation(count: 15) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Apply COUNTER_CREATE operation counter.apply( @@ -317,7 +317,7 @@ struct InternalDefaultLiveCounterTests { @Test func appliesCounterIncOperation() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data @@ -328,7 +328,7 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterInc), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Apply COUNTER_INC operation counter.apply( diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 3a2f14501..61d4ff36f 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -10,7 +10,7 @@ struct InternalDefaultLiveMapTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func getThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) #expect { _ = try map.get(key: "test", coreSDK: MockCoreSDK(channelState: channelState), delegate: MockLiveMapObjectPoolDelegate()) @@ -30,7 +30,7 @@ struct InternalDefaultLiveMapTests { func returnsNilWhenNoEntryExists() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) #expect(try map.get(key: "nonexistent", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) } @@ -43,7 +43,7 @@ struct InternalDefaultLiveMapTests { data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) } @@ -53,7 +53,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(boolean: true)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.boolValue == true) } @@ -65,7 +65,7 @@ struct InternalDefaultLiveMapTests { let bytes = Data([0x01, 0x02, 0x03]) let entry = TestFactories.mapEntry(data: ObjectData(bytes: bytes)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.dataValue == bytes) } @@ -76,7 +76,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 123.456))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.numberValue == 123.456) } @@ -87,7 +87,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.mapEntry(data: ObjectData(string: .string("test"))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.stringValue == "test") } @@ -99,7 +99,7 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: "missing")) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) } @@ -111,10 +111,9 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) delegate.objects[objectId] = .map(referencedMap) - - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) let returnedMap = result?.liveMapValue #expect(returnedMap as AnyObject === referencedMap as AnyObject) @@ -128,9 +127,9 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) delegate.objects[objectId] = .counter(referencedCounter) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) let returnedCounter = result?.liveCounterValue #expect(returnedCounter as AnyObject === referencedCounter as AnyObject) @@ -143,8 +142,7 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.mapEntry(data: ObjectData()) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) } } @@ -155,12 +153,12 @@ struct InternalDefaultLiveMapTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let state = TestFactories.objectState( objectId: "arbitrary-id", siteTimeserials: ["site1": "ts1", "site2": "ts2"], ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) } @@ -170,9 +168,9 @@ struct InternalDefaultLiveMapTests { func setsCreateOperationIsMergedToFalseWhenCreateOpAbsent() { // Given: let logger = TestLogger() - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let map = { - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // 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( @@ -198,13 +196,13 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "test") let state = TestFactories.mapObjectState( objectId: "arbitrary-id", entries: [key: entry], ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) map.replaceData(using: state, objectsPool: &pool) let newData = map.testsOnly_data #expect(newData.count == 1) @@ -219,7 +217,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation( @@ -235,7 +233,7 @@ struct InternalDefaultLiveMapTests { ], ), ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) map.replaceData(using: state, objectsPool: &pool) // Note that we just check for some basic expected side effects of merging the initial value; RTLM17 is tested in more detail elsewhere // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d) @@ -256,7 +254,7 @@ struct InternalDefaultLiveMapTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: channelState) let delegate = MockLiveMapObjectPoolDelegate() @@ -304,6 +302,7 @@ struct InternalDefaultLiveMapTests { ], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) // Test size - should only count non-tombstoned entries @@ -346,6 +345,7 @@ struct InternalDefaultLiveMapTests { ], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) let size = try map.size(coreSDK: coreSDK) @@ -376,8 +376,8 @@ struct InternalDefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Create referenced objects for testing - let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) delegate.objects["map:ref@123"] = .map(referencedMap) delegate.objects["counter:ref@456"] = .counter(referencedCounter) @@ -392,6 +392,7 @@ struct InternalDefaultLiveMapTests { ], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) let size = try map.size(coreSDK: coreSDK) @@ -436,8 +437,9 @@ struct InternalDefaultLiveMapTests { testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Try to apply operation with lower timeserial (ts1 < ts2) map.testsOnly_applyMapSetOperation( @@ -471,8 +473,9 @@ struct InternalDefaultLiveMapTests { testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) map.testsOnly_applyMapSetOperation( key: "key1", @@ -534,8 +537,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) map.testsOnly_applyMapSetOperation( key: "newKey", @@ -581,7 +584,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // Create an existing object in the pool with some data let existingObjectId = "map:existing@123" @@ -589,9 +592,11 @@ struct InternalDefaultLiveMapTests { testsOnly_data: [:], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) var pool = ObjectsPool( logger: logger, + userCallbackQueue: .main, 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 @@ -630,6 +635,7 @@ struct InternalDefaultLiveMapTests { testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 @@ -651,6 +657,7 @@ struct InternalDefaultLiveMapTests { testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 @@ -682,7 +689,7 @@ struct InternalDefaultLiveMapTests { @Test func createsNewEntryWhenNoExistingEntry() throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -701,7 +708,7 @@ struct InternalDefaultLiveMapTests { @Test func setsNewEntryTombstoneToTrue() throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -767,8 +774,9 @@ struct InternalDefaultLiveMapTests { testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) map.testsOnly_applyMapSetOperation( key: "key1", @@ -797,8 +805,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Apply merge operation with MAP_SET entries let operation = TestFactories.mapCreateOperation( @@ -824,8 +832,9 @@ struct InternalDefaultLiveMapTests { testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], objectID: "arbitrary", logger: logger, + userCallbackQueue: .main, ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Confirm that the initial data is there #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) != nil) @@ -850,8 +859,8 @@ struct InternalDefaultLiveMapTests { @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Apply merge operation let operation = TestFactories.mapCreateOperation(objectId: "arbitrary-id") @@ -869,8 +878,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Set initial data and mark create operation as merged map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) @@ -893,8 +902,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Set initial data but don't mark create operation as merged map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) @@ -919,10 +928,10 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // Set up the map with an existing site timeserial that will cause the operation to be discarded - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" @@ -956,12 +965,12 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let operation = TestFactories.mapCreateOperation( entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "value1").entry], ) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Apply MAP_CREATE operation map.apply( @@ -985,10 +994,10 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // Set initial data - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], @@ -1022,10 +1031,10 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) // Set initial data - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index cc7abe184..cf6e7f6fb 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -10,7 +10,7 @@ struct InternalDefaultRealtimeObjectsTests { /// Creates a InternalDefaultRealtimeObjects instance for testing static func createDefaultRealtimeObjects() -> InternalDefaultRealtimeObjects { let logger = TestLogger() - return InternalDefaultRealtimeObjects(logger: logger) + return InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: .main) } /// Tests for `InternalDefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index dd3db831a..c040a520f 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -9,10 +9,10 @@ struct ObjectsPoolTests { @Test func returnsExistingObject() throws { let logger = TestLogger() - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, userCallbackQueue: .main) let map = try #require(result?.mapValue) #expect(map as AnyObject === existingMap as AnyObject) } @@ -21,9 +21,9 @@ struct ObjectsPoolTests { @Test func createsZeroValueMap() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, userCallbackQueue: .main) let map = try #require(result?.mapValue) // Verify it was added to the pool @@ -38,9 +38,9 @@ struct ObjectsPoolTests { func createsZeroValueCounter() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - let result = pool.createZeroValueObject(forObjectID: "counter:123@456", logger: logger) + let result = pool.createZeroValueObject(forObjectID: "counter:123@456", logger: logger, userCallbackQueue: .main) let counter = try #require(result?.counterValue) #expect(try counter.value(coreSDK: coreSDK) == 0) @@ -54,9 +54,9 @@ struct ObjectsPoolTests { @Test func returnsNilForInvalidObjectId() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - let result = pool.createZeroValueObject(forObjectID: "invalid", logger: logger) + let result = pool.createZeroValueObject(forObjectID: "invalid", logger: logger, userCallbackQueue: .main) #expect(result == nil) } @@ -64,9 +64,9 @@ struct ObjectsPoolTests { @Test func returnsNilForUnknownType() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", logger: logger) + let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", logger: logger, userCallbackQueue: .main) #expect(result == nil) #expect(pool.entries["unknown:123@456"] == nil) } @@ -82,8 +82,8 @@ struct ObjectsPoolTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "updated_value") let objectState = TestFactories.mapObjectState( @@ -92,7 +92,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) // Verify the existing map was updated by checking side effects of InternalDefaultLiveMap.replaceData(using:) let updatedMap = try #require(pool.entries["map:hash@123"]?.mapValue) @@ -108,8 +108,8 @@ struct ObjectsPoolTests { func updatesExistingCounterObject() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@123", @@ -117,7 +117,7 @@ struct ObjectsPoolTests { count: 42, ) - pool.applySyncObjectsPool([objectState], logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) // Verify the existing counter was updated by checking side effects of InternalDefaultLiveCounter.replaceData(using:) let updatedCounter = try #require(pool.entries["counter:hash@123"]?.counterValue) @@ -133,7 +133,7 @@ struct ObjectsPoolTests { func createsNewCounterObject() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -141,7 +141,7 @@ struct ObjectsPoolTests { count: 100, ) - pool.applySyncObjectsPool([objectState], logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) // 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) @@ -160,7 +160,7 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key, entry) = TestFactories.stringMapEntry(key: "key2", value: "new_value") let objectState = TestFactories.mapObjectState( @@ -169,7 +169,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) // 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) @@ -186,7 +186,7 @@ struct ObjectsPoolTests { @Test func ignoresNonMapOrCounterObject() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let validObjectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -196,7 +196,7 @@ struct ObjectsPoolTests { let invalidObjectState = TestFactories.objectState(objectId: "invalid") - pool.applySyncObjectsPool([invalidObjectState, validObjectState], logger: logger) + pool.applySyncObjectsPool([invalidObjectState, validObjectState], logger: logger, userCallbackQueue: .main) // Check that there's no entry for the key that we don't know how to handle, and that it didn't interfere with the insertion of the we one that we do know how to handle #expect(Set(pool.entries.keys) == ["root", "counter:hash@456"]) @@ -208,11 +208,11 @@ struct ObjectsPoolTests { @Test func removesObjectsNotInSync() throws { let logger = TestLogger() - let existingMap1 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - let existingMap2 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) + let existingMap1 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingMap2 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: [ + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: [ "map:hash@1": .map(existingMap1), "map:hash@2": .map(existingMap2), "counter:hash@1": .counter(existingCounter), @@ -221,7 +221,7 @@ struct ObjectsPoolTests { // Only sync one of the existing objects let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") - pool.applySyncObjectsPool([objectState], logger: logger) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) // Verify only synced object and root remain #expect(pool.entries.count == 2) // root + map:hash@1 @@ -235,11 +235,11 @@ struct ObjectsPoolTests { @Test func doesNotRemoveRootObject() throws { let logger = TestLogger() - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) // Sync with empty list (no objects) - pool.applySyncObjectsPool([], logger: logger) + pool.applySyncObjectsPool([], logger: logger, userCallbackQueue: .main) // Verify root is preserved but other objects are removed #expect(pool.entries.count == 1) // Only root @@ -254,11 +254,11 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger) - let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, testsOnly_otherEntries: [ + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: [ "map:existing@1": .map(existingMap), "counter:existing@1": .counter(existingCounter), "map:toremove@1": .map(toBeRemovedMap), @@ -292,7 +292,7 @@ struct ObjectsPoolTests { // Note: "map:toremove@1" is not in sync, so it should be removed ] - pool.applySyncObjectsPool(syncObjects, logger: logger) + pool.applySyncObjectsPool(syncObjects, logger: logger, userCallbackQueue: .main) // Verify final state #expect(pool.entries.count == 5) // root + 4 synced objects From 4822ac3ba2b2510e919d86897dad3a173d8aa9ed Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 15 Jul 2025 18:16:21 -0300 Subject: [PATCH 060/225] Mark subscribe methods as throwing Motivation as in 3f6de86; the new spec points in [1] tell us these can throw. [1] https://github.com/ably/specification/pull/346 --- .../AblyLiveObjects/Public/PublicTypes.swift | 2 +- .../ObjectsIntegrationTests.swift | 48 +++++++++++-------- 2 files changed, 29 insertions(+), 21 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index d0da43149..34e67f2bd 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -342,7 +342,7 @@ public protocol LiveObject: AnyObject, Sendable { /// - 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) -> SubscribeResponse + func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> SubscribeResponse /// Deregisters all listeners from updates for this LiveObject. func unsubscribeAll() diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index ad42e1a8e..f4e388a4e 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -88,22 +88,30 @@ func waitFixtureChannelIsReady(_: ARTRealtime) async throws { try await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC) } -func waitForMapKeyUpdate(_ map: any LiveMap, _ key: String) async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - map.subscribe { update, subscription in - if update.update[key] != nil { - subscription.unsubscribe() - continuation.resume() +func waitForMapKeyUpdate(_ map: any LiveMap, _ key: String) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + do { + try map.subscribe { update, subscription in + if update.update[key] != nil { + subscription.unsubscribe() + continuation.resume() + } } + } catch { + continuation.resume(throwing: error) } } } -func waitForCounterUpdate(_ counter: any LiveCounter) async { - await withCheckedContinuation { (continuation: CheckedContinuation) in - counter.subscribe { _, subscription in - subscription.unsubscribe() - continuation.resume() +func waitForCounterUpdate(_ counter: any LiveCounter) async throws { + try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in + do { + try counter.subscribe { _, subscription in + subscription.unsubscribe() + continuation.resume() + } + } catch { + continuation.resume(throwing: error) } } } @@ -310,10 +318,10 @@ private struct ObjectsIntegrationTests { // Create the promise first, before the operations that will trigger it async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in group.addTask { - await waitForMapKeyUpdate(root, "counter") + try await waitForMapKeyUpdate(root, "counter") } group.addTask { - await waitForMapKeyUpdate(root, "map") + try await waitForMapKeyUpdate(root, "map") } while try await group.next() != nil {} } @@ -331,13 +339,13 @@ private struct ObjectsIntegrationTests { // Create the promise first, before the operations that will trigger it async let operationsAppliedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in group.addTask { - await waitForMapKeyUpdate(map, "anotherKey") + try await waitForMapKeyUpdate(map, "anotherKey") } group.addTask { - await waitForMapKeyUpdate(map, "shouldDelete") + try await waitForMapKeyUpdate(map, "shouldDelete") } group.addTask { - await waitForCounterUpdate(counter) + try await waitForCounterUpdate(counter) } while try await group.next() != nil {} } @@ -382,10 +390,10 @@ private struct ObjectsIntegrationTests { // Create the promise first, before the operations that will trigger it async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in group.addTask { - await waitForMapKeyUpdate(root, "counter") + try await waitForMapKeyUpdate(root, "counter") } group.addTask { - await waitForMapKeyUpdate(root, "map") + try await waitForMapKeyUpdate(root, "map") } while try await group.next() != nil {} } @@ -582,7 +590,7 @@ private struct ObjectsIntegrationTests { key: "counter", createOp: objectsHelper.counterCreateRestOp(number: 1), ) - _ = await counterCreatedPromise + _ = try await counterCreatedPromise #expect(try root.get(key: "counter") != nil, "Check counter exists on root before OBJECT_SYNC sequence with \"tombstone=true\"") @@ -636,7 +644,7 @@ private struct ObjectsIntegrationTests { key: "counter", createOp: objectsHelper.counterCreateRestOp(number: 1), ) - _ = await counterCreatedPromise + _ = try await counterCreatedPromise async let counterSubPromise: Void = withCheckedThrowingContinuation { continuation in do { From 776996a812dcfd9b140f6b20b5d4f6435c14442c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 16 Jul 2025 08:30:13 -0300 Subject: [PATCH 061/225] Fix some mistakes from cb427d8 --- Sources/AblyLiveObjects/Internal/ObjectsPool.swift | 2 +- .../InternalDefaultRealtimeObjectsTests.swift | 7 ++----- Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift | 6 +++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 1fdc406ae..8d9596608 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -143,7 +143,7 @@ internal struct ObjectsPool { return entry } - /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1. + /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1 and RTO5c2. internal mutating func applySyncObjectsPool( _ syncObjectsPool: [ObjectState], logger: AblyPlugin.Logger, diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index cf6e7f6fb..67c65f69c 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -155,9 +155,9 @@ struct InternalDefaultRealtimeObjectsTests { // MARK: - RTO5c: Post-Sync Behavior Tests - // @spec(RTO5c2, RTO5c2a) Objects not in sync are removed, except root + // A smoke test that the RTO5c post-sync behaviours get performed. They are tested in more detail in the ObjectsPool.applySyncObjectsPool tests. @Test - func removesObjectsNotInSyncButPreservesRoot() async throws { + func performsPostSyncSteps() async throws { let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Perform sync with only one object (RTO5a5 case) @@ -172,9 +172,6 @@ struct InternalDefaultRealtimeObjectsTests { let finalPool = realtimeObjects.testsOnly_objectsPool #expect(finalPool.entries["root"] != nil) // Root preserved #expect(finalPool.entries["map:synced@1"] != nil) // Synced object added - - // Note: We rely on applySyncObjectsPool being tested separately for RTO5c2 removal behavior - // as the side effect of removing pre-existing objects is tested in ObjectsPoolTests } // MARK: - Error Handling Tests diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index c040a520f..6b1e05037 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -204,7 +204,7 @@ struct ObjectsPoolTests { // MARK: - RTO5c2 Tests - // @spec(RTO5c2) Remove objects not received during sync + // @spec RTO5c2 @Test func removesObjectsNotInSync() throws { let logger = TestLogger() @@ -231,7 +231,7 @@ struct ObjectsPoolTests { #expect(pool.entries["counter:hash@1"] == nil) // Should be removed } - // @spec(RTO5c2a) Root object must not be removed + // @spec RTO5c2a @Test func doesNotRemoveRootObject() throws { let logger = TestLogger() @@ -247,7 +247,7 @@ struct ObjectsPoolTests { #expect(pool.entries["map:hash@1"] == nil) // Should be removed } - // @spec(RTO5c1, RTO5c2) Complete sync scenario with mixed operations + // A more complete example of the behaviours described in RTO5c1 and RTO5c2. @Test func handlesComplexSyncScenario() throws { let logger = TestLogger() From 392fae35156e78a4097e531a1ca13268a2bec444 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 11 Jul 2025 10:59:17 -0300 Subject: [PATCH 062/225] Implement subscriptions spec Based on [1] at 2963300. Have not implemented RTL04b1's channel mode checking for same reason as mentioned in 8d881e2. Have not currently tested `replaceData`'s return value; will do once [2] clarified. [1] https://github.com/ably/specification/pull/346 [2] https://github.com/ably/specification/pull/346/files#r2201363446 --- CONTRIBUTING.md | 4 +- .../Internal/DefaultLiveCounterUpdate.swift | 3 + .../Internal/DefaultLiveMapUpdate.swift | 3 + .../Internal/InternalDefaultLiveCounter.swift | 90 ++++++-- .../Internal/InternalDefaultLiveMap.swift | 120 +++++++--- .../InternalDefaultRealtimeObjects.swift | 1 + .../Internal/LiveObjectMutableState.swift | 81 ++++++- .../Internal/LiveObjectUpdate.swift | 26 +++ .../Internal/ObjectsPool.swift | 49 +++- .../PublicDefaultLiveCounter.swift | 4 +- .../PublicDefaultLiveMap.swift | 4 +- .../Utility/NSLock+Extensions.swift | 10 + .../Helpers/Subscriber.swift | 51 +++++ .../InternalDefaultLiveCounterTests.swift | 129 ++++++++--- .../InternalDefaultLiveMapTests.swift | 173 ++++++++++++--- .../InternalDefaultRealtimeObjectsTests.swift | 16 +- .../LiveObjectMutableStateTests.swift | 210 +++++++++++++++++- .../ObjectsPoolTests.swift | 54 ++++- 18 files changed, 896 insertions(+), 132 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift create mode 100644 Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift create mode 100644 Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift create mode 100644 Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift create mode 100644 Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a1c78efde..896d7bc21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -44,7 +44,9 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with ` ### Throwing errors - The public API of the SDK should use typed throws, and the thrown errors should be of type `ARTErrorInfo`. -- `Dictionary.mapValues` does not support typed throws. We have our own extension `ablyLiveObjects_mapValuesWithTypedThrow` which does; use this. +- 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 diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift new file mode 100644 index 000000000..b45a130d8 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift @@ -0,0 +1,3 @@ +internal struct DefaultLiveCounterUpdate: LiveCounterUpdate, Equatable { + internal var amount: Double +} diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift b/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift new file mode 100644 index 000000000..3c1453950 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift @@ -0,0 +1,3 @@ +internal struct DefaultLiveMapUpdate: LiveMapUpdate, Equatable { + internal var update: [String: LiveMapUpdateAction] +} diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index f1287b6ba..de9e9696b 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -96,14 +96,29 @@ internal final class InternalDefaultLiveCounter: Sendable { notYetImplemented() } - internal func subscribe(listener _: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { - notYetImplemented() + @discardableResult + internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { + try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in + // swiftlint:disable:next trailing_closure + try mutableState.liveObject.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + guard let self else { + return + } + + mutex.withLock { + action(&mutableState.liveObject) + } + }) + } } internal func unsubscribeAll() { - notYetImplemented() + mutex.withLock { + mutableState.liveObject.unsubscribeAll() + } } + @discardableResult internal func on(event _: LiveObjectLifecycleEvent, callback _: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { notYetImplemented() } @@ -112,31 +127,42 @@ internal final class InternalDefaultLiveCounter: Sendable { notYetImplemented() } + // 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 emit(_ update: LiveObjectUpdate) { + mutex.withLock { + mutableState.liveObject.emit(update, on: userCallbackQueue) + } + } + // MARK: - Data manipulation /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. - internal func replaceData(using state: ObjectState) { + internal func replaceData(using state: ObjectState) -> LiveObjectUpdate { mutex.withLock { mutableState.replaceData(using: state) } } /// Test-only method to merge initial value from an ObjectOperation, per RTLC10. - internal func testsOnly_mergeInitialValue(from operation: ObjectOperation) { + internal func testsOnly_mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { mutex.withLock { mutableState.mergeInitialValue(from: operation) } } /// Test-only method to apply a COUNTER_CREATE operation, per RTLC8. - internal func testsOnly_applyCounterCreateOperation(_ operation: ObjectOperation) { + internal func testsOnly_applyCounterCreateOperation(_ operation: ObjectOperation) -> LiveObjectUpdate { mutex.withLock { mutableState.applyCounterCreateOperation(operation, logger: logger) } } /// Test-only method to apply a COUNTER_INC operation, per RTLC9. - internal func testsOnly_applyCounterIncOperation(_ operation: WireObjectsCounterOp?) { + internal func testsOnly_applyCounterIncOperation(_ operation: WireObjectsCounterOp?) -> LiveObjectUpdate { mutex.withLock { mutableState.applyCounterIncOperation(operation) } @@ -156,6 +182,7 @@ internal final class InternalDefaultLiveCounter: Sendable { objectMessageSiteCode: objectMessageSiteCode, objectsPool: &objectsPool, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -164,13 +191,13 @@ internal final class InternalDefaultLiveCounter: Sendable { private struct MutableState { /// The mutable state common to all LiveObjects. - internal var liveObject: LiveObjectMutableState + internal var liveObject: LiveObjectMutableState /// The internal data that this map holds, per RTLC3. internal var data: Double /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. - internal mutating func replaceData(using state: ObjectState) { + internal mutating func replaceData(using state: ObjectState) -> LiveObjectUpdate { // RTLC6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials liveObject.siteTimeserials = state.siteTimeserials @@ -181,19 +208,32 @@ internal final class InternalDefaultLiveCounter: Sendable { data = state.counter?.count?.doubleValue ?? 0 // RTLC6d: If ObjectState.createOp is present, merge the initial value into the LiveCounter as described in RTLC10 - if let createOp = state.createOp { + return if let createOp = state.createOp { mergeInitialValue(from: createOp) + } else { + // TODO: I assume this is what to do, clarify in https://github.com/ably/specification/pull/346/files#r2201363446 + .noop } } /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC10. - internal mutating func mergeInitialValue(from operation: ObjectOperation) { + internal mutating func mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { + let update: LiveObjectUpdate + // RTLC10a: Add ObjectOperation.counter.count to data, if it exists if let operationCount = operation.counter?.count?.doubleValue { data += operationCount + // RTLC10c + update = .update(DefaultLiveCounterUpdate(amount: operationCount)) + } else { + // RTLC10d + update = .noop } + // RTLC10b: Set the private flag createOperationIsMerged to true liveObject.createOperationIsMerged = true + + return update } /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. @@ -203,6 +243,7 @@ internal final class InternalDefaultLiveCounter: Sendable { objectMessageSiteCode: String?, objectsPool: inout ObjectsPool, logger: Logger, + userCallbackQueue: DispatchQueue, ) { guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { // RTLC7b @@ -216,13 +257,17 @@ internal final class InternalDefaultLiveCounter: Sendable { switch operation.action { case .known(.counterCreate): // RTLC7d1 - applyCounterCreateOperation( + let update = applyCounterCreateOperation( operation, logger: logger, ) + // RTLC7d1a + liveObject.emit(update, on: userCallbackQueue) case .known(.counterInc): // RTLC7d2 - applyCounterIncOperation(operation.counterOp) + let update = applyCounterIncOperation(operation.counterOp) + // RTLC7d2a + liveObject.emit(update, on: userCallbackQueue) default: // RTLC7d3 logger.log("Operation \(operation) has unsupported action for LiveCounter; discarding", level: .warn) @@ -233,25 +278,28 @@ internal final class InternalDefaultLiveCounter: Sendable { internal mutating func applyCounterCreateOperation( _ operation: ObjectOperation, logger: Logger, - ) { + ) -> LiveObjectUpdate { if liveObject.createOperationIsMerged { // RTLC8b logger.log("Not applying COUNTER_CREATE because a COUNTER_CREATE has already been applied", level: .warn) - return + return .noop } - // RTLC8c - mergeInitialValue(from: operation) + // RTLC8c, RTLC8e + return mergeInitialValue(from: operation) } /// Applies a `COUNTER_INC` operation, per RTLC9. - internal mutating func applyCounterIncOperation(_ operation: WireObjectsCounterOp?) { + internal mutating func applyCounterIncOperation(_ operation: WireObjectsCounterOp?) -> LiveObjectUpdate { guard let operation else { - return + // RTL9e + return .noop } - // RTLC9b - data += operation.amount.doubleValue + // RTLC9b, RTLC9d + let amount = operation.amount.doubleValue + data += amount + return .update(DefaultLiveCounterUpdate(amount: amount)) } } } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index f49ec748e..8146c1393 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -190,14 +190,29 @@ internal final class InternalDefaultLiveMap: Sendable { notYetImplemented() } - internal func subscribe(listener _: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { - notYetImplemented() + @discardableResult + internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { + try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in + // swiftlint:disable:next trailing_closure + try mutableState.liveObject.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + guard let self else { + return + } + + mutex.withLock { + action(&mutableState.liveObject) + } + }) + } } internal func unsubscribeAll() { - notYetImplemented() + mutex.withLock { + mutableState.liveObject.unsubscribeAll() + } } + @discardableResult internal func on(event _: LiveObjectLifecycleEvent, callback _: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { notYetImplemented() } @@ -206,13 +221,24 @@ internal final class InternalDefaultLiveMap: Sendable { notYetImplemented() } + // 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 emit(_ update: LiveObjectUpdate) { + mutex.withLock { + mutableState.liveObject.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. - internal func replaceData(using state: ObjectState, objectsPool: inout ObjectsPool) { + internal func replaceData(using state: ObjectState, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { mutex.withLock { mutableState.replaceData( using: state, @@ -224,7 +250,7 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Test-only method to merge initial value from an ObjectOperation, per RTLM17. - internal func testsOnly_mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) { + internal func testsOnly_mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { mutex.withLock { mutableState.mergeInitialValue( from: operation, @@ -236,7 +262,7 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Test-only method to apply a MAP_CREATE operation, per RTLM16. - internal func testsOnly_applyMapCreateOperation(_ operation: ObjectOperation, objectsPool: inout ObjectsPool) { + internal func testsOnly_applyMapCreateOperation(_ operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { mutex.withLock { mutableState.applyMapCreateOperation( operation, @@ -274,7 +300,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationTimeserial: String?, operationData: ObjectData, objectsPool: inout ObjectsPool, - ) { + ) -> LiveObjectUpdate { mutex.withLock { mutableState.applyMapSetOperation( key: key, @@ -290,7 +316,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// Applies a `MAP_REMOVE` operation to a key, per RTLM8. /// /// This is currently exposed just so that the tests can test RTLM8 without having to go through a convoluted replaceData(…) call, but I _think_ that it's going to be used in further contexts when we introduce the handling of incoming object operations in a future spec PR. - internal func testsOnly_applyMapRemoveOperation(key: String, operationTimeserial: String?) { + internal func testsOnly_applyMapRemoveOperation(key: String, operationTimeserial: String?) -> LiveObjectUpdate { mutex.withLock { mutableState.applyMapRemoveOperation( key: key, @@ -302,7 +328,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// 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 resetData() { mutex.withLock { - mutableState.resetData() + mutableState.resetData(userCallbackQueue: userCallbackQueue) } } @@ -310,7 +336,7 @@ internal final class InternalDefaultLiveMap: Sendable { private struct MutableState { /// The mutable state common to all LiveObjects. - internal var liveObject: LiveObjectMutableState + internal var liveObject: LiveObjectMutableState /// The internal data that this map holds, per RTLM3. internal var data: [String: ObjectsMapEntry] @@ -327,7 +353,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, - ) { + ) -> LiveObjectUpdate { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials liveObject.siteTimeserials = state.siteTimeserials @@ -338,13 +364,16 @@ internal final class InternalDefaultLiveMap: Sendable { data = state.map?.entries ?? [:] // RTLM6d: If ObjectState.createOp is present, merge the initial value into the LiveMap as described in RTLM17 - if let createOp = state.createOp { + return if let createOp = state.createOp { mergeInitialValue( from: createOp, objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, ) + } else { + // TODO: I assume this is what to do, clarify in https://github.com/ably/specification/pull/346/files#r2201363446 + .noop } } @@ -354,10 +383,10 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, - ) { + ) -> LiveObjectUpdate { // RTLM17a: For each key–ObjectsMapEntry pair in ObjectOperation.map.entries - if let entries = operation.map?.entries { - for (key, entry) in entries { + let perKeyUpdates: [LiveObjectUpdate] = if let entries = operation.map?.entries { + entries.map { key, entry in if entry.tombstone == true { // RTLM17a2: If ObjectsMapEntry.tombstone is true, apply the MAP_REMOVE operation // as described in RTLM8, passing in the current key as ObjectsMapOp, and ObjectsMapEntry.timeserial as the operation's serial @@ -378,9 +407,28 @@ internal final class InternalDefaultLiveMap: Sendable { ) } } + } else { + [] } + // RTLM17b: Set the private flag createOperationIsMerged to true liveObject.createOperationIsMerged = true + + // RTLM17c: Merge the updates, skipping no-ops + // I don't love having to use uniqueKeysWithValues, when I shouldn't have to. I should be able to reason _statically_ that there are no overlapping keys. The problem that we're trying to use LiveMapUpdate throughout instead of something more communicative. But I don't know what's to come in the spec so I don't want to mess with this internal interface. + 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. @@ -404,12 +452,14 @@ internal final class InternalDefaultLiveMap: Sendable { switch operation.action { case .known(.mapCreate): // RTLM15d1 - applyMapCreateOperation( + let update = applyMapCreateOperation( operation, objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, ) + // RTLM15d1a + liveObject.emit(update, on: userCallbackQueue) case .known(.mapSet): guard let mapOp = operation.mapOp else { logger.log("Could not apply MAP_SET since operation.mapOp is missing", level: .warn) @@ -421,7 +471,7 @@ internal final class InternalDefaultLiveMap: Sendable { } // RTLM15d2 - applyMapSetOperation( + let update = applyMapSetOperation( key: mapOp.key, operationTimeserial: applicableOperation.objectMessageSerial, operationData: data, @@ -429,16 +479,20 @@ internal final class InternalDefaultLiveMap: Sendable { logger: logger, userCallbackQueue: userCallbackQueue, ) + // RTLM15d2a + liveObject.emit(update, on: userCallbackQueue) case .known(.mapRemove): guard let mapOp = operation.mapOp else { return } // RTLM15d3 - applyMapRemoveOperation( + let update = applyMapRemoveOperation( key: mapOp.key, operationTimeserial: applicableOperation.objectMessageSerial, ) + // RTLM15d3a + liveObject.emit(update, on: userCallbackQueue) default: // RTLM15d4 logger.log("Operation \(operation) has unsupported action for LiveMap; discarding", level: .warn) @@ -453,12 +507,12 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, - ) { + ) -> LiveObjectUpdate { // 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 + return .noop } // RTLM7a2: Otherwise, apply the operation // RTLM7a2a: Set ObjectsMapEntry.data to the ObjectData from the operation @@ -481,17 +535,20 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger, userCallbackQueue: userCallbackQueue) } + + // 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?) { + internal mutating func applyMapRemoveOperation(key: String, operationTimeserial: String?) -> 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) // 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 + return .noop } // RTLM8a2: Otherwise, apply the operation // RTLM8a2a: Set ObjectsMapEntry.data to undefined/null @@ -508,6 +565,8 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM8b2: Set ObjectsMapEntry.tombstone for the new entry to true data[key] = ObjectsMapEntry(tombstone: true, timeserial: operationTimeserial, data: ObjectData()) } + + return .update(DefaultLiveMapUpdate(update: [key: .removed])) } /// Determines whether a map operation can be applied to a map entry, per RTLM9. @@ -556,17 +615,17 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, - ) { + ) -> LiveObjectUpdate { if liveObject.createOperationIsMerged { // RTLM16b logger.log("Not applying MAP_CREATE because a MAP_CREATE has already been applied", level: .warn) - return + return .noop } // TODO: RTLM16c `semantics` comparison; outstanding question in https://github.com/ably/specification/pull/343/files#r2192784482 - // RTLM16d - mergeInitialValue( + // RTLM16d, RTLM16f + return mergeInitialValue( from: operation, objectsPool: &objectsPool, logger: logger, @@ -574,10 +633,15 @@ internal final class InternalDefaultLiveMap: Sendable { ) } - /// 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 mutating func resetData() { + /// 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 data = [:] + + // RTO4b2a + let mapUpdate = DefaultLiveMapUpdate(update: previousData.mapValues { _ in .removed }) + liveObject.emit(.update(mapUpdate), on: userCallbackQueue) } } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 3d3e672c1..3bc22e6a8 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -138,6 +138,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool notYetImplemented() } + @discardableResult internal func on(event _: ObjectsEvent, callback _: ObjectsEventCallback) -> any OnObjectsEventResponse { notYetImplemented() } diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index 3454909a5..4c608fc52 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -3,7 +3,7 @@ internal import AblyPlugin /// This is the equivalent of the `LiveObject` abstract class described in RTLO. /// /// ``InternalDefaultLiveCounter`` and ``InternalDefaultLiveMap`` include it by composition. -internal struct LiveObjectMutableState { +internal struct LiveObjectMutableState { // RTLO3a internal var objectID: String // RTLO3b @@ -11,6 +11,17 @@ internal struct LiveObjectMutableState { // RTLO3c internal var createOperationIsMerged = false + /// Internal bookkeeping for subscriptions. + private var subscriptionsByID: [Subscription.ID: Subscription] = [:] + + internal init( + objectID: String, + testsOnly_siteTimeserials siteTimeserials: [String: String]? = nil, + ) { + self.objectID = objectID + self.siteTimeserials = siteTimeserials ?? [:] + } + /// 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. @@ -47,4 +58,72 @@ internal struct LiveObjectMutableState { return nil } + + // MARK: - Subscriptions + + private struct Subscription: Identifiable { + var id = UUID() + var listener: LiveObjectUpdateCallback + var updateLiveObject: UpdateLiveObject + } + + /// A function that allows a `LiveObjectMutableState` 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 `LiveObjectMutableState` will store a copy of this function and thus this function should be careful not to introduce a strong reference cycle. + internal typealias UpdateLiveObject = @Sendable (_ action: (inout LiveObjectMutableState) -> Void) -> Void + + private struct SubscribeResponse: AblyLiveObjects.SubscribeResponse { + var subscriptionID: Subscription.ID + var updateLiveObject: UpdateLiveObject + + func unsubscribe() { + updateLiveObject { liveObject in + liveObject.unsubscribe(subscriptionID: subscriptionID) + } + } + } + + @discardableResult + internal mutating func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK, updateSelfLater: @escaping UpdateLiveObject) throws(ARTErrorInfo) -> any AblyLiveObjects.SubscribeResponse { + // RTLO4b2 + let currentChannelState = coreSDK.channelState + if currentChannelState == .detached || currentChannelState == .failed { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: "subscribe", + channelState: currentChannelState, + ) + .toARTErrorInfo() + } + + let subscription = Subscription(listener: listener, updateLiveObject: updateSelfLater) + subscriptionsByID[subscription.id] = subscription + return SubscribeResponse(subscriptionID: subscription.id, updateLiveObject: updateSelfLater) + } + + internal mutating func unsubscribeAll() { + subscriptionsByID.removeAll() + } + + private mutating func unsubscribe(subscriptionID: Subscription.ID) { + // RTLO4d + subscriptionsByID.removeValue(forKey: subscriptionID) + } + + internal func emit(_ update: LiveObjectUpdate, on queue: DispatchQueue) { + switch update { + case .noop: + // RTLO4b4c1 + return + case let .update(update): + // RTLO4b4c2 + for subscription in subscriptionsByID.values { + queue.async { + let response = SubscribeResponse(subscriptionID: subscription.id, updateLiveObject: subscription.updateLiveObject) + subscription.listener(update, response) + } + } + } + } } diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift b/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift new file mode 100644 index 000000000..e31a06393 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift @@ -0,0 +1,26 @@ +internal enum LiveObjectUpdate: Sendable { + case noop // RTLO4b4 + case update(Update) // RTLO4b4a + + // MARK: - Convenience getters + + /// Returns `true` if and only if this `LiveObjectUpdate` has case `noop`. + internal var isNoop: Bool { + if case .noop = self { + true + } else { + false + } + } + + /// If this `LiveObjectUpdate` has case `update`, returns the associated value. Else, returns `nil`. + internal var update: Update? { + if case let .update(update) = self { + update + } else { + nil + } + } +} + +extension LiveObjectUpdate: Equatable where Update: Equatable {} diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 8d9596608..135ea7922 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -53,6 +53,34 @@ internal struct 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 emit() { + switch self { + case let .map(map, update): + map.emit(update) + case let .counter(counter, update): + counter.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. + fileprivate func replaceData(using state: ObjectState, objectsPool: inout ObjectsPool) -> DeferredUpdate { + switch self { + case let .map(map): + .map(map, map.replaceData(using: state, objectsPool: &objectsPool)) + case let .counter(counter): + .counter(counter, counter.replaceData(using: state)) + } + } } /// Keyed by `objectId`. @@ -154,6 +182,9 @@ internal struct ObjectsPool { // 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 objectState in syncObjectsPool { receivedObjectIds.insert(objectState.objectId) @@ -163,12 +194,9 @@ internal struct ObjectsPool { logger.log("Updating existing object with ID: \(objectState.objectId)", level: .debug) // RTO5c1a1: Override the internal data for the object as per RTLC6, RTLM6 - switch existingEntry { - case let .map(map): - map.replaceData(using: objectState, objectsPool: &self) - case let .counter(counter): - counter.replaceData(using: objectState) - } + let deferredUpdate = existingEntry.replaceData(using: objectState, objectsPool: &self) + // RTO5c1a2: Store this update to emit at end + updatesToExistingObjects.append(deferredUpdate) } else { // RTO5c1b: If an object with ObjectState.objectId does not exist in the internal ObjectsPool logger.log("Creating new object with ID: \(objectState.objectId)", level: .debug) @@ -180,14 +208,14 @@ internal struct ObjectsPool { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger, userCallbackQueue: userCallbackQueue) - counter.replaceData(using: objectState) + _ = counter.replaceData(using: objectState) newEntry = .counter(counter) } else if let objectsMap = objectState.map { // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, // set its private objectId equal to ObjectState.objectId, set its private semantics // equal to ObjectState.map.semantics and override its internal data per RTLM6 let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue) - map.replaceData(using: objectState, objectsPool: &self) + _ = map.replaceData(using: objectState, objectsPool: &self) newEntry = .map(map) } else { // RTO5c1b1c: Otherwise, log a warning that an unsupported object state message has been received, and discard the current ObjectState without taking any action @@ -212,6 +240,11 @@ internal struct ObjectsPool { } } + // RTO5c7: Emit the updates to existing objects + for deferredUpdate in updatesToExistingObjects { + deferredUpdate.emit() + } + logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index ec770d502..7b6c07956 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -34,8 +34,8 @@ internal final class PublicDefaultLiveCounter: LiveCounter { try await proxied.decrement(amount: amount) } - internal func subscribe(listener: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { - proxied.subscribe(listener: listener) + internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { + try proxied.subscribe(listener: listener, coreSDK: coreSDK) } internal func unsubscribeAll() { diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 1ef96b855..a49cbfd22 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -80,8 +80,8 @@ internal final class PublicDefaultLiveMap: LiveMap { try await proxied.remove(key: key) } - internal func subscribe(listener: @escaping LiveObjectUpdateCallback) -> any SubscribeResponse { - proxied.subscribe(listener: listener) + internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { + try proxied.subscribe(listener: listener, coreSDK: coreSDK) } internal func unsubscribeAll() { diff --git a/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift b/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift new file mode 100644 index 000000000..0a9540aa1 --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift @@ -0,0 +1,10 @@ +import Foundation + +internal extension NSLock { + /// Behaves like `NSLock.withLock`, but the thrown error has the same type as that thrown by the body. (`withLock` uses `rethrows`, which is always an untyped throw.) + func ablyLiveObjects_withLockWithTypedThrow(_ body: () throws(E) -> R) throws(E) -> R { + lock() + defer { unlock() } + return try body() + } +} diff --git a/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift b/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift new file mode 100644 index 000000000..e49ebafd2 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift @@ -0,0 +1,51 @@ +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)] = [] + + /// 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 + } + mutex.withLock { + let invocation = (repeat each arg) + invocations.append(invocation) + } + if let action { + action(repeat each arg) + } + } + } +} diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index c7dfb0585..83e85728c 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -32,7 +32,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attached) // Set some test data - counter.replaceData(using: TestFactories.counterObjectState(count: 42)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 42)) #expect(try counter.value(coreSDK: coreSDK) == 42) } @@ -48,7 +48,7 @@ struct InternalDefaultLiveCounterTests { let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) - counter.replaceData(using: state) + _ = counter.replaceData(using: state) #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) } @@ -67,7 +67,7 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterCreate), ), ) - counter.replaceData(using: state) + _ = counter.replaceData(using: state) #expect(counter.testsOnly_createOperationIsMerged) return counter @@ -77,7 +77,7 @@ struct InternalDefaultLiveCounterTests { let state = TestFactories.counterObjectState( createOp: nil, // Test value - must be nil to test RTLC6b ) - counter.replaceData(using: state) + _ = counter.replaceData(using: state) // Then: #expect(!counter.testsOnly_createOperationIsMerged) @@ -92,7 +92,7 @@ struct InternalDefaultLiveCounterTests { let state = TestFactories.counterObjectState( count: 42, // Test value ) - counter.replaceData(using: state) + _ = counter.replaceData(using: state) #expect(try counter.value(coreSDK: coreSDK) == 42) } @@ -102,7 +102,7 @@ struct InternalDefaultLiveCounterTests { let logger = TestLogger() let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) - counter.replaceData(using: TestFactories.counterObjectState( + _ = counter.replaceData(using: TestFactories.counterObjectState( count: nil, // Test value - must be nil )) #expect(try counter.value(coreSDK: coreSDK) == 0) @@ -121,7 +121,7 @@ struct InternalDefaultLiveCounterTests { createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist count: 5, // Test value - must exist ) - counter.replaceData(using: state) + _ = counter.replaceData(using: state) #expect(try counter.value(coreSDK: coreSDK) == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC10a) #expect(counter.testsOnly_createOperationIsMerged) } @@ -131,6 +131,7 @@ struct InternalDefaultLiveCounterTests { /// Tests for the `testsOnly_mergeInitialValue` method, covering RTLC10 specification points struct MergeInitialValueTests { // @specOneOf(1/2) RTLC10a - with count + // @spec RTLC10c @Test func addsCounterCountToData() throws { let logger = TestLogger() @@ -138,17 +139,21 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist - counter.testsOnly_mergeInitialValue(from: operation) + let update = counter.testsOnly_mergeInitialValue(from: operation) #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 + + // Check return value + #expect(try #require(update.update).amount == 10) } // @specOneOf(2/2) RTLC10a - no count + // @spec RTLC10d @Test func doesNotModifyDataWhenCounterCountDoesNotExist() throws { let logger = TestLogger() @@ -156,7 +161,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation with no count @@ -164,9 +169,12 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterCreate), counter: nil, // Test value - must be nil ) - counter.testsOnly_mergeInitialValue(from: operation) + let update = counter.testsOnly_mergeInitialValue(from: operation) #expect(try counter.value(coreSDK: coreSDK) == 5) // Unchanged + + // Check return value + #expect(update.isNoop) } // @spec RTLC10b @@ -177,7 +185,7 @@ struct InternalDefaultLiveCounterTests { // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist - counter.testsOnly_mergeInitialValue(from: operation) + _ = counter.testsOnly_mergeInitialValue(from: operation) #expect(counter.testsOnly_createOperationIsMerged) } @@ -193,19 +201,23 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data and mark create operation as merged - counter.replaceData(using: TestFactories.counterObjectState(count: 5)) - counter.testsOnly_mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.testsOnly_mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) #expect(counter.testsOnly_createOperationIsMerged) // Try to apply another COUNTER_CREATE operation let operation = TestFactories.counterCreateOperation(count: 20) - counter.testsOnly_applyCounterCreateOperation(operation) + 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() @@ -213,40 +225,58 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data but don't mark create operation as merged - counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) #expect(!counter.testsOnly_createOperationIsMerged) // Apply COUNTER_CREATE operation let operation = TestFactories.counterCreateOperation(count: 10) - counter.testsOnly_applyCounterCreateOperation(operation) + let update = counter.testsOnly_applyCounterCreateOperation(operation) // Verify the operation was applied - initial value merged. (The full logic of RTLC10 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 RTLC9b - @Test(arguments: [ - (operation: TestFactories.counterOp(amount: 10), expectedValue: 15.0), // 5 + 10 - (operation: nil as WireObjectsCounterOp?, expectedValue: 5.0), // unchanged - ] as [(operation: WireObjectsCounterOp?, expectedValue: Double)]) - func addsAmountToData(operation: WireObjectsCounterOp?, expectedValue: Double) throws { + // @spec RTLC9d + // @spec RTLC9e + @Test( + arguments: [ + ( + operation: TestFactories.counterOp(amount: 10), + expectedValue: 15.0, // 5 + 10 + expectedUpdate: .update(.init(amount: 10)) // RTLC9d + ), + ( + operation: nil as WireObjectsCounterOp?, + expectedValue: 5.0, // unchanged + expectedUpdate: .noop // RTLC9e + ), + ] as [(operation: WireObjectsCounterOp?, expectedValue: Double, expectedUpdate: LiveObjectUpdate)], + ) + func addsAmountToData(operation: WireObjectsCounterOp?, expectedValue: Double, expectedUpdate: LiveObjectUpdate) throws { let logger = TestLogger() let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply COUNTER_INC operation - counter.testsOnly_applyCounterIncOperation(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) } } @@ -260,7 +290,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set up the counter with an existing site timeserial that will cause the operation to be discarded - counter.replaceData(using: TestFactories.counterObjectState( + _ = counter.replaceData(using: TestFactories.counterObjectState( siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" count: 5, )) @@ -288,12 +318,17 @@ struct InternalDefaultLiveCounterTests { // @specOneOf(1/2) RTLC7c - We test this spec point for each possible operation // @spec RTLC7d1 - Tests COUNTER_CREATE operation application + // @spec RTLC7d1a + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func appliesCounterCreateOperation() throws { + func appliesCounterCreateOperation() async throws { let logger = TestLogger() let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) + let subscriber = Subscriber(callbackQueue: .main) + try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + let operation = TestFactories.counterCreateOperation(count: 15) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) @@ -310,18 +345,27 @@ struct InternalDefaultLiveCounterTests { #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/2) RTLC7c - We test this spec point for each possible operation // @spec RTLC7d2 - Tests COUNTER_INC operation application + // @spec RTLC7d2a + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func appliesCounterIncOperation() throws { + func appliesCounterIncOperation() async throws { let logger = TestLogger() let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let coreSDK = MockCoreSDK(channelState: .attaching) + let subscriber = Subscriber(callbackQueue: .main) + try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + // Set initial data - counter.replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5)) #expect(try counter.value(coreSDK: coreSDK) == 5) let operation = TestFactories.objectOperation( @@ -342,8 +386,35 @@ struct InternalDefaultLiveCounterTests { #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 RTLC7d2a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(amount: 10)]) } - // @specUntested RTLC7d3 - 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 + // @spec RTLC7d3 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func noOpForOtherOperation() async throws { + let logger = TestLogger() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let coreSDK = MockCoreSDK(channelState: .attaching) + + let subscriber = Subscriber(callbackQueue: .main) + try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + // Try to apply a MAP_CREATE to the counter (not supported) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + counter.apply( + TestFactories.mapCreateOperation(), + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // Check no update was emitted + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.isEmpty) + } } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 61d4ff36f..2cdefbf81 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -159,7 +159,7 @@ struct InternalDefaultLiveMapTests { siteTimeserials: ["site1": "ts1", "site2": "ts2"], ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) } @@ -176,7 +176,7 @@ struct InternalDefaultLiveMapTests { let state = TestFactories.objectState( createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), ) - map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) return map @@ -184,7 +184,7 @@ struct InternalDefaultLiveMapTests { // When: let state = TestFactories.objectState(objectId: "arbitrary-id", createOp: nil) - map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectsPool: &pool) // Then: #expect(!map.testsOnly_createOperationIsMerged) @@ -203,7 +203,7 @@ struct InternalDefaultLiveMapTests { entries: [key: entry], ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectsPool: &pool) let newData = map.testsOnly_data #expect(newData.count == 1) #expect(Set(newData.keys) == ["key1"]) @@ -234,7 +234,7 @@ struct InternalDefaultLiveMapTests { ), ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectsPool: &pool) // Note that we just check for some basic expected side effects of merging the initial value; RTLM17 is tested in more detail elsewhere // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d) #expect(try map.get(key: "keyFromMapEntries", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromMapEntries") @@ -442,7 +442,7 @@ struct InternalDefaultLiveMapTests { var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Try to apply operation with lower timeserial (ts1 < ts2) - map.testsOnly_applyMapSetOperation( + let update = map.testsOnly_applyMapSetOperation( key: "key1", operationTimeserial: "ts1", operationData: ObjectData(objectId: "new"), @@ -453,10 +453,13 @@ struct InternalDefaultLiveMapTests { #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") // Verify that RTLM7c1 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) RTLM7c1 + // @specOneOf(1/2) RTLM7f @Test(arguments: [ // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectPool per RTLM7c) (operationData: ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), @@ -477,7 +480,7 @@ struct InternalDefaultLiveMapTests { ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - map.testsOnly_applyMapSetOperation( + let update = map.testsOnly_applyMapSetOperation( key: "key1", operationTimeserial: "ts2", operationData: operationData, @@ -516,6 +519,9 @@ struct InternalDefaultLiveMapTests { // 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]) } } @@ -525,6 +531,7 @@ struct InternalDefaultLiveMapTests { // @spec RTLM7b1 // @spec RTLM7b2 // @specOneOf(2/2) RTLM7c1 + // @specOneOf(2/2) RTLM7f @Test(arguments: [ // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectPool per RTLM7c) (operationData: ObjectData(number: NSNumber(value: 42)), expectedCreatedObjectID: nil), @@ -540,7 +547,7 @@ struct InternalDefaultLiveMapTests { let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - map.testsOnly_applyMapSetOperation( + let update = map.testsOnly_applyMapSetOperation( key: "newKey", operationTimeserial: "ts1", operationData: operationData, @@ -573,6 +580,9 @@ struct InternalDefaultLiveMapTests { // 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]) } } @@ -603,7 +613,7 @@ struct InternalDefaultLiveMapTests { delegate.objects[existingObjectId] = pool.entries[existingObjectId] // Apply MAP_SET operation that references the existing object - map.testsOnly_applyMapSetOperation( + _ = map.testsOnly_applyMapSetOperation( key: "referenceKey", operationTimeserial: "ts1", operationData: ObjectData(objectId: existingObjectId), @@ -639,15 +649,18 @@ struct InternalDefaultLiveMapTests { ) // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 - map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts1") + let update = map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts1") // 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() @@ -661,7 +674,7 @@ struct InternalDefaultLiveMapTests { ) // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 - map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts2") + let update = map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts2") // Verify the operation was applied #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) @@ -679,6 +692,9 @@ struct InternalDefaultLiveMapTests { // 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]) } } @@ -686,12 +702,13 @@ struct InternalDefaultLiveMapTests { 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 map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") + let update = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") // Verify new entry was created let entry = map.testsOnly_data["newKey"] @@ -702,6 +719,9 @@ struct InternalDefaultLiveMapTests { #expect(entry?.data.boolean == nil) #expect(entry?.data.bytes == nil) #expect(entry?.data.objectId == nil) + + // RTLM8e: Check return value + #expect(try #require(update.update).update == ["newKey": .removed]) } // @spec RTLM8b2 - Set ObjectsMapEntry.tombstone for new entry to true @@ -710,7 +730,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") + _ = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") // Verify tombstone is true for new entry #expect(map.testsOnly_data["newKey"]?.tombstone == true) @@ -778,7 +798,7 @@ struct InternalDefaultLiveMapTests { ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) - map.testsOnly_applyMapSetOperation( + _ = map.testsOnly_applyMapSetOperation( key: "key1", operationTimeserial: operationSerial, operationData: ObjectData(string: .string("new")), @@ -815,7 +835,7 @@ struct InternalDefaultLiveMapTests { "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, ], ) - map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + _ = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) // Note that we just check for some basic expected side effects of applying MAP_SET; RTLM7 is tested in more detail elsewhere // Check that it contains the data from the operation (per RTLM17a1) @@ -849,12 +869,50 @@ struct InternalDefaultLiveMapTests { objectId: "arbitrary-id", entries: ["key1": entry], ) - map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + _ = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) // Verify the MAP_REMOVE operation was applied #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) } + // @spec RTLM17c + @Test + func returnedUpdateMergesOperationUpdates() throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap( + testsOnly_data: [ + "keyThatWillBeRemoved": TestFactories.stringMapEntry(timeserial: "ts1").entry, + "keyThatWillNotBeRemoved": TestFactories.stringMapEntry(timeserial: "ts1").entry, + ], + objectID: "arbitrary", + logger: logger, + userCallbackQueue: .main, + ) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + + // Apply merge operation with MAP_CREATE and MAP_REMOVE entries (copied from RTLM17a1 and RTLM17a2 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 RTLM17c) + data: ObjectData(), + ), + "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, + ], + ) + let update = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + + // Verify merged return value per RTLM17c + #expect(try #require(update.update).update == ["keyThatWillBeRemoved": .removed, "keyFromCreateOp": .updated]) + } + // @spec RTLM17b @Test func setsCreateOperationIsMergedToTrue() { @@ -864,7 +922,7 @@ struct InternalDefaultLiveMapTests { // Apply merge operation let operation = TestFactories.mapCreateOperation(objectId: "arbitrary-id") - map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + _ = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) } @@ -882,21 +940,25 @@ struct InternalDefaultLiveMapTests { var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Set initial data and mark create operation as merged - map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) - map.testsOnly_mergeInitialValue(from: TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]), objectsPool: &pool) + _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) + _ = map.testsOnly_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]) - map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) + 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() @@ -906,17 +968,20 @@ struct InternalDefaultLiveMapTests { var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) // Set initial data but don't mark create operation as merged - map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) + _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) #expect(!map.testsOnly_createOperationIsMerged) // Apply MAP_CREATE operation let operation = TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]) - map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) + let update = map.testsOnly_applyMapCreateOperation(operation, objectsPool: &pool) // Verify the operation was applied - initial value merged. (The full logic of RTLM17 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]) } } @@ -933,7 +998,7 @@ struct InternalDefaultLiveMapTests { // Set up the map with an existing site timeserial that will cause the operation to be discarded var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - map.replaceData(using: TestFactories.mapObjectState( + _ = map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" entries: [key1: entry1], ), objectsPool: &pool) @@ -960,13 +1025,18 @@ struct InternalDefaultLiveMapTests { // @specOneOf(1/3) RTLM15c - We test this spec point for each possible operation // @spec RTLM15d1 - Tests MAP_CREATE operation application + // @spec RTLM15d1a + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func appliesMapCreateOperation() throws { + func appliesMapCreateOperation() async throws { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + 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], ) @@ -985,21 +1055,30 @@ struct InternalDefaultLiveMapTests { #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/3) RTLM15c - We test this spec point for each possible operation // @spec RTLM15d2 - Tests MAP_SET operation application + // @spec RTLM15d2a + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func appliesMapSetOperation() throws { + func appliesMapSetOperation() async throws { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + // Set initial data var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - map.replaceData(using: TestFactories.mapObjectState( + _ = map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], entries: [key1: entry1], ), objectsPool: &pool) @@ -1022,21 +1101,30 @@ struct InternalDefaultLiveMapTests { #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 RTLM15d2a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .updated])]) } // @specOneOf(3/3) RTLM15c - We test this spec point for each possible operation // @spec RTLM15d3 - Tests MAP_REMOVE operation application + // @spec RTLM15d3a + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func appliesMapRemoveOperation() throws { + func appliesMapRemoveOperation() async throws { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + // Set initial data var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - map.replaceData(using: TestFactories.mapObjectState( + _ = map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], entries: [key1: entry1], ), objectsPool: &pool) @@ -1059,8 +1147,35 @@ struct InternalDefaultLiveMapTests { #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 RTLM15d3a + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .removed])]) } - // @specUntested RTLM15d4 - 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 + // @spec RTLM15d4 + @available(iOS 17.0.0, tvOS 17.0.0, *) + @Test + func noOpForOtherOperation() async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let coreSDK = MockCoreSDK(channelState: .attaching) + + let subscriber = Subscriber(callbackQueue: .main) + try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) + + // Try to apply a COUNTER_CREATE to the map (not supported) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + map.apply( + TestFactories.counterCreateOperation(), + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectsPool: &pool, + ) + + // Check no update was emitted + let subscriberInvocations = await subscriber.getInvocations() + #expect(subscriberInvocations.isEmpty) + } } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 67c65f69c..38e404449 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -338,16 +338,22 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO4b1 // @spec RTO4b2 + // @spec RTO4b2a // @spec RTO4b3 // @spec RTO4b4 // @spec RTO4b5 + @available(iOS 17.0.0, tvOS 17.0.0, *) @Test - func handlesHasObjectsFalse() { + func handlesHasObjectsFalse() async throws { let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() // Set up initial state with additional objects in the pool using sync realtimeObjects.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"), ], @@ -355,6 +361,11 @@ struct InternalDefaultRealtimeObjectsTests { ) let originalPool = realtimeObjects.testsOnly_objectsPool + #expect(Set(originalPool.root.testsOnly_data.keys) == ["existingMap", "existingCounter"]) + + let rootSubscriber = Subscriber(callbackQueue: .main) + let coreSDK = MockCoreSDK(channelState: .attached) + try originalPool.root.subscribe(listener: rootSubscriber.createListener(), coreSDK: coreSDK) // Set up an in-progress sync sequence realtimeObjects.handleObjectSyncProtocolMessage( @@ -379,6 +390,9 @@ struct InternalDefaultRealtimeObjectsTests { #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. diff --git a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift index 47c03cbba..ff39d7d4b 100644 --- a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift +++ b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift @@ -13,7 +13,7 @@ struct LiveObjectMutableStateTests { let objectMessageSerial: String? let objectMessageSiteCode: String? let siteTimeserials: [String: String] - let expectedResult: LiveObjectMutableState.ApplicableOperation? + let expectedResult: LiveObjectMutableState.ApplicableOperation? } // @spec RTLO4a3 @@ -98,9 +98,9 @@ struct LiveObjectMutableStateTests { ), ]) func canApplyOperation(testCase: TestCase) { - let state = LiveObjectMutableState( + let state = LiveObjectMutableState( objectID: "test:object@123", - siteTimeserials: testCase.siteTimeserials, + testsOnly_siteTimeserials: testCase.siteTimeserials, ) let logger = TestLogger() @@ -113,4 +113,208 @@ struct LiveObjectMutableStateTests { #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 [ARTRealtimeChannelState]) + func subscribeThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + var mutableState = LiveObjectMutableState(objectID: "foo") + let queue = DispatchQueue.main + let subscriber = Subscriber(callbackQueue: queue) + let coreSDK = MockCoreSDK(channelState: channelState) + + #expect { + try mutableState.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 coreSDK = MockCoreSDK(channelState: .attached) + try mutableState.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 coreSDK = MockCoreSDK(channelState: .attached) + try mutableState.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 = NSLock() + private nonisolated(unsafe) var stored: LiveObjectMutableState + + init(stored: LiveObjectMutableState) { + self.stored = stored + } + + @discardableResult + func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> SubscribeResponse { + try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in + try stored.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + guard let self else { + return + } + + mutex.withLock { + action(&stored) + } + }) + } + } + + func emit(_ update: LiveObjectUpdate, on queue: DispatchQueue) { + mutex.withLock { + stored.emit(update, on: queue) + } + } + + func unsubscribeAll() { + mutex.withLock { + 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 store = MutableStateStore(stored: .init(objectID: "foo")) + let queue = DispatchQueue.main + let subscriber = Subscriber(callbackQueue: queue) + let coreSDK = MockCoreSDK(channelState: .attached) + 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-cocoa-liveobjects-plugin/issues/28")) + func unsubscribeInsideCallback_backToBackUpdates() async throws { + // Given + let store = MutableStateStore(stored: .init(objectID: "foo")) + let queue = DispatchQueue.main + let subscriber = Subscriber(callbackQueue: queue) + let coreSDK = MockCoreSDK(channelState: .attached) + // 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 store = MutableStateStore(stored: .init(objectID: "foo")) + let queue = DispatchQueue.main + let subscriber = Subscriber(callbackQueue: queue) + let coreSDK = MockCoreSDK(channelState: .attached) + // 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 store = MutableStateStore(stored: .init(objectID: "foo")) + let queue = DispatchQueue.main + let coreSDK = MockCoreSDK(channelState: .attached) + 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/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 6b1e05037..8b3d14d57 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -77,18 +77,26 @@ struct ObjectsPoolTests { // 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() throws { + func updatesExistingMapObject() async throws { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingMapSubscriber = Subscriber(callbackQueue: .main) + try existingMap.subscribe(listener: existingMapSubscriber.createListener(), coreSDK: coreSDK) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) 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], ) @@ -101,20 +109,30 @@ struct ObjectsPoolTests { #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 + let subscriberInvocations = await existingMapSubscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(update: ["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() throws { + func updatesExistingCounterObject() async throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingCounterSubscriber = Subscriber(callbackQueue: .main) + try existingCounter.subscribe(listener: existingCounterSubscriber.createListener(), coreSDK: coreSDK) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@123", siteTimeserials: ["site1": "ts1"], - count: 42, + createOp: TestFactories.counterCreateOperation(objectId: "counter:hash@123", count: 5), + count: 10, ) pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) @@ -123,9 +141,13 @@ struct ObjectsPoolTests { 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) == 42) + #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 + let subscriberInvocations = await existingCounterSubscriber.getInvocations() + #expect(subscriberInvocations.map(\.0) == [.init(amount: 5)]) // From createOp } // @spec RTO5c1b1a @@ -247,9 +269,10 @@ struct ObjectsPoolTests { #expect(pool.entries["map:hash@1"] == nil) // Should be removed } - // A more complete example of the behaviours described in RTO5c1 and RTO5c2. + // 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() throws { + func handlesComplexSyncScenario() async throws { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) @@ -258,6 +281,11 @@ struct ObjectsPoolTests { let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingMapSubscriber = Subscriber(callbackQueue: .main) + try existingMap.subscribe(listener: existingMapSubscriber.createListener(), coreSDK: coreSDK) + let existingCounterSubscriber = Subscriber(callbackQueue: .main) + try existingCounter.subscribe(listener: existingCounterSubscriber.createListener(), coreSDK: coreSDK) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: [ "map:existing@1": .map(existingMap), "counter:existing@1": .counter(existingCounter), @@ -269,12 +297,16 @@ struct ObjectsPoolTests { 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: .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 @@ -305,9 +337,17 @@ struct ObjectsPoolTests { // 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 + let existingMapSubscriberInvocations = await existingMapSubscriber.getInvocations() + #expect(existingMapSubscriberInvocations.map(\.0) == [.init(update: ["createOpKey": .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) == 100) + #expect(try updatedCounter.value(coreSDK: coreSDK) == 105) + + // Check update emitted by existing counter per RTO5c7 + let existingCounterInvocations = await existingCounterSubscriber.getInvocations() + #expect(existingCounterInvocations.map(\.0) == [.init(amount: 5)]) // New objects - verify by checking side effects of replaceData calls let newMap = try #require(pool.entries["map:new@1"]?.mapValue) From 51e3f9fe0dbe33fe2b0d6c8c8ab1aa295807f416 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 18 Jul 2025 11:10:54 -0300 Subject: [PATCH 063/225] Switch to a new internal type for LiveMap data values This is preparation for adding additional fields (e.g. tombstonedAt) per RTLM3a in [1]. [1] https://github.com/ably/specification/pull/350 --- .../Internal/InternalDefaultLiveMap.swift | 18 +++--- .../Internal/InternalObjectsMapEntry.swift | 14 +++++ .../Helpers/TestFactories.swift | 34 +++++++++++ .../InternalDefaultLiveMapTests.swift | 60 +++++++++---------- 4 files changed, 87 insertions(+), 39 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 8146c1393..59a72f46d 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -14,7 +14,7 @@ internal final class InternalDefaultLiveMap: Sendable { private nonisolated(unsafe) var mutableState: MutableState - internal var testsOnly_data: [String: ObjectsMapEntry] { + internal var testsOnly_data: [String: InternalObjectsMapEntry] { mutex.withLock { mutableState.data } @@ -50,7 +50,7 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - Initialization internal convenience init( - testsOnly_data data: [String: ObjectsMapEntry], + testsOnly_data data: [String: InternalObjectsMapEntry], objectID: String, testsOnly_semantics semantics: WireEnum? = nil, logger: AblyPlugin.Logger, @@ -66,7 +66,7 @@ internal final class InternalDefaultLiveMap: Sendable { } private init( - data: [String: ObjectsMapEntry], + data: [String: InternalObjectsMapEntry], objectID: String, semantics: WireEnum?, logger: AblyPlugin.Logger, @@ -339,7 +339,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal var liveObject: LiveObjectMutableState /// The internal data that this map holds, per RTLM3. - internal var data: [String: ObjectsMapEntry] + internal var data: [String: InternalObjectsMapEntry] /// The "private `semantics` field" of RTO5c1b1b. internal var semantics: WireEnum? @@ -361,7 +361,7 @@ internal final class InternalDefaultLiveMap: Sendable { liveObject.createOperationIsMerged = false // RTLM6c: Set data to ObjectState.map.entries, or to an empty map if it does not exist - data = state.map?.entries ?? [:] + data = state.map?.entries?.mapValues { .init(objectsMapEntry: $0) } ?? [:] // RTLM6d: If ObjectState.createOp is present, merge the initial value into the LiveMap as described in RTLM17 return if let createOp = state.createOp { @@ -527,7 +527,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM7b: If an entry does not exist in the private data for the specified key // RTLM7b1: Create a new entry in data for the specified key with the provided ObjectData and the operation's serial // RTLM7b2: Set ObjectsMapEntry.tombstone for the new entry to false - data[key] = ObjectsMapEntry(tombstone: false, timeserial: operationTimeserial, data: operationData) + data[key] = InternalObjectsMapEntry(tombstone: false, timeserial: operationTimeserial, data: operationData) } // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute @@ -563,7 +563,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM8b: If an entry does not exist in the private data for the specified key // RTLM8b1: Create a new entry in data for the specified key, with ObjectsMapEntry.data set to undefined/null and the operation's serial // RTLM8b2: Set ObjectsMapEntry.tombstone for the new entry to true - data[key] = ObjectsMapEntry(tombstone: true, timeserial: operationTimeserial, data: ObjectData()) + data[key] = InternalObjectsMapEntry(tombstone: true, timeserial: operationTimeserial, data: ObjectData()) } return .update(DefaultLiveMapUpdate(update: [key: .removed])) @@ -647,9 +647,9 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - Helper Methods - /// Converts an ObjectsMapEntry to LiveMapValue using the same logic as get(key:) + /// Converts an InternalObjectsMapEntry to LiveMapValue using the same logic as get(key:) /// This is used by entries to ensure consistent value conversion - private func convertEntryToLiveMapValue(_ entry: ObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> InternalLiveMapValue? { + private func convertEntryToLiveMapValue(_ entry: InternalObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> InternalLiveMapValue? { // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null // This is also equivalent to the RTLM14 check if entry.tombstone == true { diff --git a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift new file mode 100644 index 000000000..435dec232 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift @@ -0,0 +1,14 @@ +/// The entries stored in a `LiveMap`'s data. Same as an `ObjectsMapEntry` but with an additional `tombstonedAt` property, per RTLM3a. (This property will be added in an upcoming commit.) +internal struct InternalObjectsMapEntry { + internal var tombstone: Bool? // OME2a + internal var timeserial: String? // OME2b + internal var data: ObjectData // OME2c +} + +internal extension InternalObjectsMapEntry { + init(objectsMapEntry: ObjectsMapEntry) { + tombstone = objectsMapEntry.tombstone + timeserial = objectsMapEntry.timeserial + data = objectsMapEntry.data + } +} diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index c82df9e96..424e06ef6 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -404,6 +404,21 @@ struct TestFactories { ) } + /// Creates an InternalObjectsMapEntry with sensible defaults + /// + /// This should be kept in sync with ``mapEntry``. + static func internalMapEntry( + tombstone: Bool? = false, + timeserial: String? = "ts1", + data: ObjectData, + ) -> InternalObjectsMapEntry { + InternalObjectsMapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: data, + ) + } + /// Creates a map entry with string data static func stringMapEntry( key: String = "testKey", @@ -421,6 +436,25 @@ struct TestFactories { ) } + /// Creates an internal map entry with string data + /// + /// This should be kept in sync with ``stringMapEntry``. + static func internalStringMapEntry( + key: String = "testKey", + value: String = "testValue", + tombstone: Bool? = false, + timeserial: String? = "ts1", + ) -> (key: String, entry: InternalObjectsMapEntry) { + ( + key: key, + entry: internalMapEntry( + tombstone: tombstone, + timeserial: timeserial, + data: ObjectData(string: .string(value)), + ), + ) + } + /// Creates a map entry with number data static func numberMapEntry( key: String = "testKey", diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 2cdefbf81..081e13f9b 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -38,7 +38,7 @@ struct InternalDefaultLiveMapTests { @Test func returnsNilWhenEntryIsTombstoned() throws { let logger = TestLogger() - let entry = TestFactories.mapEntry( + let entry = TestFactories.internalMapEntry( tombstone: true, data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) @@ -51,7 +51,7 @@ struct InternalDefaultLiveMapTests { @Test func returnsBooleanValue() throws { let logger = TestLogger() - let entry = TestFactories.mapEntry(data: ObjectData(boolean: true)) + let entry = TestFactories.internalMapEntry(data: ObjectData(boolean: true)) let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) @@ -63,7 +63,7 @@ struct InternalDefaultLiveMapTests { func returnsBytesValue() throws { let logger = TestLogger() let bytes = Data([0x01, 0x02, 0x03]) - let entry = TestFactories.mapEntry(data: ObjectData(bytes: bytes)) + let entry = TestFactories.internalMapEntry(data: ObjectData(bytes: bytes)) let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) @@ -74,7 +74,7 @@ struct InternalDefaultLiveMapTests { @Test func returnsNumberValue() throws { let logger = TestLogger() - let entry = TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 123.456))) + let entry = TestFactories.internalMapEntry(data: ObjectData(number: NSNumber(value: 123.456))) let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) @@ -85,7 +85,7 @@ struct InternalDefaultLiveMapTests { @Test func returnsStringValue() throws { let logger = TestLogger() - let entry = TestFactories.mapEntry(data: ObjectData(string: .string("test"))) + let entry = TestFactories.internalMapEntry(data: ObjectData(string: .string("test"))) let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) @@ -96,7 +96,7 @@ struct InternalDefaultLiveMapTests { @Test func returnsNilWhenReferencedObjectDoesNotExist() throws { let logger = TestLogger() - let entry = TestFactories.mapEntry(data: ObjectData(objectId: "missing")) + let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: "missing")) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) @@ -108,7 +108,7 @@ struct InternalDefaultLiveMapTests { func returnsReferencedMap() throws { let logger = TestLogger() let objectId = "map1" - let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) + let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) @@ -124,7 +124,7 @@ struct InternalDefaultLiveMapTests { func returnsReferencedCounter() throws { let logger = TestLogger() let objectId = "counter1" - let entry = TestFactories.mapEntry(data: ObjectData(objectId: objectId)) + let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) @@ -139,7 +139,7 @@ struct InternalDefaultLiveMapTests { @Test func returnsNullOtherwise() throws { let logger = TestLogger() - let entry = TestFactories.mapEntry(data: ObjectData()) + let entry = TestFactories.internalMapEntry(data: ObjectData()) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) @@ -294,11 +294,11 @@ struct InternalDefaultLiveMapTests { let map = InternalDefaultLiveMap( testsOnly_data: [ // tombstone is nil, so not considered tombstoned - "active1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))), + "active1": TestFactories.internalMapEntry(data: ObjectData(string: .string("value1"))), // tombstone is false, so not considered tombstoned[ - "active2": TestFactories.mapEntry(tombstone: false, data: ObjectData(string: .string("value2"))), - "tombstoned": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned"))), - "tombstoned2": TestFactories.mapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned2"))), + "active2": TestFactories.internalMapEntry(tombstone: false, data: ObjectData(string: .string("value2"))), + "tombstoned": TestFactories.internalMapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned"))), + "tombstoned2": TestFactories.internalMapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned2"))), ], objectID: "arbitrary", logger: logger, @@ -339,9 +339,9 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let map = InternalDefaultLiveMap( testsOnly_data: [ - "key1": TestFactories.mapEntry(data: ObjectData(string: .string("value1"))), - "key2": TestFactories.mapEntry(data: ObjectData(string: .string("value2"))), - "key3": TestFactories.mapEntry(data: ObjectData(string: .string("value3"))), + "key1": TestFactories.internalMapEntry(data: ObjectData(string: .string("value1"))), + "key2": TestFactories.internalMapEntry(data: ObjectData(string: .string("value2"))), + "key3": TestFactories.internalMapEntry(data: ObjectData(string: .string("value3"))), ], objectID: "arbitrary", logger: logger, @@ -383,12 +383,12 @@ struct InternalDefaultLiveMapTests { let map = InternalDefaultLiveMap( testsOnly_data: [ - "boolean": TestFactories.mapEntry(data: ObjectData(boolean: true)), // RTLM5d2b - "bytes": TestFactories.mapEntry(data: ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c - "number": TestFactories.mapEntry(data: ObjectData(number: NSNumber(value: 42))), // RTLM5d2d - "string": TestFactories.mapEntry(data: ObjectData(string: .string("hello"))), // RTLM5d2e - "mapRef": TestFactories.mapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 - "counterRef": TestFactories.mapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 + "boolean": TestFactories.internalMapEntry(data: ObjectData(boolean: true)), // RTLM5d2b + "bytes": TestFactories.internalMapEntry(data: ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c + "number": TestFactories.internalMapEntry(data: ObjectData(number: NSNumber(value: 42))), // RTLM5d2d + "string": TestFactories.internalMapEntry(data: ObjectData(string: .string("hello"))), // RTLM5d2e + "mapRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 + "counterRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 ], objectID: "arbitrary", logger: logger, @@ -434,7 +434,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, @@ -473,7 +473,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, @@ -642,7 +642,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, @@ -667,7 +667,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.mapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, @@ -791,7 +791,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.mapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, @@ -849,7 +849,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.stringMapEntry().entry], + testsOnly_data: ["key1": TestFactories.internalStringMapEntry().entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, @@ -881,8 +881,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let map = InternalDefaultLiveMap( testsOnly_data: [ - "keyThatWillBeRemoved": TestFactories.stringMapEntry(timeserial: "ts1").entry, - "keyThatWillNotBeRemoved": TestFactories.stringMapEntry(timeserial: "ts1").entry, + "keyThatWillBeRemoved": TestFactories.internalStringMapEntry(timeserial: "ts1").entry, + "keyThatWillNotBeRemoved": TestFactories.internalStringMapEntry(timeserial: "ts1").entry, ], objectID: "arbitrary", logger: logger, From 19980574317d596cabe3f6ce4eaf48f86af3309b Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 18 Jul 2025 14:35:47 -0300 Subject: [PATCH 064/225] Inject a clock into our LiveObjects We'll use this when setting the upcoming tombstonedAt value for objects and map entries. This was all generated by Cursor; my only change was to add some locking into the mock class. --- .../Internal/DefaultInternalPlugin.swift | 2 +- .../Internal/InternalDefaultLiveCounter.swift | 12 +- .../Internal/InternalDefaultLiveMap.swift | 24 +++- .../InternalDefaultRealtimeObjects.swift | 17 ++- .../Internal/ObjectsPool.swift | 17 ++- .../Internal/SimpleClock.swift | 19 +++ .../InternalDefaultLiveCounterTests.swift | 42 +++---- .../InternalDefaultLiveMapTests.swift | 116 ++++++++++-------- .../InternalDefaultRealtimeObjectsTests.swift | 2 +- .../Mocks/MockSimpleClock.swift | 39 ++++++ .../ObjectsPoolTests.swift | 72 +++++------ 11 files changed, 238 insertions(+), 124 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/SimpleClock.swift create mode 100644 Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 695e92cca..9dd56e0fe 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -38,7 +38,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte let callbackQueue = pluginAPI.callbackQueue(for: client) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) - let liveObjects = InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: callbackQueue) + let liveObjects = InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: callbackQueue, clock: DefaultSimpleClock()) pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index de9e9696b..911022dd8 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -29,6 +29,7 @@ internal final class InternalDefaultLiveCounter: Sendable { private let logger: AblyPlugin.Logger private let userCallbackQueue: DispatchQueue + private let clock: SimpleClock // MARK: - Initialization @@ -36,20 +37,23 @@ internal final class InternalDefaultLiveCounter: Sendable { testsOnly_data data: Double, objectID: String, logger: AblyPlugin.Logger, - userCallbackQueue: DispatchQueue + userCallbackQueue: DispatchQueue, + clock: SimpleClock ) { - self.init(data: data, objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue) + self.init(data: data, objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) } private init( data: Double, objectID: String, logger: AblyPlugin.Logger, - userCallbackQueue: DispatchQueue + userCallbackQueue: DispatchQueue, + clock: SimpleClock ) { mutableState = .init(liveObject: .init(objectID: objectID), data: data) self.logger = logger self.userCallbackQueue = userCallbackQueue + self.clock = clock } /// Creates a "zero-value LiveCounter", per RTLC4. @@ -60,12 +64,14 @@ internal final class InternalDefaultLiveCounter: Sendable { objectID: String, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) -> Self { .init( data: 0, objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 59a72f46d..d31383d03 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -46,6 +46,7 @@ internal final class InternalDefaultLiveMap: Sendable { private let logger: AblyPlugin.Logger private let userCallbackQueue: DispatchQueue + private let clock: SimpleClock // MARK: - Initialization @@ -55,6 +56,7 @@ internal final class InternalDefaultLiveMap: Sendable { testsOnly_semantics semantics: WireEnum? = nil, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { self.init( data: data, @@ -62,6 +64,7 @@ internal final class InternalDefaultLiveMap: Sendable { semantics: semantics, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } @@ -71,10 +74,12 @@ internal final class InternalDefaultLiveMap: Sendable { semantics: WireEnum?, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { mutableState = .init(liveObject: .init(objectID: objectID), data: data, semantics: semantics) self.logger = logger self.userCallbackQueue = userCallbackQueue + self.clock = clock } /// Creates a "zero-value LiveMap", per RTLM4. @@ -87,6 +92,7 @@ internal final class InternalDefaultLiveMap: Sendable { semantics: WireEnum? = nil, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) -> Self { .init( data: [:], @@ -94,6 +100,7 @@ internal final class InternalDefaultLiveMap: Sendable { semantics: semantics, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } @@ -244,6 +251,7 @@ internal final class InternalDefaultLiveMap: Sendable { using: state, objectsPool: &objectsPool, logger: logger, + clock: clock, userCallbackQueue: userCallbackQueue, ) } @@ -257,6 +265,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -269,6 +278,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -288,6 +298,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -309,6 +320,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -352,6 +364,7 @@ internal final class InternalDefaultLiveMap: Sendable { using state: ObjectState, objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, + clock: SimpleClock, userCallbackQueue: DispatchQueue, ) -> LiveObjectUpdate { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials @@ -370,6 +383,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } else { // TODO: I assume this is what to do, clarify in https://github.com/ably/specification/pull/346/files#r2201363446 @@ -383,6 +397,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) -> LiveObjectUpdate { // RTLM17a: For each key–ObjectsMapEntry pair in ObjectOperation.map.entries let perKeyUpdates: [LiveObjectUpdate] = if let entries = operation.map?.entries { @@ -404,6 +419,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -439,6 +455,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { // RTLM15b @@ -457,6 +474,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) // RTLM15d1a liveObject.emit(update, on: userCallbackQueue) @@ -478,6 +496,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) // RTLM15d2a liveObject.emit(update, on: userCallbackQueue) @@ -507,6 +526,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) -> LiveObjectUpdate { // RTLM7a: If an entry exists in the private data for the specified key if let existingEntry = data[key] { @@ -533,7 +553,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute if let objectId = operationData.objectId, !objectId.isEmpty { // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 - _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger, userCallbackQueue: userCallbackQueue) + _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) } // RTLM7f @@ -615,6 +635,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) -> LiveObjectUpdate { if liveObject.createOperationIsMerged { // RTLM16b @@ -630,6 +651,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 3bc22e6a8..2b5439aac 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -10,6 +10,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool private let logger: AblyPlugin.Logger private let userCallbackQueue: DispatchQueue + private let clock: SimpleClock // These drive the testsOnly_* properties that expose the received ProtocolMessages to the test suite. private let receivedObjectProtocolMessages: AsyncStream<[InboundObjectMessage]> @@ -70,13 +71,14 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } } - internal init(logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue) { + internal init(logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock) { self.logger = logger self.userCallbackQueue = userCallbackQueue + self.clock = clock (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() - mutableState = .init(objectsPool: .init(logger: logger, userCallbackQueue: userCallbackQueue)) + mutableState = .init(objectsPool: .init(logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) } // MARK: - LiveMapObjectPoolDelegate @@ -175,6 +177,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessages: objectMessages, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, receivedObjectProtocolMessagesContinuation: receivedObjectProtocolMessagesContinuation, ) } @@ -192,6 +195,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool protocolMessageChannelSerial: protocolMessageChannelSerial, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, receivedObjectSyncProtocolMessagesContinuation: receivedObjectSyncProtocolMessagesContinuation, ) } @@ -202,7 +206,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool /// Intended as a way for tests to populate the object pool. internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String) -> ObjectsPool.Entry? { mutex.withLock { - mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue) + mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) } } @@ -264,6 +268,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool protocolMessageChannelSerial: String?, logger: Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, ) { logger.log("handleObjectSyncProtocolMessage(objectMessages: \(objectMessages), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) @@ -321,6 +326,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool completedSyncObjectsPool, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) // RTO5c6 @@ -331,6 +337,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessage, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -347,6 +354,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessages: [InboundObjectMessage], logger: Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, ) { receivedObjectProtocolMessagesContinuation.yield(objectMessages) @@ -366,6 +374,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool objectMessage, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) } } @@ -376,6 +385,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool _ objectMessage: InboundObjectMessage, logger: Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { guard let operation = objectMessage.operation else { // RTO9a1 @@ -392,6 +402,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool forObjectID: operation.objectId, logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, ) else { logger.log("Unable to create zero-value object for \(operation.objectId) when processing OBJECT message; dropping", level: .warn) return diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 135ea7922..f45ac9a1a 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -97,11 +97,13 @@ internal struct ObjectsPool { internal init( logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, testsOnly_otherEntries otherEntries: [String: Entry]? = nil, ) { self.init( logger: logger, userCallbackQueue: userCallbackQueue, + clock: clock, otherEntries: otherEntries, ) } @@ -109,11 +111,12 @@ internal struct ObjectsPool { private init( logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, otherEntries: [String: Entry]? ) { entries = otherEntries ?? [:] // TODO: What initial root entry to use? https://github.com/ably/specification/pull/333/files#r2152312933 - entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, logger: logger, userCallbackQueue: userCallbackQueue)) + entries[Self.rootKey] = .map(.createZeroValued(objectID: Self.rootKey, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) } // MARK: - Typed root @@ -140,8 +143,9 @@ internal struct ObjectsPool { /// - objectID: The ID of the object to create /// - logger: The logger to use for any created LiveObject /// - userCallbackQueue: The callback queue to use for any created LiveObject + /// - clock: The clock to use for any created LiveObject /// - Returns: The existing or newly created object - internal mutating func createZeroValueObject(forObjectID objectID: String, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue) -> Entry? { + internal mutating func createZeroValueObject(forObjectID objectID: String, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock) -> Entry? { // RTO6a: If an object with objectId exists in ObjectsPool, do not create a new object if let existingEntry = entries[objectID] { return existingEntry @@ -159,9 +163,9 @@ internal struct ObjectsPool { let entry: Entry switch typeString { case "map": - entry = .map(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue)) + entry = .map(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) case "counter": - entry = .counter(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue)) + entry = .counter(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) default: return nil } @@ -176,6 +180,7 @@ internal struct ObjectsPool { _ syncObjectsPool: [ObjectState], logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, + clock: SimpleClock, ) { logger.log("applySyncObjectsPool called with \(syncObjectsPool.count) objects", level: .debug) @@ -207,14 +212,14 @@ internal struct ObjectsPool { if objectState.counter != nil { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger, userCallbackQueue: userCallbackQueue) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) _ = counter.replaceData(using: objectState) newEntry = .counter(counter) } else if let objectsMap = objectState.map { // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, // set its private objectId equal to ObjectState.objectId, set its private semantics // equal to ObjectState.map.semantics and override its internal data per RTLM6 - let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue) + let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) _ = map.replaceData(using: objectState, objectsPool: &self) newEntry = .map(map) } else { diff --git a/Sources/AblyLiveObjects/Internal/SimpleClock.swift b/Sources/AblyLiveObjects/Internal/SimpleClock.swift new file mode 100644 index 000000000..bc6c0faa0 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/SimpleClock.swift @@ -0,0 +1,19 @@ +import Foundation + +/// A simple clock interface for getting the current time. +/// +/// This protocol allows for dependency injection of time-related functionality, +/// making it easier to test time-dependent code. +internal protocol SimpleClock: Sendable { + /// Returns the current time as a Date. + var now: Date { get } +} + +/// The default implementation of SimpleClock that uses the system clock. +internal final class DefaultSimpleClock: SimpleClock { + internal init() {} + + internal var now: Date { + Date() + } +} diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 83e85728c..4b0830a54 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -10,7 +10,7 @@ struct InternalDefaultLiveCounterTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func valueThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: channelState) #expect { @@ -28,7 +28,7 @@ struct InternalDefaultLiveCounterTests { @Test func valueReturnsCurrentDataWhenChannelIsValid() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attached) // Set some test data @@ -44,7 +44,7 @@ struct InternalDefaultLiveCounterTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) @@ -60,7 +60,7 @@ struct InternalDefaultLiveCounterTests { // Given: A counter whose createOperationIsMerged is true let logger = TestLogger() let counter = { - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Test setup: Manipulate counter so that its createOperationIsMerged gets set to true (we need to do this since we want to later assert that it gets set to false, but the default is false). let state = TestFactories.counterObjectState( createOp: TestFactories.objectOperation( @@ -87,7 +87,7 @@ struct InternalDefaultLiveCounterTests { @Test func setsDataToCounterCount() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) let state = TestFactories.counterObjectState( count: 42, // Test value @@ -100,7 +100,7 @@ struct InternalDefaultLiveCounterTests { @Test func setsDataToZeroWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) _ = counter.replaceData(using: TestFactories.counterObjectState( count: nil, // Test value - must be nil @@ -115,7 +115,7 @@ struct InternalDefaultLiveCounterTests { @Test func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) let state = TestFactories.counterObjectState( createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist @@ -135,7 +135,7 @@ struct InternalDefaultLiveCounterTests { @Test func addsCounterCountToData() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data @@ -157,7 +157,7 @@ struct InternalDefaultLiveCounterTests { @Test func doesNotModifyDataWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data @@ -181,7 +181,7 @@ struct InternalDefaultLiveCounterTests { @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist @@ -197,7 +197,7 @@ struct InternalDefaultLiveCounterTests { @Test func discardsOperationWhenCreateOperationIsMerged() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data and mark create operation as merged @@ -221,7 +221,7 @@ struct InternalDefaultLiveCounterTests { @Test func mergesInitialValue() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data but don't mark create operation as merged @@ -262,7 +262,7 @@ struct InternalDefaultLiveCounterTests { ) func addsAmountToData(operation: WireObjectsCounterOp?, expectedValue: Double, expectedUpdate: LiveObjectUpdate) throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data @@ -286,7 +286,7 @@ struct InternalDefaultLiveCounterTests { @Test func discardsOperationWhenCannotBeApplied() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) // Set up the counter with an existing site timeserial that will cause the operation to be discarded @@ -299,7 +299,7 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterInc), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply operation with serial "ts1" which is lexicographically less than existing "ts2" and thus will be applied per RTLO4a (this is a non-pathological case of RTOL4a, that spec point being fully tested elsewhere) counter.apply( @@ -323,14 +323,14 @@ struct InternalDefaultLiveCounterTests { @Test func appliesCounterCreateOperation() async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) let subscriber = Subscriber(callbackQueue: .main) try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) let operation = TestFactories.counterCreateOperation(count: 15) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply COUNTER_CREATE operation counter.apply( @@ -358,7 +358,7 @@ struct InternalDefaultLiveCounterTests { @Test func appliesCounterIncOperation() async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) let subscriber = Subscriber(callbackQueue: .main) @@ -372,7 +372,7 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterInc), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply COUNTER_INC operation counter.apply( @@ -397,14 +397,14 @@ struct InternalDefaultLiveCounterTests { @Test func noOpForOtherOperation() async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) let subscriber = Subscriber(callbackQueue: .main) try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) // Try to apply a MAP_CREATE to the counter (not supported) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) counter.apply( TestFactories.mapCreateOperation(), objectMessageSerial: "ts1", diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 081e13f9b..22828ca08 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -10,7 +10,7 @@ struct InternalDefaultLiveMapTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func getThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect { _ = try map.get(key: "test", coreSDK: MockCoreSDK(channelState: channelState), delegate: MockLiveMapObjectPoolDelegate()) @@ -30,7 +30,7 @@ struct InternalDefaultLiveMapTests { func returnsNilWhenNoEntryExists() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect(try map.get(key: "nonexistent", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) } @@ -43,7 +43,7 @@ struct InternalDefaultLiveMapTests { data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) } @@ -53,7 +53,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.internalMapEntry(data: ObjectData(boolean: true)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.boolValue == true) } @@ -65,7 +65,7 @@ struct InternalDefaultLiveMapTests { let bytes = Data([0x01, 0x02, 0x03]) let entry = TestFactories.internalMapEntry(data: ObjectData(bytes: bytes)) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.dataValue == bytes) } @@ -76,7 +76,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.internalMapEntry(data: ObjectData(number: NSNumber(value: 123.456))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.numberValue == 123.456) } @@ -87,7 +87,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.internalMapEntry(data: ObjectData(string: .string("test"))) let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.stringValue == "test") } @@ -99,7 +99,7 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: "missing")) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) } @@ -111,9 +111,9 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) delegate.objects[objectId] = .map(referencedMap) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) let returnedMap = result?.liveMapValue #expect(returnedMap as AnyObject === referencedMap as AnyObject) @@ -127,9 +127,9 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) delegate.objects[objectId] = .counter(referencedCounter) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) let returnedCounter = result?.liveCounterValue #expect(returnedCounter as AnyObject === referencedCounter as AnyObject) @@ -142,7 +142,7 @@ struct InternalDefaultLiveMapTests { let entry = TestFactories.internalMapEntry(data: ObjectData()) let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: delegate) == nil) } } @@ -153,12 +153,12 @@ struct InternalDefaultLiveMapTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let state = TestFactories.objectState( objectId: "arbitrary-id", siteTimeserials: ["site1": "ts1", "site2": "ts2"], ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) _ = map.replaceData(using: state, objectsPool: &pool) #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) } @@ -168,9 +168,9 @@ struct InternalDefaultLiveMapTests { func setsCreateOperationIsMergedToFalseWhenCreateOpAbsent() { // Given: let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let map = { - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Test setup: Manipulate map so that its createOperationIsMerged gets set to true (we need to do this since we want to later assert that it gets set to false, but the default is false). let state = TestFactories.objectState( @@ -196,13 +196,13 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "test") let state = TestFactories.mapObjectState( objectId: "arbitrary-id", entries: [key: entry], ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) _ = map.replaceData(using: state, objectsPool: &pool) let newData = map.testsOnly_data #expect(newData.count == 1) @@ -217,7 +217,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let state = TestFactories.objectState( objectId: "arbitrary-id", createOp: TestFactories.mapCreateOperation( @@ -233,7 +233,7 @@ struct InternalDefaultLiveMapTests { ], ), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) _ = map.replaceData(using: state, objectsPool: &pool) // Note that we just check for some basic expected side effects of merging the initial value; RTLM17 is tested in more detail elsewhere // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d) @@ -254,7 +254,7 @@ struct InternalDefaultLiveMapTests { @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: channelState) let delegate = MockLiveMapObjectPoolDelegate() @@ -303,6 +303,7 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) // Test size - should only count non-tombstoned entries @@ -346,6 +347,7 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) let size = try map.size(coreSDK: coreSDK) @@ -376,8 +378,8 @@ struct InternalDefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Create referenced objects for testing - let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) delegate.objects["map:ref@123"] = .map(referencedMap) delegate.objects["counter:ref@456"] = .counter(referencedCounter) @@ -393,6 +395,7 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) let size = try map.size(coreSDK: coreSDK) @@ -438,8 +441,9 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Try to apply operation with lower timeserial (ts1 < ts2) let update = map.testsOnly_applyMapSetOperation( @@ -477,8 +481,9 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let update = map.testsOnly_applyMapSetOperation( key: "key1", @@ -544,8 +549,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let update = map.testsOnly_applyMapSetOperation( key: "newKey", @@ -594,7 +599,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Create an existing object in the pool with some data let existingObjectId = "map:existing@123" @@ -603,10 +608,12 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) var pool = ObjectsPool( logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), testsOnly_otherEntries: [existingObjectId: .map(existingObject)], ) // Populate the delegate so that when we "verify the MAP_SET operation was applied correctly" using map.get below it returns the referenced object @@ -646,6 +653,7 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 @@ -671,6 +679,7 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 @@ -706,7 +715,7 @@ struct InternalDefaultLiveMapTests { @Test func createsNewEntryWhenNoExistingEntry() throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let update = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -728,7 +737,7 @@ struct InternalDefaultLiveMapTests { @Test func setsNewEntryTombstoneToTrue() throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) _ = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") @@ -795,8 +804,9 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) _ = map.testsOnly_applyMapSetOperation( key: "key1", @@ -825,8 +835,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply merge operation with MAP_SET entries let operation = TestFactories.mapCreateOperation( @@ -853,8 +863,9 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Confirm that the initial data is there #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) != nil) @@ -887,8 +898,9 @@ struct InternalDefaultLiveMapTests { objectID: "arbitrary", logger: logger, userCallbackQueue: .main, + clock: MockSimpleClock(), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply merge operation with MAP_CREATE and MAP_REMOVE entries (copied from RTLM17a1 and RTLM17a2 test cases) let operation = TestFactories.mapCreateOperation( @@ -917,8 +929,8 @@ struct InternalDefaultLiveMapTests { @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply merge operation let operation = TestFactories.mapCreateOperation(objectId: "arbitrary-id") @@ -936,8 +948,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Set initial data and mark create operation as merged _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) @@ -964,8 +976,8 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Set initial data but don't mark create operation as merged _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) @@ -993,10 +1005,10 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Set up the map with an existing site timeserial that will cause the operation to be discarded - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) _ = map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" @@ -1032,7 +1044,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let subscriber = Subscriber(callbackQueue: .main) try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) @@ -1040,7 +1052,7 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.mapCreateOperation( entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "value1").entry], ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply MAP_CREATE operation map.apply( @@ -1070,13 +1082,13 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let subscriber = Subscriber(callbackQueue: .main) try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) // Set initial data - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) _ = map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], @@ -1116,13 +1128,13 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let subscriber = Subscriber(callbackQueue: .main) try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) // Set initial data - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) _ = map.replaceData(using: TestFactories.mapObjectState( siteTimeserials: [:], @@ -1158,14 +1170,14 @@ struct InternalDefaultLiveMapTests { @Test func noOpForOtherOperation() async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attaching) let subscriber = Subscriber(callbackQueue: .main) try map.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) // Try to apply a COUNTER_CREATE to the map (not supported) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) map.apply( TestFactories.counterCreateOperation(), objectMessageSerial: "ts1", diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 38e404449..82f177cb8 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -10,7 +10,7 @@ struct InternalDefaultRealtimeObjectsTests { /// Creates a InternalDefaultRealtimeObjects instance for testing static func createDefaultRealtimeObjects() -> InternalDefaultRealtimeObjects { let logger = TestLogger() - return InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: .main) + return InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) } /// Tests for `InternalDefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift b/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift new file mode 100644 index 000000000..8e1fc1ef4 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift @@ -0,0 +1,39 @@ +@testable import AblyLiveObjects +import Foundation + +/// A mock implementation of SimpleClock for testing purposes. +final class MockSimpleClock: SimpleClock { + private let mutex = NSLock() + + /// The current time that this mock clock will return. + private nonisolated(unsafe) var currentTime: Date + + /// Creates a new MockSimpleClock with the specified current time. + /// - Parameter currentTime: The time that this clock should return. Defaults to the current system time. + init(currentTime: Date = Date()) { + self.currentTime = currentTime + } + + /// Returns the current time set for this mock clock. + var now: Date { + mutex.withLock { + currentTime + } + } + + /// Updates the current time of this mock clock. + /// - Parameter newTime: The new time to set. + func setTime(_ newTime: Date) { + mutex.withLock { + currentTime = newTime + } + } + + /// Advances the clock by the specified time interval. + /// - Parameter interval: The time interval to advance by. + func advance(by interval: TimeInterval) { + mutex.withLock { + currentTime = currentTime.addingTimeInterval(interval) + } + } +} diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 8b3d14d57..eb3b8321c 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -9,10 +9,10 @@ struct ObjectsPoolTests { @Test func returnsExistingObject() throws { let logger = TestLogger() - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, userCallbackQueue: .main) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let map = try #require(result?.mapValue) #expect(map as AnyObject === existingMap as AnyObject) } @@ -21,9 +21,9 @@ struct ObjectsPoolTests { @Test func createsZeroValueMap() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, userCallbackQueue: .main) + let result = pool.createZeroValueObject(forObjectID: "map:123@456", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let map = try #require(result?.mapValue) // Verify it was added to the pool @@ -38,9 +38,9 @@ struct ObjectsPoolTests { func createsZeroValueCounter() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = pool.createZeroValueObject(forObjectID: "counter:123@456", logger: logger, userCallbackQueue: .main) + let result = pool.createZeroValueObject(forObjectID: "counter:123@456", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let counter = try #require(result?.counterValue) #expect(try counter.value(coreSDK: coreSDK) == 0) @@ -54,9 +54,9 @@ struct ObjectsPoolTests { @Test func returnsNilForInvalidObjectId() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = pool.createZeroValueObject(forObjectID: "invalid", logger: logger, userCallbackQueue: .main) + let result = pool.createZeroValueObject(forObjectID: "invalid", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect(result == nil) } @@ -64,9 +64,9 @@ struct ObjectsPoolTests { @Test func returnsNilForUnknownType() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", logger: logger, userCallbackQueue: .main) + let result = pool.createZeroValueObject(forObjectID: "unknown:123@456", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) #expect(result == nil) #expect(pool.entries["unknown:123@456"] == nil) } @@ -85,10 +85,10 @@ struct ObjectsPoolTests { let logger = TestLogger() let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let existingMapSubscriber = Subscriber(callbackQueue: .main) try existingMap.subscribe(listener: existingMapSubscriber.createListener(), coreSDK: coreSDK) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) let (key, entry) = TestFactories.stringMapEntry(key: "key1", value: "updated_value") let objectState = TestFactories.mapObjectState( @@ -100,7 +100,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify the existing map was updated by checking side effects of InternalDefaultLiveMap.replaceData(using:) let updatedMap = try #require(pool.entries["map:hash@123"]?.mapValue) @@ -123,10 +123,10 @@ struct ObjectsPoolTests { func updatesExistingCounterObject() async throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let existingCounterSubscriber = Subscriber(callbackQueue: .main) try existingCounter.subscribe(listener: existingCounterSubscriber.createListener(), coreSDK: coreSDK) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@123", @@ -135,7 +135,7 @@ struct ObjectsPoolTests { count: 10, ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify the existing counter was updated by checking side effects of InternalDefaultLiveCounter.replaceData(using:) let updatedCounter = try #require(pool.entries["counter:hash@123"]?.counterValue) @@ -155,7 +155,7 @@ struct ObjectsPoolTests { func createsNewCounterObject() throws { let logger = TestLogger() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let objectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -163,7 +163,7 @@ struct ObjectsPoolTests { count: 100, ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify a new counter was created and data was set by checking side effects of InternalDefaultLiveCounter.replaceData(using:) let newCounter = try #require(pool.entries["counter:hash@456"]?.counterValue) @@ -182,7 +182,7 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let (key, entry) = TestFactories.stringMapEntry(key: "key2", value: "new_value") let objectState = TestFactories.mapObjectState( @@ -191,7 +191,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify a new map was created and data was set by checking side effects of InternalDefaultLiveMap.replaceData(using:) let newMap = try #require(pool.entries["map:hash@789"]?.mapValue) @@ -208,7 +208,7 @@ struct ObjectsPoolTests { @Test func ignoresNonMapOrCounterObject() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let validObjectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -218,7 +218,7 @@ struct ObjectsPoolTests { let invalidObjectState = TestFactories.objectState(objectId: "invalid") - pool.applySyncObjectsPool([invalidObjectState, validObjectState], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([invalidObjectState, validObjectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Check that there's no entry for the key that we don't know how to handle, and that it didn't interfere with the insertion of the we one that we do know how to handle #expect(Set(pool.entries.keys) == ["root", "counter:hash@456"]) @@ -230,11 +230,11 @@ struct ObjectsPoolTests { @Test func removesObjectsNotInSync() throws { let logger = TestLogger() - let existingMap1 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - let existingMap2 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingMap1 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let existingMap2 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: [ + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: [ "map:hash@1": .map(existingMap1), "map:hash@2": .map(existingMap2), "counter:hash@1": .counter(existingCounter), @@ -243,7 +243,7 @@ struct ObjectsPoolTests { // Only sync one of the existing objects let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify only synced object and root remain #expect(pool.entries.count == 2) // root + map:hash@1 @@ -257,11 +257,11 @@ struct ObjectsPoolTests { @Test func doesNotRemoveRootObject() throws { let logger = TestLogger() - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) // Sync with empty list (no objects) - pool.applySyncObjectsPool([], logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool([], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify root is preserved but other objects are removed #expect(pool.entries.count == 1) // Only root @@ -277,16 +277,16 @@ struct ObjectsPoolTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) - let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main) + let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let existingMapSubscriber = Subscriber(callbackQueue: .main) try existingMap.subscribe(listener: existingMapSubscriber.createListener(), coreSDK: coreSDK) let existingCounterSubscriber = Subscriber(callbackQueue: .main) try existingCounter.subscribe(listener: existingCounterSubscriber.createListener(), coreSDK: coreSDK) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, testsOnly_otherEntries: [ + var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: [ "map:existing@1": .map(existingMap), "counter:existing@1": .counter(existingCounter), "map:toremove@1": .map(toBeRemovedMap), @@ -324,7 +324,7 @@ struct ObjectsPoolTests { // Note: "map:toremove@1" is not in sync, so it should be removed ] - pool.applySyncObjectsPool(syncObjects, logger: logger, userCallbackQueue: .main) + pool.applySyncObjectsPool(syncObjects, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify final state #expect(pool.entries.count == 5) // root + 4 synced objects From 94efe5a842d83d974e343d7beeeb25ff1d32a088 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 18 Jul 2025 16:01:16 -0300 Subject: [PATCH 065/225] Add new serialTimestamp fields Per [1] at 488e932. Preparation for implementing this tombstoning spec. [1] https://github.com/ably/specification/pull/350 --- .../AblyLiveObjects/Protocol/ObjectMessage.swift | 6 ++++++ .../Protocol/WireObjectMessage.swift | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index a15ea0db9..d83df1fae 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -14,6 +14,7 @@ internal struct InboundObjectMessage { internal var object: ObjectState? // OM2g internal var serial: String? // OM2h internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j } /// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. @@ -27,6 +28,7 @@ internal struct OutboundObjectMessage { internal var object: ObjectState? // OM2g internal var serial: String? // OM2h internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j } internal struct ObjectOperation { @@ -65,6 +67,7 @@ internal struct ObjectsMapEntry { internal var tombstone: Bool? // OME2a internal var timeserial: String? // OME2b internal var data: ObjectData // OME2c + internal var serialTimestamp: Date? // OME2d } internal struct ObjectsMap { @@ -104,6 +107,7 @@ internal extension InboundObjectMessage { } serial = wireObjectMessage.serial siteCode = wireObjectMessage.siteCode + serialTimestamp = wireObjectMessage.serialTimestamp } } @@ -123,6 +127,7 @@ internal extension OutboundObjectMessage { object: object?.toWire(format: format), serial: serial, siteCode: siteCode, + serialTimestamp: serialTimestamp, ) } } @@ -360,6 +365,7 @@ internal extension ObjectsMapEntry { tombstone = wireObjectsMapEntry.tombstone timeserial = wireObjectsMapEntry.timeserial data = try .init(wireObjectData: wireObjectsMapEntry.data, format: format) + serialTimestamp = wireObjectsMapEntry.serialTimestamp } /// Converts this `ObjectsMapEntry` to a `WireObjectsMapEntry`, applying the data encoding rules of OD4. diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 348cc4836..a562af2bf 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -15,6 +15,7 @@ internal struct InboundWireObjectMessage { internal var object: WireObjectState? // OM2g internal var serial: String? // OM2h internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j } /// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. @@ -28,6 +29,7 @@ internal struct OutboundWireObjectMessage { internal var object: WireObjectState? // OM2g internal var serial: String? // OM2h internal var siteCode: String? // OM2i + internal var serialTimestamp: Date? // OM2j } /// The keys for decoding an `InboundWireObjectMessage` or encoding an `OutboundWireObjectMessage`. @@ -41,6 +43,7 @@ internal enum WireObjectMessageWireKey: String { case object case serial case siteCode + case serialTimestamp } internal extension InboundWireObjectMessage { @@ -96,6 +99,7 @@ internal extension InboundWireObjectMessage { object = try wireObject.optionalDecodableValueForKey(WireObjectMessageWireKey.object.rawValue) serial = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.serial.rawValue) siteCode = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.siteCode.rawValue) + serialTimestamp = try wireObject.optionalAblyProtocolDateValueForKey(WireObjectMessageWireKey.serialTimestamp.rawValue) } } @@ -131,6 +135,9 @@ extension OutboundWireObjectMessage: WireObjectEncodable { if let object { result[WireObjectMessageWireKey.object.rawValue] = .object(object.toWireObject) } + if let serialTimestamp { + result[WireObjectMessageWireKey.serialTimestamp.rawValue] = .number(NSNumber(value: serialTimestamp.timeIntervalSince1970 * 1000)) + } return result } } @@ -386,6 +393,7 @@ internal struct WireObjectsMapEntry { internal var tombstone: Bool? // OME2a internal var timeserial: String? // OME2b internal var data: WireObjectData // OME2c + internal var serialTimestamp: Date? // OME2d } extension WireObjectsMapEntry: WireObjectCodable { @@ -393,12 +401,14 @@ extension WireObjectsMapEntry: WireObjectCodable { case tombstone case timeserial case data + case serialTimestamp } internal init(wireObject: [String: WireValue]) throws(InternalError) { tombstone = try wireObject.optionalBoolValueForKey(WireKey.tombstone.rawValue) timeserial = try wireObject.optionalStringValueForKey(WireKey.timeserial.rawValue) data = try wireObject.decodableValueForKey(WireKey.data.rawValue) + serialTimestamp = try wireObject.optionalAblyProtocolDateValueForKey(WireKey.serialTimestamp.rawValue) } internal var toWireObject: [String: WireValue] { @@ -412,6 +422,9 @@ extension WireObjectsMapEntry: WireObjectCodable { if let timeserial { result[WireKey.timeserial.rawValue] = .string(timeserial) } + if let serialTimestamp { + result[WireKey.serialTimestamp.rawValue] = .number(NSNumber(value: serialTimestamp.timeIntervalSince1970 * 1000)) + } return result } From 4724a0582d233b51e4abc7b9244f25ed49df11c9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 18 Jul 2025 16:50:06 -0300 Subject: [PATCH 066/225] Add placeholder protocol for polymorphic LiveObject functionality Preparation for adding the `tombstone` method from [1]. (This approach is a _bit_ weird but it's what I could think of that's compatible with the existing LiveObjectMutableState approach.) [1] https://github.com/ably/specification/pull/350 --- .../Internal/InternalDefaultLiveCounter.swift | 36 ++++++++--------- .../Internal/InternalDefaultLiveMap.swift | 40 +++++++++---------- .../Internal/InternalLiveObject.swift | 8 ++++ 3 files changed, 46 insertions(+), 38 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/InternalLiveObject.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 911022dd8..0c311272b 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -11,19 +11,19 @@ internal final class InternalDefaultLiveCounter: Sendable { internal var testsOnly_siteTimeserials: [String: String] { mutex.withLock { - mutableState.liveObject.siteTimeserials + mutableState.liveObjectMutableState.siteTimeserials } } internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { - mutableState.liveObject.createOperationIsMerged + mutableState.liveObjectMutableState.createOperationIsMerged } } internal var testsOnly_objectID: String { mutex.withLock { - mutableState.liveObject.objectID + mutableState.liveObjectMutableState.objectID } } @@ -50,7 +50,7 @@ internal final class InternalDefaultLiveCounter: Sendable { userCallbackQueue: DispatchQueue, clock: SimpleClock ) { - mutableState = .init(liveObject: .init(objectID: objectID), data: data) + mutableState = .init(liveObjectMutableState: .init(objectID: objectID), data: data) self.logger = logger self.userCallbackQueue = userCallbackQueue self.clock = clock @@ -106,13 +106,13 @@ internal final class InternalDefaultLiveCounter: Sendable { internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in // swiftlint:disable:next trailing_closure - try mutableState.liveObject.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + try mutableState.liveObjectMutableState.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in guard let self else { return } mutex.withLock { - action(&mutableState.liveObject) + action(&mutableState.liveObjectMutableState) } }) } @@ -120,7 +120,7 @@ internal final class InternalDefaultLiveCounter: Sendable { internal func unsubscribeAll() { mutex.withLock { - mutableState.liveObject.unsubscribeAll() + mutableState.liveObjectMutableState.unsubscribeAll() } } @@ -140,7 +140,7 @@ internal final class InternalDefaultLiveCounter: Sendable { /// This is used to instruct this counter to emit updates during an `OBJECT_SYNC`. internal func emit(_ update: LiveObjectUpdate) { mutex.withLock { - mutableState.liveObject.emit(update, on: userCallbackQueue) + mutableState.liveObjectMutableState.emit(update, on: userCallbackQueue) } } @@ -195,9 +195,9 @@ internal final class InternalDefaultLiveCounter: Sendable { // MARK: - Mutable state and the operations that affect it - private struct MutableState { + private struct MutableState: InternalLiveObject { /// The mutable state common to all LiveObjects. - internal var liveObject: LiveObjectMutableState + internal var liveObjectMutableState: LiveObjectMutableState /// The internal data that this map holds, per RTLC3. internal var data: Double @@ -205,10 +205,10 @@ internal final class InternalDefaultLiveCounter: Sendable { /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. internal mutating func replaceData(using state: ObjectState) -> LiveObjectUpdate { // RTLC6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials - liveObject.siteTimeserials = state.siteTimeserials + liveObjectMutableState.siteTimeserials = state.siteTimeserials // RTLC6b: Set the private flag createOperationIsMerged to false - liveObject.createOperationIsMerged = false + liveObjectMutableState.createOperationIsMerged = false // RTLC6c: Set data to the value of ObjectState.counter.count, or to 0 if it does not exist data = state.counter?.count?.doubleValue ?? 0 @@ -237,7 +237,7 @@ internal final class InternalDefaultLiveCounter: Sendable { } // RTLC10b: Set the private flag createOperationIsMerged to true - liveObject.createOperationIsMerged = true + liveObjectMutableState.createOperationIsMerged = true return update } @@ -251,14 +251,14 @@ internal final class InternalDefaultLiveCounter: Sendable { logger: Logger, userCallbackQueue: DispatchQueue, ) { - guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { + guard let applicableOperation = liveObjectMutableState.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { // RTLC7b logger.log("Operation \(operation) (serial: \(String(describing: objectMessageSerial)), siteCode: \(String(describing: objectMessageSiteCode))) should not be applied; discarding", level: .debug) return } // RTLC7c - liveObject.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial + liveObjectMutableState.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial switch operation.action { case .known(.counterCreate): @@ -268,12 +268,12 @@ internal final class InternalDefaultLiveCounter: Sendable { logger: logger, ) // RTLC7d1a - liveObject.emit(update, on: userCallbackQueue) + liveObjectMutableState.emit(update, on: userCallbackQueue) case .known(.counterInc): // RTLC7d2 let update = applyCounterIncOperation(operation.counterOp) // RTLC7d2a - liveObject.emit(update, on: userCallbackQueue) + liveObjectMutableState.emit(update, on: userCallbackQueue) default: // RTLC7d3 logger.log("Operation \(operation) has unsupported action for LiveCounter; discarding", level: .warn) @@ -285,7 +285,7 @@ internal final class InternalDefaultLiveCounter: Sendable { _ operation: ObjectOperation, logger: Logger, ) -> LiveObjectUpdate { - if liveObject.createOperationIsMerged { + if liveObjectMutableState.createOperationIsMerged { // RTLC8b logger.log("Not applying COUNTER_CREATE because a COUNTER_CREATE has already been applied", level: .warn) return .noop diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index d31383d03..3db67a9d5 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -22,7 +22,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal var testsOnly_objectID: String { mutex.withLock { - mutableState.liveObject.objectID + mutableState.liveObjectMutableState.objectID } } @@ -34,13 +34,13 @@ internal final class InternalDefaultLiveMap: Sendable { internal var testsOnly_siteTimeserials: [String: String] { mutex.withLock { - mutableState.liveObject.siteTimeserials + mutableState.liveObjectMutableState.siteTimeserials } } internal var testsOnly_createOperationIsMerged: Bool { mutex.withLock { - mutableState.liveObject.createOperationIsMerged + mutableState.liveObjectMutableState.createOperationIsMerged } } @@ -76,7 +76,7 @@ internal final class InternalDefaultLiveMap: Sendable { userCallbackQueue: DispatchQueue, clock: SimpleClock, ) { - mutableState = .init(liveObject: .init(objectID: objectID), data: data, semantics: semantics) + mutableState = .init(liveObjectMutableState: .init(objectID: objectID), data: data, semantics: semantics) self.logger = logger self.userCallbackQueue = userCallbackQueue self.clock = clock @@ -201,13 +201,13 @@ internal final class InternalDefaultLiveMap: Sendable { internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in // swiftlint:disable:next trailing_closure - try mutableState.liveObject.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + try mutableState.liveObjectMutableState.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in guard let self else { return } mutex.withLock { - action(&mutableState.liveObject) + action(&mutableState.liveObjectMutableState) } }) } @@ -215,7 +215,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal func unsubscribeAll() { mutex.withLock { - mutableState.liveObject.unsubscribeAll() + mutableState.liveObjectMutableState.unsubscribeAll() } } @@ -235,7 +235,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// This is used to instruct this map to emit updates during an `OBJECT_SYNC`. internal func emit(_ update: LiveObjectUpdate) { mutex.withLock { - mutableState.liveObject.emit(update, on: userCallbackQueue) + mutableState.liveObjectMutableState.emit(update, on: userCallbackQueue) } } @@ -346,9 +346,9 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - Mutable state and the operations that affect it - private struct MutableState { + private struct MutableState: InternalLiveObject { /// The mutable state common to all LiveObjects. - internal var liveObject: LiveObjectMutableState + internal var liveObjectMutableState: LiveObjectMutableState /// The internal data that this map holds, per RTLM3. internal var data: [String: InternalObjectsMapEntry] @@ -368,10 +368,10 @@ internal final class InternalDefaultLiveMap: Sendable { userCallbackQueue: DispatchQueue, ) -> LiveObjectUpdate { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials - liveObject.siteTimeserials = state.siteTimeserials + liveObjectMutableState.siteTimeserials = state.siteTimeserials // RTLM6b: Set the private flag createOperationIsMerged to false - liveObject.createOperationIsMerged = false + liveObjectMutableState.createOperationIsMerged = false // RTLM6c: Set data to ObjectState.map.entries, or to an empty map if it does not exist data = state.map?.entries?.mapValues { .init(objectsMapEntry: $0) } ?? [:] @@ -428,7 +428,7 @@ internal final class InternalDefaultLiveMap: Sendable { } // RTLM17b: Set the private flag createOperationIsMerged to true - liveObject.createOperationIsMerged = true + liveObjectMutableState.createOperationIsMerged = true // RTLM17c: Merge the updates, skipping no-ops // I don't love having to use uniqueKeysWithValues, when I shouldn't have to. I should be able to reason _statically_ that there are no overlapping keys. The problem that we're trying to use LiveMapUpdate throughout instead of something more communicative. But I don't know what's to come in the spec so I don't want to mess with this internal interface. @@ -457,14 +457,14 @@ internal final class InternalDefaultLiveMap: Sendable { userCallbackQueue: DispatchQueue, clock: SimpleClock, ) { - guard let applicableOperation = liveObject.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { + guard let applicableOperation = liveObjectMutableState.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { // RTLM15b logger.log("Operation \(operation) (serial: \(String(describing: objectMessageSerial)), siteCode: \(String(describing: objectMessageSiteCode))) should not be applied; discarding", level: .debug) return } // RTLM15c - liveObject.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial + liveObjectMutableState.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial switch operation.action { case .known(.mapCreate): @@ -477,7 +477,7 @@ internal final class InternalDefaultLiveMap: Sendable { clock: clock, ) // RTLM15d1a - liveObject.emit(update, on: userCallbackQueue) + liveObjectMutableState.emit(update, on: userCallbackQueue) case .known(.mapSet): guard let mapOp = operation.mapOp else { logger.log("Could not apply MAP_SET since operation.mapOp is missing", level: .warn) @@ -499,7 +499,7 @@ internal final class InternalDefaultLiveMap: Sendable { clock: clock, ) // RTLM15d2a - liveObject.emit(update, on: userCallbackQueue) + liveObjectMutableState.emit(update, on: userCallbackQueue) case .known(.mapRemove): guard let mapOp = operation.mapOp else { return @@ -511,7 +511,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationTimeserial: applicableOperation.objectMessageSerial, ) // RTLM15d3a - liveObject.emit(update, on: userCallbackQueue) + liveObjectMutableState.emit(update, on: userCallbackQueue) default: // RTLM15d4 logger.log("Operation \(operation) has unsupported action for LiveMap; discarding", level: .warn) @@ -637,7 +637,7 @@ internal final class InternalDefaultLiveMap: Sendable { userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> LiveObjectUpdate { - if liveObject.createOperationIsMerged { + if liveObjectMutableState.createOperationIsMerged { // RTLM16b logger.log("Not applying MAP_CREATE because a MAP_CREATE has already been applied", level: .warn) return .noop @@ -663,7 +663,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTO4b2a let mapUpdate = DefaultLiveMapUpdate(update: previousData.mapValues { _ in .removed }) - liveObject.emit(.update(mapUpdate), on: userCallbackQueue) + liveObjectMutableState.emit(.update(mapUpdate), on: userCallbackQueue) } } diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift b/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift new file mode 100644 index 000000000..e36dce1cf --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift @@ -0,0 +1,8 @@ +/// Provides RTLO spec point functionality common to all LiveObjects. +/// +/// This exists in addition to ``LiveObjectMutableState`` to enable polymorphism. +internal protocol InternalLiveObject { + associatedtype Update: Sendable + + var liveObjectMutableState: LiveObjectMutableState { get set } +} From ec37ccaebab8d8ea931a4957845060f294cd34f2 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 24 Jul 2025 14:11:32 +0100 Subject: [PATCH 067/225] Make initialValue a string There isn't a spec PR for this yet, but it's needed in order to implement the write spec [1]. Asked about it in [2]. It's implemented in JS in [3]. Got Cursor to do this. [1] https://github.com/ably/specification/pull/353 [2] https://github.com/ably/specification/pull/353/files#r2228017382 [3] https://github.com/ably/ably-js/pull/2065 --- .../Protocol/ObjectMessage.swift | 39 ++--------- .../Protocol/WireObjectMessage.swift | 16 +---- .../Helpers/TestFactories.swift | 4 +- .../ObjectMessageTests.swift | 67 ------------------- .../WireObjectMessageTests.swift | 9 --- 5 files changed, 9 insertions(+), 126 deletions(-) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index d83df1fae..32201b1e1 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -39,8 +39,7 @@ internal struct ObjectOperation { internal var map: ObjectsMap? // OOP3e internal var counter: WireObjectsCounter? // OOP3f internal var nonce: String? // OOP3g - internal var initialValue: Data? // OOP3h - internal var initialValueEncoding: String? // OOP3i + internal var initialValue: String? // OOP3h } internal struct ObjectData { @@ -157,41 +156,14 @@ internal extension ObjectOperation { nonce = nil // Do not access on inbound data, per OOP3h initialValue = nil - // Do not access on inbound data, per OOP3i - initialValueEncoding = nil } - /// Converts this `ObjectOperation` to a `WireObjectOperation`, applying the data encoding rules of OD4 and OOP5. + /// Converts this `ObjectOperation` to a `WireObjectOperation`, applying the data encoding rules of OD4. /// /// - Parameters: - /// - format: The format to use when applying the encoding rules of OD4 and OOP5. + /// - format: The format to use when applying the encoding rules of OD4. func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectOperation { - // OOP5: Encode initialValue based on format - let wireInitialValue: StringOrData? - let wireInitialValueEncoding: String? - - if let initialValue { - switch format { - case .messagePack: - // OOP5a: When the MessagePack protocol is used - // OOP5a1: A binary ObjectOperation.initialValue is encoded as a MessagePack binary type - wireInitialValue = .data(initialValue) - // OOP5a2: Set ObjectOperation.initialValueEncoding to msgpack - wireInitialValueEncoding = "msgpack" - - case .json: - // OOP5b: When the JSON protocol is used - // OOP5b1: A binary ObjectOperation.initialValue is Base64-encoded and represented as a JSON string - wireInitialValue = .string(initialValue.base64EncodedString()) - // OOP5b2: Set ObjectOperation.initialValueEncoding to json - wireInitialValueEncoding = "json" - } - } else { - wireInitialValue = nil - wireInitialValueEncoding = nil - } - - return .init( + .init( action: action, objectId: objectId, mapOp: mapOp?.toWire(format: format), @@ -199,8 +171,7 @@ internal extension ObjectOperation { map: map?.toWire(format: format), counter: counter, nonce: nonce, - initialValue: wireInitialValue, - initialValueEncoding: wireInitialValueEncoding, + initialValue: initialValue, ) } } diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index a562af2bf..d9a26350f 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -165,8 +165,7 @@ internal struct WireObjectOperation { internal var map: WireObjectsMap? // OOP3e internal var counter: WireObjectsCounter? // OOP3f internal var nonce: String? // OOP3g - internal var initialValue: StringOrData? // OOP3h - internal var initialValueEncoding: String? // OOP3i + internal var initialValue: String? // OOP3h } extension WireObjectOperation: WireObjectCodable { @@ -179,7 +178,6 @@ extension WireObjectOperation: WireObjectCodable { case counter case nonce case initialValue - case initialValueEncoding } internal init(wireObject: [String: WireValue]) throws(InternalError) { @@ -194,8 +192,6 @@ extension WireObjectOperation: WireObjectCodable { nonce = nil // Do not access on inbound data, per OOP3h initialValue = nil - // Do not access on inbound data, per OOP3i - initialValueEncoding = nil } internal var toWireObject: [String: WireValue] { @@ -220,10 +216,7 @@ extension WireObjectOperation: WireObjectCodable { result[WireKey.nonce.rawValue] = .string(nonce) } if let initialValue { - result[WireKey.initialValue.rawValue] = initialValue.toWireValue - } - if let initialValueEncoding { - result[WireKey.initialValueEncoding.rawValue] = .string(initialValueEncoding) + result[WireKey.initialValue.rawValue] = .string(initialValue) } return result @@ -486,10 +479,7 @@ extension WireObjectData: WireObjectCodable { /// A type that can be either a string or binary data. /// -/// Used to represent: -/// -/// - the values that `WireObjectData.bytes` might hold, after being encoded per OD4 or before being decoded per OD5 -/// - the values that `WireObjectOperation.initialValue` might hold, after being encoded per OOP5 +/// Used to represent the values that `WireObjectData.bytes` might hold, after being encoded per OD4 or before being decoded per OD5. internal enum StringOrData: WireCodable { case string(String) case data(Data) diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index 424e06ef6..362e24ed7 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -341,8 +341,7 @@ struct TestFactories { map: ObjectsMap? = nil, counter: WireObjectsCounter? = nil, nonce: String? = nil, - initialValue: Data? = nil, - initialValueEncoding: String? = nil, + initialValue: String? = nil, ) -> ObjectOperation { ObjectOperation( action: action, @@ -353,7 +352,6 @@ struct TestFactories { counter: counter, nonce: nonce, initialValue: initialValue, - initialValueEncoding: initialValueEncoding, ) } diff --git a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift index 7a614f470..12182b65d 100644 --- a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift @@ -498,71 +498,4 @@ struct ObjectMessageTests { } } } - - struct ObjectOperationTests { - struct EncodingTests { - @Test(arguments: [EncodingFormat.json.rawValue, EncodingFormat.messagePack.rawValue]) - func noInitialValue(formatRawValue: EncodingFormat.RawValue) throws { - let format = try #require(EncodingFormat(rawValue: formatRawValue)) - let operation = ObjectOperation( - action: .known(.mapSet), - objectId: "test-id", - ) - let wireOperation = operation.toWire(format: format) - - #expect(wireOperation.initialValue == nil) - #expect(wireOperation.initialValueEncoding == nil) - } - - struct MessagePackTests { - // @spec OOP5a1 - // @spec OOP5a2 - @Test - func initialValue() { - let testData = Data([1, 2, 3, 4]) - let operation = ObjectOperation( - action: .known(.mapSet), - objectId: "test-id", - initialValue: testData, - ) - let wireOperation = operation.toWire(format: .messagePack) - - // OOP5a1: A binary ObjectOperation.initialValue is encoded as a MessagePack binary type - switch wireOperation.initialValue { - case let .data(data): - #expect(data == testData) - default: - Issue.record("Expected .data case") - } - // OOP5a2: Set ObjectOperation.initialValueEncoding to msgpack - #expect(wireOperation.initialValueEncoding == "msgpack") - } - } - - struct JSONTests { - // @spec OOP5b1 - // @spec OOP5b2 - @Test - func initialValue() { - let testData = Data([1, 2, 3, 4]) - let operation = ObjectOperation( - action: .known(.mapSet), - objectId: "test-id", - initialValue: testData, - ) - let wireOperation = operation.toWire(format: .json) - - // OOP5b1: A binary ObjectOperation.initialValue is Base64-encoded and represented as a JSON string - switch wireOperation.initialValue { - case let .string(base64String): - #expect(base64String == testData.base64EncodedString()) - default: - Issue.record("Expected .string case") - } - // OOP5b2: Set ObjectOperation.initialValueEncoding to json - #expect(wireOperation.initialValueEncoding == "json") - } - } - } - } } diff --git a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift index 4b77924bd..c5503f298 100644 --- a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -118,7 +118,6 @@ enum WireObjectMessageTests { counter: nil, nonce: nil, initialValue: nil, - initialValueEncoding: nil, ), object: nil, serial: "s1", @@ -173,7 +172,6 @@ enum WireObjectMessageTests { "map": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], "counter": ["count": 42], "nonce": "nonce1", - "initialValueEncoding": "utf8", ] let op = try WireObjectOperation(wireObject: wire) #expect(op.action == .known(.mapCreate)) @@ -189,8 +187,6 @@ enum WireObjectMessageTests { // Per OOP3g we should not try and extract this #expect(op.nonce == nil) // Per OOP3h we should not try and extract this - #expect(op.initialValueEncoding == nil) - // Per OOP3i we should not try and extract this #expect(op.initialValue == nil) } @@ -209,7 +205,6 @@ enum WireObjectMessageTests { #expect(op.counter == nil) #expect(op.nonce == nil) #expect(op.initialValue == nil) - #expect(op.initialValueEncoding == nil) } @Test @@ -236,7 +231,6 @@ enum WireObjectMessageTests { counter: WireObjectsCounter(count: 42), nonce: "nonce1", initialValue: nil, - initialValueEncoding: "utf8", ) let wire = op.toWireObject #expect(wire == [ @@ -247,7 +241,6 @@ enum WireObjectMessageTests { "map": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], "counter": ["count": 42], "nonce": "nonce1", - "initialValueEncoding": "utf8", ]) } @@ -262,7 +255,6 @@ enum WireObjectMessageTests { counter: nil, nonce: nil, initialValue: nil, - initialValueEncoding: nil, ) let wire = op.toWireObject #expect(wire == [ @@ -326,7 +318,6 @@ enum WireObjectMessageTests { counter: nil, nonce: nil, initialValue: nil, - initialValueEncoding: nil, ), map: WireObjectsMap( semantics: .known(.lww), From 842d9e4dd324db602ba65d5b8cadaa978c4ace13 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 24 Jul 2025 14:45:30 +0100 Subject: [PATCH 068/225] Expose LiveObjects' ID internally This will be needed for the write API (to extract object IDs from the entries that the user supplies when creating a LiveMap). --- .../Internal/InternalDefaultLiveCounter.swift | 14 ++++++++------ .../Internal/InternalDefaultLiveMap.swift | 14 ++++++++------ Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift | 12 ++++++------ 3 files changed, 22 insertions(+), 18 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 0c311272b..25158eddf 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -21,12 +21,6 @@ internal final class InternalDefaultLiveCounter: Sendable { } } - internal var testsOnly_objectID: String { - mutex.withLock { - mutableState.liveObjectMutableState.objectID - } - } - private let logger: AblyPlugin.Logger private let userCallbackQueue: DispatchQueue private let clock: SimpleClock @@ -75,6 +69,14 @@ internal final class InternalDefaultLiveCounter: Sendable { ) } + // MARK: - Data access + + internal var objectID: String { + mutex.withLock { + mutableState.liveObjectMutableState.objectID + } + } + // MARK: - Internal methods that back LiveCounter conformance internal func value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 3db67a9d5..fb5b896fa 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -20,12 +20,6 @@ internal final class InternalDefaultLiveMap: Sendable { } } - internal var testsOnly_objectID: String { - mutex.withLock { - mutableState.liveObjectMutableState.objectID - } - } - internal var testsOnly_semantics: WireEnum? { mutex.withLock { mutableState.semantics @@ -104,6 +98,14 @@ internal final class InternalDefaultLiveMap: Sendable { ) } + // MARK: - Data access + + internal var objectID: String { + mutex.withLock { + mutableState.liveObjectMutableState.objectID + } + } + // MARK: - Internal methods that back LiveMap conformance /// Returns the value associated with a given key, following RTLM5d specification. diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index eb3b8321c..505a2f873 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -30,7 +30,7 @@ struct ObjectsPoolTests { #expect(pool.entries["map:123@456"]?.mapValue != nil) // Verify the objectID is correctly set - #expect(map.testsOnly_objectID == "map:123@456") + #expect(map.objectID == "map:123@456") } // @spec RTO6b3 @@ -47,7 +47,7 @@ struct ObjectsPoolTests { // Verify it was added to the pool #expect(pool.entries["counter:123@456"]?.counterValue != nil) // Verify the objectID is correctly set - #expect(counter.testsOnly_objectID == "counter:123@456") + #expect(counter.objectID == "counter:123@456") } // Sense check to see how it behaves when given an object ID not in the format of RTO6b1 (spec isn't prescriptive here) @@ -172,7 +172,7 @@ struct ObjectsPoolTests { // Checking site timeserials to verify they were set by replaceData #expect(newCounter.testsOnly_siteTimeserials == ["site2": "ts2"]) // Verify the objectID is correctly set per RTO5c1b1a - #expect(newCounter.testsOnly_objectID == "counter:hash@456") + #expect(newCounter.objectID == "counter:hash@456") } // @spec RTO5c1b1b @@ -200,7 +200,7 @@ struct ObjectsPoolTests { // Checking site timeserials to verify they were set by replaceData #expect(newMap.testsOnly_siteTimeserials == ["site3": "ts3"]) // Verify the objectID and semantics are correctly set per RTO5c1b1b - #expect(newMap.testsOnly_objectID == "map:hash@789") + #expect(newMap.objectID == "map:hash@789") #expect(newMap.testsOnly_semantics == .known(.lww)) } @@ -354,14 +354,14 @@ struct ObjectsPoolTests { // Checking map data to verify the new map was created and replaceData was called #expect(try newMap.get(key: "new", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") // Verify the objectID and semantics are correctly set per RTO5c1b1b - #expect(newMap.testsOnly_objectID == "map:new@1") + #expect(newMap.objectID == "map:new@1") #expect(newMap.testsOnly_semantics == .known(.lww)) let newCounter = try #require(pool.entries["counter:new@1"]?.counterValue) // Checking counter value to verify the new counter was created and replaceData was called #expect(try newCounter.value(coreSDK: coreSDK) == 50) // Verify the objectID is correctly set per RTO5c1b1a - #expect(newCounter.testsOnly_objectID == "counter:new@1") + #expect(newCounter.objectID == "counter:new@1") // Removed object #expect(pool.entries["map:toremove@1"] == nil) From c74a23b7c69a5aa9c75103db1290a05229844714 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 24 Jul 2025 17:09:59 +0100 Subject: [PATCH 069/225] Expose mergeInitialValue methods internally This will be needed for the write API (to incorporate the values that the user supplies when creating a LiveObject). --- .../Internal/InternalDefaultLiveCounter.swift | 4 ++-- .../Internal/InternalDefaultLiveMap.swift | 4 ++-- .../InternalDefaultLiveCounterTests.swift | 10 +++++----- .../InternalDefaultLiveMapTests.swift | 12 ++++++------ 4 files changed, 15 insertions(+), 15 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 25158eddf..2e6e14619 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -155,8 +155,8 @@ internal final class InternalDefaultLiveCounter: Sendable { } } - /// Test-only method to merge initial value from an ObjectOperation, per RTLC10. - internal func testsOnly_mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { + /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC10. + internal func mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { mutex.withLock { mutableState.mergeInitialValue(from: operation) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index fb5b896fa..9e4763fc9 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -259,8 +259,8 @@ internal final class InternalDefaultLiveMap: Sendable { } } - /// Test-only method to merge initial value from an ObjectOperation, per RTLM17. - internal func testsOnly_mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { + /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM17. + internal func mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { mutex.withLock { mutableState.mergeInitialValue( from: operation, diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 4b0830a54..2c6fb5eed 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -128,7 +128,7 @@ struct InternalDefaultLiveCounterTests { } } - /// Tests for the `testsOnly_mergeInitialValue` method, covering RTLC10 specification points + /// Tests for the `mergeInitialValue` method, covering RTLC10 specification points struct MergeInitialValueTests { // @specOneOf(1/2) RTLC10a - with count // @spec RTLC10c @@ -144,7 +144,7 @@ struct InternalDefaultLiveCounterTests { // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist - let update = counter.testsOnly_mergeInitialValue(from: operation) + let update = counter.mergeInitialValue(from: operation) #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 @@ -169,7 +169,7 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterCreate), counter: nil, // Test value - must be nil ) - let update = counter.testsOnly_mergeInitialValue(from: operation) + let update = counter.mergeInitialValue(from: operation) #expect(try counter.value(coreSDK: coreSDK) == 5) // Unchanged @@ -185,7 +185,7 @@ struct InternalDefaultLiveCounterTests { // Apply merge operation let operation = TestFactories.counterCreateOperation(count: 10) // Test value - must exist - _ = counter.testsOnly_mergeInitialValue(from: operation) + _ = counter.mergeInitialValue(from: operation) #expect(counter.testsOnly_createOperationIsMerged) } @@ -202,7 +202,7 @@ struct InternalDefaultLiveCounterTests { // Set initial data and mark create operation as merged _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) - _ = counter.testsOnly_mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) + _ = counter.mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) #expect(counter.testsOnly_createOperationIsMerged) // Try to apply another COUNTER_CREATE operation diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 22828ca08..c6cc86133 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -827,7 +827,7 @@ struct InternalDefaultLiveMapTests { } } - /// Tests for the `testsOnly_mergeInitialValue` method, covering RTLM17 specification points + /// Tests for the `mergeInitialValue` method, covering RTLM17 specification points struct MergeInitialValueTests { // @spec RTLM17a1 @Test @@ -845,7 +845,7 @@ struct InternalDefaultLiveMapTests { "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, ], ) - _ = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + _ = map.mergeInitialValue(from: operation, objectsPool: &pool) // Note that we just check for some basic expected side effects of applying MAP_SET; RTLM7 is tested in more detail elsewhere // Check that it contains the data from the operation (per RTLM17a1) @@ -880,7 +880,7 @@ struct InternalDefaultLiveMapTests { objectId: "arbitrary-id", entries: ["key1": entry], ) - _ = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + _ = map.mergeInitialValue(from: operation, objectsPool: &pool) // Verify the MAP_REMOVE operation was applied #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) @@ -919,7 +919,7 @@ struct InternalDefaultLiveMapTests { "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, ], ) - let update = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + let update = map.mergeInitialValue(from: operation, objectsPool: &pool) // Verify merged return value per RTLM17c #expect(try #require(update.update).update == ["keyThatWillBeRemoved": .removed, "keyFromCreateOp": .updated]) @@ -934,7 +934,7 @@ struct InternalDefaultLiveMapTests { // Apply merge operation let operation = TestFactories.mapCreateOperation(objectId: "arbitrary-id") - _ = map.testsOnly_mergeInitialValue(from: operation, objectsPool: &pool) + _ = map.mergeInitialValue(from: operation, objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) } @@ -953,7 +953,7 @@ struct InternalDefaultLiveMapTests { // Set initial data and mark create operation as merged _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) - _ = map.testsOnly_mergeInitialValue(from: TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]), objectsPool: &pool) + _ = map.mergeInitialValue(from: TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]), objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) // Try to apply another MAP_CREATE operation From f62a45792d4728253b702319f1102b1444c9d59e Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 24 Jul 2025 17:59:23 +0100 Subject: [PATCH 070/225] Expose public types' proxied objects internally This will be needed for the write API (to extract object IDs from the entries that the user supplies when creating a LiveMap). --- .../Public Proxy Objects/PublicDefaultLiveCounter.swift | 5 +---- .../Public/Public Proxy Objects/PublicDefaultLiveMap.swift | 5 +---- Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift | 2 +- 3 files changed, 3 insertions(+), 9 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 7b6c07956..b769317d1 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -4,10 +4,7 @@ import Ably /// /// This is largely a wrapper around ``InternalDefaultLiveCounter``. internal final class PublicDefaultLiveCounter: LiveCounter { - private let proxied: InternalDefaultLiveCounter - internal var testsOnly_proxied: InternalDefaultLiveCounter { - proxied - } + internal let proxied: InternalDefaultLiveCounter // MARK: - Dependencies that hold a strong reference to `proxied` diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index a49cbfd22..b29d695c3 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -4,10 +4,7 @@ import Ably /// /// This is largely a wrapper around ``InternalDefaultLiveMap``. internal final class PublicDefaultLiveMap: LiveMap { - private let proxied: InternalDefaultLiveMap - internal var testsOnly_proxied: InternalDefaultLiveMap { - proxied - } + internal let proxied: InternalDefaultLiveMap // MARK: - Dependencies that hold a strong reference to `proxied` diff --git a/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift index 067f2f197..23c849802 100644 --- a/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift @@ -162,7 +162,7 @@ struct ObjectLifetimesTests { weakPublicRealtimeObjects: objects, weakInternalRealtimeObjects: objects.testsOnly_proxied, strongPublicLiveObject: root, - weakInternalLiveObject: root.testsOnly_proxied, + weakInternalLiveObject: root.proxied, ) } From 25455fd77774f33c51b77ed2f85efa6643e0caad Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 29 Jul 2025 11:45:12 +0100 Subject: [PATCH 071/225] Add an AsyncSequence API for subscribing to LiveObject updates Generated by Cursor at my request. Useful for tests. Will refine this (e.g. to hide the usage of AsyncStream, and maybe tweak the name) in #4. --- .../AblyLiveObjects/Public/PublicTypes.swift | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 34e67f2bd..68fa6638d 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -371,3 +371,29 @@ public protocol OnLiveObjectLifecycleEventResponse: Sendable { /// Deregisters the listener passed to the `on` call. func off() } + +// MARK: - AsyncSequence Extensions + +/// Extension to provide AsyncSequence-based subscription for `LiveObject` updates. +public extension LiveObject { + /// Returns an `AsyncSequence` that emits updates to this `LiveObject`. + /// + /// This provides an alternative to the callback-based ``subscribe(listener:)`` method, + /// allowing you to use Swift's structured concurrency features like `for await` loops. + /// + /// - Returns: An AsyncSequence that emits ``Update`` values when the object is updated. + /// - Throws: An ``ARTErrorInfo`` if the subscription fails. + func updates() throws(ARTErrorInfo) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream(of: Update.self) + + let subscription = try subscribe { update, _ in + continuation.yield(update) + } + + continuation.onTermination = { _ in + subscription.unsubscribe() + } + + return stream + } +} From 37e14d74eb6298795e278bc1d5abe1ef8bd4e60e Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 29 Jul 2025 13:53:11 +0100 Subject: [PATCH 072/225] Add Equatable conformance to (Internal)LiveMapValue Generated by Cursor at my request (although I largely replaced its code). Useful for tests. --- .../Internal/InternalLiveMapValue.swift | 23 ++++++++++++++++- .../AblyLiveObjects/Public/PublicTypes.swift | 25 +++++++++++++++++-- 2 files changed, 45 insertions(+), 3 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift index f63d27133..95b36fe31 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -1,7 +1,7 @@ import Foundation /// Same as the public ``LiveMapValue`` type but with associated values of internal type. -internal enum InternalLiveMapValue: Sendable { +internal enum InternalLiveMapValue: Sendable, Equatable { case primitive(PrimitiveObjectValue) case liveMap(InternalDefaultLiveMap) case liveCounter(InternalDefaultLiveCounter) @@ -51,4 +51,25 @@ internal enum InternalLiveMapValue: Sendable { internal var dataValue: Data? { primitiveValue?.dataValue } + + // MARK: - Equatable Implementation + + internal static func == (lhs: InternalLiveMapValue, rhs: InternalLiveMapValue) -> Bool { + switch lhs { + case let .primitive(lhsValue): + if case let .primitive(rhsValue) = rhs, lhsValue == rhsValue { + return true + } + case let .liveMap(lhsMap): + if case let .liveMap(rhsMap) = rhs, lhsMap === rhsMap { + return true + } + case let .liveCounter(lhsCounter): + if case let .liveCounter(rhsCounter) = rhs, lhsCounter === rhsCounter { + return true + } + } + + return false + } } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 68fa6638d..140191295 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -83,7 +83,7 @@ public protocol RealtimeObjects: Sendable { /// Represents the type of data stored for a given key in a ``LiveMap``. /// It may be a primitive value (``PrimitiveObjectValue``), or another ``LiveObject``. -public enum LiveMapValue: Sendable { +public enum LiveMapValue: Sendable, Equatable { case primitive(PrimitiveObjectValue) case liveMap(any LiveMap) case liveCounter(any LiveCounter) @@ -133,6 +133,27 @@ public enum LiveMapValue: Sendable { public var dataValue: Data? { primitiveValue?.dataValue } + + // MARK: - Equatable Implementation + + public static func == (lhs: LiveMapValue, rhs: LiveMapValue) -> Bool { + switch lhs { + case let .primitive(lhsValue): + if case let .primitive(rhsValue) = rhs, lhsValue == rhsValue { + return true + } + case let .liveMap(lhsMap): + if case let .liveMap(rhsMap) = rhs, lhsMap === rhsMap { + return true + } + case let .liveCounter(lhsCounter): + if case let .liveCounter(rhsCounter) = rhs, lhsCounter === rhsCounter { + return true + } + } + + return false + } } /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. @@ -265,7 +286,7 @@ public protocol LiveMapUpdate: Sendable { } /// Represents a primitive value that can be stored in a ``LiveMap``. -public enum PrimitiveObjectValue: Sendable { +public enum PrimitiveObjectValue: Sendable, Equatable { case string(String) case number(Double) case bool(Bool) From 76ca4982ae58f39324e115cdfcf9f142f3c43442 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 29 Jul 2025 15:16:51 +0100 Subject: [PATCH 073/225] Add logging to PublicObjectsStore I'm trying to debug an issue and this will be helpful. Code largely generated by Cursor at my instruction. --- .../Public/ARTRealtimeChannel+Objects.swift | 3 ++ .../InternalLiveMapValue+ToPublic.swift | 7 ++- .../PublicDefaultLiveCounter.swift | 5 ++- .../PublicDefaultLiveMap.swift | 8 +++- .../PublicDefaultRealtimeObjects.swift | 6 ++- .../PublicObjectsStore.swift | 43 ++++++++++++++++--- 6 files changed, 60 insertions(+), 12 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index 964baabae..d36b5ccfb 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -18,10 +18,13 @@ public extension ARTRealtimeChannel { pluginAPI: Plugin.defaultPluginAPI, ) + let logger = pluginAPI.logger(for: underlyingObjects.channel) + return PublicObjectsStore.shared.getOrCreateRealtimeObjects( proxying: internalObjects, creationArgs: .init( coreSDK: coreSDK, + logger: logger, ), ) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift index 92acbe1ae..e5b599d00 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift @@ -1,16 +1,19 @@ +internal import AblyPlugin + internal extension InternalLiveMapValue { // MARK: - Mapping to public types struct PublicValueCreationArgs { internal var coreSDK: CoreSDK internal var mapDelegate: LiveMapObjectPoolDelegate + internal var logger: AblyPlugin.Logger internal var toCounterCreationArgs: PublicObjectsStore.CounterCreationArgs { - .init(coreSDK: coreSDK) + .init(coreSDK: coreSDK, logger: logger) } internal var toMapCreationArgs: PublicObjectsStore.MapCreationArgs { - .init(coreSDK: coreSDK, delegate: mapDelegate) + .init(coreSDK: coreSDK, delegate: mapDelegate, logger: logger) } } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index b769317d1..19c2bfc41 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -1,4 +1,5 @@ import Ably +internal import AblyPlugin /// Our default implementation of ``LiveCounter``. /// @@ -9,10 +10,12 @@ internal final class PublicDefaultLiveCounter: LiveCounter { // MARK: - Dependencies that hold a strong reference to `proxied` private let coreSDK: CoreSDK + private let logger: AblyPlugin.Logger - internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK) { + internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK, logger: AblyPlugin.Logger) { self.proxied = proxied self.coreSDK = coreSDK + self.logger = logger } // MARK: - `LiveCounter` protocol diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index b29d695c3..f3a9c9ef2 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -1,4 +1,5 @@ import Ably +internal import AblyPlugin /// Our default implementation of ``LiveMap``. /// @@ -10,11 +11,13 @@ internal final class PublicDefaultLiveMap: LiveMap { private let coreSDK: CoreSDK private let delegate: LiveMapObjectPoolDelegate + private let logger: AblyPlugin.Logger - internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) { + internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate, logger: AblyPlugin.Logger) { self.proxied = proxied self.coreSDK = coreSDK self.delegate = delegate + self.logger = logger } // MARK: - `LiveMap` protocol @@ -24,6 +27,7 @@ internal final class PublicDefaultLiveMap: LiveMap { creationArgs: .init( coreSDK: coreSDK, mapDelegate: delegate, + logger: logger, ), ) } @@ -43,6 +47,7 @@ internal final class PublicDefaultLiveMap: LiveMap { creationArgs: .init( coreSDK: coreSDK, mapDelegate: delegate, + logger: logger, ), ) ) @@ -63,6 +68,7 @@ internal final class PublicDefaultLiveMap: LiveMap { creationArgs: .init( coreSDK: coreSDK, mapDelegate: delegate, + logger: logger, ), ) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index 09828035d..4a0f9ffea 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -1,4 +1,5 @@ import Ably +internal import AblyPlugin /// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. /// @@ -12,10 +13,12 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { // MARK: - Dependencies that hold a strong reference to `proxied` private let coreSDK: CoreSDK + private let logger: AblyPlugin.Logger - internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK) { + internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK, logger: AblyPlugin.Logger) { self.proxied = proxied self.coreSDK = coreSDK + self.logger = logger } // MARK: - `RealtimeObjects` protocol @@ -27,6 +30,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { creationArgs: .init( coreSDK: coreSDK, delegate: proxied, + logger: logger, ), ) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift index 39e7f9078..4be075345 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift @@ -1,3 +1,4 @@ +internal import AblyPlugin import Foundation /// Stores the public objects that wrap the SDK's internal components. @@ -20,6 +21,7 @@ internal final class PublicObjectsStore: Sendable { internal struct RealtimeObjectsCreationArgs { internal var coreSDK: CoreSDK + internal var logger: AblyPlugin.Logger } /// Fetches the cached `PublicDefaultRealtimeObjects` that wraps a given `InternalDefaultRealtimeObjects`, creating a new public object if there isn't already one. @@ -31,6 +33,7 @@ internal final class PublicObjectsStore: Sendable { internal struct CounterCreationArgs { internal var coreSDK: CoreSDK + internal var logger: AblyPlugin.Logger } /// Fetches the cached `PublicDefaultLiveCounter` that wraps a given `InternalDefaultLiveCounter`, creating a new public object if there isn't already one. @@ -43,6 +46,7 @@ internal final class PublicObjectsStore: Sendable { internal struct MapCreationArgs { internal var coreSDK: CoreSDK internal var delegate: LiveMapObjectPoolDelegate + internal var logger: AblyPlugin.Logger } /// Fetches the cached `PublicDefaultLiveMap` that wraps a given `InternalDefaultLiveMap`, creating a new public object if there isn't already one. @@ -64,27 +68,37 @@ internal final class PublicObjectsStore: Sendable { /// Fetches the proxy that wraps `proxied`, creating a new proxy if there isn't already one. Stores a weak reference to the proxy. mutating func getOrCreate( proxying proxied: some AnyObject, + logger: AblyPlugin.Logger, + logObjectType: String, createProxy: () -> Proxy, ) -> Proxy { // Remove any entries that are no longer useful - removeDeallocatedEntries() + removeDeallocatedEntries(logger: logger, logObjectType: logObjectType) // Do the get-or-create let proxiedObjectIdentifier = ObjectIdentifier(proxied) if let existing = proxiesByProxiedObjectIdentifier[proxiedObjectIdentifier]?.referenced { + logger.log("Reusing existing \(logObjectType) proxy (proxy: \(ObjectIdentifier(existing)), proxied: \(proxiedObjectIdentifier))", level: .debug) return existing } let created = createProxy() proxiesByProxiedObjectIdentifier[proxiedObjectIdentifier] = .init(referenced: created) + logger.log("Creating new \(logObjectType) proxy (proxy: \(ObjectIdentifier(created)), proxied: \(proxiedObjectIdentifier))", level: .debug) return created } - private mutating func removeDeallocatedEntries() { - proxiesByProxiedObjectIdentifier = proxiesByProxiedObjectIdentifier.filter { entry in - entry.value.referenced != nil + private mutating func removeDeallocatedEntries(logger: AblyPlugin.Logger, logObjectType: String) { + var keysToRemove: Set = [] + for (proxiedObjectIdentifier, weakProxyRef) in proxiesByProxiedObjectIdentifier where weakProxyRef.referenced == nil { + logger.log("Clearing unused \(logObjectType) proxy from cache (proxied: \(proxiedObjectIdentifier))", level: .debug) + keysToRemove.insert(proxiedObjectIdentifier) + } + + for key in keysToRemove { + proxiesByProxiedObjectIdentifier.removeValue(forKey: key) } } } @@ -93,10 +107,15 @@ internal final class PublicObjectsStore: Sendable { proxying proxied: InternalDefaultRealtimeObjects, creationArgs: RealtimeObjectsCreationArgs, ) -> PublicDefaultRealtimeObjects { - realtimeObjectsProxies.getOrCreate(proxying: proxied) { + realtimeObjectsProxies.getOrCreate( + proxying: proxied, + logger: creationArgs.logger, + logObjectType: "RealtimeObjects", + ) { .init( proxied: proxied, coreSDK: creationArgs.coreSDK, + logger: creationArgs.logger, ) } } @@ -105,10 +124,15 @@ internal final class PublicObjectsStore: Sendable { proxying proxied: InternalDefaultLiveCounter, creationArgs: CounterCreationArgs, ) -> PublicDefaultLiveCounter { - counterProxies.getOrCreate(proxying: proxied) { + counterProxies.getOrCreate( + proxying: proxied, + logger: creationArgs.logger, + logObjectType: "LiveCounter", + ) { .init( proxied: proxied, coreSDK: creationArgs.coreSDK, + logger: creationArgs.logger, ) } } @@ -117,11 +141,16 @@ internal final class PublicObjectsStore: Sendable { proxying proxied: InternalDefaultLiveMap, creationArgs: MapCreationArgs, ) -> PublicDefaultLiveMap { - mapProxies.getOrCreate(proxying: proxied) { + mapProxies.getOrCreate( + proxying: proxied, + logger: creationArgs.logger, + logObjectType: "LiveMap", + ) { .init( proxied: proxied, coreSDK: creationArgs.coreSDK, delegate: creationArgs.delegate, + logger: creationArgs.logger, ) } } From 116ee63821732fa8d6a3a720790ee304d9e0fd12 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 21 Jul 2025 14:31:59 +0100 Subject: [PATCH 074/225] Allow SyncObjectsPool entries to contain additional data This is preparation for tombstoning an object per [1]; we'll need the ObjectMessage's serialTimestamp. [1] https://github.com/ably/specification/pull/350 --- .../InternalDefaultRealtimeObjects.swift | 20 ++++++++++--- .../Internal/ObjectsPool.swift | 30 +++++++++---------- .../Internal/SyncObjectsPoolEntry.swift | 6 ++++ .../ObjectsPoolTests.swift | 14 ++++----- 4 files changed, 44 insertions(+), 26 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 2b5439aac..9bc9e763f 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -45,7 +45,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool internal var id: String /// The `ObjectMessage`s gathered during this sync sequence. - internal var syncObjectsPool: [ObjectState] + internal var syncObjectsPool: [SyncObjectsPoolEntry] /// `OBJECT` ProtocolMessages that were received during this sync sequence, to be applied once the sync sequence is complete, per RTO7a. internal var bufferedObjectOperations: [InboundObjectMessage] @@ -276,7 +276,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) // If populated, this contains a full set of sync data for the channel, and should be applied to the ObjectsPool. - let completedSyncObjectsPool: [ObjectState]? + let completedSyncObjectsPool: [SyncObjectsPoolEntry]? // If populated, this contains a set of buffered inbound OBJECT messages that should be applied. let completedSyncBufferedObjectOperations: [InboundObjectMessage]? @@ -305,7 +305,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } // RTO5b - updatedSyncSequence.syncObjectsPool.append(contentsOf: objectMessages.compactMap(\.object)) + updatedSyncSequence.syncObjectsPool.append(contentsOf: objectMessages.compactMap { objectMessage in + if let object = objectMessage.object { + .init(state: object) + } else { + nil + } + }) syncSequence = updatedSyncSequence @@ -316,7 +322,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } } else { // RTO5a5: The sync data is contained entirely within this single OBJECT_SYNC - completedSyncObjectsPool = objectMessages.compactMap(\.object) + completedSyncObjectsPool = objectMessages.compactMap { objectMessage in + if let object = objectMessage.object { + .init(state: object) + } else { + nil + } + } completedSyncBufferedObjectOperations = nil } diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index f45ac9a1a..da52da349 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -177,7 +177,7 @@ internal struct ObjectsPool { /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1 and RTO5c2. internal mutating func applySyncObjectsPool( - _ syncObjectsPool: [ObjectState], + _ syncObjectsPool: [SyncObjectsPoolEntry], logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, @@ -191,46 +191,46 @@ internal struct ObjectsPool { var updatesToExistingObjects: [ObjectsPool.Entry.DeferredUpdate] = [] // RTO5c1: For each ObjectState member in the SyncObjectsPool list - for objectState in syncObjectsPool { - receivedObjectIds.insert(objectState.objectId) + for syncObjectsPoolEntry in syncObjectsPool { + receivedObjectIds.insert(syncObjectsPoolEntry.state.objectId) // RTO5c1a: If an object with ObjectState.objectId exists in the internal ObjectsPool - if let existingEntry = entries[objectState.objectId] { - logger.log("Updating existing object with ID: \(objectState.objectId)", level: .debug) + if let existingEntry = entries[syncObjectsPoolEntry.state.objectId] { + logger.log("Updating existing object with ID: \(syncObjectsPoolEntry.state.objectId)", level: .debug) // RTO5c1a1: Override the internal data for the object as per RTLC6, RTLM6 - let deferredUpdate = existingEntry.replaceData(using: objectState, objectsPool: &self) + let deferredUpdate = existingEntry.replaceData(using: syncObjectsPoolEntry.state, objectsPool: &self) // RTO5c1a2: Store this update to emit at end updatesToExistingObjects.append(deferredUpdate) } else { // RTO5c1b: If an object with ObjectState.objectId does not exist in the internal ObjectsPool - logger.log("Creating new object with ID: \(objectState.objectId)", level: .debug) + logger.log("Creating new object with ID: \(syncObjectsPoolEntry.state.objectId)", level: .debug) // RTO5c1b1: Create a new LiveObject using the data from ObjectState and add it to the internal ObjectsPool: let newEntry: Entry? - if objectState.counter != nil { + if syncObjectsPoolEntry.state.counter != nil { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: objectState.objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) - _ = counter.replaceData(using: objectState) + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: syncObjectsPoolEntry.state.objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) + _ = counter.replaceData(using: syncObjectsPoolEntry.state) newEntry = .counter(counter) - } else if let objectsMap = objectState.map { + } else if let objectsMap = syncObjectsPoolEntry.state.map { // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, // set its private objectId equal to ObjectState.objectId, set its private semantics // equal to ObjectState.map.semantics and override its internal data per RTLM6 - let map = InternalDefaultLiveMap.createZeroValued(objectID: objectState.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) - _ = map.replaceData(using: objectState, objectsPool: &self) + let map = InternalDefaultLiveMap.createZeroValued(objectID: syncObjectsPoolEntry.state.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) + _ = map.replaceData(using: syncObjectsPoolEntry.state, objectsPool: &self) newEntry = .map(map) } else { // RTO5c1b1c: Otherwise, log a warning that an unsupported object state message has been received, and discard the current ObjectState without taking any action - logger.log("Unsupported object state message received for objectId: \(objectState.objectId)", level: .warn) + logger.log("Unsupported object state message received for objectId: \(syncObjectsPoolEntry.state.objectId)", level: .warn) newEntry = nil } if let newEntry { // Note that we will never replace the root object here, and thus never break the RTO3b invariant that the root object is always a map. This is because the pool always contains a root object and thus we always go through the RTO5c1a branch of the `if` above. - entries[objectState.objectId] = newEntry + entries[syncObjectsPoolEntry.state.objectId] = newEntry } } } diff --git a/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift b/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift new file mode 100644 index 000000000..40337b14e --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift @@ -0,0 +1,6 @@ +import Foundation + +/// The contents of the spec's `SyncObjectsPool` that is built during an `OBJECT_SYNC` sync sequence. +internal struct SyncObjectsPoolEntry { + internal var state: ObjectState +} diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 505a2f873..65e67247b 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -100,7 +100,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify the existing map was updated by checking side effects of InternalDefaultLiveMap.replaceData(using:) let updatedMap = try #require(pool.entries["map:hash@123"]?.mapValue) @@ -135,7 +135,7 @@ struct ObjectsPoolTests { count: 10, ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify the existing counter was updated by checking side effects of InternalDefaultLiveCounter.replaceData(using:) let updatedCounter = try #require(pool.entries["counter:hash@123"]?.counterValue) @@ -163,7 +163,7 @@ struct ObjectsPoolTests { count: 100, ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify a new counter was created and data was set by checking side effects of InternalDefaultLiveCounter.replaceData(using:) let newCounter = try #require(pool.entries["counter:hash@456"]?.counterValue) @@ -191,7 +191,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify a new map was created and data was set by checking side effects of InternalDefaultLiveMap.replaceData(using:) let newMap = try #require(pool.entries["map:hash@789"]?.mapValue) @@ -218,7 +218,7 @@ struct ObjectsPoolTests { let invalidObjectState = TestFactories.objectState(objectId: "invalid") - pool.applySyncObjectsPool([invalidObjectState, validObjectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([invalidObjectState, validObjectState].map { .init(state: $0) }, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Check that there's no entry for the key that we don't know how to handle, and that it didn't interfere with the insertion of the we one that we do know how to handle #expect(Set(pool.entries.keys) == ["root", "counter:hash@456"]) @@ -243,7 +243,7 @@ struct ObjectsPoolTests { // Only sync one of the existing objects let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") - pool.applySyncObjectsPool([objectState], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify only synced object and root remain #expect(pool.entries.count == 2) // root + map:hash@1 @@ -324,7 +324,7 @@ struct ObjectsPoolTests { // Note: "map:toremove@1" is not in sync, so it should be removed ] - pool.applySyncObjectsPool(syncObjects, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool(syncObjects.map { .init(state: $0) }, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify final state #expect(pool.entries.count == 5) // root + 4 synced objects From 29aa9fc01f86ee713c722530cf4485c4c3fc9f03 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 21 Jul 2025 12:00:41 +0100 Subject: [PATCH 075/225] Accept a serialTimestamp in replaceData and applySyncObjectsPool Preparation for making these methods perform tombstoning per [1] (not implemented yet). [1] https://github.com/ably/specification/pull/350 --- .../Internal/InternalDefaultLiveCounter.swift | 21 ++++++- .../Internal/InternalDefaultLiveMap.swift | 13 ++++- .../InternalDefaultRealtimeObjects.swift | 5 +- .../Internal/ObjectsPool.swift | 46 ++++++++++++++-- .../Internal/SyncObjectsPoolEntry.swift | 9 +++ .../InternalDefaultLiveCounterTests.swift | 33 ++++++----- .../InternalDefaultLiveMapTests.swift | 55 ++++++++++++------- .../ObjectsPoolTests.swift | 14 ++--- 8 files changed, 144 insertions(+), 52 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 2e6e14619..635783fec 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -149,9 +149,15 @@ internal final class InternalDefaultLiveCounter: Sendable { // MARK: - Data manipulation /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. - internal func replaceData(using state: ObjectState) -> LiveObjectUpdate { + /// + /// - Parameters: + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this counter. + internal func replaceData( + using state: ObjectState, + objectMessageSerialTimestamp: Date?, + ) -> LiveObjectUpdate { mutex.withLock { - mutableState.replaceData(using: state) + mutableState.replaceData(using: state, objectMessageSerialTimestamp: objectMessageSerialTimestamp) } } @@ -181,6 +187,7 @@ internal final class InternalDefaultLiveCounter: Sendable { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, ) { mutex.withLock { @@ -188,6 +195,7 @@ internal final class InternalDefaultLiveCounter: Sendable { operation, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, @@ -205,7 +213,13 @@ internal final class InternalDefaultLiveCounter: Sendable { internal var data: Double /// Replaces the internal data of this counter with the provided ObjectState, per RTLC6. - internal mutating func replaceData(using state: ObjectState) -> LiveObjectUpdate { + /// + /// - Parameters: + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this counter. + internal mutating func replaceData( + using state: ObjectState, + objectMessageSerialTimestamp: Date?, + ) -> LiveObjectUpdate { // RTLC6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials liveObjectMutableState.siteTimeserials = state.siteTimeserials @@ -249,6 +263,7 @@ internal final class InternalDefaultLiveCounter: Sendable { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, logger: Logger, userCallbackQueue: DispatchQueue, diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 9e4763fc9..e7880e771 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -247,10 +247,16 @@ internal final class InternalDefaultLiveMap: Sendable { /// /// - Parameters: /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. - internal func replaceData(using state: ObjectState, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this map. + internal func replaceData( + using state: ObjectState, + objectMessageSerialTimestamp: Date?, + objectsPool: inout ObjectsPool, + ) -> LiveObjectUpdate { mutex.withLock { mutableState.replaceData( using: state, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, logger: logger, clock: clock, @@ -290,6 +296,7 @@ internal final class InternalDefaultLiveMap: Sendable { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, ) { mutex.withLock { @@ -297,6 +304,7 @@ internal final class InternalDefaultLiveMap: Sendable { operation, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, logger: logger, userCallbackQueue: userCallbackQueue, @@ -362,8 +370,10 @@ internal final class InternalDefaultLiveMap: Sendable { /// /// - Parameters: /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this map. internal mutating func replaceData( using state: ObjectState, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, clock: SimpleClock, @@ -454,6 +464,7 @@ internal final class InternalDefaultLiveMap: Sendable { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, logger: Logger, userCallbackQueue: DispatchQueue, diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 9bc9e763f..ba5741d4c 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -307,7 +307,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool // RTO5b updatedSyncSequence.syncObjectsPool.append(contentsOf: objectMessages.compactMap { objectMessage in if let object = objectMessage.object { - .init(state: object) + .init(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) } else { nil } @@ -324,7 +324,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool // RTO5a5: The sync data is contained entirely within this single OBJECT_SYNC completedSyncObjectsPool = objectMessages.compactMap { objectMessage in if let object = objectMessage.object { - .init(state: object) + .init(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) } else { nil } @@ -432,6 +432,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool operation, objectMessageSerial: objectMessage.serial, objectMessageSiteCode: objectMessage.siteCode, + objectMessageSerialTimestamp: objectMessage.serialTimestamp, objectsPool: &objectsPool, ) } diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index da52da349..f047e9da1 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -34,6 +34,7 @@ internal struct ObjectsPool { _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, + objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, ) { switch self { @@ -42,6 +43,7 @@ internal struct ObjectsPool { operation, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, ) case let .counter(counter): @@ -49,6 +51,7 @@ internal struct ObjectsPool { operation, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, ) } @@ -73,12 +76,32 @@ internal struct ObjectsPool { /// Overrides the internal data for the object as per RTLC6, RTLM6. /// /// Returns a ``DeferredUpdate`` which contains the object plus an update that should be emitted on this object once the `SyncObjectsPool` has been applied. - fileprivate func replaceData(using state: ObjectState, objectsPool: inout ObjectsPool) -> DeferredUpdate { + /// + /// - Parameters: + /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone the object. + fileprivate func replaceData( + using state: ObjectState, + objectMessageSerialTimestamp: Date?, + objectsPool: inout ObjectsPool, + ) -> DeferredUpdate { switch self { case let .map(map): - .map(map, map.replaceData(using: state, objectsPool: &objectsPool)) + .map( + map, + map.replaceData( + using: state, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + objectsPool: &objectsPool, + ), + ) case let .counter(counter): - .counter(counter, counter.replaceData(using: state)) + .counter( + counter, + counter.replaceData( + using: state, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + ), + ) } } } @@ -199,7 +222,11 @@ internal struct ObjectsPool { logger.log("Updating existing object with ID: \(syncObjectsPoolEntry.state.objectId)", level: .debug) // RTO5c1a1: Override the internal data for the object as per RTLC6, RTLM6 - let deferredUpdate = existingEntry.replaceData(using: syncObjectsPoolEntry.state, objectsPool: &self) + let deferredUpdate = existingEntry.replaceData( + using: syncObjectsPoolEntry.state, + objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, + objectsPool: &self, + ) // RTO5c1a2: Store this update to emit at end updatesToExistingObjects.append(deferredUpdate) } else { @@ -213,14 +240,21 @@ internal struct ObjectsPool { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 let counter = InternalDefaultLiveCounter.createZeroValued(objectID: syncObjectsPoolEntry.state.objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) - _ = counter.replaceData(using: syncObjectsPoolEntry.state) + _ = counter.replaceData( + using: syncObjectsPoolEntry.state, + objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, + ) newEntry = .counter(counter) } else if let objectsMap = syncObjectsPoolEntry.state.map { // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, // set its private objectId equal to ObjectState.objectId, set its private semantics // equal to ObjectState.map.semantics and override its internal data per RTLM6 let map = InternalDefaultLiveMap.createZeroValued(objectID: syncObjectsPoolEntry.state.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) - _ = map.replaceData(using: syncObjectsPoolEntry.state, objectsPool: &self) + _ = map.replaceData( + using: syncObjectsPoolEntry.state, + objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, + objectsPool: &self, + ) newEntry = .map(map) } else { // RTO5c1b1c: Otherwise, log a warning that an unsupported object state message has been received, and discard the current ObjectState without taking any action diff --git a/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift b/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift index 40337b14e..3a7021377 100644 --- a/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift +++ b/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift @@ -3,4 +3,13 @@ import Foundation /// The contents of the spec's `SyncObjectsPool` that is built during an `OBJECT_SYNC` sync sequence. internal struct SyncObjectsPoolEntry { internal var state: ObjectState + /// The `serialTimestamp` of the `ObjectMessage` that generated this entry. + internal var objectMessageSerialTimestamp: Date? + + // We replace the default memberwise initializer because we don't want a default argument for objectMessageSerialTimestamp (want to make sure we don't forget to set it whenever we create an entry). + // swiftlint:disable:next unneeded_synthesized_initializer + internal init(state: ObjectState, objectMessageSerialTimestamp: Date?) { + self.state = state + self.objectMessageSerialTimestamp = objectMessageSerialTimestamp + } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 2c6fb5eed..6ce42481c 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -32,7 +32,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attached) // Set some test data - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 42)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 42), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 42) } @@ -48,7 +48,7 @@ struct InternalDefaultLiveCounterTests { let state = TestFactories.counterObjectState( siteTimeserials: ["site1": "ts1"], // Test value ) - _ = counter.replaceData(using: state) + _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) } @@ -67,7 +67,7 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterCreate), ), ) - _ = counter.replaceData(using: state) + _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) #expect(counter.testsOnly_createOperationIsMerged) return counter @@ -77,7 +77,7 @@ struct InternalDefaultLiveCounterTests { let state = TestFactories.counterObjectState( createOp: nil, // Test value - must be nil to test RTLC6b ) - _ = counter.replaceData(using: state) + _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) // Then: #expect(!counter.testsOnly_createOperationIsMerged) @@ -92,7 +92,7 @@ struct InternalDefaultLiveCounterTests { let state = TestFactories.counterObjectState( count: 42, // Test value ) - _ = counter.replaceData(using: state) + _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 42) } @@ -104,7 +104,8 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) _ = counter.replaceData(using: TestFactories.counterObjectState( count: nil, // Test value - must be nil - )) + ), objectMessageSerialTimestamp: nil) + #expect(try counter.value(coreSDK: coreSDK) == 0) } } @@ -121,7 +122,7 @@ struct InternalDefaultLiveCounterTests { createOp: TestFactories.counterCreateOperation(count: 10), // Test value - must exist count: 5, // Test value - must exist ) - _ = counter.replaceData(using: state) + _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC10a) #expect(counter.testsOnly_createOperationIsMerged) } @@ -139,7 +140,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation @@ -161,7 +162,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply merge operation with no count @@ -201,7 +202,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data and mark create operation as merged - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) _ = counter.mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) #expect(counter.testsOnly_createOperationIsMerged) @@ -225,7 +226,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data but don't mark create operation as merged - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) #expect(!counter.testsOnly_createOperationIsMerged) // Apply COUNTER_CREATE operation @@ -266,7 +267,7 @@ struct InternalDefaultLiveCounterTests { let coreSDK = MockCoreSDK(channelState: .attaching) // Set initial data - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply COUNTER_INC operation @@ -293,7 +294,7 @@ struct InternalDefaultLiveCounterTests { _ = counter.replaceData(using: TestFactories.counterObjectState( siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" count: 5, - )) + ), objectMessageSerialTimestamp: nil) let operation = TestFactories.objectOperation( action: .known(.counterInc), @@ -306,6 +307,7 @@ struct InternalDefaultLiveCounterTests { operation, objectMessageSerial: "ts1", // Less than existing "ts2" objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -337,6 +339,7 @@ struct InternalDefaultLiveCounterTests { operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -365,7 +368,7 @@ struct InternalDefaultLiveCounterTests { try counter.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) // Set initial data - _ = counter.replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5)) + _ = counter.replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5), objectMessageSerialTimestamp: nil) #expect(try counter.value(coreSDK: coreSDK) == 5) let operation = TestFactories.objectOperation( @@ -379,6 +382,7 @@ struct InternalDefaultLiveCounterTests { operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -409,6 +413,7 @@ struct InternalDefaultLiveCounterTests { TestFactories.mapCreateOperation(), objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index c6cc86133..4a67d2e42 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -159,7 +159,7 @@ struct InternalDefaultLiveMapTests { siteTimeserials: ["site1": "ts1", "site2": "ts2"], ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - _ = map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) #expect(map.testsOnly_siteTimeserials == ["site1": "ts1", "site2": "ts2"]) } @@ -176,7 +176,7 @@ struct InternalDefaultLiveMapTests { let state = TestFactories.objectState( createOp: TestFactories.mapCreateOperation(objectId: "arbitrary-id"), ) - _ = map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) return map @@ -184,7 +184,7 @@ struct InternalDefaultLiveMapTests { // When: let state = TestFactories.objectState(objectId: "arbitrary-id", createOp: nil) - _ = map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) // Then: #expect(!map.testsOnly_createOperationIsMerged) @@ -203,7 +203,7 @@ struct InternalDefaultLiveMapTests { entries: [key: entry], ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - _ = map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) let newData = map.testsOnly_data #expect(newData.count == 1) #expect(Set(newData.keys) == ["key1"]) @@ -234,7 +234,7 @@ struct InternalDefaultLiveMapTests { ), ) var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - _ = map.replaceData(using: state, objectsPool: &pool) + _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) // Note that we just check for some basic expected side effects of merging the initial value; RTLM17 is tested in more detail elsewhere // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d) #expect(try map.get(key: "keyFromMapEntries", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromMapEntries") @@ -952,7 +952,7 @@ struct InternalDefaultLiveMapTests { var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Set initial data and mark create operation as merged - _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) + _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectMessageSerialTimestamp: nil, objectsPool: &pool) _ = map.mergeInitialValue(from: TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]), objectsPool: &pool) #expect(map.testsOnly_createOperationIsMerged) @@ -980,7 +980,7 @@ struct InternalDefaultLiveMapTests { var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Set initial data but don't mark create operation as merged - _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectsPool: &pool) + _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectMessageSerialTimestamp: nil, objectsPool: &pool) #expect(!map.testsOnly_createOperationIsMerged) // Apply MAP_CREATE operation @@ -1010,10 +1010,14 @@ struct InternalDefaultLiveMapTests { // Set up the map with an existing site timeserial that will cause the operation to be discarded var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - _ = map.replaceData(using: TestFactories.mapObjectState( - siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" - entries: [key1: entry1], - ), objectsPool: &pool) + _ = map.replaceData( + using: TestFactories.mapObjectState( + siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" + entries: [key1: entry1], + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) let operation = TestFactories.objectOperation( action: .known(.mapSet), @@ -1025,6 +1029,7 @@ struct InternalDefaultLiveMapTests { operation, objectMessageSerial: "ts1", // Less than existing "ts2" objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -1059,6 +1064,7 @@ struct InternalDefaultLiveMapTests { operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -1090,10 +1096,14 @@ struct InternalDefaultLiveMapTests { // Set initial data var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - _ = map.replaceData(using: TestFactories.mapObjectState( - siteTimeserials: [:], - entries: [key1: entry1], - ), objectsPool: &pool) + _ = map.replaceData( + using: TestFactories.mapObjectState( + siteTimeserials: [:], + entries: [key1: entry1], + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") let operation = TestFactories.objectOperation( @@ -1106,6 +1116,7 @@ struct InternalDefaultLiveMapTests { operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -1136,10 +1147,14 @@ struct InternalDefaultLiveMapTests { // Set initial data var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - _ = map.replaceData(using: TestFactories.mapObjectState( - siteTimeserials: [:], - entries: [key1: entry1], - ), objectsPool: &pool) + _ = map.replaceData( + using: TestFactories.mapObjectState( + siteTimeserials: [:], + entries: [key1: entry1], + ), + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") let operation = TestFactories.objectOperation( @@ -1152,6 +1167,7 @@ struct InternalDefaultLiveMapTests { operation, objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) @@ -1182,6 +1198,7 @@ struct InternalDefaultLiveMapTests { TestFactories.counterCreateOperation(), objectMessageSerial: "ts1", objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, objectsPool: &pool, ) diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 65e67247b..2609d9f4d 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -100,7 +100,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify the existing map was updated by checking side effects of InternalDefaultLiveMap.replaceData(using:) let updatedMap = try #require(pool.entries["map:hash@123"]?.mapValue) @@ -135,7 +135,7 @@ struct ObjectsPoolTests { count: 10, ) - pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify the existing counter was updated by checking side effects of InternalDefaultLiveCounter.replaceData(using:) let updatedCounter = try #require(pool.entries["counter:hash@123"]?.counterValue) @@ -163,7 +163,7 @@ struct ObjectsPoolTests { count: 100, ) - pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify a new counter was created and data was set by checking side effects of InternalDefaultLiveCounter.replaceData(using:) let newCounter = try #require(pool.entries["counter:hash@456"]?.counterValue) @@ -191,7 +191,7 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify a new map was created and data was set by checking side effects of InternalDefaultLiveMap.replaceData(using:) let newMap = try #require(pool.entries["map:hash@789"]?.mapValue) @@ -218,7 +218,7 @@ struct ObjectsPoolTests { let invalidObjectState = TestFactories.objectState(objectId: "invalid") - pool.applySyncObjectsPool([invalidObjectState, validObjectState].map { .init(state: $0) }, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([invalidObjectState, validObjectState].map { .init(state: $0, objectMessageSerialTimestamp: nil) }, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Check that there's no entry for the key that we don't know how to handle, and that it didn't interfere with the insertion of the we one that we do know how to handle #expect(Set(pool.entries.keys) == ["root", "counter:hash@456"]) @@ -243,7 +243,7 @@ struct ObjectsPoolTests { // Only sync one of the existing objects let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") - pool.applySyncObjectsPool([.init(state: objectState)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify only synced object and root remain #expect(pool.entries.count == 2) // root + map:hash@1 @@ -324,7 +324,7 @@ struct ObjectsPoolTests { // Note: "map:toremove@1" is not in sync, so it should be removed ] - pool.applySyncObjectsPool(syncObjects.map { .init(state: $0) }, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.applySyncObjectsPool(syncObjects.map { .init(state: $0, objectMessageSerialTimestamp: nil) }, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) // Verify final state #expect(pool.entries.count == 5) // root + 4 synced objects From 951473c176b8c48a38d8a239fdf8e49e5db888ab Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 22 Jul 2025 11:46:53 +0100 Subject: [PATCH 076/225] Pull the RTLM14d "is map entry tombstoned?" check into method The logic for this check is going to expand when we implement the tombstoning spec [1], so let's DRY it up. [1] https://github.com/ably/specification/pull/350 --- .../Internal/InternalDefaultLiveMap.swift | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index e7880e771..6b779ddf4 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -147,9 +147,7 @@ internal final class InternalDefaultLiveMap: Sendable { return mutex.withLock { // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map mutableState.data.values.count { entry in - // RTLM14a: The method returns true if ObjectsMapEntry.tombstone is true - // RTLM14b: Otherwise, it returns false - entry.tombstone != true + !Self.isEntryTombstoned(entry) } } } @@ -170,7 +168,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned var result: [(key: String, value: InternalLiveMapValue)] = [] - for (key, entry) in mutableState.data { + for (key, entry) in mutableState.data where !Self.isEntryTombstoned(entry) { // Convert entry to LiveMapValue using the same logic as get(key:) if let value = convertEntryToLiveMapValue(entry, delegate: delegate) { result.append((key: key, value: value)) @@ -682,11 +680,16 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - Helper Methods + /// Returns whether a map entry should be considered tombstoned, per the check described in RTLM14. + private static func isEntryTombstoned(_ entry: InternalObjectsMapEntry) -> Bool { + // RTLM14a, RTLM14b + entry.tombstone == true + } + /// Converts an InternalObjectsMapEntry to LiveMapValue using the same logic as get(key:) /// This is used by entries to ensure consistent value conversion private func convertEntryToLiveMapValue(_ entry: InternalObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> InternalLiveMapValue? { // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null - // This is also equivalent to the RTLM14 check if entry.tombstone == true { return nil } From 83de58af4952099242bafd17d8b90079bdd28ab8 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 29 Jul 2025 16:52:54 +0100 Subject: [PATCH 077/225] Bump ably-cocoa to latest commit on LiveObjects integration branch The commit that we're currently using no longer exists due to rebases of feature branches (which have now been merged). --- ably-cocoa | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ably-cocoa b/ably-cocoa index 4e0601a45..c72e1d749 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 4e0601a4567235a2e5afd36eb19d363057b642da +Subproject commit c72e1d7498bfcd0562f67442601e5056c2592631 From b701b4646a3f5a29e3be12f7731d3cd1e4e6fe22 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 30 Jul 2025 14:18:17 +0100 Subject: [PATCH 078/225] Create a PartialObjectOperation type We need a type that represents (when considering [1] at cb11ba8) RTO13's "partial ObjectOperation"; namely an ObjectOperation without an objectId property. This is something that is easy to represent in TypeScript but a bit of a faff in Swift; we have to create a new type for it. Code largely generated by Cursor at my instruction. [1] https://github.com/ably/specification/pull/353 --- .../Protocol/ObjectMessage.swift | 93 +++++++++++++++++-- .../Protocol/WireObjectMessage.swift | 62 +++++++++++-- 2 files changed, 141 insertions(+), 14 deletions(-) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 32201b1e1..caf21cd7e 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -31,6 +31,19 @@ internal struct OutboundObjectMessage { internal var serialTimestamp: Date? // OM2j } +/// A partial version of `ObjectOperation` that excludes the `objectId` property. Used for encoding initial values where the `objectId` is not yet known. +/// +/// `ObjectOperation` delegates its encoding and decoding to `PartialObjectOperation`. +internal struct PartialObjectOperation { + internal var action: WireEnum // OOP3a + internal var mapOp: ObjectsMapOp? // OOP3c + internal var counterOp: WireObjectsCounterOp? // OOP3d + internal var map: ObjectsMap? // OOP3e + internal var counter: WireObjectsCounter? // OOP3f + internal var nonce: String? // OOP3g + internal var initialValue: String? // OOP3h +} + internal struct ObjectOperation { internal var action: WireEnum // OOP3a internal var objectId: String // OOP3b @@ -141,16 +154,81 @@ internal extension ObjectOperation { wireObjectOperation: WireObjectOperation, format: AblyPlugin.EncodingFormat ) throws(InternalError) { - action = wireObjectOperation.action + // Decode the objectId first since it's not part of PartialObjectOperation objectId = wireObjectOperation.objectId - mapOp = try wireObjectOperation.mapOp.map { wireObjectsMapOp throws(InternalError) in + + // Delegate to PartialObjectOperation for decoding + let partialOperation = try PartialObjectOperation( + partialWireObjectOperation: PartialWireObjectOperation( + action: wireObjectOperation.action, + mapOp: wireObjectOperation.mapOp, + counterOp: wireObjectOperation.counterOp, + map: wireObjectOperation.map, + counter: wireObjectOperation.counter, + nonce: wireObjectOperation.nonce, + initialValue: wireObjectOperation.initialValue, + ), + format: format, + ) + + // Copy the decoded values + action = partialOperation.action + mapOp = partialOperation.mapOp + counterOp = partialOperation.counterOp + map = partialOperation.map + counter = partialOperation.counter + nonce = partialOperation.nonce + initialValue = partialOperation.initialValue + } + + /// Converts this `ObjectOperation` to a `WireObjectOperation`, applying the data encoding rules of OD4. + /// + /// - Parameters: + /// - format: The format to use when applying the encoding rules of OD4. + func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectOperation { + let partialWireOperation = PartialObjectOperation( + action: action, + mapOp: mapOp, + counterOp: counterOp, + map: map, + counter: counter, + nonce: nonce, + initialValue: initialValue, + ).toWire(format: format) + + // Create WireObjectOperation from PartialWireObjectOperation and add objectId + return WireObjectOperation( + action: partialWireOperation.action, + objectId: objectId, + mapOp: partialWireOperation.mapOp, + counterOp: partialWireOperation.counterOp, + map: partialWireOperation.map, + counter: partialWireOperation.counter, + nonce: partialWireOperation.nonce, + initialValue: partialWireOperation.initialValue, + ) + } +} + +internal extension PartialObjectOperation { + /// Initializes a `PartialObjectOperation` from a `PartialWireObjectOperation`, applying the data decoding rules of OD5. + /// + /// - Parameters: + /// - format: The format to use when applying the decoding rules of OD5. + /// - Throws: `InternalError` if JSON or Base64 decoding fails. + init( + partialWireObjectOperation: PartialWireObjectOperation, + format: AblyPlugin.EncodingFormat + ) throws(InternalError) { + action = partialWireObjectOperation.action + mapOp = try partialWireObjectOperation.mapOp.map { wireObjectsMapOp throws(InternalError) in try .init(wireObjectsMapOp: wireObjectsMapOp, format: format) } - counterOp = wireObjectOperation.counterOp - map = try wireObjectOperation.map.map { wireMap throws(InternalError) in + counterOp = partialWireObjectOperation.counterOp + map = try partialWireObjectOperation.map.map { wireMap throws(InternalError) in try .init(wireObjectsMap: wireMap, format: format) } - counter = wireObjectOperation.counter + counter = partialWireObjectOperation.counter // Do not access on inbound data, per OOP3g nonce = nil @@ -158,14 +236,13 @@ internal extension ObjectOperation { initialValue = nil } - /// Converts this `ObjectOperation` to a `WireObjectOperation`, applying the data encoding rules of OD4. + /// Converts this `PartialObjectOperation` to a `PartialWireObjectOperation`, applying the data encoding rules of OD4. /// /// - Parameters: /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectOperation { + func toWire(format: AblyPlugin.EncodingFormat) -> PartialWireObjectOperation { .init( action: action, - objectId: objectId, mapOp: mapOp?.toWire(format: format), counterOp: counterOp, map: map?.toWire(format: format), diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index d9a26350f..163cde4d1 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -157,9 +157,11 @@ internal enum ObjectsMapSemantics: Int { case lww = 0 } -internal struct WireObjectOperation { +/// A partial version of `WireObjectOperation` that excludes the `objectId` property. Used for encoding initial values where the `objectId` is not yet known. +/// +/// `WireObjectOperation` delegates its encoding and decoding to `PartialWireObjectOperation`. +internal struct PartialWireObjectOperation { internal var action: WireEnum // OOP3a - internal var objectId: String // OOP3b internal var mapOp: WireObjectsMapOp? // OOP3c internal var counterOp: WireObjectsCounterOp? // OOP3d internal var map: WireObjectsMap? // OOP3e @@ -168,10 +170,9 @@ internal struct WireObjectOperation { internal var initialValue: String? // OOP3h } -extension WireObjectOperation: WireObjectCodable { +extension PartialWireObjectOperation: WireObjectCodable { internal enum WireKey: String { case action - case objectId case mapOp case counterOp case map @@ -182,7 +183,6 @@ extension WireObjectOperation: WireObjectCodable { internal init(wireObject: [String: WireValue]) throws(InternalError) { action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) - objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) mapOp = try wireObject.optionalDecodableValueForKey(WireKey.mapOp.rawValue) counterOp = try wireObject.optionalDecodableValueForKey(WireKey.counterOp.rawValue) map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) @@ -197,7 +197,6 @@ extension WireObjectOperation: WireObjectCodable { internal var toWireObject: [String: WireValue] { var result: [String: WireValue] = [ WireKey.action.rawValue: .number(action.rawValue as NSNumber), - WireKey.objectId.rawValue: .string(objectId), ] if let mapOp { @@ -223,6 +222,57 @@ extension WireObjectOperation: WireObjectCodable { } } +internal struct WireObjectOperation { + internal var action: WireEnum // OOP3a + internal var objectId: String // OOP3b + internal var mapOp: WireObjectsMapOp? // OOP3c + internal var counterOp: WireObjectsCounterOp? // OOP3d + internal var map: WireObjectsMap? // OOP3e + internal var counter: WireObjectsCounter? // OOP3f + internal var nonce: String? // OOP3g + internal var initialValue: String? // OOP3h +} + +extension WireObjectOperation: WireObjectCodable { + internal enum WireKey: String { + case objectId + } + + internal init(wireObject: [String: WireValue]) throws(InternalError) { + // Decode the objectId first since it's not part of PartialWireObjectOperation + objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) + + // Delegate to PartialWireObjectOperation for decoding + let partialOperation = try PartialWireObjectOperation(wireObject: wireObject) + + // Copy the decoded values + action = partialOperation.action + mapOp = partialOperation.mapOp + counterOp = partialOperation.counterOp + map = partialOperation.map + counter = partialOperation.counter + nonce = partialOperation.nonce + initialValue = partialOperation.initialValue + } + + internal var toWireObject: [String: WireValue] { + var result = PartialWireObjectOperation( + action: action, + mapOp: mapOp, + counterOp: counterOp, + map: map, + counter: counter, + nonce: nonce, + initialValue: initialValue, + ).toWireObject + + // Add the objectId field + result[WireKey.objectId.rawValue] = .string(objectId) + + return result + } +} + internal struct WireObjectState { internal var objectId: String // OST2a internal var siteTimeserials: [String: String] // OST2b From 2d132ef4b9eb39dc0befc8454a51b637d5c5944d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 30 Jul 2025 14:48:47 +0100 Subject: [PATCH 079/225] Extract operations' channel state validation to helper Code largely generated by Cursor at my request. --- .../AblyLiveObjects/Internal/CoreSDK.swift | 25 +++++++++++++++++ .../Internal/InternalDefaultLiveCounter.swift | 9 +------ .../Internal/InternalDefaultLiveMap.swift | 27 +++---------------- .../InternalDefaultRealtimeObjects.swift | 9 +------ .../Internal/LiveObjectMutableState.swift | 9 +------ 5 files changed, 31 insertions(+), 48 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index e7bc5b9e0..00695c18a 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -40,3 +40,28 @@ internal final class DefaultCoreSDK: CoreSDK { channel.state } } + +// MARK: - Channel State Validation + +/// Extension on CoreSDK to provide channel state validation utilities. +internal extension CoreSDK { + /// Validates that the channel is not in any of the specified invalid states. + /// + /// - Parameters: + /// - invalidStates: Array of channel states that are considered invalid for the operation + /// - operationDescription: A description of the operation being performed, used in error messages + /// - Throws: `ARTErrorInfo` with code 90001 and statusCode 400 if the channel is in any of the invalid states + func validateChannelState( + notIn invalidStates: [ARTRealtimeChannelState], + operationDescription: String, + ) throws(ARTErrorInfo) { + let currentChannelState = channelState + if invalidStates.contains(currentChannelState) { + throw LiveObjectsError.objectsOperationFailedInvalidChannelState( + operationDescription: operationDescription, + channelState: currentChannelState, + ) + .toARTErrorInfo() + } + } +} diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 635783fec..d1149f263 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -81,14 +81,7 @@ internal final class InternalDefaultLiveCounter: Sendable { internal func value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveCounter.value", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveCounter.value") return mutex.withLock { // RTLC5c diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 6b779ddf4..3790b57d3 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -111,14 +111,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// Returns the value associated with a given key, following RTLM5d specification. internal func get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveMap.get", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.get") let entry = mutex.withLock { mutableState.data[key] @@ -135,14 +128,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal func size(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Int { // RTLM10c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveMap.size", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.size") return mutex.withLock { // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map @@ -154,14 +140,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal func entries(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { // RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "LiveMap.entries", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.entries") return mutex.withLock { // RTLM11d: Returns key-value pairs from the internal data map diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index ba5741d4c..81c9a86ff 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -93,14 +93,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool internal func getRoot(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { // RTO1b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "getRoot", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "getRoot") let syncStatus = mutex.withLock { mutableState.syncStatus diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index 4c608fc52..f35fc9ba7 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -88,14 +88,7 @@ internal struct LiveObjectMutableState { @discardableResult internal mutating func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK, updateSelfLater: @escaping UpdateLiveObject) throws(ARTErrorInfo) -> any AblyLiveObjects.SubscribeResponse { // RTLO4b2 - let currentChannelState = coreSDK.channelState - if currentChannelState == .detached || currentChannelState == .failed { - throw LiveObjectsError.objectsOperationFailedInvalidChannelState( - operationDescription: "subscribe", - channelState: currentChannelState, - ) - .toARTErrorInfo() - } + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "subscribe") let subscription = Subscription(listener: listener, updateLiveObject: updateSelfLater) subscriptionsByID[subscription.id] = subscription From bbf82295ed4cc5b144b1500d1a7afca10ba3912b Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 30 Jul 2025 15:11:17 +0100 Subject: [PATCH 080/225] Add placeholder for spec's #publish method Based on [1] at cb11ba8. Implementation deferred to #47. [1] https://github.com/ably/specification/pull/353 --- Sources/AblyLiveObjects/Internal/CoreSDK.swift | 6 ++++-- .../Internal/InternalDefaultRealtimeObjects.swift | 4 ++-- .../Public Proxy Objects/PublicDefaultRealtimeObjects.swift | 4 ++-- Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift | 4 ++-- Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift | 2 +- 5 files changed, 11 insertions(+), 9 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 00695c18a..252e5b0eb 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -5,7 +5,8 @@ internal import AblyPlugin /// /// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to AblyPlugin's Objective-C API (i.e. boxing). internal protocol CoreSDK: AnyObject, Sendable { - func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) + /// Implements the internal `#publish` method of RTO15. + func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) /// Returns the current state of the Realtime channel that this wraps. var channelState: ARTRealtimeChannelState { get } @@ -28,7 +29,8 @@ internal final class DefaultCoreSDK: CoreSDK { // MARK: - CoreSDK conformance - internal func sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + internal func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + // TODO: Implement the full spec of RTO15 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/47) try await DefaultInternalPlugin.sendObject( objectMessages: objectMessages, channel: channel, diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 81c9a86ff..6d4133530 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -206,8 +206,8 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool // MARK: - Sending `OBJECT` ProtocolMessage // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. - internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage], coreSDK: CoreSDK) async throws(InternalError) { - try await coreSDK.sendObject(objectMessages: objectMessages) + internal func testsOnly_publish(objectMessages: [OutboundObjectMessage], coreSDK: CoreSDK) async throws(InternalError) { + try await coreSDK.publish(objectMessages: objectMessages) } // MARK: - Testing diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index 4a0f9ffea..94be8e405 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -75,8 +75,8 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { proxied.testsOnly_receivedObjectProtocolMessages } - internal func testsOnly_sendObject(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { - try await proxied.testsOnly_sendObject(objectMessages: objectMessages, coreSDK: coreSDK) + internal func testsOnly_publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + try await proxied.testsOnly_publish(objectMessages: objectMessages, coreSDK: coreSDK) } internal var testsOnly_receivedObjectSyncProtocolMessages: AsyncStream<[InboundObjectMessage]> { diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index d33ca22d1..1a1445d84 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -90,7 +90,7 @@ struct AblyLiveObjectsTests { // (This objectId comes from copying that which was given in an expected value in an error message from Realtime) let realtimeCreatedMapObjectID = "map:iC4Nq8EbTSEmw-_tDJdVV8HfiBvJGpZmO_WbGbh0_-4@\(currentAblyTimestamp)" - try await channel.testsOnly_nonTypeErasedObjects.testsOnly_sendObject(objectMessages: [ + try await channel.testsOnly_nonTypeErasedObjects.testsOnly_publish(objectMessages: [ OutboundObjectMessage( operation: .init( action: .known(.mapCreate), @@ -110,7 +110,7 @@ struct AblyLiveObjectsTests { // 7. Now, send an invalid OBJECT ProtocolMessage to check that ably-cocoa correctly reports on its NACK. let invalidObjectThrownError = try await #require(throws: ARTErrorInfo.self) { do throws(InternalError) { - try await channel.testsOnly_nonTypeErasedObjects.testsOnly_sendObject(objectMessages: [ + try await channel.testsOnly_nonTypeErasedObjects.testsOnly_publish(objectMessages: [ .init(), ]) } catch { diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 6893a5f75..4883717b4 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -11,7 +11,7 @@ final class MockCoreSDK: CoreSDK { _channelState = channelState } - func sendObject(objectMessages _: [AblyLiveObjects.OutboundObjectMessage]) async throws(AblyLiveObjects.InternalError) { + func publish(objectMessages _: [AblyLiveObjects.OutboundObjectMessage]) async throws(AblyLiveObjects.InternalError) { protocolRequirementNotImplemented() } From cb9a11cca5a18529d46c49b98a6250c41f7ba0a7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 30 Jul 2025 09:47:02 +0100 Subject: [PATCH 081/225] Add JSON objects and arrays to allowed LiveMap values Public API based on [1] at 6d43429. [1] https://github.com/ably/ably-js/pull/2052 --- .../Internal/InternalDefaultLiveMap.swift | 10 +++-- .../Internal/InternalLiveMapValue.swift | 10 +++++ .../AblyLiveObjects/Public/PublicTypes.swift | 24 ++++++++++-- .../AblyLiveObjects/Utility/JSONValue.swift | 26 ++++++------- .../InternalDefaultLiveMapTests.swift | 38 ++++++++++++++++--- 5 files changed, 84 insertions(+), 24 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 3790b57d3..9676fe0d7 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -695,9 +695,13 @@ internal final class InternalDefaultLiveMap: Sendable { switch string { case let .string(string): return .primitive(.string(string)) - case .json: - // TODO: Understand how to handle JSON values (https://github.com/ably/specification/pull/333/files#r2164561055) - notYetImplemented() + case let .json(objectOrArray): + switch objectOrArray { + case let .array(array): + return .primitive(.jsonArray(array)) + case let .object(object): + return .primitive(.jsonObject(object)) + } } } diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift index 95b36fe31..0d6cb79b3 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -52,6 +52,16 @@ internal enum InternalLiveMapValue: Sendable, Equatable { primitiveValue?.dataValue } + /// If this `InternalLiveMapValue` has case `primitive` with a JSON array value, this returns that value. Else, it returns `nil`. + internal var jsonArrayValue: [JSONValue]? { + primitiveValue?.jsonArrayValue + } + + /// If this `InternalLiveMapValue` has case `primitive` with a JSON object value, this returns that value. Else, it returns `nil`. + internal var jsonObjectValue: [String: JSONValue]? { + primitiveValue?.jsonObjectValue + } + // MARK: - Equatable Implementation internal static func == (lhs: InternalLiveMapValue, rhs: InternalLiveMapValue) -> Bool { diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 140191295..be64bcac5 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -175,7 +175,7 @@ public protocol BatchContextLiveMap: AnyObject, Sendable { /// Mirrors the ``LiveMap/get(key:)`` method and returns the value associated with a key in the map. /// /// - Parameter key: The key to retrieve the value for. - /// - Returns: A ``LiveObject``, a primitive type (string, number, boolean, 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. + /// - 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) -> LiveMapValue? /// Returns the number of key-value pairs in the map. @@ -226,14 +226,14 @@ public protocol BatchContextLiveCounter: AnyObject, Sendable { /// 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, or binary data (see ``PrimitiveObjectValue``). +/// 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 (see ``PrimitiveObjectValue``). 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, 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. + /// - 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. @@ -291,6 +291,8 @@ public enum PrimitiveObjectValue: Sendable, Equatable { case number(Double) case bool(Bool) case data(Data) + case jsonArray([JSONValue]) + case jsonObject([String: JSONValue]) // MARK: - Convenience getters for associated values @@ -325,6 +327,22 @@ public enum PrimitiveObjectValue: Sendable, Equatable { } return nil } + + /// If this `PrimitiveObjectValue` 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 `PrimitiveObjectValue` 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 + } } /// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift index d03b86e51..3030dcaa1 100644 --- a/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -25,7 +25,7 @@ import Foundation /// ``` /// /// > Note: To write a `JSONValue` that corresponds to the `null` JSON value, you must explicitly write `.null`. `JSONValue` deliberately does not implement the `ExpressibleByNilLiteral` protocol in order to avoid confusion between a value of type `JSONValue?` and a `JSONValue` with case `.null`. -internal indirect enum JSONValue: Sendable, Equatable { +public indirect enum JSONValue: Sendable, Equatable { case object([String: JSONValue]) case array([JSONValue]) case string(String) @@ -36,7 +36,7 @@ internal indirect enum JSONValue: Sendable, Equatable { // MARK: - Convenience getters for associated values /// If this `JSONValue` has case `object`, this returns the associated value. Else, it returns `nil`. - internal var objectValue: [String: JSONValue]? { + public var objectValue: [String: JSONValue]? { if case let .object(objectValue) = self { objectValue } else { @@ -45,7 +45,7 @@ internal indirect enum JSONValue: Sendable, Equatable { } /// If this `JSONValue` has case `array`, this returns the associated value. Else, it returns `nil`. - internal var arrayValue: [JSONValue]? { + public var arrayValue: [JSONValue]? { if case let .array(arrayValue) = self { arrayValue } else { @@ -54,7 +54,7 @@ internal indirect enum JSONValue: Sendable, Equatable { } /// If this `JSONValue` has case `string`, this returns the associated value. Else, it returns `nil`. - internal var stringValue: String? { + public var stringValue: String? { if case let .string(stringValue) = self { stringValue } else { @@ -63,7 +63,7 @@ internal indirect enum JSONValue: Sendable, Equatable { } /// If this `JSONValue` has case `number`, this returns the associated value. Else, it returns `nil`. - internal var numberValue: NSNumber? { + public var numberValue: NSNumber? { if case let .number(numberValue) = self { numberValue } else { @@ -72,7 +72,7 @@ internal indirect enum JSONValue: Sendable, Equatable { } /// If this `JSONValue` has case `bool`, this returns the associated value. Else, it returns `nil`. - internal var boolValue: Bool? { + public var boolValue: Bool? { if case let .bool(boolValue) = self { boolValue } else { @@ -81,7 +81,7 @@ internal indirect enum JSONValue: Sendable, Equatable { } /// Returns true if and only if this `JSONValue` has case `null`. - internal var isNull: Bool { + public var isNull: Bool { if case .null = self { true } else { @@ -91,37 +91,37 @@ internal indirect enum JSONValue: Sendable, Equatable { } extension JSONValue: ExpressibleByDictionaryLiteral { - internal init(dictionaryLiteral elements: (String, JSONValue)...) { + public init(dictionaryLiteral elements: (String, JSONValue)...) { self = .object(.init(uniqueKeysWithValues: elements)) } } extension JSONValue: ExpressibleByArrayLiteral { - internal init(arrayLiteral elements: JSONValue...) { + public init(arrayLiteral elements: JSONValue...) { self = .array(elements) } } extension JSONValue: ExpressibleByStringLiteral { - internal init(stringLiteral value: String) { + public init(stringLiteral value: String) { self = .string(value) } } extension JSONValue: ExpressibleByIntegerLiteral { - internal init(integerLiteral value: Int) { + public init(integerLiteral value: Int) { self = .number(value as NSNumber) } } extension JSONValue: ExpressibleByFloatLiteral { - internal init(floatLiteral value: Double) { + public init(floatLiteral value: Double) { self = .number(value as NSNumber) } } extension JSONValue: ExpressibleByBooleanLiteral { - internal init(booleanLiteral value: Bool) { + public init(booleanLiteral value: Bool) { self = .bool(value) } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 4a67d2e42..528fea676 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -81,7 +81,7 @@ struct InternalDefaultLiveMapTests { #expect(result?.numberValue == 123.456) } - // @spec RTLM5d2e + // @specOneOf(1/3) RTLM5d2e - When `string` is a string @Test func returnsStringValue() throws { let logger = TestLogger() @@ -92,6 +92,28 @@ struct InternalDefaultLiveMapTests { #expect(result?.stringValue == "test") } + // @specOneOf(2/3) RTLM5d2e - When `string` is a JSON array + @Test + func returnsJSONArrayValue() throws { + let logger = TestLogger() + let entry = TestFactories.internalMapEntry(data: ObjectData(string: .json(.array(["foo"])))) + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) + #expect(result?.jsonArrayValue == ["foo"]) + } + + // @specOneOf(3/3) RTLM5d2e - When `string` is a JSON object + @Test + func returnsJSONObjectValue() throws { + let logger = TestLogger() + let entry = TestFactories.internalMapEntry(data: ObjectData(string: .json(.object(["foo": "bar"])))) + let coreSDK = MockCoreSDK(channelState: .attaching) + let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) + #expect(result?.jsonObjectValue == ["foo": "bar"]) + } + // @spec RTLM5d2f1 @Test func returnsNilWhenReferencedObjectDoesNotExist() throws { @@ -389,6 +411,8 @@ struct InternalDefaultLiveMapTests { "bytes": TestFactories.internalMapEntry(data: ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c "number": TestFactories.internalMapEntry(data: ObjectData(number: NSNumber(value: 42))), // RTLM5d2d "string": TestFactories.internalMapEntry(data: ObjectData(string: .string("hello"))), // RTLM5d2e + "jsonArray": TestFactories.internalMapEntry(data: ObjectData(string: .json(.array(["foo"])))), // RTLM5d2e + "jsonObject": TestFactories.internalMapEntry(data: ObjectData(string: .json(.object(["foo": "bar"])))), // RTLM5d2e "mapRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 "counterRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 ], @@ -403,16 +427,18 @@ struct InternalDefaultLiveMapTests { let keys = try map.keys(coreSDK: coreSDK, delegate: delegate) let values = try map.values(coreSDK: coreSDK, delegate: delegate) - #expect(size == 6) - #expect(entries.count == 6) - #expect(keys.count == 6) - #expect(values.count == 6) + #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 @@ -420,6 +446,8 @@ struct InternalDefaultLiveMapTests { #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 } From 31894f88c4b690e83bc9649e3ffea6ed7e6534fd Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 30 Jul 2025 11:34:26 +0100 Subject: [PATCH 082/225] Remove incorrect comment --- Tests/AblyLiveObjectsTests/ObjectMessageTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift index 12182b65d..ccf830951 100644 --- a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift @@ -328,7 +328,7 @@ struct ObjectMessageTests { } struct JSONTests { - // @specOneOf(1/3) OD5b1 - This spec point is a bit weirdly worded, but here we're testing the case where `encoding` is not set and hence OD5a2 does not apply to the `string` property + // @specOneOf(1/3) OD5b1 @Test func boolean() throws { let wireData = WireObjectData(boolean: true) From 562f7b6918f90314c7555af023bb300c6b9858f4 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 30 Jul 2025 11:35:08 +0100 Subject: [PATCH 083/225] Fix typo in comment --- Tests/AblyLiveObjectsTests/ObjectMessageTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift index ccf830951..ff357d6d7 100644 --- a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift @@ -355,7 +355,7 @@ struct ObjectMessageTests { #expect(objectData.string == nil) } - // @specOneOf(3/3) OD5b1 - This spec point is a bit weirdly worded, but here we're testing the case where `encoding` is not set and hence OD5a3 does not apply to the `string` property + // @specOneOf(3/3) OD5b1 - This spec point is a bit weirdly worded, but here we're testing the case where `encoding` is not set and hence OD5b3 does not apply to the `string` property @Test func string() throws { let testString = "hello world" From 40343829db4b8c7c731e6ebac7b415c84499f586 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 30 Jul 2025 10:30:40 +0100 Subject: [PATCH 084/225] Update the way that JSON map values are encoded on wire MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This implements the change described in [1]. It has not yet been specified — have created #46 for adding specification point references later. (We need to implement it now because Realtime has removed support for the previous wire representation.) Resolves #44. [1] https://ably.atlassian.net/wiki/spaces/LOB/pages/4203380843/LODR-040+ObjectData+JSON+encoding+of+object+and+array+values --- .../Internal/InternalDefaultLiveMap.swift | 20 +-- .../Protocol/ObjectMessage.swift | 47 ++---- .../Protocol/WireObjectMessage.swift | 12 +- .../Helpers/TestFactories.swift | 10 +- .../InternalDefaultLiveMapTests.swift | 50 +++--- .../ObjectMessageTests.swift | 150 +++++++----------- .../ObjectsPoolTests.swift | 4 +- .../WireObjectMessageTests.swift | 6 - 8 files changed, 122 insertions(+), 177 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 9676fe0d7..78033b51b 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -692,16 +692,16 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM5d2e: If ObjectsMapEntry.data.string exists, return it if let string = entry.data.string { - switch string { - case let .string(string): - return .primitive(.string(string)) - case let .json(objectOrArray): - switch objectOrArray { - case let .array(array): - return .primitive(.jsonArray(array)) - case let .object(object): - return .primitive(.jsonObject(object)) - } + return .primitive(.string(string)) + } + + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + if let json = entry.data.json { + switch json { + case let .array(array): + return .primitive(.jsonArray(array)) + case let .object(object): + return .primitive(.jsonObject(object)) } } diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index caf21cd7e..a9adea8e9 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -56,18 +56,12 @@ internal struct ObjectOperation { } internal struct ObjectData { - /// The values that the `string` property might hold, before being encoded per OD4 or after being decoded per OD5. - internal enum StringPropertyContent { - case string(String) - case json(JSONObjectOrArray) - } - internal var objectId: String? // OD2a - internal var encoding: String? // OD2b internal var boolean: Bool? // OD2c internal var bytes: Data? // OD2d internal var number: NSNumber? // OD2e - internal var string: StringPropertyContent? // OD2f + internal var string: String? // OD2f + internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) } internal struct ObjectsMapOp { @@ -264,9 +258,9 @@ internal extension ObjectData { format: AblyPlugin.EncodingFormat ) throws(InternalError) { objectId = wireObjectData.objectId - encoding = wireObjectData.encoding boolean = wireObjectData.boolean number = wireObjectData.number + string = wireObjectData.string // OD5: Decode data based on format switch format { @@ -300,16 +294,12 @@ internal extension ObjectData { } } - if let wireString = wireObjectData.string { - // OD5a2, OD5b3: If ObjectData.encoding is set to "json", the ObjectData.string content is decoded by parsing the string as JSON - if wireObjectData.encoding == "json" { - let jsonValue = try JSONObjectOrArray(jsonString: wireString) - string = .json(jsonValue) - } else { - string = .string(wireString) - } + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + if let wireJson = wireObjectData.json { + let jsonValue = try JSONObjectOrArray(jsonString: wireJson) + json = jsonValue } else { - string = nil + json = nil } } @@ -334,20 +324,6 @@ internal extension ObjectData { nil } - // 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 - let (wireString, wireEncoding): (String?, String?) = if let stringContent = string { - switch stringContent { - case let .string(str): - (str, nil) - case let .json(jsonValue): - // OD4c5, OD4d5: A payload consisting of a JSON-encodable object or array is stringified as a JSON object or array, represented as a JSON string and the result is set on the ObjectData.string attribute. The ObjectData.encoding attribute is then set to "json" - (jsonValue.toJSONString, "json") - } - } else { - (nil, nil) - } - let wireNumber: NSNumber? = if let number { switch format { case .json: @@ -363,11 +339,14 @@ internal extension ObjectData { return .init( objectId: objectId, - encoding: wireEncoding, boolean: boolean, bytes: wireBytes, number: wireNumber, - string: wireString, + // 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-cocoa-liveobjects-plugin/issues/46) + json: json?.toJSONString, ) } } diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 163cde4d1..b0d7514ce 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -475,30 +475,30 @@ extension WireObjectsMapEntry: WireObjectCodable { internal struct WireObjectData { internal var objectId: String? // OD2a - internal var encoding: String? // OD2b 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-cocoa-liveobjects-plugin/issues/46) } extension WireObjectData: WireObjectCodable { internal enum WireKey: String { case objectId - case encoding case boolean case bytes case number case string + case json } internal init(wireObject: [String: WireValue]) throws(InternalError) { objectId = try wireObject.optionalStringValueForKey(WireKey.objectId.rawValue) - encoding = try wireObject.optionalStringValueForKey(WireKey.encoding.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] { @@ -507,9 +507,6 @@ extension WireObjectData: WireObjectCodable { if let objectId { result[WireKey.objectId.rawValue] = .string(objectId) } - if let encoding { - result[WireKey.encoding.rawValue] = .string(encoding) - } if let boolean { result[WireKey.boolean.rawValue] = .bool(boolean) } @@ -522,6 +519,9 @@ extension WireObjectData: WireObjectCodable { if let string { result[WireKey.string.rawValue] = .string(string) } + if let json { + result[WireKey.json.rawValue] = .string(json) + } return result } diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index 362e24ed7..dd7cc38dc 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -429,7 +429,7 @@ struct TestFactories { entry: mapEntry( tombstone: tombstone, timeserial: timeserial, - data: ObjectData(string: .string(value)), + data: ObjectData(string: value), ), ) } @@ -448,7 +448,7 @@ struct TestFactories { entry: internalMapEntry( tombstone: tombstone, timeserial: timeserial, - data: ObjectData(string: .string(value)), + data: ObjectData(string: value), ), ) } @@ -539,7 +539,7 @@ struct TestFactories { entries: [String: String] = ["key1": "value1", "key2": "value2"], ) -> ObjectsMap { let mapEntries = entries.mapValues { value in - mapEntry(data: ObjectData(string: .string(value))) + mapEntry(data: ObjectData(string: value)) } return objectsMap(entries: mapEntries) } @@ -567,7 +567,7 @@ struct TestFactories { objectId: objectId, mapOp: ObjectsMapOp( key: key, - data: ObjectData(string: .string(value)), + data: ObjectData(string: value), ), ), serial: serial, @@ -676,7 +676,7 @@ struct TestFactories { entries: [String: String] = ["key1": "value1", "key2": "value2"], ) -> InboundObjectMessage { let mapEntries = entries.mapValues { value in - mapEntry(data: ObjectData(string: .string(value))) + mapEntry(data: ObjectData(string: value)) } return rootObjectMessage(entries: mapEntries) } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 528fea676..80e22a985 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -81,33 +81,35 @@ struct InternalDefaultLiveMapTests { #expect(result?.numberValue == 123.456) } - // @specOneOf(1/3) RTLM5d2e - When `string` is a string + // @spec RTLM5d2e @Test func returnsStringValue() throws { let logger = TestLogger() - let entry = TestFactories.internalMapEntry(data: ObjectData(string: .string("test"))) + let entry = TestFactories.internalMapEntry(data: ObjectData(string: "test")) let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.stringValue == "test") } - // @specOneOf(2/3) RTLM5d2e - When `string` is a JSON array + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // Tests when `json` is a JSON array @Test func returnsJSONArrayValue() throws { let logger = TestLogger() - let entry = TestFactories.internalMapEntry(data: ObjectData(string: .json(.array(["foo"])))) + let entry = TestFactories.internalMapEntry(data: ObjectData(json: .array(["foo"]))) let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) #expect(result?.jsonArrayValue == ["foo"]) } - // @specOneOf(3/3) RTLM5d2e - When `string` is a JSON object + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // Tests when `json` is a JSON object @Test func returnsJSONObjectValue() throws { let logger = TestLogger() - let entry = TestFactories.internalMapEntry(data: ObjectData(string: .json(.object(["foo": "bar"])))) + let entry = TestFactories.internalMapEntry(data: ObjectData(json: .object(["foo": "bar"]))) let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) @@ -316,11 +318,11 @@ struct InternalDefaultLiveMapTests { let map = InternalDefaultLiveMap( testsOnly_data: [ // tombstone is nil, so not considered tombstoned - "active1": TestFactories.internalMapEntry(data: ObjectData(string: .string("value1"))), + "active1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), // tombstone is false, so not considered tombstoned[ - "active2": TestFactories.internalMapEntry(tombstone: false, data: ObjectData(string: .string("value2"))), - "tombstoned": TestFactories.internalMapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned"))), - "tombstoned2": TestFactories.internalMapEntry(tombstone: true, data: ObjectData(string: .string("tombstoned2"))), + "active2": TestFactories.internalMapEntry(tombstone: false, data: ObjectData(string: "value2")), + "tombstoned": TestFactories.internalMapEntry(tombstone: true, data: ObjectData(string: "tombstoned")), + "tombstoned2": TestFactories.internalMapEntry(tombstone: true, data: ObjectData(string: "tombstoned2")), ], objectID: "arbitrary", logger: logger, @@ -362,9 +364,9 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let map = InternalDefaultLiveMap( testsOnly_data: [ - "key1": TestFactories.internalMapEntry(data: ObjectData(string: .string("value1"))), - "key2": TestFactories.internalMapEntry(data: ObjectData(string: .string("value2"))), - "key3": TestFactories.internalMapEntry(data: ObjectData(string: .string("value3"))), + "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, @@ -410,9 +412,9 @@ struct InternalDefaultLiveMapTests { "boolean": TestFactories.internalMapEntry(data: ObjectData(boolean: true)), // RTLM5d2b "bytes": TestFactories.internalMapEntry(data: ObjectData(bytes: Data([0x01, 0x02, 0x03]))), // RTLM5d2c "number": TestFactories.internalMapEntry(data: ObjectData(number: NSNumber(value: 42))), // RTLM5d2d - "string": TestFactories.internalMapEntry(data: ObjectData(string: .string("hello"))), // RTLM5d2e - "jsonArray": TestFactories.internalMapEntry(data: ObjectData(string: .json(.array(["foo"])))), // RTLM5d2e - "jsonObject": TestFactories.internalMapEntry(data: ObjectData(string: .json(.object(["foo": "bar"])))), // RTLM5d2e + "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-cocoa-liveobjects-plugin/issues/46) + "jsonObject": TestFactories.internalMapEntry(data: ObjectData(json: .object(["foo": "bar"]))), // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) "mapRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "map:ref@123")), // RTLM5d2f2 "counterRef": TestFactories.internalMapEntry(data: ObjectData(objectId: "counter:ref@456")), // RTLM5d2f2 ], @@ -465,7 +467,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, @@ -505,7 +507,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, @@ -677,7 +679,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: "ts2", data: ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, @@ -703,7 +705,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, @@ -828,7 +830,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: entrySerial, data: ObjectData(string: .string("existing")))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(timeserial: entrySerial, data: ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, @@ -839,7 +841,7 @@ struct InternalDefaultLiveMapTests { _ = map.testsOnly_applyMapSetOperation( key: "key1", operationTimeserial: operationSerial, - operationData: ObjectData(string: .string("new")), + operationData: ObjectData(string: "new"), objectsPool: &pool, ) @@ -1049,7 +1051,7 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.objectOperation( action: .known(.mapSet), - mapOp: ObjectsMapOp(key: "key1", data: ObjectData(string: .string("new"))), + mapOp: ObjectsMapOp(key: "key1", data: 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) @@ -1136,7 +1138,7 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.objectOperation( action: .known(.mapSet), - mapOp: ObjectsMapOp(key: "key1", data: ObjectData(string: .string("new"))), + mapOp: ObjectsMapOp(key: "key1", data: ObjectData(string: "new")), ) // Apply MAP_SET operation diff --git a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift index ff357d6d7..a973dea74 100644 --- a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift @@ -10,7 +10,6 @@ struct ObjectMessageTests { struct EncodingTests { struct MessagePackTests { // @spec OD4c1 - // @specOneOf(1/8) OD4b @Test func boolean() { let objectData = ObjectData(boolean: true) @@ -21,12 +20,10 @@ struct ObjectMessageTests { #expect(wireData.bytes == nil) #expect(wireData.number == nil) #expect(wireData.string == nil) - // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d - #expect(wireData.encoding == nil) + #expect(wireData.json == nil) } // @spec OD4c2 - // @specOneOf(2/8) OD4b @Test func binary() { let testData = Data([1, 2, 3, 4]) @@ -43,12 +40,10 @@ struct ObjectMessageTests { } #expect(wireData.number == nil) #expect(wireData.string == nil) - // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d - #expect(wireData.encoding == nil) + #expect(wireData.json == nil) } // @spec OD4c3 - // @specOneOf(3/8) OD4b @Test(arguments: [15, 42.0]) func number(testNumber: NSNumber) throws { let objectData = ObjectData(number: testNumber) @@ -63,16 +58,14 @@ struct ObjectMessageTests { #expect(number == testNumber) #expect(wireData.number == testNumber) #expect(wireData.string == nil) - // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d - #expect(wireData.encoding == nil) + #expect(wireData.json == nil) } // @spec OD4c4 - // @specOneOf(4/8) OD4b @Test func string() { let testString = "hello world" - let objectData = ObjectData(string: .string(testString)) + 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 @@ -80,32 +73,29 @@ struct ObjectMessageTests { #expect(wireData.bytes == nil) #expect(wireData.number == nil) #expect(wireData.string == testString) - // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d - #expect(wireData.encoding == nil) + #expect(wireData.json == nil) } - // @spec OD4c5 + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-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(string: .json(jsonObjectOrArray)) + let objectData = ObjectData(json: jsonObjectOrArray) let wireData = objectData.toWire(format: .messagePack) - // OD4c5: A payload consisting of a JSON-encodable object or array is stringified as a JSON object or array, represented as a JSON string and the result is set on the ObjectData.string attribute. The ObjectData.encoding attribute is then set to "json" #expect(wireData.boolean == nil) #expect(wireData.bytes == nil) #expect(wireData.number == nil) - #expect(wireData.string == expectedJSONString) - #expect(wireData.encoding == "json") + #expect(wireData.string == nil) + #expect(wireData.json == expectedJSONString) } } struct JSONTests { // @spec OD4d1 - // @specOneOf(5/8) OD4b @Test func boolean() { let objectData = ObjectData(boolean: true) @@ -116,12 +106,10 @@ struct ObjectMessageTests { #expect(wireData.bytes == nil) #expect(wireData.number == nil) #expect(wireData.string == nil) - // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d - #expect(wireData.encoding == nil) + #expect(wireData.json == nil) } // @spec OD4d2 - // @specOneOf(6/8) OD4b @Test func binary() { let testData = Data([1, 2, 3, 4]) @@ -138,12 +126,10 @@ struct ObjectMessageTests { } #expect(wireData.number == nil) #expect(wireData.string == nil) - // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d - #expect(wireData.encoding == nil) + #expect(wireData.json == nil) } // @spec OD4d3 - // @specOneOf(7/8) OD4b @Test func number() { let testNumber = NSNumber(value: 42) @@ -155,16 +141,14 @@ struct ObjectMessageTests { #expect(wireData.bytes == nil) #expect(wireData.number == testNumber) #expect(wireData.string == nil) - // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d - #expect(wireData.encoding == nil) + #expect(wireData.json == nil) } // @spec OD4d4 - // @specOneOf(8/8) OD4b @Test func string() { let testString = "hello world" - let objectData = ObjectData(string: .string(testString)) + 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 @@ -172,26 +156,24 @@ struct ObjectMessageTests { #expect(wireData.bytes == nil) #expect(wireData.number == nil) #expect(wireData.string == testString) - // OD4b: ObjectData.encoding must be left unset unless specified otherwise by the payload encoding procedure in OD4c and OD4d - #expect(wireData.encoding == nil) + #expect(wireData.json == nil) } - // @spec OD4d5 + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-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(string: .json(jsonObjectOrArray)) + let objectData = ObjectData(json: jsonObjectOrArray) let wireData = objectData.toWire(format: .json) - // OD4d5: A payload consisting of a JSON-encodable object or array is stringified as a JSON object or array, represented as a JSON string and the result is set on the ObjectData.string attribute. The ObjectData.encoding attribute is then set to "json" #expect(wireData.boolean == nil) #expect(wireData.bytes == nil) #expect(wireData.number == nil) - #expect(wireData.string == expectedJSONString) - #expect(wireData.encoding == "json") + #expect(wireData.string == nil) + #expect(wireData.json == expectedJSONString) } } } @@ -209,6 +191,7 @@ struct ObjectMessageTests { #expect(objectData.bytes == nil) #expect(objectData.number == nil) #expect(objectData.string == nil) + #expect(objectData.json == nil) } // @specOneOf(2/5) OD5a1 @@ -223,6 +206,7 @@ struct ObjectMessageTests { #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 @@ -238,6 +222,7 @@ struct ObjectMessageTests { #expect(objectData.bytes == nil) #expect(objectData.number == nil) #expect(objectData.string == nil) + #expect(objectData.json == nil) } // @specOneOf(4/5) OD5a1 @@ -252,9 +237,10 @@ struct ObjectMessageTests { #expect(objectData.bytes == nil) #expect(objectData.number == testNumber) #expect(objectData.string == nil) + #expect(objectData.json == nil) } - // @specOneOf(5/5) OD5a1 - This spec point is a bit weirdly worded, but here we're testing the case where `encoding` is not set and hence OD5a2 does not apply to the `string` property + // @specOneOf(5/5) OD5a1 @Test func string() throws { let testString = "hello world" @@ -265,38 +251,31 @@ struct ObjectMessageTests { #expect(objectData.boolean == nil) #expect(objectData.bytes == nil) #expect(objectData.number == nil) - switch objectData.string { - case let .string(str): - #expect(str == testString) - default: - Issue.record("Expected .string case") - } + #expect(objectData.string == testString) + #expect(objectData.json == nil) } - // @specOneOf(1/3) OD5a2 + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) @Test func json() throws { let jsonString = "{\"key\":\"value\",\"number\":123}" - let wireData = WireObjectData(encoding: "json", string: jsonString) + let wireData = WireObjectData(json: jsonString) let objectData = try ObjectData(wireObjectData: wireData, format: .messagePack) - // OD5a2: If ObjectData.encoding is set to "json", the ObjectData.string content is decoded by parsing the string as JSON + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) #expect(objectData.boolean == nil) #expect(objectData.bytes == nil) #expect(objectData.number == nil) - switch objectData.string { - case let .json(jsonValue): - #expect(jsonValue == ["key": "value", "number": 123]) - default: - Issue.record("Expected .json case") - } + #expect(objectData.string == nil) + #expect(objectData.json == ["key": "value", "number": 123]) } - // @specOneOf(2/3) OD5a2 - The spec doesn't say what to do if JSON parsing fails; I'm choosing to treat it as an error + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-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(encoding: "json", string: invalidJsonString) + let wireData = WireObjectData(json: invalidJsonString) // Should throw when JSON parsing fails, even in MessagePack format #expect(throws: InternalError.self) { @@ -304,7 +283,8 @@ struct ObjectMessageTests { } } - // @specOneOf(3/3) OD5a2 - 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 + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-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\"", @@ -318,7 +298,7 @@ struct ObjectMessageTests { "null", ]) func json_validJsonButNotObjectOrArray(jsonString: String) { - let wireData = WireObjectData(encoding: "json", string: jsonString) + let wireData = WireObjectData(json: jsonString) // Should throw when JSON is valid but not an object or array #expect(throws: InternalError.self) { @@ -339,6 +319,7 @@ struct ObjectMessageTests { #expect(objectData.bytes == nil) #expect(objectData.number == nil) #expect(objectData.string == nil) + #expect(objectData.json == nil) } // @specOneOf(2/3) OD5b1 @@ -353,9 +334,10 @@ struct ObjectMessageTests { #expect(objectData.bytes == nil) #expect(objectData.number == testNumber) #expect(objectData.string == nil) + #expect(objectData.json == nil) } - // @specOneOf(3/3) OD5b1 - This spec point is a bit weirdly worded, but here we're testing the case where `encoding` is not set and hence OD5b3 does not apply to the `string` property + // @specOneOf(3/3) OD5b1 @Test func string() throws { let testString = "hello world" @@ -366,12 +348,8 @@ struct ObjectMessageTests { #expect(objectData.boolean == nil) #expect(objectData.bytes == nil) #expect(objectData.number == nil) - switch objectData.string { - case let .string(str): - #expect(str == testString) - default: - Issue.record("Expected .string case") - } + #expect(objectData.string == testString) + #expect(objectData.json == nil) } // @specOneOf(1/2) OB5b2 @@ -387,6 +365,7 @@ struct ObjectMessageTests { #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 @@ -401,30 +380,26 @@ struct ObjectMessageTests { } } - // @specOneOf(1/3) OD5b3 + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) @Test func json() throws { let jsonString = "{\"key\":\"value\",\"number\":123}" - let wireData = WireObjectData(encoding: "json", string: jsonString) + let wireData = WireObjectData(json: jsonString) let objectData = try ObjectData(wireObjectData: wireData, format: .json) - // OD5b3: If ObjectData.encoding is set to "json", the ObjectData.string content is decoded by parsing the string as JSON #expect(objectData.boolean == nil) #expect(objectData.bytes == nil) #expect(objectData.number == nil) - switch objectData.string { - case let .json(jsonValue): - #expect(jsonValue == ["key": "value", "number": 123]) - default: - Issue.record("Expected .json case") - } + #expect(objectData.string == nil) + #expect(objectData.json == ["key": "value", "number": 123]) } - // @specOneOf(2/3) OD5b3 - The spec doesn't say what to do if JSON parsing fails; I'm choosing to treat it as an error + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-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(encoding: "json", string: invalidJsonString) + let wireData = WireObjectData(json: invalidJsonString) // Should throw when JSON parsing fails #expect(throws: InternalError.self) { @@ -432,7 +407,8 @@ struct ObjectMessageTests { } } - // @specOneOf(3/3) OD5b3 - 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 + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-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\"", @@ -446,7 +422,7 @@ struct ObjectMessageTests { "null", ]) func json_validJsonButNotObjectOrArray(jsonString: String) { - let wireData = WireObjectData(encoding: "json", string: jsonString) + let wireData = WireObjectData(json: jsonString) // Should throw when JSON is valid but not an object or array #expect(throws: InternalError.self) { @@ -467,9 +443,9 @@ struct ObjectMessageTests { ObjectData(boolean: true), ObjectData(bytes: Data([1, 2, 3, 4])), ObjectData(number: NSNumber(value: 42)), - ObjectData(string: .string("hello world")), - ObjectData(string: .json(["key": "value", "number": 123])), - ObjectData(string: .json([123, "hello world"])), + 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)) @@ -485,17 +461,11 @@ struct ObjectMessageTests { // Compare number values #expect(decodedData.number == originalData.number) - // Compare string values, handling both .string and .json cases - switch (decodedData.string, originalData.string) { - case (.none, .none): - break - case let (.string(decoded), .string(original)): - #expect(decoded == original) - case let (.json(decoded), .json(original)): - #expect(decoded == original) - default: - Issue.record("String cases did not match") - } + // Compare string values + #expect(decodedData.string == originalData.string) + + // Compare JSON values + #expect(decodedData.json == originalData.json) } } } diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 2609d9f4d..89514d5f3 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -300,7 +300,7 @@ struct ObjectsPoolTests { createOp: TestFactories.mapCreateOperation(objectId: "map:existing@1", entries: [ "createOpKey": TestFactories.stringMapEntry(value: "bar").entry, ]), - entries: ["updated": TestFactories.mapEntry(data: ObjectData(string: .string("updated")))], + entries: ["updated": TestFactories.mapEntry(data: ObjectData(string: "updated"))], ), // Update existing counter TestFactories.counterObjectState( @@ -313,7 +313,7 @@ struct ObjectsPoolTests { TestFactories.mapObjectState( objectId: "map:new@1", siteTimeserials: ["site3": "ts3"], - entries: ["new": TestFactories.mapEntry(data: ObjectData(string: .string("new")))], + entries: ["new": TestFactories.mapEntry(data: ObjectData(string: "new"))], ), // Create new counter TestFactories.counterObjectState( diff --git a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift index c5503f298..7b63b9ba4 100644 --- a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -360,14 +360,12 @@ enum WireObjectMessageTests { func decodesAllFields() throws { let json: [String: WireValue] = [ "objectId": "obj1", - "encoding": "utf8", "boolean": true, "number": 42, "string": "value1", ] let data = try WireObjectData(wireObject: json) #expect(data.objectId == "obj1") - #expect(data.encoding == "utf8") #expect(data.boolean == true) #expect(data.number == 42) #expect(data.string == "value1") @@ -378,7 +376,6 @@ enum WireObjectMessageTests { let json: [String: WireValue] = [:] let data = try WireObjectData(wireObject: json) #expect(data.objectId == nil) - #expect(data.encoding == nil) #expect(data.boolean == nil) #expect(data.bytes == nil) #expect(data.number == nil) @@ -389,7 +386,6 @@ enum WireObjectMessageTests { func encodesAllFields() { let data = WireObjectData( objectId: "obj1", - encoding: "utf8", boolean: true, bytes: nil, number: 42, @@ -398,7 +394,6 @@ enum WireObjectMessageTests { let wire = data.toWireObject #expect(wire == [ "objectId": "obj1", - "encoding": "utf8", "boolean": true, "number": 42, "string": "value1", @@ -409,7 +404,6 @@ enum WireObjectMessageTests { func encodesWithOptionalFieldsNil() { let data = WireObjectData( objectId: nil, - encoding: nil, boolean: nil, bytes: nil, number: nil, From 133c85b9e5a41c81f593ee98321f63f0d50526d4 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 18 Jul 2025 10:07:45 -0300 Subject: [PATCH 085/225] Implement tombstone() method Based on [1] at 488e932. Haven't updated tests due to being in a bit of a rush; deferred to #52. [1] https://github.com/ably/specification/pull/350 --- .../Internal/InternalDefaultLiveCounter.swift | 6 +++ .../Internal/InternalDefaultLiveMap.swift | 6 +++ .../Internal/InternalLiveObject.swift | 42 +++++++++++++++++++ .../Internal/LiveObjectMutableState.swift | 10 +++++ 4 files changed, 64 insertions(+) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index d1149f263..01e5b793c 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -317,5 +317,11 @@ internal final class InternalDefaultLiveCounter: Sendable { data += amount return .update(DefaultLiveCounterUpdate(amount: amount)) } + + /// Needed for ``InternalLiveObject`` conformance. + mutating func resetDataToZeroValued() { + // RTLC4 + data = 0 + } } } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 78033b51b..cea06017b 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -655,6 +655,12 @@ internal final class InternalDefaultLiveMap: Sendable { let mapUpdate = DefaultLiveMapUpdate(update: previousData.mapValues { _ in .removed }) liveObjectMutableState.emit(.update(mapUpdate), on: userCallbackQueue) } + + /// Needed for ``InternalLiveObject`` conformance. + mutating func resetDataToZeroValued() { + // RTLM4 + data = [:] + } } // MARK: - Helper Methods diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift b/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift index e36dce1cf..d80d53b49 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift @@ -1,3 +1,5 @@ +internal import AblyPlugin + /// Provides RTLO spec point functionality common to all LiveObjects. /// /// This exists in addition to ``LiveObjectMutableState`` to enable polymorphism. @@ -5,4 +7,44 @@ internal protocol InternalLiveObject { associatedtype Update: Sendable var liveObjectMutableState: LiveObjectMutableState { get set } + + /// Resets the LiveObject's internal data to that of a zero-value, per RTLO4e4. + mutating func resetDataToZeroValued() +} + +internal extension InternalLiveObject { + /// Convenience method for tombstoning a `LiveObject`, as specified in RTLO4e. + mutating func tombstone( + objectMessageSerialTimestamp: Date?, + logger: Logger, + clock: SimpleClock, + ) { + // 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() + } + + /// Applies an `OBJECT_DELETE` operation, per RTLO5. + mutating func applyObjectDeleteOperation( + objectMessageSerialTimestamp: Date?, + logger: Logger, + clock: SimpleClock, + ) { + // RTLO5b + tombstone( + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, + ) + } } diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index f35fc9ba7..f33262083 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -10,6 +10,14 @@ internal struct LiveObjectMutableState { 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? /// Internal bookkeeping for subscriptions. private var subscriptionsByID: [Subscription.ID: Subscription] = [:] @@ -17,9 +25,11 @@ internal struct LiveObjectMutableState { 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`. From 279034b179857579b5ab11cdc27a74ddd2826783 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 21 Jul 2025 11:09:35 +0100 Subject: [PATCH 086/225] Update LiveMap.get to handle tombstoned objects Based on spec referenced in 133c85b; same comment re testing applies too. --- .../Internal/InternalDefaultLiveCounter.swift | 9 ++++++++ .../Internal/InternalDefaultLiveMap.swift | 21 ++++++++++++++++++- .../Internal/ObjectsPool.swift | 10 +++++++++ 3 files changed, 39 insertions(+), 1 deletion(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 01e5b793c..d7378147b 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -196,6 +196,15 @@ internal final class InternalDefaultLiveCounter: Sendable { } } + // MARK: - LiveObject + + /// Returns the object's RTLO3d `isTombstone` property. + internal var isTombstone: Bool { + mutex.withLock { + mutableState.liveObjectMutableState.isTombstone + } + } + // MARK: - Mutable state and the operations that affect it private struct MutableState: InternalLiveObject { diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index cea06017b..5b8a68a2b 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -113,6 +113,11 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.get") + // RTLM5e - Return nil if self is tombstone + if isTombstone { + return nil + } + let entry = mutex.withLock { mutableState.data[key] } @@ -331,6 +336,15 @@ internal final class InternalDefaultLiveMap: Sendable { } } + // MARK: - LiveObject + + /// Returns the object's RTLO3d `isTombstone` property. + internal var isTombstone: Bool { + mutex.withLock { + mutableState.liveObjectMutableState.isTombstone + } + } + // MARK: - Mutable state and the operations that affect it private struct MutableState: InternalLiveObject { @@ -718,7 +732,12 @@ internal final class InternalDefaultLiveMap: Sendable { return nil } - // RTLM5d2f2: If an object with id objectId exists, return it + // RTLM5d2f3: If referenced object is tombstoned, return nil + if poolEntry.isTombstone { + return nil + } + + // RTLM5d2f2: Return referenced object switch poolEntry { case let .map(map): return .liveMap(map) diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index f047e9da1..0691ac7a2 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -104,6 +104,16 @@ internal struct ObjectsPool { ) } } + + /// Returns the object's RTLO3d `isTombstone` property. + internal var isTombstone: Bool { + switch self { + case let .counter(counter): + counter.isTombstone + case let .map(map): + map.isTombstone + } + } } /// Keyed by `objectId`. From 7279174a52c7be2f614344484f825b03d2b9d09e Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 21 Jul 2025 11:09:51 +0100 Subject: [PATCH 087/225] Update "apply OBJECT" to exit early if already tombstoned Based on spec referenced in 133c85b; same comment re testing applies too. --- .../Internal/InternalDefaultLiveCounter.swift | 6 ++++++ .../AblyLiveObjects/Internal/InternalDefaultLiveMap.swift | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index d7378147b..b538d437e 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -279,6 +279,12 @@ internal final class InternalDefaultLiveCounter: Sendable { // RTLC7c 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 + } + switch operation.action { case .known(.counterCreate): // RTLC7d1 diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 5b8a68a2b..3527d07a5 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -470,6 +470,12 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM15c 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 + } + switch operation.action { case .known(.mapCreate): // RTLM15d1 From 2fb4681b83184c695d948104a1303cb2ec432c94 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 21 Jul 2025 11:25:53 +0100 Subject: [PATCH 088/225] Update "replace data" to exit early if tombstoned Based on spec referenced in 133c85b; same comment re testing applies too. --- .../Internal/InternalDefaultLiveCounter.swift | 5 +++++ .../AblyLiveObjects/Internal/InternalDefaultLiveMap.swift | 5 +++++ 2 files changed, 10 insertions(+) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index b538d437e..174b2f160 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -225,6 +225,11 @@ internal final class InternalDefaultLiveCounter: Sendable { // 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 + } + // RTLC6b: Set the private flag createOperationIsMerged to false liveObjectMutableState.createOperationIsMerged = false diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 3527d07a5..a7e42b649 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -373,6 +373,11 @@ internal final class InternalDefaultLiveMap: Sendable { // 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 + } + // RTLM6b: Set the private flag createOperationIsMerged to false liveObjectMutableState.createOperationIsMerged = false From 173a9d91efe904b6d6cbc25d97b22237b39af8ef Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 21 Jul 2025 15:23:51 +0100 Subject: [PATCH 089/225] Make "replace data" perform tombstoning when state indicates it Based on spec referenced in 133c85b; same comment re testing applies too. --- .../Internal/InternalDefaultLiveCounter.swift | 23 ++++++++++++++++++- .../Internal/InternalDefaultLiveMap.swift | 14 +++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 174b2f160..b990de31e 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -150,7 +150,12 @@ internal final class InternalDefaultLiveCounter: Sendable { objectMessageSerialTimestamp: Date?, ) -> LiveObjectUpdate { mutex.withLock { - mutableState.replaceData(using: state, objectMessageSerialTimestamp: objectMessageSerialTimestamp) + mutableState.replaceData( + using: state, + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, + ) } } @@ -221,6 +226,8 @@ internal final class InternalDefaultLiveCounter: Sendable { internal mutating func replaceData( using state: ObjectState, objectMessageSerialTimestamp: Date?, + logger: Logger, + clock: SimpleClock, ) -> LiveObjectUpdate { // RTLC6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials liveObjectMutableState.siteTimeserials = state.siteTimeserials @@ -230,6 +237,20 @@ internal final class InternalDefaultLiveCounter: Sendable { return .noop } + // RTLC6f: Tombstone if state indicates tombstoned + if state.tombstone { + let dataBeforeTombstoning = data + + tombstone( + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, + ) + + // RTLC6f1 + return .update(.init(amount: -dataBeforeTombstoning)) + } + // RTLC6b: Set the private flag createOperationIsMerged to false liveObjectMutableState.createOperationIsMerged = false diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index a7e42b649..b04635f5a 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -378,6 +378,20 @@ internal final class InternalDefaultLiveMap: Sendable { return .noop } + // RTLM6f: Tombstone if state indicates tombstoned + if state.tombstone { + let dataBeforeTombstoning = data + + tombstone( + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, + ) + + // RTLM6f1 + return .update(.init(update: dataBeforeTombstoning.mapValues { _ in .removed })) + } + // RTLM6b: Set the private flag createOperationIsMerged to false liveObjectMutableState.createOperationIsMerged = false From 9e99e18d2ce43ece6c78f3d0cde5be79a3c2dd03 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 21 Jul 2025 15:46:53 +0100 Subject: [PATCH 090/225] Apply OBJECT_DELETE operation Based on spec referenced in 133c85b; same comment re testing applies too. --- .../Internal/InternalDefaultLiveCounter.swift | 14 ++++++++++++++ .../Internal/InternalDefaultLiveMap.swift | 12 ++++++++++++ 2 files changed, 26 insertions(+) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index b990de31e..d83e4bd48 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -196,6 +196,7 @@ internal final class InternalDefaultLiveCounter: Sendable { objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, logger: logger, + clock: clock, userCallbackQueue: userCallbackQueue, ) } @@ -294,6 +295,7 @@ internal final class InternalDefaultLiveCounter: Sendable { objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, logger: Logger, + clock: SimpleClock, userCallbackQueue: DispatchQueue, ) { guard let applicableOperation = liveObjectMutableState.canApplyOperation(objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, logger: logger) else { @@ -325,6 +327,18 @@ internal final class InternalDefaultLiveCounter: Sendable { let update = applyCounterIncOperation(operation.counterOp) // RTLC7d2a liveObjectMutableState.emit(update, on: userCallbackQueue) + case .known(.objectDelete): + let dataBeforeApplyingOperation = data + + // RTLC7d4 + applyObjectDeleteOperation( + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, + ) + + // RTLC7d4a + liveObjectMutableState.emit(.update(.init(amount: -dataBeforeApplyingOperation)), on: userCallbackQueue) default: // RTLC7d3 logger.log("Operation \(operation) has unsupported action for LiveCounter; discarding", level: .warn) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index b04635f5a..4adc528c7 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -541,6 +541,18 @@ internal final class InternalDefaultLiveMap: Sendable { ) // RTLM15d3a liveObjectMutableState.emit(update, on: userCallbackQueue) + case .known(.objectDelete): + let dataBeforeApplyingOperation = data + + // RTLM15d5 + applyObjectDeleteOperation( + objectMessageSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, + ) + + // RTLM15d5a + liveObjectMutableState.emit(.update(.init(update: dataBeforeApplyingOperation.mapValues { _ in .removed })), on: userCallbackQueue) default: // RTLM15d4 logger.log("Operation \(operation) has unsupported action for LiveMap; discarding", level: .warn) From be96ae714b638b1ded1231e231847831d9bf2635 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 21 Jul 2025 16:55:11 +0100 Subject: [PATCH 091/225] Update map "replace data", MAP_SET, MAP_REMOVE to set entry tombstonedAt Based on spec referenced in 133c85b; same comment re testing applies too. --- .../Internal/InternalDefaultLiveMap.swift | 63 +++++++++++++++---- .../Internal/InternalObjectsMapEntry.swift | 15 +++-- .../Helpers/TestFactories.swift | 8 +-- .../InternalDefaultLiveMapTests.swift | 38 ++++++----- 4 files changed, 85 insertions(+), 39 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 4adc528c7..ad473ec5c 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -320,11 +320,14 @@ internal final class InternalDefaultLiveMap: Sendable { /// Applies a `MAP_REMOVE` operation to a key, per RTLM8. /// /// This is currently exposed just so that the tests can test RTLM8 without having to go through a convoluted replaceData(…) call, but I _think_ that it's going to be used in further contexts when we introduce the handling of incoming object operations in a future spec PR. - internal func testsOnly_applyMapRemoveOperation(key: String, operationTimeserial: String?) -> LiveObjectUpdate { + internal func testsOnly_applyMapRemoveOperation(key: String, operationTimeserial: String?, operationSerialTimestamp: Date?) -> LiveObjectUpdate { mutex.withLock { mutableState.applyMapRemoveOperation( key: key, operationTimeserial: operationTimeserial, + operationSerialTimestamp: operationSerialTimestamp, + logger: logger, + clock: clock, ) } } @@ -396,7 +399,25 @@ internal final class InternalDefaultLiveMap: Sendable { liveObjectMutableState.createOperationIsMerged = false // RTLM6c: Set data to ObjectState.map.entries, or to an empty map if it does not exist - data = state.map?.entries?.mapValues { .init(objectsMapEntry: $0) } ?? [:] + 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 RTLM17 return if let createOp = state.createOp { @@ -426,10 +447,13 @@ internal final class InternalDefaultLiveMap: Sendable { entries.map { key, entry in if entry.tombstone == true { // RTLM17a2: If ObjectsMapEntry.tombstone is true, apply the MAP_REMOVE operation - // as described in RTLM8, passing in the current key as ObjectsMapOp, and ObjectsMapEntry.timeserial as the operation's serial + // as described in RTLM8, passing in the current key as ObjectsMapOp, 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 { // RTLM17a1: If ObjectsMapEntry.tombstone is false, apply the MAP_SET operation @@ -538,6 +562,9 @@ internal final class InternalDefaultLiveMap: Sendable { let update = applyMapRemoveOperation( key: mapOp.key, operationTimeserial: applicableOperation.objectMessageSerial, + operationSerialTimestamp: objectMessageSerialTimestamp, + logger: logger, + clock: clock, ) // RTLM15d3a liveObjectMutableState.emit(update, on: userCallbackQueue) @@ -578,17 +605,17 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM7a2: Otherwise, apply the operation // RTLM7a2a: Set ObjectsMapEntry.data to the ObjectData from the operation // RTLM7a2b: Set ObjectsMapEntry.timeserial to the operation's serial - // RTLM7a2c: Set ObjectsMapEntry.tombstone to false + // RTLM7a2c: Set ObjectsMapEntry.tombstone to false (same as RTLM7a2d: Set ObjectsMapEntry.tombstonedAt to nil) var updatedEntry = existingEntry updatedEntry.data = operationData updatedEntry.timeserial = operationTimeserial - updatedEntry.tombstone = false + updatedEntry.tombstonedAt = nil data[key] = updatedEntry } else { // RTLM7b: If an entry does not exist in the private data for the specified key // RTLM7b1: Create a new entry in data for the specified key with the provided ObjectData and the operation's serial - // RTLM7b2: Set ObjectsMapEntry.tombstone for the new entry to false - data[key] = InternalObjectsMapEntry(tombstone: false, timeserial: operationTimeserial, data: operationData) + // 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) } // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute @@ -602,9 +629,21 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Applies a `MAP_REMOVE` operation to a key, per RTLM8. - internal mutating func applyMapRemoveOperation(key: String, operationTimeserial: String?) -> LiveObjectUpdate { + 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) + // 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 @@ -614,17 +653,19 @@ internal final class InternalDefaultLiveMap: Sendable { // 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 + // RTLM8a2c: Set ObjectsMapEntry.tombstone to true (equivalent to next point) + // RTLM8a2d: Set ObjectsMapEntry.tombstonedAt per RTLM8a2d var updatedEntry = existingEntry updatedEntry.data = ObjectData() updatedEntry.timeserial = operationTimeserial - updatedEntry.tombstone = true + 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 - data[key] = InternalObjectsMapEntry(tombstone: true, timeserial: operationTimeserial, data: ObjectData()) + // RTLM8b3: Set ObjectsMapEntry.tombstonedAt per RTLM8f + data[key] = InternalObjectsMapEntry(tombstonedAt: tombstonedAt, timeserial: operationTimeserial, data: ObjectData()) } return .update(DefaultLiveMapUpdate(update: [key: .removed])) diff --git a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift index 435dec232..da969c3ba 100644 --- a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift +++ b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift @@ -1,13 +1,20 @@ -/// The entries stored in a `LiveMap`'s data. Same as an `ObjectsMapEntry` but with an additional `tombstonedAt` property, per RTLM3a. (This property will be added in an upcoming commit.) +import Foundation + +/// The entries stored in a `LiveMap`'s data. Same as an `ObjectsMapEntry` but with an additional `tombstonedAt` property, per RTLM3a. internal struct InternalObjectsMapEntry { - internal var tombstone: Bool? // OME2a + internal var tombstonedAt: Date? // RTLM3a + internal var tombstone: Bool { + // TODO: Confirm that we don't need to store this (https://github.com/ably/specification/pull/350/files#r2213895661) + tombstonedAt != nil + } + internal var timeserial: String? // OME2b internal var data: ObjectData // OME2c } internal extension InternalObjectsMapEntry { - init(objectsMapEntry: ObjectsMapEntry) { - tombstone = objectsMapEntry.tombstone + init(objectsMapEntry: ObjectsMapEntry, tombstonedAt: Date?) { + self.tombstonedAt = tombstonedAt timeserial = objectsMapEntry.timeserial data = objectsMapEntry.data } diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index dd7cc38dc..3d6966c38 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -406,12 +406,12 @@ struct TestFactories { /// /// This should be kept in sync with ``mapEntry``. static func internalMapEntry( - tombstone: Bool? = false, + tombstonedAt: Date? = nil, timeserial: String? = "ts1", data: ObjectData, ) -> InternalObjectsMapEntry { InternalObjectsMapEntry( - tombstone: tombstone, + tombstonedAt: tombstonedAt, timeserial: timeserial, data: data, ) @@ -440,13 +440,13 @@ struct TestFactories { static func internalStringMapEntry( key: String = "testKey", value: String = "testValue", - tombstone: Bool? = false, + tombstonedAt: Date? = nil, timeserial: String? = "ts1", ) -> (key: String, entry: InternalObjectsMapEntry) { ( key: key, entry: internalMapEntry( - tombstone: tombstone, + tombstonedAt: tombstonedAt, timeserial: timeserial, data: ObjectData(string: value), ), diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 80e22a985..2f36190c3 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -39,7 +39,7 @@ struct InternalDefaultLiveMapTests { func returnsNilWhenEntryIsTombstoned() throws { let logger = TestLogger() let entry = TestFactories.internalMapEntry( - tombstone: true, + tombstonedAt: Date(), data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) let coreSDK = MockCoreSDK(channelState: .attaching) @@ -317,12 +317,11 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let map = InternalDefaultLiveMap( testsOnly_data: [ - // tombstone is nil, so not considered tombstoned + // tombstonedAt is nil, so not considered tombstoned "active1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), - // tombstone is false, so not considered tombstoned[ - "active2": TestFactories.internalMapEntry(tombstone: false, data: ObjectData(string: "value2")), - "tombstoned": TestFactories.internalMapEntry(tombstone: true, data: ObjectData(string: "tombstoned")), - "tombstoned2": TestFactories.internalMapEntry(tombstone: true, data: ObjectData(string: "tombstoned2")), + // 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, @@ -332,24 +331,23 @@ struct InternalDefaultLiveMapTests { // Test size - should only count non-tombstoned entries let size = try map.size(coreSDK: coreSDK) - #expect(size == 2) + #expect(size == 1) // Test entries - should only return non-tombstoned entries let entries = try map.entries(coreSDK: coreSDK, delegate: delegate) - #expect(entries.count == 2) - #expect(Set(entries.map(\.key)) == ["active1", "active2"]) + #expect(entries.count == 1) + #expect(Set(entries.map(\.key)) == ["active1"]) #expect(entries.first { $0.key == "active1" }?.value.stringValue == "value1") - #expect(entries.first { $0.key == "active2" }?.value.stringValue == "value2") // Test keys - should only return keys from non-tombstoned entries let keys = try map.keys(coreSDK: coreSDK, delegate: delegate) - #expect(keys.count == 2) - #expect(Set(keys) == ["active1", "active2"]) + #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 == 2) - #expect(Set(values.compactMap(\.stringValue)) == Set(["value1", "value2"])) + #expect(values.count == 1) + #expect(Set(values.compactMap(\.stringValue)) == Set(["value1"])) } // MARK: - Consistency Tests @@ -507,7 +505,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstone: true, timeserial: "ts1", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstonedAt: Date(), timeserial: "ts1", data: ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, @@ -687,7 +685,7 @@ struct InternalDefaultLiveMapTests { ) // Try to apply operation with lower timeserial (ts1 < ts2), cannot be applied per RTLM9 - let update = map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts1") + 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") @@ -705,7 +703,7 @@ struct InternalDefaultLiveMapTests { let delegate = MockLiveMapObjectPoolDelegate() let coreSDK = MockCoreSDK(channelState: .attaching) let map = InternalDefaultLiveMap( - testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstone: false, timeserial: "ts1", data: ObjectData(string: "existing"))], + testsOnly_data: ["key1": TestFactories.internalMapEntry(tombstonedAt: nil, timeserial: "ts1", data: ObjectData(string: "existing"))], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, @@ -713,7 +711,7 @@ struct InternalDefaultLiveMapTests { ) // Apply operation with higher timeserial (ts2 > ts1), so can be applied per RTLM9 - let update = map.testsOnly_applyMapRemoveOperation(key: "key1", operationTimeserial: "ts2") + 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) @@ -747,7 +745,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let update = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") + let update = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1", operationSerialTimestamp: nil) // Verify new entry was created let entry = map.testsOnly_data["newKey"] @@ -769,7 +767,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - _ = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1") + _ = map.testsOnly_applyMapRemoveOperation(key: "newKey", operationTimeserial: "ts1", operationSerialTimestamp: nil) // Verify tombstone is true for new entry #expect(map.testsOnly_data["newKey"]?.tombstone == true) From b8670322ae8dfa00929396eee731e2d4e8c93a8f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 22 Jul 2025 12:18:15 +0100 Subject: [PATCH 092/225] Make "is map entry tombstoned" check referenced object per RTLM14c Based on spec referenced in 133c85b; same comment re testing applies too. --- .../Internal/InternalDefaultLiveMap.swift | 24 ++++++++++++++----- .../PublicDefaultLiveMap.swift | 2 +- .../InternalDefaultLiveMapTests.swift | 8 +++---- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index ad473ec5c..98946c85f 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -131,14 +131,14 @@ internal final class InternalDefaultLiveMap: Sendable { return convertEntryToLiveMapValue(entry, delegate: delegate) } - internal func size(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Int { + internal func size(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) 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.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.size") return mutex.withLock { // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map mutableState.data.values.count { entry in - !Self.isEntryTombstoned(entry) + !Self.isEntryTombstoned(entry, delegate: delegate) } } } @@ -152,7 +152,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned var result: [(key: String, value: InternalLiveMapValue)] = [] - for (key, entry) in mutableState.data where !Self.isEntryTombstoned(entry) { + for (key, entry) in mutableState.data where !Self.isEntryTombstoned(entry, delegate: delegate) { // Convert entry to LiveMapValue using the same logic as get(key:) if let value = convertEntryToLiveMapValue(entry, delegate: delegate) { result.append((key: key, value: value)) @@ -758,9 +758,21 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - Helper Methods /// Returns whether a map entry should be considered tombstoned, per the check described in RTLM14. - private static func isEntryTombstoned(_ entry: InternalObjectsMapEntry) -> Bool { - // RTLM14a, RTLM14b - entry.tombstone == true + private static func isEntryTombstoned(_ entry: InternalObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> Bool { + // RTLM14a + if entry.tombstone { + return true + } + + // RTLM14c + if let objectId = entry.data.objectId { + if let poolEntry = delegate.getObjectFromPool(id: objectId), poolEntry.isTombstone { + return true + } + } + + // RTLM14b + return false } /// Converts an InternalObjectsMapEntry to LiveMapValue using the same logic as get(key:) diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index f3a9c9ef2..3569a4cc5 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -34,7 +34,7 @@ internal final class PublicDefaultLiveMap: LiveMap { internal var size: Int { get throws(ARTErrorInfo) { - try proxied.size(coreSDK: coreSDK) + try proxied.size(coreSDK: coreSDK, delegate: delegate) } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 2f36190c3..d68e7ccde 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -284,7 +284,7 @@ struct InternalDefaultLiveMapTests { // Define actions to test let actions: [(String, () throws -> Any)] = [ - ("size", { try map.size(coreSDK: coreSDK) }), + ("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) }), @@ -330,7 +330,7 @@ struct InternalDefaultLiveMapTests { ) // Test size - should only count non-tombstoned entries - let size = try map.size(coreSDK: coreSDK) + let size = try map.size(coreSDK: coreSDK, delegate: delegate) #expect(size == 1) // Test entries - should only return non-tombstoned entries @@ -372,7 +372,7 @@ struct InternalDefaultLiveMapTests { clock: MockSimpleClock(), ) - let size = try map.size(coreSDK: coreSDK) + 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) @@ -422,7 +422,7 @@ struct InternalDefaultLiveMapTests { clock: MockSimpleClock(), ) - let size = try map.size(coreSDK: coreSDK) + 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) From 2167277f9d22b6e27c76018141082b19e14945a3 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 23 Jul 2025 09:52:29 +0100 Subject: [PATCH 093/225] Perform garbage collection per RTO10 Based on spec referenced in 133c85b; same comment re testing applies too. Have not yet implemented RTO10b2's server-specified grace period; have split this work into #32. --- .../Internal/InternalDefaultLiveCounter.swift | 7 +++ .../Internal/InternalDefaultLiveMap.swift | 39 ++++++++++++ .../InternalDefaultRealtimeObjects.swift | 59 ++++++++++++++++++- .../Internal/ObjectsPool.swift | 37 ++++++++++++ 4 files changed, 141 insertions(+), 1 deletion(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index d83e4bd48..fe9fba8a9 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -211,6 +211,13 @@ internal final class InternalDefaultLiveCounter: Sendable { } } + /// Returns the object's RTLO3e `tombstonedAt` property. + internal var tombstonedAt: Date? { + mutex.withLock { + mutableState.liveObjectMutableState.tombstonedAt + } + } + // MARK: - Mutable state and the operations that affect it private struct MutableState: InternalLiveObject { diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 98946c85f..427a09c69 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -339,6 +339,13 @@ internal final class InternalDefaultLiveMap: Sendable { } } + /// Releases entries that were tombstoned more than `gracePeriod` ago, per RTLM19. + internal func releaseTombstonedEntries(gracePeriod: TimeInterval, clock: SimpleClock) { + mutex.withLock { + mutableState.releaseTombstonedEntries(gracePeriod: gracePeriod, logger: logger, clock: clock) + } + } + // MARK: - LiveObject /// Returns the object's RTLO3d `isTombstone` property. @@ -348,6 +355,13 @@ internal final class InternalDefaultLiveMap: Sendable { } } + /// Returns the object's RTLO3e `tombstonedAt` property. + internal var tombstonedAt: Date? { + mutex.withLock { + mutableState.liveObjectMutableState.tombstonedAt + } + } + // MARK: - Mutable state and the operations that affect it private struct MutableState: InternalLiveObject { @@ -753,6 +767,31 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM4 data = [:] } + + /// 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 + } + } } // MARK: - Helper Methods diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 6d4133530..904265f7a 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -18,6 +18,26 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool 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 RTO10b grace period for which we will retain tombstoned objects and map entries. + private nonisolated(unsafe) var garbageCollectionGracePeriod: 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 { + /// 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 `gcGracePeriod` of a `CONNECTED` `ProtocolMessage` from Realtime. + /// + /// This default value comes from RTO10b3; can be overridden for testing. + internal var gracePeriod: TimeInterval = 24 * 60 * 60 + } + internal var testsOnly_objectsPool: ObjectsPool { mutex.withLock { mutableState.objectsPool @@ -71,7 +91,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } } - internal init(logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock) { + internal init(logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, garbageCollectionOptions: GarbageCollectionOptions = .init()) { self.logger = logger self.userCallbackQueue = userCallbackQueue self.clock = clock @@ -79,6 +99,30 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() mutableState = .init(objectsPool: .init(logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) + garbageCollectionInterval = garbageCollectionOptions.interval + garbageCollectionGracePeriod = garbageCollectionOptions.gracePeriod + + 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) * 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: - LiveMapObjectPoolDelegate @@ -210,6 +254,19 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool try await coreSDK.publish(objectMessages: objectMessages) } + // MARK: - Garbage collection of deleted objects and map entries + + /// Performs garbage collection of tombstoned objects and map entries, per RTO10c. + internal func performGarbageCollection() { + mutex.withLock { + mutableState.objectsPool.performGarbageCollection( + gracePeriod: garbageCollectionGracePeriod, + clock: clock, + logger: logger, + ) + } + } + // MARK: - Testing /// Finishes the following streams, to allow a test to perform assertions about which elements the streams have emitted to this moment: diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 0691ac7a2..933198a53 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -114,6 +114,15 @@ internal struct ObjectsPool { map.isTombstone } } + + internal var tombstonedAt: Date? { + switch self { + case let .counter(counter): + counter.tombstonedAt + case let .map(map): + map.tombstonedAt + } + } } /// Keyed by `objectId`. @@ -308,4 +317,32 @@ internal struct ObjectsPool { // 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.resetData() } + + /// Performs garbage collection of tombstoned objects and map entries, per RTO10c. + internal mutating func performGarbageCollection(gracePeriod: TimeInterval, clock: SimpleClock, logger: Logger) { + 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.releaseTombstonedEntries(gracePeriod: gracePeriod, clock: clock) + } + + // RTO10c1b + 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 + } + } } From f942ce8a79b6d722bd8fa9ae5895bff769544768 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 31 Jul 2025 17:03:16 +0100 Subject: [PATCH 094/225] Make some stuff Equatable to help with tests --- .../AblyLiveObjects/Protocol/ObjectMessage.swift | 14 +++++++------- .../Protocol/WireObjectMessage.swift | 4 ++-- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index a9adea8e9..8a5e2a960 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -18,7 +18,7 @@ internal struct InboundObjectMessage { } /// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. -internal struct OutboundObjectMessage { +internal struct OutboundObjectMessage: Equatable { internal var id: String? // OM2a internal var clientId: String? // OM2b internal var connectionId: String? @@ -44,7 +44,7 @@ internal struct PartialObjectOperation { internal var initialValue: String? // OOP3h } -internal struct ObjectOperation { +internal struct ObjectOperation: Equatable { internal var action: WireEnum // OOP3a internal var objectId: String // OOP3b internal var mapOp: ObjectsMapOp? // OOP3c @@ -55,7 +55,7 @@ internal struct ObjectOperation { internal var initialValue: String? // OOP3h } -internal struct ObjectData { +internal struct ObjectData: Equatable { internal var objectId: String? // OD2a internal var boolean: Bool? // OD2c internal var bytes: Data? // OD2d @@ -64,24 +64,24 @@ internal struct ObjectData { internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) } -internal struct ObjectsMapOp { +internal struct ObjectsMapOp: Equatable { internal var key: String // OMO2a internal var data: ObjectData? // OMO2b } -internal struct ObjectsMapEntry { +internal struct ObjectsMapEntry: Equatable { internal var tombstone: Bool? // OME2a internal var timeserial: String? // OME2b internal var data: ObjectData // OME2c internal var serialTimestamp: Date? // OME2d } -internal struct ObjectsMap { +internal struct ObjectsMap: Equatable { internal var semantics: WireEnum // OMP3a internal var entries: [String: ObjectsMapEntry]? // OMP3b } -internal struct ObjectState { +internal struct ObjectState: Equatable { internal var objectId: String // OST2a internal var siteTimeserials: [String: String] // OST2b internal var tombstone: Bool // OST2c diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index b0d7514ce..30ed3aef8 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -356,7 +356,7 @@ extension WireObjectsMapOp: WireObjectCodable { } } -internal struct WireObjectsCounterOp { +internal struct WireObjectsCounterOp: Equatable { internal var amount: NSNumber // OCO2a } @@ -410,7 +410,7 @@ extension WireObjectsMap: WireObjectCodable { } } -internal struct WireObjectsCounter { +internal struct WireObjectsCounter: Equatable { internal var count: NSNumber? // OCN2a } From 51563836b276fc4c09cb97cbf5a40125562dc6e6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 1 Aug 2025 10:11:28 +0100 Subject: [PATCH 095/225] Remove action from PartialObjectOperation Should have done this in b701b46. --- .../AblyLiveObjects/Protocol/ObjectMessage.swift | 15 +++++---------- .../Protocol/WireObjectMessage.swift | 16 ++++++---------- 2 files changed, 11 insertions(+), 20 deletions(-) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 8a5e2a960..23719a19f 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -31,11 +31,10 @@ internal struct OutboundObjectMessage: Equatable { internal var serialTimestamp: Date? // OM2j } -/// A partial version of `ObjectOperation` that excludes the `objectId` property. Used for encoding initial values where the `objectId` is not yet known. +/// A partial version of `ObjectOperation` that excludes the `action` and `objectId` property. Used for encoding initial values which don't include the `action` and where the `objectId` is not yet known. /// /// `ObjectOperation` delegates its encoding and decoding to `PartialObjectOperation`. internal struct PartialObjectOperation { - internal var action: WireEnum // OOP3a internal var mapOp: ObjectsMapOp? // OOP3c internal var counterOp: WireObjectsCounterOp? // OOP3d internal var map: ObjectsMap? // OOP3e @@ -148,13 +147,13 @@ internal extension ObjectOperation { wireObjectOperation: WireObjectOperation, format: AblyPlugin.EncodingFormat ) throws(InternalError) { - // Decode the objectId first since it's not part of PartialObjectOperation + // Decode the action and objectId first they're not part of PartialObjectOperation + action = wireObjectOperation.action objectId = wireObjectOperation.objectId // Delegate to PartialObjectOperation for decoding let partialOperation = try PartialObjectOperation( partialWireObjectOperation: PartialWireObjectOperation( - action: wireObjectOperation.action, mapOp: wireObjectOperation.mapOp, counterOp: wireObjectOperation.counterOp, map: wireObjectOperation.map, @@ -166,7 +165,6 @@ internal extension ObjectOperation { ) // Copy the decoded values - action = partialOperation.action mapOp = partialOperation.mapOp counterOp = partialOperation.counterOp map = partialOperation.map @@ -181,7 +179,6 @@ internal extension ObjectOperation { /// - format: The format to use when applying the encoding rules of OD4. func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectOperation { let partialWireOperation = PartialObjectOperation( - action: action, mapOp: mapOp, counterOp: counterOp, map: map, @@ -190,9 +187,9 @@ internal extension ObjectOperation { initialValue: initialValue, ).toWire(format: format) - // Create WireObjectOperation from PartialWireObjectOperation and add objectId + // Create WireObjectOperation from PartialWireObjectOperation and add action and objectId return WireObjectOperation( - action: partialWireOperation.action, + action: action, objectId: objectId, mapOp: partialWireOperation.mapOp, counterOp: partialWireOperation.counterOp, @@ -214,7 +211,6 @@ internal extension PartialObjectOperation { partialWireObjectOperation: PartialWireObjectOperation, format: AblyPlugin.EncodingFormat ) throws(InternalError) { - action = partialWireObjectOperation.action mapOp = try partialWireObjectOperation.mapOp.map { wireObjectsMapOp throws(InternalError) in try .init(wireObjectsMapOp: wireObjectsMapOp, format: format) } @@ -236,7 +232,6 @@ internal extension PartialObjectOperation { /// - format: The format to use when applying the encoding rules of OD4. func toWire(format: AblyPlugin.EncodingFormat) -> PartialWireObjectOperation { .init( - action: action, mapOp: mapOp?.toWire(format: format), counterOp: counterOp, map: map?.toWire(format: format), diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 30ed3aef8..d7316fc59 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -157,11 +157,10 @@ internal enum ObjectsMapSemantics: Int { case lww = 0 } -/// A partial version of `WireObjectOperation` that excludes the `objectId` property. Used for encoding initial values where the `objectId` is not yet known. +/// A partial version of `WireObjectOperation` that excludes the `action` and `objectId` property. Used for encoding initial values which don't include the `action` and where the `objectId` is not yet known. /// /// `WireObjectOperation` delegates its encoding and decoding to `PartialWireObjectOperation`. internal struct PartialWireObjectOperation { - internal var action: WireEnum // OOP3a internal var mapOp: WireObjectsMapOp? // OOP3c internal var counterOp: WireObjectsCounterOp? // OOP3d internal var map: WireObjectsMap? // OOP3e @@ -172,7 +171,6 @@ internal struct PartialWireObjectOperation { extension PartialWireObjectOperation: WireObjectCodable { internal enum WireKey: String { - case action case mapOp case counterOp case map @@ -182,7 +180,6 @@ extension PartialWireObjectOperation: WireObjectCodable { } internal init(wireObject: [String: WireValue]) throws(InternalError) { - action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) mapOp = try wireObject.optionalDecodableValueForKey(WireKey.mapOp.rawValue) counterOp = try wireObject.optionalDecodableValueForKey(WireKey.counterOp.rawValue) map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) @@ -195,9 +192,7 @@ extension PartialWireObjectOperation: WireObjectCodable { } internal var toWireObject: [String: WireValue] { - var result: [String: WireValue] = [ - WireKey.action.rawValue: .number(action.rawValue as NSNumber), - ] + var result: [String: WireValue] = [:] if let mapOp { result[WireKey.mapOp.rawValue] = .object(mapOp.toWireObject) @@ -235,18 +230,19 @@ internal struct WireObjectOperation { extension WireObjectOperation: WireObjectCodable { internal enum WireKey: String { + case action case objectId } internal init(wireObject: [String: WireValue]) throws(InternalError) { - // Decode the objectId first since it's not part of PartialWireObjectOperation + // Decode the action and objectId first since they're not part of PartialWireObjectOperation + action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) // Delegate to PartialWireObjectOperation for decoding let partialOperation = try PartialWireObjectOperation(wireObject: wireObject) // Copy the decoded values - action = partialOperation.action mapOp = partialOperation.mapOp counterOp = partialOperation.counterOp map = partialOperation.map @@ -257,7 +253,6 @@ extension WireObjectOperation: WireObjectCodable { internal var toWireObject: [String: WireValue] { var result = PartialWireObjectOperation( - action: action, mapOp: mapOp, counterOp: counterOp, map: map, @@ -267,6 +262,7 @@ extension WireObjectOperation: WireObjectCodable { ).toWireObject // Add the objectId field + result[WireKey.action.rawValue] = .number(action.rawValue as NSNumber) result[WireKey.objectId.rawValue] = .string(objectId) return result From bf05657369cf734eb39a7b32866dac2e09c12211 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 1 Aug 2025 10:28:05 +0100 Subject: [PATCH 096/225] Add Cursor rule re raw string literals --- .cursor/rules/swift.mdc | 1 + 1 file changed, 1 insertion(+) diff --git a/.cursor/rules/swift.mdc b/.cursor/rules/swift.mdc index 0ebf73704..3fa734a61 100644 --- a/.cursor/rules/swift.mdc +++ b/.cursor/rules/swift.mdc @@ -11,6 +11,7 @@ When writing Swift: - When writing initializer expressions, when the type that is being initialized can be inferred, favour using the implicit `.init(…)` form instead of explicitly writing the type name. - When writing enum value expressions, when the type that is being initialized can be inferred, favour using the implicit `.caseName` form instead of explicitly writing the type name. - When writing JSONValue or WireValue types, favour using the literal syntax enabled by their conformance to the `ExpressibleBy*Literal` protocols where possible. +- When writing a JSON string, favour using Swift raw string literals instead of escaping double quotes. - When you need to import the following modules inside the AblyLiveObjects library code (that is, in non-test code), do so in the following way: - Ably: use `import Ably` - AblyPlugin: use `internal import AblyPlugin` From dcdb3502faea6d89bae290804a677df758f2cca7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 24 Jul 2025 11:39:37 +0100 Subject: [PATCH 097/225] Implement the createMap and createCounter methods MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Based on [1] at cb11ba8. Internal interfaces by me, implementation and tests by Cursor (both with some tweaking by me). Haven't implemented: - the RTO11e etc echoMessages check — deferred to #49 - the RTO11c etc channel mode checking - same reason as 392fae3 - using server time for generating object ID — deferred to #50 [1] https://github.com/ably/specification/pull/353 --- .../InternalDefaultRealtimeObjects.swift | 84 +++++- .../Internal/InternalLiveMapValue.swift | 54 ++++ .../Internal/InternalObjectsMapEntry.swift | 2 +- .../Internal/ObjectCreationHelpers.swift | 223 +++++++++++++++ .../Internal/ObjectsPool.swift | 89 ++++++ .../PublicDefaultRealtimeObjects.swift | 43 ++- Sources/AblyLiveObjects/Utility/Errors.swift | 13 +- .../AblyLiveObjects/Utility/JSONValue.swift | 18 ++ .../InternalDefaultRealtimeObjectsTests.swift | 261 +++++++++++++++++- .../Mocks/MockCoreSDK.swift | 16 +- .../ObjectCreationHelpersTests.swift | 216 +++++++++++++++ 11 files changed, 1001 insertions(+), 18 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift create mode 100644 Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 904265f7a..91486f3db 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -157,20 +157,88 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } } - internal func createMap(entries _: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { - notYetImplemented() + internal func createMap(entries: [String: InternalLiveMapValue], coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { + do throws(InternalError) { + // RTO11d + do { + try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createMap") + } catch { + throw error.toInternalError() + } + + // RTO11f + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + let timestamp = clock.now + let creationOperation = ObjectCreationHelpers.creationOperationForLiveMap( + entries: entries, + timestamp: timestamp, + ) + + // RTO11g + try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) + + // RTO11h + return mutex.withLock { + mutableState.objectsPool.getOrCreateMap( + creationOperation: creationOperation, + logger: logger, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } catch { + throw error.toARTErrorInfo() + } } - internal func createMap() async throws(ARTErrorInfo) -> any LiveMap { - notYetImplemented() + internal func createMap(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { + // RTO11f4b + try await createMap(entries: [:], coreSDK: coreSDK) } - internal func createCounter(count _: Double) async throws(ARTErrorInfo) -> any LiveCounter { - notYetImplemented() + internal func createCounter(count: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { + do throws(InternalError) { + // RTO12d + do { + try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createCounter") + } catch { + throw error.toInternalError() + } + + // RTO12f1 + if !count.isFinite { + throw LiveObjectsError.counterInitialValueInvalid(value: count).toARTErrorInfo().toInternalError() + } + + // RTO12f + + // TODO: This is a stopgap; change to use server time per RTO12f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + let timestamp = clock.now + let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter( + count: count, + timestamp: timestamp, + ) + + // RTO12g + try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) + + // RTO12h + return mutex.withLock { + mutableState.objectsPool.getOrCreateCounter( + creationOperation: creationOperation, + logger: logger, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + } catch { + throw error.toARTErrorInfo() + } } - internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter { - notYetImplemented() + internal func createCounter(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { + // RTO12f2a + try await createCounter(count: 0, coreSDK: coreSDK) } internal func batch(callback _: sending BatchCallback) async throws { diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift index 0d6cb79b3..657a37f37 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -6,6 +6,60 @@ internal enum InternalLiveMapValue: Sendable, Equatable { case liveMap(InternalDefaultLiveMap) case liveCounter(InternalDefaultLiveCounter) + // MARK: - Creating from a public LiveMapValue + + /// Converts a public ``LiveMapValue`` into an ``InternalLiveMapValue``. + /// + /// Needed in order to access the internals of user-provided LiveObject-valued LiveMap entries to extract their object ID. + internal init(liveMapValue: LiveMapValue) { + switch liveMapValue { + case let .primitive(primitiveValue): + self = .primitive(primitiveValue) + 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-cocoa-liveobjects-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-cocoa-liveobjects-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 RTO11f4 and RTLM20e4. + internal var toObjectData: ObjectData { + // RTO11f4c1: Create an ObjectsMapEntry for the current value + switch self { + case let .primitive(primitiveValue): + switch primitiveValue { + 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): + // RTO11f4c1a: If the value is of type LiveMap, set ObjectsMapEntry.data.objectId to the objectId of that object + .init(objectId: liveMap.objectID) + case let .liveCounter(liveCounter): + // RTO11f4c1a: If the value is of type LiveCounter, set ObjectsMapEntry.data.objectId to the objectId of that object + .init(objectId: liveCounter.objectID) + } + } + // MARK: - Convenience getters for associated values /// If this `InternalLiveMapValue` has case `primitive`, this returns the associated value. Else, it returns `nil`. diff --git a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift index da969c3ba..40478ee3f 100644 --- a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift +++ b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift @@ -1,7 +1,7 @@ import Foundation /// The entries stored in a `LiveMap`'s data. Same as an `ObjectsMapEntry` but with an additional `tombstonedAt` property, per RTLM3a. -internal struct InternalObjectsMapEntry { +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) diff --git a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift new file mode 100644 index 000000000..b82f7d845 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift @@ -0,0 +1,223 @@ +internal import AblyPlugin +import CryptoKit +import Foundation + +/// Helpers for creating a new LiveObject. +/// +/// These generate an object ID and the `ObjectMessage` needed to create the LiveObject. +internal enum ObjectCreationHelpers { + /// The metadata that `createCounter` needs in order to request that Realtime create a LiveCounter and to populate the local objects pool. + internal struct CounterCreationOperation { + /// The generated object ID. Needed for populating the local objects pool. + /// + /// We include this property separately as a non-nil value, instead of expecting the caller to fish the nullable value out of ``objectMessage``. + internal var objectID: String + + /// The operation that should be merged into any created LiveCounter. + /// + /// 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 operation: ObjectOperation + + /// 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 operation that should be merged into any 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 operation: ObjectOperation + + /// 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 { + // RTO12f2: Create initial value for the new LiveCounter + let initialValue = PartialObjectOperation( + counter: WireObjectsCounter(count: NSNumber(value: count)), + ) + + // RTO12f3: Create an initial value JSON string as described in RTO13 + let initialValueJSONString = createInitialValueJSONString(from: initialValue) + + // 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, + counter: WireObjectsCounter(count: NSNumber(value: count)), + nonce: nonce, + initialValue: initialValueJSONString, + ) + + // Create the OutboundObjectMessage + let objectMessage = OutboundObjectMessage( + operation: operation, + ) + + return CounterCreationOperation( + objectID: objectId, + operation: operation, + 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 creationOperationForLiveMap( + entries: [String: InternalLiveMapValue], + timestamp: Date, + ) -> MapCreationOperation { + // RTO11f4: Create initial value for the new LiveMap + let mapEntries = entries.mapValues { liveMapValue -> ObjectsMapEntry in + ObjectsMapEntry(data: liveMapValue.toObjectData) + } + + let initialValue = PartialObjectOperation( + map: ObjectsMap( + semantics: .known(.lww), + entries: mapEntries, + ), + ) + + // RTO11f5: Create an initial value JSON string as described in RTO13 + let initialValueJSONString = createInitialValueJSONString(from: initialValue) + + // 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 semantics = ObjectsMapSemantics.lww + let operation = ObjectOperation( + action: .known(.mapCreate), + objectId: objectId, + map: ObjectsMap( + semantics: .known(semantics), + entries: mapEntries, + ), + nonce: nonce, + initialValue: initialValueJSONString, + ) + + // Create the OutboundObjectMessage + let objectMessage = OutboundObjectMessage( + operation: operation, + ) + + return MapCreationOperation( + objectID: objectId, + operation: operation, + objectMessage: objectMessage, + semantics: semantics, + ) + } + + // MARK: - Private Helper Methods + + /// Creates an initial value JSON string from a PartialObjectOperation, per RTO13. + private static func createInitialValueJSONString(from initialValue: PartialObjectOperation) -> String { + // RTO13b: Encode the initial value using OM4 encoding + let partialWireObjectOperation = initialValue.toWire(format: .json) + let jsonObject = partialWireObjectOperation.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") + } + } + + // RTO13c + 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/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 933198a53..8d2d7e4ff 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -306,6 +306,95 @@ internal struct ObjectsPool { logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) } + /// Gets or creates a counter object in the pool, implementing the "find or create zero-value" behavior of RTO12h1. + /// + /// - Parameters: + /// - creationOperation: The CounterCreationOperation containing the object ID and operation to merge + /// - 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 counter object + internal mutating func getOrCreateCounter( + creationOperation: ObjectCreationHelpers.CounterCreationOperation, + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) -> InternalDefaultLiveCounter { + // RTO12h2: If an object with the ObjectMessage.operation.objectId exists in the internal ObjectsPool, return it + if let existingEntry = entries[creationOperation.objectID] { + switch existingEntry { + case let .counter(counter): + return counter + case .map: + // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/36 + preconditionFailure("Expected counter object with ID \(creationOperation.objectID) but found map object") + } + } + + // RTO12h3: Otherwise, if the object does not exist in the internal ObjectsPool: + // RTO12h3a: Create a zero-value LiveCounter, set its objectId to ObjectMessage.operation.objectId, and merge the initial value + let counter = InternalDefaultLiveCounter.createZeroValued( + objectID: creationOperation.objectID, + logger: logger, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + + // Merge the initial value from the creation operation + _ = counter.mergeInitialValue(from: creationOperation.operation) + + // RTO12h3b: Add the created LiveCounter instance to the internal ObjectsPool + entries[creationOperation.objectID] = .counter(counter) + + // RTO12h3c: Return the created LiveCounter instance + return counter + } + + /// Gets or creates a map object in the pool, implementing the "find or create zero-value" behavior of RTO11h1. + /// + /// - Parameters: + /// - creationOperation: The MapCreationOperation containing the object ID and operation to merge + /// - 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 map object + internal mutating func getOrCreateMap( + creationOperation: ObjectCreationHelpers.MapCreationOperation, + logger: AblyPlugin.Logger, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + ) -> InternalDefaultLiveMap { + // RTO11h2: If an object with the ObjectMessage.operation.objectId exists in the internal ObjectsPool, return it + if let existingEntry = entries[creationOperation.objectID] { + switch existingEntry { + case let .map(map): + return map + case .counter: + // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/36 + preconditionFailure("Expected map object with ID \(creationOperation.objectID) but found counter object") + } + } + + // RTO11h3: Otherwise, if the object does not exist in the internal ObjectsPool: + // RTO11h3a: Create a zero-value LiveMap, set its objectId to ObjectMessage.operation.objectId, set its semantics to ObjectMessage.operation.map.semantics, and merge the initial value + let map = InternalDefaultLiveMap.createZeroValued( + objectID: creationOperation.objectID, + semantics: .known(creationOperation.semantics), + logger: logger, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + + // Merge the initial value from the creation operation + _ = map.mergeInitialValue(from: creationOperation.operation, objectsPool: &self) + + // RTO11h3b: Add the created LiveMap instance to the internal ObjectsPool + entries[creationOperation.objectID] = .map(map) + + // RTO11h3c: Return the created LiveMap instance + return map + } + /// 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 reset() { let root = root diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index 94be8e405..34b5abf7c 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -36,19 +36,54 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { } internal func createMap(entries: [String: LiveMapValue]) async throws(ARTErrorInfo) -> any LiveMap { - try await proxied.createMap(entries: entries) + 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, + delegate: proxied, + logger: logger, + ), + ) } internal func createMap() async throws(ARTErrorInfo) -> any LiveMap { - try await proxied.createMap() + let internalMap = try await proxied.createMap(coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateMap( + proxying: internalMap, + creationArgs: .init( + coreSDK: coreSDK, + delegate: proxied, + logger: logger, + ), + ) } internal func createCounter(count: Double) async throws(ARTErrorInfo) -> any LiveCounter { - try await proxied.createCounter(count: count) + let internalCounter = try await proxied.createCounter(count: count, coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateCounter( + proxying: internalCounter, + creationArgs: .init( + coreSDK: coreSDK, + logger: logger, + ), + ) } internal func createCounter() async throws(ARTErrorInfo) -> any LiveCounter { - try await proxied.createCounter() + let internalCounter = try await proxied.createCounter(coreSDK: coreSDK) + + return PublicObjectsStore.shared.getOrCreateCounter( + proxying: internalCounter, + creationArgs: .init( + coreSDK: coreSDK, + logger: logger, + ), + ) } internal func batch(callback: sending BatchCallback) async throws { diff --git a/Sources/AblyLiveObjects/Utility/Errors.swift b/Sources/AblyLiveObjects/Utility/Errors.swift index e44d47f98..941d3e8ba 100644 --- a/Sources/AblyLiveObjects/Utility/Errors.swift +++ b/Sources/AblyLiveObjects/Utility/Errors.swift @@ -6,19 +6,26 @@ import Ably 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: ARTRealtimeChannelState) + case counterInitialValueInvalid(value: Double) + case counterIncrementAmountInvalid(amount: Double) /// 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 } } /// The ``ARTErrorInfo/statusCode`` that should be returned for this error. internal var statusCode: Int { switch self { - case .objectsOperationFailedInvalidChannelState: + case .objectsOperationFailedInvalidChannelState, + .counterInitialValueInvalid, + .counterIncrementAmountInvalid: 400 } } @@ -28,6 +35,10 @@ internal enum LiveObjectsError { 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)" } } diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift index 3030dcaa1..48c33f3f2 100644 --- a/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -177,6 +177,24 @@ internal enum JSONObjectOrArray: Equatable { throw ConversionError.incompatibleJSONValue(jsonValue).toInternalError() } } + + // MARK: - Convenience getters for associated values + + /// If this `JSONObjectOrArray` has case `object`, this returns the associated value. Else, it returns `nil`. + internal var objectValue: [String: JSONValue]? { + if case let .object(value) = self { + return value + } + return nil + } + + /// If this `JSONObjectOrArray` has case `array`, this returns the associated value. Else, it returns `nil`. + internal var arrayValue: [JSONValue]? { + if case let .array(value) = self { + return value + } + return nil + } } extension JSONObjectOrArray: ExpressibleByDictionaryLiteral { diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 82f177cb8..fee10e3c5 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -8,9 +8,9 @@ struct InternalDefaultRealtimeObjectsTests { // MARK: - Test Helpers /// Creates a InternalDefaultRealtimeObjects instance for testing - static func createDefaultRealtimeObjects() -> InternalDefaultRealtimeObjects { + static func createDefaultRealtimeObjects(clock: SimpleClock = MockSimpleClock()) -> InternalDefaultRealtimeObjects { let logger = TestLogger() - return InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + return InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: .main, clock: clock) } /// Tests for `InternalDefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. @@ -1040,4 +1040,261 @@ struct InternalDefaultRealtimeObjectsTests { } } } + + /// 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 [ARTRealtimeChannelState]) + func throwsIfChannelIsInInvalidState(channelState: ARTRealtimeChannelState) async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: channelState) + let entries: [String: InternalLiveMapValue] = ["testKey": .primitive(.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 RTO11g + // @spec RTO11h3a + // @spec RTO11h3b + @Test + func publishesObjectMessageAndCreatesMap() async throws { + let clock = MockSimpleClock(currentTime: .init(timeIntervalSince1970: 1_754_042_434)) + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(clock: clock) + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + // Call createMap + let returnedMap = try await realtimeObjects.createMap( + entries: [ + "stringKey": .primitive(.string("stringValue")), + ], + coreSDK: coreSDK, + ) + + // Verify ObjectMessage was published (RTO11g) + #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:")) + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + #expect(objectID.contains("1754042434000")) // check contains the mock clock's timestamp in milliseconds + #expect(publishedMessage.operation?.map?.entries == [ + "stringKey": .init(data: .init(string: "stringValue")), + ]) + + // Verify initial value was merged per RTO11h3a + #expect(returnedMap.testsOnly_data == ["stringKey": .init(data: .init(string: "stringValue"))]) + + // Verify object was added to pool per RTO11h3b + #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.mapValue === returnedMap) + } + + // @spec RTO11f4b + @Test + func withNoEntriesArgumentCreatesEmptyMap() async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + // 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 RTO11f4b + let mapOperation = publishedMessage.operation?.map + #expect(mapOperation?.entries?.isEmpty == true) + + // Verify LiveMap has expected entries + #expect(result.testsOnly_data.isEmpty) + } + + // @spec RTO11h2 + @Test + func returnsExistingObjectIfAlreadyInPool() async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) + + // 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 + } + } + + // Call createMap - the publishHandler will create the object with the generated ID + let result = try await realtimeObjects.createMap(entries: ["testKey": .primitive(.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 [ARTRealtimeChannelState]) + func throwsIfChannelIsInInvalidState(channelState: ARTRealtimeChannelState) async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: channelState) + + 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 RTO12g + // @spec RTO12h3a + // @spec RTO12h3b + @Test + func publishesObjectMessageAndCreatesCounter() async throws { + let clock = MockSimpleClock(currentTime: .init(timeIntervalSince1970: 1_754_042_434)) + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(clock: clock) + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + // Call createCounter + let returnedCounter = try await realtimeObjects.createCounter(count: 10.5, coreSDK: coreSDK) + + // Verify ObjectMessage was published (RTO12g) + #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:")) + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + #expect(objectID.contains("1754042434000")) // check contains the mock clock's timestamp in milliseconds + #expect(publishedMessage.operation?.counter?.count == 10.5) + + // Verify initial value was merged per RTO12h3a + #expect(try returnedCounter.value(coreSDK: coreSDK) == 10.5) + + // Verify object was added to pool per RTO12h3b + #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.counterValue === returnedCounter) + } + + // @spec RTO12f2a + @Test + func withNoEntriesArgumentCreatesWithZeroValue() async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) + + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + // 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 RTO12f2a + let counterOperation = publishedMessage.operation?.counter + // swiftlint:disable:next empty_count + #expect(counterOperation?.count == 0) + + // Verify LiveCounter has zero value + #expect(try result.value(coreSDK: coreSDK) == 0) + } + + // @spec RTO12h2 + @Test + func returnsExistingObjectIfAlreadyInPool() async throws { + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let coreSDK = MockCoreSDK(channelState: .attached) + + // 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 + } + } + + // 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) + } + } + } } diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 4883717b4..f01b59eaf 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -6,13 +6,18 @@ final class MockCoreSDK: CoreSDK { private let mutex = NSLock() private nonisolated(unsafe) var _channelState: ARTRealtimeChannelState + private nonisolated(unsafe) var _publishHandler: (([OutboundObjectMessage]) async throws(InternalError) -> Void)? init(channelState: ARTRealtimeChannelState) { _channelState = channelState } - func publish(objectMessages _: [AblyLiveObjects.OutboundObjectMessage]) async throws(AblyLiveObjects.InternalError) { - protocolRequirementNotImplemented() + func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + if let handler = _publishHandler { + try await handler(objectMessages) + } else { + protocolRequirementNotImplemented() + } } var channelState: ARTRealtimeChannelState { @@ -27,4 +32,11 @@ final class MockCoreSDK: CoreSDK { } } } + + /// Sets a custom publish handler for testing + func setPublishHandler(_ handler: @escaping ([OutboundObjectMessage]) async throws(InternalError) -> Void) { + mutex.withLock { + _publishHandler = handler + } + } } diff --git a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift new file mode 100644 index 000000000..126b94264 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift @@ -0,0 +1,216 @@ +@testable import AblyLiveObjects +import Foundation +import Testing + +struct ObjectCreationHelpersTests { + struct CreationOperationTests { + // @spec RTO11f4c + // @spec RTO11f4c1a + // @spec RTO11f4c1a + // @spec RTO11f4c1b + // @spec RTO11f4c1c + // @spec RTO11f4c1d + // @spec RTO11f4c1e + // @spec RTO11f4c1f + // @spec RTO11f4c2 + // @spec RTO11f9 + // @spec RTO11f11 + // @spec RTO11f12 + // @spec RTO11f13 + // @specOneOf(1/2) RTO13 + @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 referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "referencedMapID", logger: logger, userCallbackQueue: .main, clock: clock) + let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "referencedCounterID", logger: logger, userCallbackQueue: .main, clock: clock) + + // When + let creationOperation = ObjectCreationHelpers.creationOperationForLiveMap( + entries: [ + // RTO11f4c1a + "mapRef": .liveMap(referencedMap), + // RTO11f4c1a + "counterRef": .liveCounter(referencedCounter), + // RTO11f4c1b + "jsonArrayKey": .primitive(.jsonArray([.string("arrayItem1"), .string("arrayItem2")])), + "jsonObjectKey": .primitive(.jsonObject(["nestedKey": .string("nestedValue")])), + // RTO11f4c1c + "stringKey": .primitive(.string("stringValue")), + // RTO11f4c1d + "numberKey": .primitive(.number(42.5)), + // RTO11f4c1e + "booleanKey": .primitive(.bool(true)), + // RTO11f4c1f + "dataKey": .primitive(.data(Data([0x01, 0x02, 0x03]))), + ], + timestamp: timestamp, + ) + + // Then + + // Check that the denormalized properties match those of the ObjectMessage + #expect(creationOperation.objectMessage.operation == creationOperation.operation) + #expect(creationOperation.objectMessage.operation?.map?.semantics == .known(creationOperation.semantics)) + + // Check that the initial value JSON is correctly populated on the initialValue property per RTO11f12, using the RTO11f4 partial ObjectOperation and correctly encoded per RTO13 + let initialValueString = try #require(creationOperation.operation.initialValue) + let deserializedInitialValue = try #require(try JSONObjectOrArray(jsonString: initialValueString).objectValue) + #expect(deserializedInitialValue == [ + "map": [ + // RTO11f4a + "semantics": .number(ObjectsMapSemantics.lww.rawValue as NSNumber), + "entries": [ + // RTO11f4c1a + "mapRef": [ + "data": [ + "objectId": "referencedMapID", + ], + ], + "counterRef": [ + "data": [ + "objectId": "referencedCounterID", + ], + ], + // RTO11f4c1b + "jsonArrayKey": [ + "data": [ + "json": #"["arrayItem1","arrayItem2"]"#, + ], + ], + "jsonObjectKey": [ + "data": [ + "json": #"{"nestedKey":"nestedValue"}"#, + ], + ], + // RTO11f4c1c + "stringKey": [ + "data": [ + "string": "stringValue", + ], + ], + // RTO11f4c1d + "numberKey": [ + "data": [ + "number": 42.5, + ], + ], + // RTO11f4c1e + "booleanKey": [ + "data": [ + "boolean": true, + ], + ], + // RTO11f4c1f + "dataKey": [ + "data": [ + "bytes": .string(Data([0x01, 0x02, 0x03]).base64EncodedString()), + ], + ], + ], + ], + ]) + + // Check that the partial ObjectOperation properties are set on the ObjectMessage, per RTO11f13 + + #expect(creationOperation.objectMessage.operation?.map?.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(creationOperation.objectMessage.operation?.map?.entries == expectedEntries) + + // Check the other ObjectMessage properties + + // RTO11f9 + #expect(creationOperation.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: creationOperation.operation.objectId) != nil) + + // Check that nonce has been populated per RTO11f11 (we make no assertions about its format or randomness) + #expect(creationOperation.operation.nonce != nil) + } + + // @spec RTO12f2a + // @spec RTO12f10 + // @spec RTO12f6 + // @spec RTO12f8 + // @spec RTO12f9 + // @specOneOf(2/2) RTO13 + @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 the denormalized properties match those of the ObjectMessage + #expect(creationOperation.objectMessage.operation == creationOperation.operation) + + // Check that the initial value JSON is correctly populated on the initialValue property per RTO12f10, using the RTO12f2 partial ObjectOperation and correctly encoded per RTO13 + let initialValueString = try #require(creationOperation.operation.initialValue) + let deserializedInitialValue = try #require(try JSONObjectOrArray(jsonString: initialValueString).objectValue) + #expect(deserializedInitialValue == [ + "counter": [ + // RTO12f2a + "count": 10.5, + ], + ]) + + // Check that the partial ObjectOperation properties are set on the ObjectMessage, per RTO12f10 + + #expect(creationOperation.objectMessage.operation?.counter?.count == 10.5) + + // Check the other ObjectMessage properties + + // RTO12f7 + #expect(creationOperation.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: creationOperation.operation.objectId) != nil) + + // Check that nonce has been populated per RTO12f9 (we make no assertions about its format or randomness) + #expect(creationOperation.operation.nonce != nil) + } + } + + /// 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") + } + } +} From d9c327e4deb12f33bb8cd8bee3d2c5db4be08a1f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 29 Jul 2025 10:00:12 +0100 Subject: [PATCH 098/225] Implement increment / decrement / set / delete Based on [1] at cb11ba8. Code by me, tests by Cursor and tidied up by me. Channel mode checking and echoMessages check omitted as in e65643a. [1] https://github.com/ably/specification/pull/353 --- .../Internal/InternalDefaultLiveCounter.swift | 42 ++++- .../Internal/InternalDefaultLiveMap.swift | 59 +++++- .../PublicDefaultLiveCounter.swift | 4 +- .../PublicDefaultLiveMap.swift | 6 +- .../InternalDefaultLiveCounterTests.swift | 116 ++++++++++++ .../InternalDefaultLiveMapTests.swift | 177 ++++++++++++++++++ 6 files changed, 392 insertions(+), 12 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index fe9fba8a9..5cd3fd276 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -89,12 +89,46 @@ internal final class InternalDefaultLiveCounter: Sendable { } } - internal func increment(amount _: Double) async throws(ARTErrorInfo) { - notYetImplemented() + internal func increment(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + do throws(InternalError) { + // RTLC12c + do { + try coreSDK.validateChannelState( + notIn: [.detached, .failed, .suspended], + operationDescription: "LiveCounter.increment", + ) + } catch { + throw error.toInternalError() + } + + // RTLC12e1 + if !amount.isFinite { + throw LiveObjectsError.counterIncrementAmountInvalid(amount: amount).toARTErrorInfo().toInternalError() + } + + let objectMessage = OutboundObjectMessage( + operation: .init( + // RTLC12e2 + action: .known(.counterInc), + // RTLC12e3 + objectId: objectID, + counterOp: .init( + // RTLC12e4 + amount: .init(value: amount), + ), + ), + ) + + // RTLC12f + try await coreSDK.publish(objectMessages: [objectMessage]) + } catch { + throw error.toARTErrorInfo() + } } - internal func decrement(amount _: Double) async throws(ARTErrorInfo) { - notYetImplemented() + internal func decrement(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + // RTLC13b + try await increment(amount: -amount, coreSDK: coreSDK) } @discardableResult diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 427a09c69..095469099 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -173,12 +173,63 @@ internal final class InternalDefaultLiveMap: Sendable { try entries(coreSDK: coreSDK, delegate: delegate).map(\.value) } - internal func set(key _: String, value _: LiveMapValue) async throws(ARTErrorInfo) { - notYetImplemented() + internal func set(key: String, value: InternalLiveMapValue, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + do throws(InternalError) { + // RTLM20c + do { + try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.set") + } catch { + throw error.toInternalError() + } + + let objectMessage = OutboundObjectMessage( + operation: .init( + // RTLM20e2 + action: .known(.mapSet), + // RTLM20e3 + objectId: objectID, + mapOp: .init( + // RTLM20e4 + key: key, + // RTLM20e5 + data: value.toObjectData, + ), + ), + ) + + try await coreSDK.publish(objectMessages: [objectMessage]) + } catch { + throw error.toARTErrorInfo() + } } - internal func remove(key _: String) async throws(ARTErrorInfo) { - notYetImplemented() + internal func remove(key: String, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + do throws(InternalError) { + // RTLM21c + do { + try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.remove") + } catch { + throw error.toInternalError() + } + + let objectMessage = OutboundObjectMessage( + operation: .init( + // RTLM21e2 + action: .known(.mapRemove), + // RTLM21e3 + objectId: objectID, + mapOp: .init( + // RTLM21e4 + key: key, + ), + ), + ) + + // RTLM21f + try await coreSDK.publish(objectMessages: [objectMessage]) + } catch { + throw error.toARTErrorInfo() + } } @discardableResult diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 19c2bfc41..146d05e67 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -27,11 +27,11 @@ internal final class PublicDefaultLiveCounter: LiveCounter { } internal func increment(amount: Double) async throws(ARTErrorInfo) { - try await proxied.increment(amount: amount) + try await proxied.increment(amount: amount, coreSDK: coreSDK) } internal func decrement(amount: Double) async throws(ARTErrorInfo) { - try await proxied.decrement(amount: amount) + try await proxied.decrement(amount: amount, coreSDK: coreSDK) } internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 3569a4cc5..52e207d20 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -76,11 +76,13 @@ internal final class PublicDefaultLiveMap: LiveMap { } internal func set(key: String, value: LiveMapValue) async throws(ARTErrorInfo) { - try await proxied.set(key: key, value: value) + let internalValue = InternalLiveMapValue(liveMapValue: value) + + try await proxied.set(key: key, value: internalValue, coreSDK: coreSDK) } internal func remove(key: String) async throws(ARTErrorInfo) { - try await proxied.remove(key: key) + try await proxied.remove(key: key, coreSDK: coreSDK) } internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 6ce42481c..039ec5171 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -422,4 +422,120 @@ struct InternalDefaultLiveCounterTests { #expect(subscriberInvocations.isEmpty) } } + + /// Tests for the `increment` method, covering RTLC12 specification points + struct IncrementTests { + // @spec RTLC12c + @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: ARTRealtimeChannelState) async throws { + let logger = TestLogger() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState) + + await #expect { + try await counter.increment(amount: 10, coreSDK: coreSDK) + } 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 counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + await #expect { + try await counter.increment(amount: amount, coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + + return errorInfo.code == 40003 && errorInfo.statusCode == 400 + } + } + + // @spec RTLC12e2 + // @spec RTLC12e3 + // @spec RTLC12e4 + // @spec RTLC12f + func publishesCorrectObjectMessage() async throws { + let logger = TestLogger() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + try await counter.increment(amount: 10.5, coreSDK: coreSDK) + + let expectedMessage = OutboundObjectMessage( + operation: ObjectOperation( + // RTLC12e2 + action: .known(.counterInc), + // RTLC12e3 + objectId: "counter:test@123", + // RTLC12e4 + counterOp: WireObjectsCounterOp(amount: NSNumber(value: 10.5)), + ), + ) + // RTLC12f + #expect(publishedMessages.count == 1) + #expect(publishedMessages[0] == expectedMessage) + } + + @Test + func throwsErrorWhenPublishFails() async throws { + let logger = TestLogger() + let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + coreSDK.setPublishHandler { _ throws(InternalError) in + throw InternalError.other(.generic(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]))) + } + + await #expect { + try await counter.increment(amount: 10, coreSDK: coreSDK) + } 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 counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + try await counter.decrement(amount: 10.5, coreSDK: coreSDK) + + // RTLC12f + #expect(publishedMessages.count == 1) + #expect(publishedMessages[0].operation?.counterOp?.amount == -10.5 /* i.e. assert the amount gets negated */ ) + } + } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index d68e7ccde..989821665 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -1235,4 +1235,181 @@ struct InternalDefaultLiveMapTests { #expect(subscriberInvocations.isEmpty) } } + + /// Tests for the `set` method, covering RTLM20 specification points + struct SetTests { + // @spec RTLM20c + @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: ARTRealtimeChannelState) async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState) + + await #expect { + try await map.set(key: "test", value: .primitive(.string("value")), coreSDK: coreSDK) + } 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 RTLM20e4 + // @spec RTLM20e5a + // @spec RTLM20e5b + // @spec RTLM20e5c + // @spec RTLM20e5d + // @spec RTLM20e5e + // @spec RTLM20e5f + // @spec RTLM20f + @Test(arguments: [ + // RTLM20e5a + (value: .liveMap(.createZeroValued(objectID: "map:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock())), expectedData: .init(objectId: "map:test@123")), + (value: .liveCounter(.createZeroValued(objectID: "map:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock())), expectedData: .init(objectId: "map:test@123")), + // RTLM20e5b + (value: .primitive(.jsonArray(["test"])), expectedData: .init(json: .array(["test"]))), + (value: .primitive(.jsonObject(["foo": "bar"])), expectedData: .init(json: .object(["foo": "bar"]))), + // RTLM20e5c + (value: .primitive(.string("test")), expectedData: .init(string: "test")), + // RTLM20e5d + (value: .primitive(.number(42.5)), expectedData: .init(number: NSNumber(value: 42.5))), + // RTLM20e5e + (value: .primitive(.bool(true)), expectedData: .init(boolean: true)), + // RTLM20e5f + (value: .primitive(.data(Data([0x01, 0x02]))), expectedData: .init(bytes: Data([0x01, 0x02]))), + ] as [(value: InternalLiveMapValue, expectedData: ObjectData)]) + func publishesCorrectObjectMessageForDifferentValueTypes(value: InternalLiveMapValue, expectedData: ObjectData) async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + var publishedMessage: OutboundObjectMessage? + coreSDK.setPublishHandler { messages in + publishedMessage = messages.first + } + + try await map.set(key: "testKey", value: value, coreSDK: coreSDK) + + let expectedMessage = OutboundObjectMessage( + operation: ObjectOperation( + // RTLM20e2 + action: .known(.mapSet), + // RTLM20e3 + objectId: "map:test@123", + mapOp: ObjectsMapOp( + // RTLM20e4 + key: "testKey", + // RTLM20e5 + data: expectedData, + ), + ), + ) + // RTLM20f + let message = try #require(publishedMessage) + #expect(message == expectedMessage) + } + + @Test + func throwsErrorWhenPublishFails() async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + coreSDK.setPublishHandler { _ throws(InternalError) in + throw NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]).toInternalError() + } + + await #expect { + try await map.set(key: "testKey", value: .primitive(.string("testValue")), coreSDK: coreSDK) + } 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 [ARTRealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: ARTRealtimeChannelState) async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: channelState) + + await #expect { + try await map.remove(key: "test", coreSDK: coreSDK) + } 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 RTLM21e4 + // @spec RTLM21f + func publishesCorrectObjectMessage() async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } + + try await map.remove(key: "testKey", coreSDK: coreSDK) + + let expectedMessage = OutboundObjectMessage( + operation: ObjectOperation( + // RTLM21e2 + action: .known(.mapRemove), + // RTLM21e3 + objectId: "map:test@123", + mapOp: ObjectsMapOp( + // RTLM21e4 + key: "testKey", + data: nil, + ), + ), + ) + // RTLM21f + #expect(publishedMessages.count == 1) + #expect(publishedMessages[0] == expectedMessage) + } + + @Test + func throwsErrorWhenPublishFails() async throws { + let logger = TestLogger() + let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let coreSDK = MockCoreSDK(channelState: .attached) + + coreSDK.setPublishHandler { _ throws(InternalError) in + throw NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]).toInternalError() + } + + await #expect { + try await map.remove(key: "testKey", coreSDK: coreSDK) + } throws: { error in + guard let errorInfo = error as? ARTErrorInfo else { + return false + } + return errorInfo.message.contains("Publish failed") + } + } + } } From 4e8e076373c0a0aa3090d639afffc51936f8957c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 29 Jul 2025 12:00:46 +0100 Subject: [PATCH 099/225] Add another smoke test of the public API Just to convince myself that the write API is somewhat working. Will get further confidence upon porting across more of the JS integration tests in the future. --- .../AblyLiveObjectsTests.swift | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index 1a1445d84..c9cb540b2 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -122,4 +122,67 @@ struct AblyLiveObjectsTests { #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": .primitive(.bool(true)), + "numberKey": .primitive(.number(10)), + ]) + #expect( + try Dictionary(uniqueKeysWithValues: map.entries) == [ + "boolKey": .primitive(.bool(true)), + "numberKey": .primitive(.number(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": .primitive(.bool(true)), + "numberKey": .primitive(.number(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": .primitive(.number(10)), + "counterKey": .liveCounter(counter), + ], + ) + } } From 733b70e656206df1419ac338f43ce10884681d08 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 4 Aug 2025 09:52:18 +0100 Subject: [PATCH 100/225] Add ability to prefix integration test log message For debugging tests. --- .../Helpers/ClientHelper.swift | 36 +++++++++++++++++++ .../ObjectsIntegrationTests.swift | 12 ++++--- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift b/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift index ff3dcb715..f1de7d64e 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift @@ -22,10 +22,43 @@ enum ClientHelper { if let autoConnect = options.autoConnect { clientOptions.autoConnect = autoConnect } + if let logIdentifier = options.logIdentifier { + let logger = PrefixedLogger(prefix: "(\(logIdentifier)) ") + clientOptions.logHandler = logger + } 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() @@ -36,5 +69,8 @@ enum ClientHelper { struct PartialClientOptions: Encodable, Hashable { var useBinaryProtocol: Bool? var autoConnect: Bool? + + /// 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/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index f4e388a4e..6fbbd0b83 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -177,17 +177,21 @@ private struct TestScenario { private func forScenarios(_ scenarios: [TestScenario]) -> [TestCase] { scenarios.map { scenario -> [TestCase] in + var clientOptions = ClientHelper.PartialClientOptions(logIdentifier: "client1") + if scenario.allTransportsAndProtocols { - [true, false].map { useBinaryProtocol -> TestCase in - .init( + return [true, false].map { useBinaryProtocol -> TestCase in + clientOptions.useBinaryProtocol = useBinaryProtocol + + return .init( disabled: scenario.disabled, scenario: scenario, - options: .init(useBinaryProtocol: useBinaryProtocol), + options: clientOptions, channelName: "\(scenario.description) \(useBinaryProtocol ? "binary" : "text")", ) } } else { - [.init(disabled: scenario.disabled, scenario: scenario, options: .init(), channelName: scenario.description)] + return [.init(disabled: scenario.disabled, scenario: scenario, options: clientOptions, channelName: scenario.description)] } } .flatMap(\.self) From bebbc9d5a276e9bc4ae26571a08a7d2508f7dcbf Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 4 Aug 2025 10:14:07 +0100 Subject: [PATCH 101/225] Improve readability of outbound ObjectMessage log messages The log output was really hard to read; asked Cursor to do this with the following guidance: - one InboundObjectMessage per line - I don't need to see any of the type names in the output, just their properties - I don't need to see "Optional" in the output; if it's nil just write nil, if it's non-nil then just display the wrapped value Have not checked its output in any detail. --- .../InternalDefaultRealtimeObjects.swift | 4 +- .../Protocol/ObjectMessage.swift | 110 ++++++++++++++++++ .../Protocol/WireObjectMessage.swift | 46 ++++++++ .../Utility/LoggingUtilities.swift | 14 +++ 4 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 Sources/AblyLiveObjects/Utility/LoggingUtilities.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 91486f3db..2f8b494ec 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -389,7 +389,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool clock: SimpleClock, receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, ) { - logger.log("handleObjectSyncProtocolMessage(objectMessages: \(objectMessages), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) + logger.log("handleObjectSyncProtocolMessage(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)), protocolMessageChannelSerial: \(String(describing: protocolMessageChannelSerial)))", level: .debug) receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) @@ -489,7 +489,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool ) { receivedObjectProtocolMessagesContinuation.yield(objectMessages) - logger.log("handleObjectProtocolMessage(objectMessages: \(objectMessages))", level: .debug) + logger.log("handleObjectProtocolMessage(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) if let existingSyncSequence = syncSequence { // RTO8a: Buffer the OBJECT message, to be handled once the sync completes diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 23719a19f..4e31b7448 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -468,3 +468,113 @@ internal extension ObjectState { ) } } + +// MARK: - CustomDebugStringConvertible + +extension InboundObjectMessage: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + if let id { parts.append("id: \(id)") } + if let clientId { parts.append("clientId: \(clientId)") } + if let connectionId { parts.append("connectionId: \(connectionId)") } + if let extras { parts.append("extras: \(extras)") } + if let timestamp { parts.append("timestamp: \(timestamp)") } + if let operation { parts.append("operation: \(operation)") } + if let object { parts.append("object: \(object)") } + if let serial { parts.append("serial: \(serial)") } + if let siteCode { parts.append("siteCode: \(siteCode)") } + if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension ObjectOperation: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("action: \(action)") + parts.append("objectId: \(objectId)") + if let mapOp { parts.append("mapOp: \(mapOp)") } + if let counterOp { parts.append("counterOp: \(counterOp)") } + if let map { parts.append("map: \(map)") } + if let counter { parts.append("counter: \(counter)") } + if let nonce { parts.append("nonce: \(nonce)") } + if let initialValue { parts.append("initialValue: \(initialValue)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension ObjectState: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("objectId: \(objectId)") + parts.append("siteTimeserials: \(siteTimeserials)") + parts.append("tombstone: \(tombstone)") + if let createOp { parts.append("createOp: \(createOp)") } + if let map { parts.append("map: \(map)") } + if let counter { parts.append("counter: \(counter)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension ObjectsMapOp: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("key: \(key)") + if let data { parts.append("data: \(data)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension ObjectsMap: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("semantics: \(semantics)") + if let entries { + let formattedEntries = entries + .map { key, entry in + "\(key): \(entry)" + } + .joined(separator: ", ") + parts.append("entries: { \(formattedEntries) }") + } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension ObjectsMapEntry: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + if let tombstone { parts.append("tombstone: \(tombstone)") } + if let timeserial { parts.append("timeserial: \(timeserial)") } + parts.append("data: \(data)") + if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension ObjectData: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + if let objectId { parts.append("objectId: \(objectId)") } + if let boolean { parts.append("boolean: \(boolean)") } + if let bytes { parts.append("bytes: \(bytes.count) bytes") } + if let number { parts.append("number: \(number)") } + if let string { parts.append("string: \(string)") } + if let json { parts.append("json: \(json)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index d7316fc59..3c46e4bab 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -555,3 +555,49 @@ internal enum StringOrData: WireCodable { } } } + +// MARK: - CustomDebugStringConvertible + +extension WireObjectsCounter: CustomDebugStringConvertible { + internal var debugDescription: String { + if let count { + "{ count: \(count) }" + } else { + "{ count: nil }" + } + } +} + +extension WireObjectsCounterOp: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ amount: \(amount) }" + } +} + +extension WireObjectsMapEntry: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + if let tombstone { parts.append("tombstone: \(tombstone)") } + if let timeserial { parts.append("timeserial: \(timeserial)") } + parts.append("data: \(data)") + if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension WireObjectData: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + if let objectId { parts.append("objectId: \(objectId)") } + if let boolean { parts.append("boolean: \(boolean)") } + if let bytes { parts.append("bytes: \(bytes)") } + if let number { parts.append("number: \(number)") } + if let string { parts.append("string: \(string)") } + if let json { parts.append("json: \(json)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} diff --git a/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift b/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift new file mode 100644 index 000000000..c1939abdc --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift @@ -0,0 +1,14 @@ +import Foundation + +internal enum LoggingUtilities { + /// Formats an array of object messages for logging with one message per line. + /// - Parameter objectMessages: The array of object messages to format + /// - Returns: A formatted string with one message per line + internal static func formatObjectMessagesForLogging(_ objectMessages: [some CustomDebugStringConvertible]) -> String { + guard !objectMessages.isEmpty else { + return "[]" + } + + return "[\n" + objectMessages.map { " \($0)" }.joined(separator: ",\n") + "\n]" + } +} From d62f10180d7f47d0111ddeda406db4c6a113f6fd Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 4 Aug 2025 10:54:09 +0100 Subject: [PATCH 102/225] Log outbound ObjectMessages --- .../AblyLiveObjects/Internal/CoreSDK.swift | 7 ++++++- .../Protocol/ObjectMessage.swift | 19 +++++++++++++++++++ .../Public/ARTRealtimeChannel+Objects.swift | 5 +++-- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 252e5b0eb..753841158 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -16,20 +16,25 @@ internal final class DefaultCoreSDK: CoreSDK { private let channel: AblyPlugin.RealtimeChannel private let client: AblyPlugin.RealtimeClient private let pluginAPI: PluginAPIProtocol + private let logger: AblyPlugin.Logger internal init( channel: AblyPlugin.RealtimeChannel, client: AblyPlugin.RealtimeClient, - pluginAPI: PluginAPIProtocol + pluginAPI: PluginAPIProtocol, + logger: AblyPlugin.Logger ) { self.channel = channel self.client = client self.pluginAPI = pluginAPI + self.logger = logger } // MARK: - CoreSDK conformance internal func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + logger.log("publish(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) + // TODO: Implement the full spec of RTO15 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/47) try await DefaultInternalPlugin.sendObject( objectMessages: objectMessages, diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 4e31b7448..adf1812a3 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -490,6 +490,25 @@ extension InboundObjectMessage: CustomDebugStringConvertible { } } +extension OutboundObjectMessage: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + if let id { parts.append("id: \(id)") } + if let clientId { parts.append("clientId: \(clientId)") } + if let connectionId { parts.append("connectionId: \(connectionId)") } + if let extras { parts.append("extras: \(extras)") } + if let timestamp { parts.append("timestamp: \(timestamp)") } + if let operation { parts.append("operation: \(operation)") } + if let object { parts.append("object: \(object)") } + if let serial { parts.append("serial: \(serial)") } + if let siteCode { parts.append("siteCode: \(siteCode)") } + if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + extension ObjectOperation: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index d36b5ccfb..da4791781 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -12,14 +12,15 @@ public extension ARTRealtimeChannel { let underlyingObjects = pluginAPI.underlyingObjects(forPublicRealtimeChannel: self) let internalObjects = DefaultInternalPlugin.realtimeObjects(for: underlyingObjects.channel, pluginAPI: pluginAPI) + let logger = pluginAPI.logger(for: underlyingObjects.channel) + let coreSDK = DefaultCoreSDK( channel: underlyingObjects.channel, client: underlyingObjects.client, pluginAPI: Plugin.defaultPluginAPI, + logger: logger, ) - let logger = pluginAPI.logger(for: underlyingObjects.channel) - return PublicObjectsStore.shared.getOrCreateRealtimeObjects( proxying: internalObjects, creationArgs: .init( From d83e93bb1586a0af1d364ab82ad7bf876e611d56 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 4 Aug 2025 11:48:47 +0100 Subject: [PATCH 103/225] Call pluginAPI.sendObject on ably-cocoa internal queue The bumped ably-cocoa submodule requires this. --- .../AblyLiveObjects/Internal/CoreSDK.swift | 1 + .../Internal/DefaultInternalPlugin.swift | 21 ++++++++++++------- ably-cocoa | 2 +- 3 files changed, 15 insertions(+), 9 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 753841158..bab61f693 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -39,6 +39,7 @@ internal final class DefaultCoreSDK: CoreSDK { try await DefaultInternalPlugin.sendObject( objectMessages: objectMessages, channel: channel, + client: client, pluginAPI: pluginAPI, ) } diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 9dd56e0fe..3ca858bef 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -128,19 +128,24 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte internal static func sendObject( objectMessages: [OutboundObjectMessage], channel: AblyPlugin.RealtimeChannel, + client: AblyPlugin.RealtimeClient, pluginAPI: PluginAPIProtocol, ) async throws(InternalError) { let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in - pluginAPI.sendObject( - withObjectMessages: objectMessageBoxes, - channel: channel, - ) { error in - if let error { - continuation.resume(returning: .failure(error.toInternalError())) - } else { - continuation.resume(returning: .success(())) + let internalQueue = pluginAPI.internalQueue(for: client) + + internalQueue.async { + pluginAPI.sendObject( + withObjectMessages: objectMessageBoxes, + channel: channel, + ) { error in + if let error { + continuation.resume(returning: .failure(error.toInternalError())) + } else { + continuation.resume(returning: .success(())) + } } } }.get() diff --git a/ably-cocoa b/ably-cocoa index c72e1d749..bc1936855 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit c72e1d7498bfcd0562f67442601e5056c2592631 +Subproject commit bc19368559fe1b860d18f375e421e8fd2526572f From b96e585d5f12d9ec887c6d10e42d1cc82ff2efb8 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 4 Aug 2025 12:26:53 +0100 Subject: [PATCH 104/225] Make ObjectsMapEntry.data nullable Implements https://github.com/ably/specification/pull/355. --- .../Internal/InternalDefaultLiveMap.swift | 22 +++++++++---------- .../Internal/InternalObjectsMapEntry.swift | 2 +- .../Protocol/ObjectMessage.swift | 12 ++++++---- .../Protocol/WireObjectMessage.swift | 13 ++++++----- .../Helpers/TestFactories.swift | 2 +- .../InternalDefaultLiveMapTests.swift | 17 ++++---------- .../WireObjectMessageTests.swift | 12 +++++----- 7 files changed, 38 insertions(+), 42 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 095469099..3f6c16545 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -655,7 +655,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal mutating func applyMapSetOperation( key: String, operationTimeserial: String?, - operationData: ObjectData, + operationData: ObjectData?, objectsPool: inout ObjectsPool, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, @@ -684,7 +684,7 @@ internal final class InternalDefaultLiveMap: Sendable { } // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute - if let objectId = operationData.objectId, !objectId.isEmpty { + if let objectId = operationData?.objectId, !objectId.isEmpty { // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) } @@ -721,7 +721,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM8a2c: Set ObjectsMapEntry.tombstone to true (equivalent to next point) // RTLM8a2d: Set ObjectsMapEntry.tombstonedAt per RTLM8a2d var updatedEntry = existingEntry - updatedEntry.data = ObjectData() + updatedEntry.data = nil updatedEntry.timeserial = operationTimeserial updatedEntry.tombstonedAt = tombstonedAt data[key] = updatedEntry @@ -730,7 +730,7 @@ internal final class InternalDefaultLiveMap: Sendable { // 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: ObjectData()) + data[key] = InternalObjectsMapEntry(tombstonedAt: tombstonedAt, timeserial: operationTimeserial, data: nil) } return .update(DefaultLiveMapUpdate(update: [key: .removed])) @@ -855,7 +855,7 @@ internal final class InternalDefaultLiveMap: Sendable { } // RTLM14c - if let objectId = entry.data.objectId { + if let objectId = entry.data?.objectId { if let poolEntry = delegate.getObjectFromPool(id: objectId), poolEntry.isTombstone { return true } @@ -876,27 +876,27 @@ internal final class InternalDefaultLiveMap: Sendable { // 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 { + if let boolean = entry.data?.boolean { return .primitive(.bool(boolean)) } // RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it - if let bytes = entry.data.bytes { + if let bytes = entry.data?.bytes { return .primitive(.data(bytes)) } // RTLM5d2d: If ObjectsMapEntry.data.number exists, return it - if let number = entry.data.number { + if let number = entry.data?.number { return .primitive(.number(number.doubleValue)) } // RTLM5d2e: If ObjectsMapEntry.data.string exists, return it - if let string = entry.data.string { + if let string = entry.data?.string { return .primitive(.string(string)) } // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) - if let json = entry.data.json { + if let json = entry.data?.json { switch json { case let .array(array): return .primitive(.jsonArray(array)) @@ -906,7 +906,7 @@ internal final class InternalDefaultLiveMap: Sendable { } // RTLM5d2f: If ObjectsMapEntry.data.objectId exists, get the object stored at that objectId from the internal ObjectsPool - if let objectId = entry.data.objectId { + if let objectId = entry.data?.objectId { // RTLM5d2f1: If an object with id objectId does not exist, return undefined/null guard let poolEntry = delegate.getObjectFromPool(id: objectId) else { return nil diff --git a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift index 40478ee3f..4922ddfc3 100644 --- a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift +++ b/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift @@ -9,7 +9,7 @@ internal struct InternalObjectsMapEntry: Equatable { } internal var timeserial: String? // OME2b - internal var data: ObjectData // OME2c + internal var data: ObjectData? // OME2c } internal extension InternalObjectsMapEntry { diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index adf1812a3..0a2401993 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -71,7 +71,7 @@ internal struct ObjectsMapOp: Equatable { internal struct ObjectsMapEntry: Equatable { internal var tombstone: Bool? // OME2a internal var timeserial: String? // OME2b - internal var data: ObjectData // OME2c + internal var data: ObjectData? // OME2c internal var serialTimestamp: Date? // OME2d } @@ -386,7 +386,11 @@ internal extension ObjectsMapEntry { ) throws(InternalError) { tombstone = wireObjectsMapEntry.tombstone timeserial = wireObjectsMapEntry.timeserial - data = try .init(wireObjectData: wireObjectsMapEntry.data, format: format) + data = if let wireObjectData = wireObjectsMapEntry.data { + try .init(wireObjectData: wireObjectData, format: format) + } else { + nil + } serialTimestamp = wireObjectsMapEntry.serialTimestamp } @@ -398,7 +402,7 @@ internal extension ObjectsMapEntry { .init( tombstone: tombstone, timeserial: timeserial, - data: data.toWire(format: format), + data: data?.toWire(format: format), ) } } @@ -576,7 +580,7 @@ extension ObjectsMapEntry: CustomDebugStringConvertible { if let tombstone { parts.append("tombstone: \(tombstone)") } if let timeserial { parts.append("timeserial: \(timeserial)") } - parts.append("data: \(data)") + if let data { parts.append("data: \(data)") } if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } return "{ " + parts.joined(separator: ", ") + " }" diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 3c46e4bab..fad740a3d 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -431,7 +431,7 @@ extension WireObjectsCounter: WireObjectCodable { internal struct WireObjectsMapEntry { internal var tombstone: Bool? // OME2a internal var timeserial: String? // OME2b - internal var data: WireObjectData // OME2c + internal var data: WireObjectData? // OME2c internal var serialTimestamp: Date? // OME2d } @@ -446,15 +446,16 @@ extension WireObjectsMapEntry: WireObjectCodable { internal init(wireObject: [String: WireValue]) throws(InternalError) { tombstone = try wireObject.optionalBoolValueForKey(WireKey.tombstone.rawValue) timeserial = try wireObject.optionalStringValueForKey(WireKey.timeserial.rawValue) - data = try wireObject.decodableValueForKey(WireKey.data.rawValue) + data = try wireObject.optionalDecodableValueForKey(WireKey.data.rawValue) serialTimestamp = try wireObject.optionalAblyProtocolDateValueForKey(WireKey.serialTimestamp.rawValue) } internal var toWireObject: [String: WireValue] { - var result: [String: WireValue] = [ - WireKey.data.rawValue: .object(data.toWireObject), - ] + var result: [String: WireValue] = [:] + if let data { + result[WireKey.data.rawValue] = .object(data.toWireObject) + } if let tombstone { result[WireKey.tombstone.rawValue] = .bool(tombstone) } @@ -580,7 +581,7 @@ extension WireObjectsMapEntry: CustomDebugStringConvertible { if let tombstone { parts.append("tombstone: \(tombstone)") } if let timeserial { parts.append("timeserial: \(timeserial)") } - parts.append("data: \(data)") + if let data { parts.append("data: \(data)") } if let serialTimestamp { parts.append("serialTimestamp: \(serialTimestamp)") } return "{ " + parts.joined(separator: ", ") + " }" diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index 3d6966c38..441bf12fe 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -393,7 +393,7 @@ struct TestFactories { static func mapEntry( tombstone: Bool? = false, timeserial: String? = "ts1", - data: ObjectData, + data: ObjectData?, ) -> ObjectsMapEntry { ObjectsMapEntry( tombstone: tombstone, diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 989821665..daa2f503e 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -534,8 +534,8 @@ struct InternalDefaultLiveMapTests { } // RTLM7a2a: 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) + #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") @@ -717,12 +717,7 @@ struct InternalDefaultLiveMapTests { #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) // RTLM8a2a: Set ObjectsMapEntry.data to undefined/null - let entry = map.testsOnly_data["key1"] - #expect(entry?.data.string == nil) - #expect(entry?.data.number == nil) - #expect(entry?.data.boolean == nil) - #expect(entry?.data.bytes == nil) - #expect(entry?.data.objectId == nil) + #expect(map.testsOnly_data["key1"]?.data == nil) // RTLM8a2b: Set ObjectsMapEntry.timeserial to the operation's serial #expect(map.testsOnly_data["key1"]?.timeserial == "ts2") @@ -751,11 +746,7 @@ struct InternalDefaultLiveMapTests { let entry = map.testsOnly_data["newKey"] #expect(entry != nil) #expect(entry?.timeserial == "ts1") - #expect(entry?.data.string == nil) - #expect(entry?.data.number == nil) - #expect(entry?.data.boolean == nil) - #expect(entry?.data.bytes == nil) - #expect(entry?.data.objectId == nil) + #expect(entry?.data == nil) // RTLM8e: Check return value #expect(try #require(update.update).update == ["newKey": .removed]) diff --git a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift index 7b63b9ba4..16c28f127 100644 --- a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -180,7 +180,7 @@ enum WireObjectMessageTests { #expect(op.mapOp?.data?.string == "value1") #expect(op.counterOp?.amount == 42) #expect(op.map?.semantics == .known(.lww)) - #expect(op.map?.entries?["key1"]?.data.string == "value1") + #expect(op.map?.entries?["key1"]?.data?.string == "value1") #expect(op.map?.entries?["key1"]?.tombstone == false) #expect(op.counter?.count == 42) @@ -282,7 +282,7 @@ enum WireObjectMessageTests { #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"]?.data?.string == "value1") #expect(state.map?.entries?["key1"]?.tombstone == false) #expect(state.counter?.count == 42) } @@ -488,10 +488,10 @@ enum WireObjectMessageTests { ] let map = try WireObjectsMap(wireObject: json) #expect(map.semantics == .known(.lww)) - #expect(map.entries?["key1"]?.data.string == "value1") + #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"]?.data?.string == "value2") #expect(map.entries?["key2"]?.tombstone == true) #expect(map.entries?["key2"]?.timeserial == nil) } @@ -584,7 +584,7 @@ enum WireObjectMessageTests { "timeserial": "ts1", ] let entry = try WireObjectsMapEntry(wireObject: json) - #expect(entry.data.string == "value1") + #expect(entry.data?.string == "value1") #expect(entry.tombstone == true) #expect(entry.timeserial == "ts1") } @@ -593,7 +593,7 @@ enum WireObjectMessageTests { func decodesWithOptionalFieldsAbsent() throws { let json: [String: WireValue] = ["data": ["string": "value1"]] let entry = try WireObjectsMapEntry(wireObject: json) - #expect(entry.data.string == "value1") + #expect(entry.data?.string == "value1") #expect(entry.tombstone == nil) #expect(entry.timeserial == nil) } From 548b56d5b08a6428cf22cc9c1f788623212a0a01 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 4 Aug 2025 14:05:17 +0100 Subject: [PATCH 105/225] Fix test bug where fixtures kept getting regenerated Mistake in fa255c1. --- .../JS Integration Tests/ObjectsIntegrationTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 6fbbd0b83..4919217e7 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -226,6 +226,7 @@ private actor ObjectsFixturesTrait: SuiteTrait, TestScoping { try await helper.initForChannel(objectsFixturesChannel) } } + self.setupTask = setupTask try await setupTask.value } From 5c65c741a69de74a2cd2a9c21960531eb283346e Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 4 Aug 2025 15:29:20 +0100 Subject: [PATCH 106/225] Move map and counter accessor methods into the mutable state This gives us atomicity instead of breaking it up into multiple mutex acquisitions. Should have done this when I implemented these methods. --- .../Internal/InternalDefaultLiveCounter.swift | 16 +- .../Internal/InternalDefaultLiveMap.swift | 215 +++++++++--------- 2 files changed, 123 insertions(+), 108 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 5cd3fd276..a5d7058ad 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -80,12 +80,8 @@ internal final class InternalDefaultLiveCounter: Sendable { // MARK: - Internal methods that back LiveCounter conformance internal func value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { - // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveCounter.value") - - return mutex.withLock { - // RTLC5c - mutableState.data + try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in + try mutableState.value(coreSDK: coreSDK) } } @@ -419,5 +415,13 @@ internal final class InternalDefaultLiveCounter: Sendable { // RTLC4 data = 0 } + + internal func value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { + // RTLC5b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveCounter.value") + + // RTLC5c + return data + } } } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 3f6c16545..53321db97 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -110,56 +110,20 @@ internal final class InternalDefaultLiveMap: Sendable { /// Returns the value associated with a given key, following RTLM5d specification. internal func get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { - // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.get") - - // RTLM5e - Return nil if self is tombstone - if isTombstone { - return nil - } - - let entry = mutex.withLock { - mutableState.data[key] - } - - // RTLM5d1: If no ObjectsMapEntry exists at the key, return undefined/null - guard let entry else { - return nil + try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in + try mutableState.get(key: key, coreSDK: coreSDK, delegate: delegate) } - - // RTLM5d2: If a ObjectsMapEntry exists at the key, convert it using the shared logic - return convertEntryToLiveMapValue(entry, delegate: delegate) } internal func size(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) 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.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.size") - - return mutex.withLock { - // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map - mutableState.data.values.count { entry in - !Self.isEntryTombstoned(entry, delegate: delegate) - } + try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in + try mutableState.size(coreSDK: coreSDK, delegate: delegate) } } internal func entries(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { - // RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 - try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.entries") - - return mutex.withLock { - // RTLM11d: Returns key-value pairs from the internal data map - // RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned - var result: [(key: String, value: InternalLiveMapValue)] = [] - - for (key, entry) in mutableState.data where !Self.isEntryTombstoned(entry, delegate: delegate) { - // Convert entry to LiveMapValue using the same logic as get(key:) - if let value = convertEntryToLiveMapValue(entry, delegate: delegate) { - result.append((key: key, value: value)) - } - } - - return result + try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in + try mutableState.entries(coreSDK: coreSDK, delegate: delegate) } } @@ -843,90 +807,137 @@ internal final class InternalDefaultLiveMap: Sendable { return !shouldRelease } } - } - // MARK: - Helper Methods + /// Returns the value associated with a given key, following RTLM5d specification. + internal func get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { + // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.get") - /// Returns whether a map entry should be considered tombstoned, per the check described in RTLM14. - private static func isEntryTombstoned(_ entry: InternalObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> Bool { - // RTLM14a - if entry.tombstone { - return true - } + // RTLM5e - Return nil if self is tombstone + if liveObjectMutableState.isTombstone { + return nil + } - // RTLM14c - if let objectId = entry.data?.objectId { - if let poolEntry = delegate.getObjectFromPool(id: objectId), poolEntry.isTombstone { - return true + // 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 convertEntryToLiveMapValue(entry, delegate: delegate) } - // RTLM14b - return false - } + internal func size(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) 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.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.size") - /// Converts an InternalObjectsMapEntry to LiveMapValue using the same logic as get(key:) - /// This is used by entries to ensure consistent value conversion - private func convertEntryToLiveMapValue(_ entry: InternalObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> InternalLiveMapValue? { - // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null - if entry.tombstone == true { - return nil + // RTLM10d: Returns the number of non-tombstoned entries (per RTLM14) in the internal data map + return data.values.count { entry in + !Self.isEntryTombstoned(entry, delegate: delegate) + } } - // Handle primitive values in the order specified by RTLM5d2b through RTLM5d2e + internal func entries(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { + // RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 + try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.entries") - // RTLM5d2b: If ObjectsMapEntry.data.boolean exists, return it - if let boolean = entry.data?.boolean { - return .primitive(.bool(boolean)) - } + // 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)] = [] - // RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it - if let bytes = entry.data?.bytes { - return .primitive(.data(bytes)) - } + for (key, entry) in data where !Self.isEntryTombstoned(entry, delegate: delegate) { + // Convert entry to LiveMapValue using the same logic as get(key:) + if let value = convertEntryToLiveMapValue(entry, delegate: delegate) { + result.append((key: key, value: value)) + } + } - // RTLM5d2d: If ObjectsMapEntry.data.number exists, return it - if let number = entry.data?.number { - return .primitive(.number(number.doubleValue)) + return result } - // RTLM5d2e: If ObjectsMapEntry.data.string exists, return it - if let string = entry.data?.string { - return .primitive(.string(string)) - } + // MARK: - Helper Methods - // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) - if let json = entry.data?.json { - switch json { - case let .array(array): - return .primitive(.jsonArray(array)) - case let .object(object): - return .primitive(.jsonObject(object)) + /// Returns whether a map entry should be considered tombstoned, per the check described in RTLM14. + private static func isEntryTombstoned(_ entry: InternalObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> Bool { + // RTLM14a + if entry.tombstone { + return true } + + // RTLM14c + if let objectId = entry.data?.objectId { + if let poolEntry = delegate.getObjectFromPool(id: objectId), poolEntry.isTombstone { + return true + } + } + + // RTLM14b + return false } - // 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 = delegate.getObjectFromPool(id: objectId) else { + /// Converts an InternalObjectsMapEntry to LiveMapValue using the same logic as get(key:) + /// This is used by entries to ensure consistent value conversion + private func convertEntryToLiveMapValue(_ entry: InternalObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> InternalLiveMapValue? { + // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null + if entry.tombstone == true { return nil } - // RTLM5d2f3: If referenced object is tombstoned, return nil - if poolEntry.isTombstone { - 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 .primitive(.bool(boolean)) } - // RTLM5d2f2: Return referenced object - switch poolEntry { - case let .map(map): - return .liveMap(map) - case let .counter(counter): - return .liveCounter(counter) + // RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it + if let bytes = entry.data?.bytes { + return .primitive(.data(bytes)) + } + + // RTLM5d2d: If ObjectsMapEntry.data.number exists, return it + if let number = entry.data?.number { + return .primitive(.number(number.doubleValue)) + } + + // RTLM5d2e: If ObjectsMapEntry.data.string exists, return it + if let string = entry.data?.string { + return .primitive(.string(string)) + } + + // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + if let json = entry.data?.json { + switch json { + case let .array(array): + return .primitive(.jsonArray(array)) + case let .object(object): + return .primitive(.jsonObject(object)) + } } - } - // RTLM5d2g: Otherwise, return undefined/null - return nil + // 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 = delegate.getObjectFromPool(id: objectId) else { + return nil + } + + // RTLM5d2f3: If referenced object is tombstoned, return nil + if poolEntry.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 + } } } From c75fa2af36f694c2078817096053f8a15104503a Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 4 Aug 2025 16:14:53 +0100 Subject: [PATCH 107/225] Make integration test subscription behaviour consistent with JS tests Make sure that we perform the subscription synchronously so as not to miss events. This is a nuance that I missed when looking at Cursor's generated code in fa255c1. The way in which I've chosen to do this is not particularly elegant (and to be honest Swift doesn't make it very easy to do so) but I think that it's the closest to the JS code, which hopefully will mean Cursor will be able to easily apply this pattern when translating further JS tests. --- .../ObjectsIntegrationTests.swift | 76 ++++++++----------- 1 file changed, 30 insertions(+), 46 deletions(-) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 4919217e7..106a3046b 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -88,32 +88,12 @@ func waitFixtureChannelIsReady(_: ARTRealtime) async throws { try await Task.sleep(nanoseconds: 5 * NSEC_PER_SEC) } -func waitForMapKeyUpdate(_ map: any LiveMap, _ key: String) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - do { - try map.subscribe { update, subscription in - if update.update[key] != nil { - subscription.unsubscribe() - continuation.resume() - } - } - } catch { - continuation.resume(throwing: error) - } - } +func waitForMapKeyUpdate(_ updates: AsyncStream, _ key: String) async { + _ = await updates.first { $0.update[key] != nil } } -func waitForCounterUpdate(_ counter: any LiveCounter) async throws { - try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - do { - try counter.subscribe { _, subscription in - subscription.unsubscribe() - continuation.resume() - } - } catch { - continuation.resume(throwing: error) - } - } +func waitForCounterUpdate(_ updates: AsyncStream) async { + _ = await updates.first { _ in true } } // 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 🤷 @@ -321,12 +301,14 @@ private struct ObjectsIntegrationTests { let objects = ctx.objects // Create the promise first, before the operations that will trigger it + let objectsCreatedPromiseUpdates1 = try root.updates() + let objectsCreatedPromiseUpdates2 = try root.updates() async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in group.addTask { - try await waitForMapKeyUpdate(root, "counter") + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "counter") } group.addTask { - try await waitForMapKeyUpdate(root, "map") + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "map") } while try await group.next() != nil {} } @@ -342,15 +324,18 @@ private struct ObjectsIntegrationTests { _ = try await (setMapPromise, setCounterPromise, objectsCreatedPromise) // Create the promise first, before the operations that will trigger it + let operationsAppliedPromiseUpdates1 = try map.updates() + let operationsAppliedPromiseUpdates2 = try map.updates() + let operationsAppliedPromiseUpdates3 = try counter.updates() async let operationsAppliedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in group.addTask { - try await waitForMapKeyUpdate(map, "anotherKey") + await waitForMapKeyUpdate(operationsAppliedPromiseUpdates1, "anotherKey") } group.addTask { - try await waitForMapKeyUpdate(map, "shouldDelete") + await waitForMapKeyUpdate(operationsAppliedPromiseUpdates2, "shouldDelete") } group.addTask { - try await waitForCounterUpdate(counter) + await waitForCounterUpdate(operationsAppliedPromiseUpdates3) } while try await group.next() != nil {} } @@ -393,12 +378,14 @@ private struct ObjectsIntegrationTests { let client = ctx.client // Create the promise first, before the operations that will trigger it + let objectsCreatedPromiseUpdates1 = try root.updates() + let objectsCreatedPromiseUpdates2 = try root.updates() async let objectsCreatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in group.addTask { - try await waitForMapKeyUpdate(root, "counter") + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates1, "counter") } group.addTask { - try await waitForMapKeyUpdate(root, "map") + await waitForMapKeyUpdate(objectsCreatedPromiseUpdates2, "map") } while try await group.next() != nil {} } @@ -588,14 +575,15 @@ private struct ObjectsIntegrationTests { let channelName = ctx.channelName let channel = ctx.channel - async let counterCreatedPromise: Void = waitForMapKeyUpdate(root, "counter") + 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), ) - _ = try await counterCreatedPromise + _ = await counterCreatedPromise #expect(try root.get(key: "counter") != nil, "Check counter exists on root before OBJECT_SYNC sequence with \"tombstone=true\"") @@ -642,25 +630,21 @@ private struct ObjectsIntegrationTests { let channelName = ctx.channelName let channel = ctx.channel - async let counterCreatedPromise: Void = waitForMapKeyUpdate(root, "counter") + 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), ) - _ = try await counterCreatedPromise - - async let counterSubPromise: Void = withCheckedThrowingContinuation { continuation in - do { - try #require(root.get(key: "counter")?.liveCounterValue).subscribe { update, _ in - #expect(update.amount == -1, "Check counter subscription callback is called with an expected update object after OBJECT_SYNC sequence with \"tombstone=true\"") - continuation.resume() - } - } catch { - continuation.resume(throwing: error) - } - } + _ = 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 try await objectsHelper.processObjectStateMessageOnChannel( From c4bab5faa672893fbb83d801074bfd9c6f48a849 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 4 Aug 2025 12:35:47 +0100 Subject: [PATCH 108/225] Enable disabled integration tests The features that these depended on have now been implemented. --- .../JS Integration Tests/ObjectsIntegrationTests.swift | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 106a3046b..32b095ed4 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -293,7 +293,7 @@ private struct ObjectsIntegrationTests { }, ), .init( - disabled: true, // Uses LiveMap.set which we haven't implemented yet + disabled: false, allTransportsAndProtocols: true, description: "OBJECT_SYNC sequence builds object tree with all operations applied", action: { ctx in @@ -368,7 +368,7 @@ private struct ObjectsIntegrationTests { }, ), .init( - disabled: true, // Uses LiveMap.set which we haven't implemented yet + disabled: false, allTransportsAndProtocols: false, description: "OBJECT_SYNC sequence does not change references to existing objects", action: { ctx in @@ -510,7 +510,7 @@ private struct ObjectsIntegrationTests { }, ), .init( - disabled: true, // This relies on the LiveMap.get returning `nil` when the referenced object's internal `tombstone` flag is true; this is not yet specified, have asked in https://ably-real-time.slack.com/archives/D067YAXGYQ5/p1751376526929339 + disabled: false, allTransportsAndProtocols: false, description: "OBJECT_SYNC sequence with object state \"tombstone\" property creates tombstoned object", action: { ctx in @@ -566,7 +566,7 @@ private struct ObjectsIntegrationTests { }, ), .init( - disabled: true, // Uses LiveMap.subscribe (through waitForMapKeyUpdate) which we haven't implemented yet. It also seems to rely on the same internal `tombstone` flag as the previous test. + disabled: false, allTransportsAndProtocols: true, description: "OBJECT_SYNC sequence with object state \"tombstone\" property deletes existing object", action: { ctx in @@ -621,7 +621,7 @@ private struct ObjectsIntegrationTests { }, ), .init( - disabled: true, // Uses LiveMap.subscribe (through waitForMapKeyUpdate) which we haven't implemented yet + disabled: false, allTransportsAndProtocols: true, description: "OBJECT_SYNC sequence with object state \"tombstone\" property triggers subscription callback for existing object", action: { ctx in From cd56508b4ec2c7738981ffc3ff45c8ae48787bbf Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 13 Aug 2025 12:59:56 +0100 Subject: [PATCH 109/225] Bump iOS and tvOS version used in CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit There don't seem to be any 18.0 devices on the GitHub runner any more (which I don't understand, because per [1] there should be 🤷). [1] https://github.com/actions/runner-images/blob/f469601a3a60bd8f86efd594385d21e9e72691f2/images/macos/macos-15-arm64-Readme.md#L229 --- Sources/BuildTool/Platform.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/BuildTool/Platform.swift b/Sources/BuildTool/Platform.swift index c963d2c1e..e5c59d9a8 100644 --- a/Sources/BuildTool/Platform.swift +++ b/Sources/BuildTool/Platform.swift @@ -11,9 +11,9 @@ enum Platform: String, CaseIterable { case .macOS: .fixed(platform: "macOS") case .iOS: - .lookup(destinationPredicate: .init(runtime: "iOS-18-0", deviceType: "iPhone-16")) + .lookup(destinationPredicate: .init(runtime: "iOS-18-4", deviceType: "iPhone-16")) case .tvOS: - .lookup(destinationPredicate: .init(runtime: "tvOS-18-0", deviceType: "Apple-TV-4K-3rd-generation-4K")) + .lookup(destinationPredicate: .init(runtime: "tvOS-18-4", deviceType: "Apple-TV-4K-3rd-generation-4K")) } } From c5a5736733a5cf80e6caf8b5747b3acc57e634e9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 13 Aug 2025 15:29:51 +0100 Subject: [PATCH 110/225] Fix garbage collection sleep duration Was incorrect for non-integer number of seconds. Mistake in 2167277. --- .../Internal/InternalDefaultRealtimeObjects.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 2f8b494ec..0d2f1be67 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -106,7 +106,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool do { while true { logger.log("Will perform garbage collection in \(garbageCollectionInterval)s", level: .debug) - try await Task.sleep(nanoseconds: UInt64(garbageCollectionInterval) * NSEC_PER_SEC) + try await Task.sleep(nanoseconds: UInt64(garbageCollectionInterval * Double(NSEC_PER_SEC))) guard let self else { return From e6f500e73a555e245fdba9522bf12c28e200717f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 11 Aug 2025 10:13:49 +0100 Subject: [PATCH 111/225] Inject protocol message on internal queue in integration tests Mistake in fa255c1. --- .../JS Integration Tests/ObjectsHelper.swift | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift index bd81079ab..15cdfb12d 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift @@ -318,20 +318,25 @@ final class ObjectsHelper: Sendable { private func processDeserializedProtocolMessage( _ deserialized: [String: JSONValue], channel: ARTRealtimeChannel, - ) { - let jsonEncoder = ARTJsonEncoder() - let encoder = ARTJsonLikeEncoder( - rest: channel.internal.realtime!.rest, - delegate: jsonEncoder, - logger: channel.internal.logger, - ) + ) async { + await withCheckedContinuation { continuation in + channel.internal.queue.async { + let jsonEncoder = ARTJsonEncoder() + let encoder = ARTJsonLikeEncoder( + rest: channel.internal.realtime!.rest, + delegate: jsonEncoder, + logger: channel.internal.logger, + ) - let foundationObject = deserialized.toJSONSerializationInput - let protocolMessage = withExtendedLifetime(jsonEncoder) { - encoder.protocolMessage(from: foundationObject)! - } + let foundationObject = deserialized.toJSONSerializationInput + let protocolMessage = withExtendedLifetime(jsonEncoder) { + encoder.protocolMessage(from: foundationObject)! + } - channel.internal.onChannelMessage(protocolMessage) + channel.internal.onChannelMessage(protocolMessage) + continuation.resume() + } + } } /// Processes an object operation message on a channel @@ -341,7 +346,7 @@ final class ObjectsHelper: Sendable { siteCode: String, state: [[String: JSONValue]]? = nil, ) async throws { - processDeserializedProtocolMessage( + await processDeserializedProtocolMessage( objectOperationMessage( channelName: channel.name, serial: serial, @@ -358,7 +363,7 @@ final class ObjectsHelper: Sendable { syncSerial: String, state: [[String: JSONValue]]? = nil, ) async throws { - processDeserializedProtocolMessage( + await processDeserializedProtocolMessage( objectStateMessage( channelName: channel.name, syncSerial: syncSerial, From 6f289070c5bfe000e10ffb828b9ab40660ed6985 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 11 Aug 2025 10:16:27 +0100 Subject: [PATCH 112/225] Remove unnecessary `throws` in test helper Mistake in fa255c1. --- .../JS Integration Tests/ObjectsHelper.swift | 4 ++-- .../JS Integration Tests/ObjectsIntegrationTests.swift | 8 ++++---- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift index 15cdfb12d..27e91c707 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift @@ -345,7 +345,7 @@ final class ObjectsHelper: Sendable { serial: String, siteCode: String, state: [[String: JSONValue]]? = nil, - ) async throws { + ) async { await processDeserializedProtocolMessage( objectOperationMessage( channelName: channel.name, @@ -362,7 +362,7 @@ final class ObjectsHelper: Sendable { channel: ARTRealtimeChannel, syncSerial: String, state: [[String: JSONValue]]? = nil, - ) async throws { + ) async { await processDeserializedProtocolMessage( objectStateMessage( channelName: channel.name, diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 32b095ed4..c23728a7a 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -513,7 +513,7 @@ private struct ObjectsIntegrationTests { disabled: false, allTransportsAndProtocols: false, description: "OBJECT_SYNC sequence with object state \"tombstone\" property creates tombstoned object", - action: { ctx in + action: { ctx throws in let root = ctx.root let objectsHelper = ctx.objectsHelper let channel = ctx.channel @@ -521,7 +521,7 @@ private struct ObjectsIntegrationTests { let mapId = objectsHelper.fakeMapObjectId() let counterId = objectsHelper.fakeCounterObjectId() - try await objectsHelper.processObjectStateMessageOnChannel( + await objectsHelper.processObjectStateMessageOnChannel( channel: channel, syncSerial: "serial:", // empty serial so sync sequence ends immediately // add object states with tombstone=true @@ -588,7 +588,7 @@ private struct ObjectsIntegrationTests { #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 - try await objectsHelper.processObjectStateMessageOnChannel( + await objectsHelper.processObjectStateMessageOnChannel( channel: channel, syncSerial: "serial:", // empty serial so sync sequence ends immediately state: [ @@ -647,7 +647,7 @@ private struct ObjectsIntegrationTests { }() // inject an OBJECT_SYNC message where a counter is now tombstoned - try await objectsHelper.processObjectStateMessageOnChannel( + await objectsHelper.processObjectStateMessageOnChannel( channel: channel, syncSerial: "serial:", // empty serial so sync sequence ends immediately state: [ From 0c05c5761d73a10ee2f9f29e71761006bab06aff Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 11 Aug 2025 10:56:03 +0100 Subject: [PATCH 113/225] Fix typo --- Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift b/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift index dc43adc3d..133fd8697 100644 --- a/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift @@ -43,7 +43,7 @@ internal extension ExtendedJSONValue { /// Converts an `ExtendedJSONValue` to an object. /// - /// The contract for what this will return are the same as those of `JSONValue.toJSONSerializationInputElemtn`, 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. + /// 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(serializeExtraValue: (Extra) -> Any) -> Any { switch self { case let .object(underlying): From b8fe642382377631121c71d57830fe7f5630acd0 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 11 Aug 2025 10:56:09 +0100 Subject: [PATCH 114/225] Change integration tests ProtocolMessage injection to use WireValue So that we can inject binary data in upcoming tests. --- .../JS Integration Tests/ObjectsHelper.swift | 58 ++++++++++--------- 1 file changed, 30 insertions(+), 28 deletions(-) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift index 27e91c707..09a9b90c1 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift @@ -130,8 +130,8 @@ final class ObjectsHelper: Sendable { // MARK: - Wire Object Messages /// Creates a map create operation - func mapCreateOp(objectId: String? = nil, entries: [String: JSONValue]? = nil) -> [String: JSONValue] { - var operation: [String: JSONValue] = [ + func mapCreateOp(objectId: String? = nil, entries: [String: WireValue]? = nil) -> [String: WireValue] { + var operation: [String: WireValue] = [ "action": .number(NSNumber(value: Actions.mapCreate.rawValue)), "nonce": .string(nonce()), "map": .object(["semantics": .number(NSNumber(value: 0))]), @@ -151,7 +151,7 @@ final class ObjectsHelper: Sendable { } /// Creates a map set operation - func mapSetOp(objectId: String, key: String, data: JSONValue) -> [String: JSONValue] { + func mapSetOp(objectId: String, key: String, data: WireValue) -> [String: WireValue] { [ "operation": .object([ "action": .number(NSNumber(value: Actions.mapSet.rawValue)), @@ -165,7 +165,7 @@ final class ObjectsHelper: Sendable { } /// Creates a map remove operation - func mapRemoveOp(objectId: String, key: String) -> [String: JSONValue] { + func mapRemoveOp(objectId: String, key: String) -> [String: WireValue] { [ "operation": .object([ "action": .number(NSNumber(value: Actions.mapRemove.rawValue)), @@ -178,8 +178,8 @@ final class ObjectsHelper: Sendable { } /// Creates a counter create operation - func counterCreateOp(objectId: String? = nil, count: Int? = nil) -> [String: JSONValue] { - var operation: [String: JSONValue] = [ + func counterCreateOp(objectId: String? = nil, count: Int? = nil) -> [String: WireValue] { + var operation: [String: WireValue] = [ "action": .number(NSNumber(value: Actions.counterCreate.rawValue)), "nonce": .string(nonce()), ] @@ -196,7 +196,7 @@ final class ObjectsHelper: Sendable { } /// Creates a counter increment operation - func counterIncOp(objectId: String, amount: Int) -> [String: JSONValue] { + func counterIncOp(objectId: String, amount: Int) -> [String: WireValue] { [ "operation": .object([ "action": .number(NSNumber(value: Actions.counterInc.rawValue)), @@ -209,7 +209,7 @@ final class ObjectsHelper: Sendable { } /// Creates an object delete operation - func objectDeleteOp(objectId: String) -> [String: JSONValue] { + func objectDeleteOp(objectId: String) -> [String: WireValue] { [ "operation": .object([ "action": .number(NSNumber(value: Actions.objectDelete.rawValue)), @@ -222,11 +222,11 @@ final class ObjectsHelper: Sendable { func mapObject( objectId: String, siteTimeserials: [String: String], - initialEntries: [String: JSONValue]? = nil, - materialisedEntries: [String: JSONValue]? = nil, + initialEntries: [String: WireValue]? = nil, + materialisedEntries: [String: WireValue]? = nil, tombstone: Bool = false, - ) -> [String: JSONValue] { - var object: [String: JSONValue] = [ + ) -> [String: WireValue] { + var object: [String: WireValue] = [ "objectId": .string(objectId), "siteTimeserials": .object(siteTimeserials.mapValues { .string($0) }), "tombstone": .bool(tombstone), @@ -251,14 +251,14 @@ final class ObjectsHelper: Sendable { initialCount: Int? = nil, materialisedCount: Int? = nil, tombstone: Bool = false, - ) -> [String: JSONValue] { - let materialisedCountValue: JSONValue = if let materialisedCount { + ) -> [String: WireValue] { + let materialisedCountValue: WireValue = if let materialisedCount { .number(NSNumber(value: materialisedCount)) } else { .null } - var object: [String: JSONValue] = [ + var object: [String: WireValue] = [ "objectId": .string(objectId), "siteTimeserials": .object(siteTimeserials.mapValues { .string($0) }), "tombstone": .bool(tombstone), @@ -280,8 +280,8 @@ final class ObjectsHelper: Sendable { channelName: String, serial: String, siteCode: String, - state: [[String: JSONValue]]? = nil, - ) -> [String: JSONValue] { + state: [[String: WireValue]]? = nil, + ) -> [String: WireValue] { let stateWithSerials = state?.map { objectMessage in var message = objectMessage message["serial"] = .string(serial) @@ -289,7 +289,7 @@ final class ObjectsHelper: Sendable { return message } - let stateArray = stateWithSerials?.map { dict in JSONValue.object(dict) } ?? [] + let stateArray = stateWithSerials?.map { dict in WireValue.object(dict) } ?? [] return [ "action": .number(NSNumber(value: 19)), // OBJECT @@ -303,9 +303,9 @@ final class ObjectsHelper: Sendable { func objectStateMessage( channelName: String, syncSerial: String, - state: [[String: JSONValue]]? = nil, - ) -> [String: JSONValue] { - let stateArray = state?.map { dict in JSONValue.object(dict) } ?? [] + 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), @@ -316,20 +316,22 @@ final class ObjectsHelper: Sendable { /// This is the equivalent of the JS ObjectHelper's channel.processMessage(createPM(…)). private func processDeserializedProtocolMessage( - _ deserialized: [String: JSONValue], + _ deserialized: [String: WireValue], channel: ARTRealtimeChannel, ) async { await withCheckedContinuation { continuation in channel.internal.queue.async { - let jsonEncoder = ARTJsonEncoder() + let useBinaryProtocol = channel.realtimeInternal.options.useBinaryProtocol + let jsonLikeEncoderDelegate: ARTJsonLikeEncoderDelegate = useBinaryProtocol ? ARTMsgPackEncoder() : ARTJsonEncoder() + let encoder = ARTJsonLikeEncoder( rest: channel.internal.realtime!.rest, - delegate: jsonEncoder, + delegate: jsonLikeEncoderDelegate, logger: channel.internal.logger, ) - let foundationObject = deserialized.toJSONSerializationInput - let protocolMessage = withExtendedLifetime(jsonEncoder) { + let foundationObject = deserialized.toAblyPluginDataDictionary + let protocolMessage = withExtendedLifetime(jsonLikeEncoderDelegate) { encoder.protocolMessage(from: foundationObject)! } @@ -344,7 +346,7 @@ final class ObjectsHelper: Sendable { channel: ARTRealtimeChannel, serial: String, siteCode: String, - state: [[String: JSONValue]]? = nil, + state: [[String: WireValue]]? = nil, ) async { await processDeserializedProtocolMessage( objectOperationMessage( @@ -361,7 +363,7 @@ final class ObjectsHelper: Sendable { func processObjectStateMessageOnChannel( channel: ARTRealtimeChannel, syncSerial: String, - state: [[String: JSONValue]]? = nil, + state: [[String: WireValue]]? = nil, ) async { await processDeserializedProtocolMessage( objectStateMessage( From aa552c48072fedfd4aac43131b82a5941e9a6692 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 11 Aug 2025 13:11:41 +0100 Subject: [PATCH 115/225] Add ability to override implementation of `publish` Will be needed by some upcoming integration tests that I'm porting from JS. There might be a better way to do this (injecting a special CoreSDK and thus not having to add this hook to the RealtimeObjects itself) but it'll do. --- .../AblyLiveObjects/Internal/CoreSDK.swift | 37 +++++++++++++++++++ .../PublicDefaultRealtimeObjects.swift | 9 +++++ .../Mocks/MockCoreSDK.swift | 4 ++ 3 files changed, 50 insertions(+) diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index bab61f693..51f054602 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -8,16 +8,31 @@ internal protocol CoreSDK: AnyObject, Sendable { /// Implements the internal `#publish` method of RTO15. func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) + /// Replaces the implementation of ``publish(objectMessages:)``. + /// + /// 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(InternalError) -> Void) + /// Returns the current state of the Realtime channel that this wraps. var channelState: ARTRealtimeChannelState { get } } internal final class DefaultCoreSDK: CoreSDK { + /// Used to synchronize access to internal mutable state. + private let mutex = NSLock() + private let channel: AblyPlugin.RealtimeChannel private let client: AblyPlugin.RealtimeClient private let pluginAPI: PluginAPIProtocol private let logger: AblyPlugin.Logger + /// If set to true, ``publish(objectMessages:)`` will behave like a no-op. + /// + /// This enables the `testsOnly_overridePublish(with:)` test hook. + /// + /// - Note: This should be `throws(InternalError)` 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 -> Void)? + internal init( channel: AblyPlugin.RealtimeChannel, client: AblyPlugin.RealtimeClient, @@ -35,6 +50,22 @@ internal final class DefaultCoreSDK: CoreSDK { internal func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { logger.log("publish(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) + // Use the overridden implementation if supplied + let overriddenImplementation = mutex.withLock { + overriddenPublishImplementation + } + if let overriddenImplementation { + do { + try await overriddenImplementation(objectMessages) + } catch { + guard let internalError = error as? InternalError else { + preconditionFailure("Expected InternalError, got \(error)") + } + throw internalError + } + return + } + // TODO: Implement the full spec of RTO15 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/47) try await DefaultInternalPlugin.sendObject( objectMessages: objectMessages, @@ -44,6 +75,12 @@ internal final class DefaultCoreSDK: CoreSDK { ) } + internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(InternalError) -> Void) { + mutex.withLock { + overriddenPublishImplementation = newImplementation + } + } + internal var channelState: ARTRealtimeChannelState { channel.state } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index 34b5abf7c..3d97b8b5e 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -117,4 +117,13 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { 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(InternalError) -> Void) { + coreSDK.testsOnly_overridePublish(with: newImplementation) + } } diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index f01b59eaf..3bb48bbfe 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -20,6 +20,10 @@ final class MockCoreSDK: CoreSDK { } } + func testsOnly_overridePublish(with _: @escaping ([OutboundObjectMessage]) async throws(InternalError) -> Void) { + protocolRequirementNotImplemented() + } + var channelState: ARTRealtimeChannelState { get { mutex.withLock { From 5cb337ab838ed2299e2d5d37df605f960c3c9801 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 11 Aug 2025 13:18:14 +0100 Subject: [PATCH 116/225] Fix type of counter REST operations amount in test helper Mistake in fa255c1. --- .../JS Integration Tests/ObjectsHelper.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift index 09a9b90c1..f7b045ac0 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift @@ -445,7 +445,7 @@ final class ObjectsHelper: Sendable { } /// Creates a counter create REST operation - func counterCreateRestOp(objectId: String? = nil, nonce: String? = nil, number: Int? = nil) -> [String: JSONValue] { + func counterCreateRestOp(objectId: String? = nil, nonce: String? = nil, number: Double? = nil) -> [String: JSONValue] { var opBody: [String: JSONValue] = [ "operation": .string(Actions.counterCreate.stringValue), ] @@ -463,7 +463,7 @@ final class ObjectsHelper: Sendable { } /// Creates a counter increment REST operation - func counterIncRestOp(objectId: String, number: Int) -> [String: JSONValue] { + func counterIncRestOp(objectId: String, number: Double) -> [String: JSONValue] { [ "operation": .string(Actions.counterInc.stringValue), "objectId": .string(objectId), From 7651229bc3348daeb0bb87eb8762838d617d2979 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 11 Aug 2025 15:03:33 +0100 Subject: [PATCH 117/225] Implement applyOperationsScenarios + fixtures This continues the porting of the JS integration tests that was started in fa255c1, and continues the same approach and attitude taken there. It is based on the same version of ably-js as in that commit. I've created #60 for evaluating these ported tests for correctness and consistency, and for bringing them in line with the latest ably-js. --- .../ObjectsIntegrationTests.swift | 1238 ++++++++++++++++- 1 file changed, 1237 insertions(+), 1 deletion(-) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index c23728a7a..d5b973c8f 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -115,6 +115,86 @@ func waitForObjectSync(_ realtime: ARTRealtime) async throws { private let objectsFixturesChannel = "objects_fixtures" +// MARK: - Top-level fixtures (ported from JS objects.test.js) + +// 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: .primitive(.string("stringValue")) + ), + ( + key: "emptyStringKey", + data: ["string": .string("")], + liveMapValue: .primitive(.string("")) + ), + ( + key: "bytesKey", + data: ["bytes": .string("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")], + liveMapValue: .primitive(.data(Data(base64Encoded: "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")!)) + ), + ( + key: "emptyBytesKey", + data: ["bytes": .string("")], + liveMapValue: .primitive(.data(Data(base64Encoded: "")!)) + ), + ( + key: "maxSafeIntegerKey", + data: ["number": .number(.init(value: Int.max))], + liveMapValue: .primitive(.number(Double(Int.max))) + ), + ( + key: "negativeMaxSafeIntegerKey", + data: ["number": .number(.init(value: -Int.max))], + liveMapValue: .primitive(.number(-Double(Int.max))) + ), + ( + key: "numberKey", + data: ["number": .number(1)], + liveMapValue: .primitive(.number(1)) + ), + ( + key: "zeroKey", + data: ["number": .number(0)], + liveMapValue: .primitive(.number(0)) + ), + ( + key: "trueKey", + data: ["boolean": .bool(true)], + liveMapValue: .primitive(.bool(true)) + ), + ( + key: "falseKey", + data: ["boolean": .bool(false)], + liveMapValue: .primitive(.bool(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. @@ -676,7 +756,1163 @@ private struct ObjectsIntegrationTests { ] let applyOperationsScenarios: [TestScenario] = [ - // TODO: Implement these scenarios + .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.doubleValue, "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.doubleValue, "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) + + let counterUpdatedPromiseUpdates = try counter.updates() + async let counterUpdatedPromise: Void = waitForCounterUpdate(counterUpdatedPromiseUpdates) + + // Use the public API to increment - this will send COUNTER_INC internally + try await counter.increment(amount: Double(increment)) + _ = await counterUpdatedPromise + + #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") + + let keyRemovedPromiseUpdates = try map.updates() + async let keyRemovedPromise: Void = waitForMapKeyUpdate(keyRemovedPromiseUpdates, "shouldDelete") + + // Send MAP_REMOVE op using the public API + try await map.remove(key: "shouldDelete") + _ = await keyRemovedPromise + + // 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, amount: 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, amount: 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, amount: 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) + }, + ), ] let applyOperationsDuringSyncScenarios: [TestScenario] = [ From bb6d5befad0afd5bda0ca23625e9276930bde033 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 11 Aug 2025 14:59:23 +0100 Subject: [PATCH 118/225] Implement applyOperationsDuringSyncScenarios As in 8be3aed. --- .../JS Integration Tests/ObjectsHelper.swift | 2 +- .../ObjectsIntegrationTests.swift | 409 +++++++++++++++++- 2 files changed, 409 insertions(+), 2 deletions(-) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift index f7b045ac0..9f6671e54 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift @@ -472,7 +472,7 @@ final class ObjectsHelper: Sendable { } /// Sends an operation request to the REST API - private func operationRequest(channelName: String, opBody: [String: JSONValue]) async throws -> OperationResult { + func operationRequest(channelName: String, opBody: [String: JSONValue]) async throws -> OperationResult { let path = "/channels/\(channelName)/objects" do { diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index d5b973c8f..c110defd4 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -1916,7 +1916,414 @@ private struct ObjectsIntegrationTests { ] let applyOperationsDuringSyncScenarios: [TestScenario] = [ - // TODO: Implement these scenarios + .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.doubleValue, "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 when new OBJECT_SYNC sequence starts", + 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, expect them to be discarded when sync with new sequence id starts + // 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))], + ) + } + + // Start new sync with new sequence id + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "otherserial:cursor", + ) + + // Inject another operation that should be applied when latest sync ends + await objectsHelper.processObjectOperationMessageOnChannel( + channel: channel, + serial: lexicoTimeserial(seriesId: "bbb", timestamp: 0, counter: 0), + siteCode: "bbb", + state: [objectsHelper.mapSetOp(objectId: "root", key: "foo", data: .object(["string": .string("bar")]))], + ) + + // End sync + await objectsHelper.processObjectStateMessageOnChannel( + channel: channel, + syncSerial: "otherserial:", + ) + + // Check root doesn't have data from operations received during first sync + for keyData in primitiveKeyData { + #expect(try root.get(key: keyData.key) == nil, "Check \"\(keyData.key)\" key doesn't exist on root when OBJECT_SYNC has ended") + } + + // Check root has data from operations received during second sync + #expect(try #require(root.get(key: "foo")?.stringValue) == "bar", "Check root has data from operations received during second OBJECT_SYNC sequence") + }, + ), + .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, amount: 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 buffers 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.doubleValue, "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] = [ From 70306a0a8dc13e74e85efddd7cc90d6b0da397e1 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 11 Aug 2025 15:01:45 +0100 Subject: [PATCH 119/225] Implement writeApiScenarios As in 8be3aed. --- .../ObjectsIntegrationTests.swift | 826 +++++++++++++++++- 1 file changed, 825 insertions(+), 1 deletion(-) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index c110defd4..c558818ca 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -2327,7 +2327,831 @@ private struct ObjectsIntegrationTests { ] let writeApiScenarios: [TestScenario] = [ - // TODO: Implement these scenarios + .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 + + let counterUpdatedPromiseUpdates = try counter.updates() + async let counterUpdatedPromise: Void = waitForCounterUpdate(counterUpdatedPromiseUpdates) + + try await counter.increment(amount: increment) + _ = await counterUpdatedPromise + + #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 + + let counterUpdatedPromiseUpdates = try counter.updates() + async let counterUpdatedPromise: Void = waitForCounterUpdate(counterUpdatedPromiseUpdates) + + try await counter.decrement(amount: decrement) + _ = await counterUpdatedPromise + + #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 + + 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 {} + } + + _ = 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 {} + } + _ = try await keysUpdatedPromise + + // Check everything is applied correctly + for keyData in primitiveKeyData { + let actualValue = try #require(try root.get(key: keyData.key)) + + switch keyData.liveMapValue { + case let .primitive(.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 .primitive(.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 .primitive(.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 .primitive(.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) + + let keysUpdatedPromiseUpdates1 = try root.updates() + let keysUpdatedPromiseUpdates2 = try root.updates() + async let keysUpdatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(keysUpdatedPromiseUpdates1, "counter2") + } + group.addTask { + await waitForMapKeyUpdate(keysUpdatedPromiseUpdates2, "map2") + } + while try await group.next() != nil {} + } + + 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, keysUpdatedPromise) + + 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) + + let keysUpdatedPromiseUpdates1 = try map.updates() + let keysUpdatedPromiseUpdates2 = try map.updates() + async let keysUpdatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in + group.addTask { + await waitForMapKeyUpdate(keysUpdatedPromiseUpdates1, "foo") + } + group.addTask { + await waitForMapKeyUpdate(keysUpdatedPromiseUpdates2, "bar") + } + while try await group.next() != nil {} + } + + async let removeFooPromise: Void = map.remove(key: "foo") + async let removeBarPromise: Void = map.remove(key: "bar") + _ = try await (removeFooPromise, removeBarPromise, keysUpdatedPromise) + + #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 doesn't come from a CREATE op + let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) + internallyTypedObjects.testsOnly_overridePublish(with: { _ in }) + + 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(InternalError) 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 error.toInternalError() + } + }) + + let counter = try await objects.createCounter(count: 1) + + // Counter should be created with forged initial value instead of the actual one + #expect(try counter.value == 10, "Check counter value has the expected initial value from a 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: { _ in + // Do nothing - prevent publishing + }) + + // Create counter locally, should have an initial value set + let counter = try await objects.createCounter(count: 1) + let internalCounter = try #require(counter as? PublicDefaultLiveCounter) + let counterId = internalCounter.proxied.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: (any LiveMap).self, returning: [any LiveMap].self) { group in + for mapFixture in primitiveMapsFixtures { + group.addTask { + if let entries = mapFixture.liveMapEntries { + try await objects.createMap(entries: entries) + } else { + try await objects.createMap() + } + } + } + + var results: [any LiveMap] = [] + while let map = try await group.next() { + results.append(map) + } + return results + } + + 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 .primitive(.data(expectedData)): + let actualData = try #require(actualValue?.dataValue) + #expect(actualData == expectedData, "Check map #\(i + 1) has correct value for \"\(key)\" key") + case let .primitive(.string(expectedString)): + let actualString = try #require(actualValue?.stringValue) + #expect(actualString == expectedString, "Check map #\(i + 1) has correct value for \"\(key)\" key") + case let .primitive(.number(expectedNumber)): + let actualNumber = try #require(actualValue?.numberValue) + #expect(actualNumber == expectedNumber, "Check map #\(i + 1) has correct value for \"\(key)\" key") + case let .primitive(.bool(expectedBool)): + let actualBool = try #require(actualValue?.boolValue as Bool?) + #expect(actualBool == expectedBool, "Check map #\(i + 1) has correct value for \"\(key)\" key") + case .primitive(.jsonArray), .primitive(.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": .primitive(.string("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: { _ in }) + + // prevent publishing of ops to realtime so we guarantee that the initial value doesn't come from a CREATE op + let map = try await objects.createMap(entries: ["foo": .primitive(.string("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(InternalError) 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 error.toInternalError() + } + }) + + let map = try await objects.createMap(entries: ["foo": .primitive(.string("bar"))]) + + // Map should be created with forged initial value instead of the actual one + #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 a CREATE operation after object creation") + #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 so we can guarantee order of operations + let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) + internallyTypedObjects.testsOnly_overridePublish(with: { _ in + // Do nothing - prevent publishing + }) + + // Create map locally, should have an initial value set + let map = try await objects.createMap(entries: ["foo": .primitive(.string("bar"))]) + let internalMap = try #require(map as? PublicDefaultLiveMap) + let mapId = internalMap.proxied.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] = [ From a7f768b47dbadaac6dafc5dee3c26185dc460db8 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 11 Aug 2025 15:02:47 +0100 Subject: [PATCH 120/225] Implement the subscriptionsCallbacksScenarios As in 8be3aed. Cursor struggled a bit when porting the JS tests, not considering some Swift concurrency subtleties (the fact that `async let` or `Task` tasks do not have any "synchronous part" thus needing to be careful about not missing events, same as in c75fa2a, and the lack of any ordering guarantees when triggering `Task`s), so I introduced the Subscription.addListener and MainActorStorage types to perform more work synchronously to be in line with the JS tests. --- .../Helpers/Subscriber.swift | 40 +- .../ObjectsIntegrationTests.swift | 501 ++++++++++++++++++ 2 files changed, 540 insertions(+), 1 deletion(-) diff --git a/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift b/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift index e49ebafd2..5b733bff0 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift @@ -9,6 +9,7 @@ final class Subscriber: Sendable { // 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`. /// @@ -39,13 +40,50 @@ final class Subscriber: Sendable { guard let self else { return } - mutex.withLock { + 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/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index c558818ca..12573ef01 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -304,6 +304,20 @@ 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(.objectsFixtures) @@ -3201,6 +3215,493 @@ private struct ObjectsIntegrationTests { } } + @available(iOS 17.0.0, tvOS 17.0.0, *) + enum SubscriptionCallbacksScenarios: Scenarios { + struct Context { + 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, + 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( + root: root, + objectsHelper: objectsHelper, + channelName: testCase.channelName, + channel: channel, + sampleMapKey: sampleMapKey, + sampleMapObjectId: sampleMapResult.objectId, + sampleCounterKey: sampleCounterKey, + sampleCounterObjectId: sampleCounterResult.objectId, + ), + ) + } + } + // TODO: Implement the remaining scenarios } From b69a40d5f36fdc287bbb54f9f6900485b1ff6200 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 13 Aug 2025 12:48:39 +0100 Subject: [PATCH 121/225] Add the ability to specify GC options via client options We'll use this when porting the garbage collection JS integration tests. --- .../Internal/ARTClientOptions+Objects.swift | 45 +++++++++++++++++++ .../Internal/DefaultInternalPlugin.swift | 8 +++- .../InternalDefaultRealtimeObjects.swift | 2 +- .../Helpers/ClientHelper.swift | 6 ++- ably-cocoa | 2 +- 5 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift diff --git a/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift b/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift new file mode 100644 index 000000000..dac94d1ad --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift @@ -0,0 +1,45 @@ +internal import AblyPlugin + +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 = PluginAPI.sharedInstance().pluginOptionsValue( + forKey: Self.garbageCollectionOptionsKey, + clientOptions: self, + ) + + 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") + } + + PluginAPI.sharedInstance().setPluginOptionsValue( + Box(boxed: newValue), + forKey: Self.garbageCollectionOptionsKey, + clientOptions: self, + ) + } + } +} diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 3ca858bef..207ffd803 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -36,9 +36,15 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte internal func prepare(_ channel: AblyPlugin.RealtimeChannel, client: AblyPlugin.RealtimeClient) { let logger = pluginAPI.logger(for: channel) let callbackQueue = pluginAPI.callbackQueue(for: client) + let options = pluginAPI.options(for: client) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) - let liveObjects = InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: callbackQueue, clock: DefaultSimpleClock()) + let liveObjects = InternalDefaultRealtimeObjects( + logger: logger, + userCallbackQueue: callbackQueue, + clock: DefaultSimpleClock(), + garbageCollectionOptions: options.garbageCollectionOptions ?? .init(), + ) pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 0d2f1be67..4fe17a013 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -26,7 +26,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool 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 { + internal struct GarbageCollectionOptions: Encodable, Hashable { /// The RTO10a interval at which we will perform garbage collection. /// /// The default value comes from the suggestion in RTO10a. diff --git a/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift b/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift index f1de7d64e..04892098a 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift @@ -1,5 +1,5 @@ import Ably -import AblyLiveObjects +@testable import AblyLiveObjects /// Helper for creating ably-cocoa objects, for use in integration tests. enum ClientHelper { @@ -26,6 +26,9 @@ enum ClientHelper { let logger = PrefixedLogger(prefix: "(\(logIdentifier)) ") clientOptions.logHandler = logger } + if let garbageCollectionOptions = options.garbageCollectionOptions { + clientOptions.garbageCollectionOptions = garbageCollectionOptions + } return ARTRealtime(options: clientOptions) } @@ -69,6 +72,7 @@ enum ClientHelper { 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/ably-cocoa b/ably-cocoa index bc1936855..5096ca37c 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit bc19368559fe1b860d18f375e421e8fd2526572f +Subproject commit 5096ca37c6c39f8f33e261f49faf2a6f9d03e529 From 792683d23ab285ddbbe8959b6be9baf1c6f9d8d2 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 13 Aug 2025 14:06:20 +0100 Subject: [PATCH 122/225] Implement the tombstonesGCScenarios As in 8be3aed. Only noteworthy thing here is my increasing of the overridden GC interval compared to its value in the JS tests (see code comment). --- .../InternalDefaultRealtimeObjects.swift | 12 + .../Internal/ObjectsPool.swift | 9 +- .../ObjectsIntegrationTests.swift | 218 ++++++++++++++++++ 3 files changed, 238 insertions(+), 1 deletion(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 4fe17a013..af12e8a5d 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -98,6 +98,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() + (completedGarbageCollectionEvents, completedGarbageCollectionsEventsContinuation) = AsyncStream.makeStream() mutableState = .init(objectsPool: .init(logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) garbageCollectionInterval = garbageCollectionOptions.interval garbageCollectionGracePeriod = garbageCollectionOptions.gracePeriod @@ -331,10 +332,19 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool gracePeriod: garbageCollectionGracePeriod, clock: clock, logger: logger, + eventsContinuation: completedGarbageCollectionsEventsContinuation, ) } } + // These drive the testsOnly_completedGarbageCollectionEvents property that informs the test suite when a garbage collection cycle has completed. + private let completedGarbageCollectionEvents: AsyncStream + private let completedGarbageCollectionsEventsContinuation: AsyncStream.Continuation + /// Emits an element whenever a garbage collection cycle has completed. + internal var testsOnly_completedGarbageCollectionEvents: AsyncStream { + completedGarbageCollectionEvents + } + // MARK: - Testing /// Finishes the following streams, to allow a test to perform assertions about which elements the streams have emitted to this moment: @@ -342,10 +352,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool /// - testsOnly_receivedObjectProtocolMessages /// - testsOnly_receivedObjectStateProtocolMessages /// - testsOnly_waitingForSyncEvents + /// - testsOnly_completedGarbageCollectionEvents internal func testsOnly_finishAllTestHelperStreams() { receivedObjectProtocolMessagesContinuation.finish() receivedObjectSyncProtocolMessagesContinuation.finish() waitingForSyncEventsContinuation.finish() + completedGarbageCollectionsEventsContinuation.finish() } // MARK: - Mutable state and the operations that affect it diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 8d2d7e4ff..d2ff64816 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -408,7 +408,12 @@ internal struct ObjectsPool { } /// Performs garbage collection of tombstoned objects and map entries, per RTO10c. - internal mutating func performGarbageCollection(gracePeriod: TimeInterval, clock: SimpleClock, logger: Logger) { + internal mutating func 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 @@ -433,5 +438,7 @@ internal struct ObjectsPool { } return !shouldRelease } + + eventsContinuation.yield() } } diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 12573ef01..7f0e984e7 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -96,6 +96,20 @@ func waitForCounterUpdate(_ updates: AsyncStream) async { _ = await updates.first { _ in true } } +// 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 { @@ -3703,6 +3717,210 @@ private struct ObjectsIntegrationTests { } // TODO: Implement the remaining scenarios + + // 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 waitForGCCycles: @Sendable (Int) async -> 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 waitForGCCycles = ctx.waitForGCCycles + + // 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.isTombstone == true, + "Check object's \"tombstone\" flag is set to \"true\" after OBJECT_DELETE", + ) + + // We expect 2 cycles to guarantee that grace period has expired, which will always be + // true based on the test config used + await waitForGCCycles(2) + + // 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 waitForGCCycles = ctx.waitForGCCycles + + 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", + ) + + // We expect 2 cycles to guarantee that grace period has expired, which will always be + // true based on the test config used + await waitForGCCycles(2) + + // 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 + options.garbageCollectionOptions = .init( + interval: 2.0, // JS uses 0.5s but I've found that, at least testing locally, this was not enough to compensate for the clock skew between my local clock and whatever was used to generate the tombstonedAt timestamps server-side. + gracePeriod: 0.25, + ) + + 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 a specific number of GC cycles + let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) + let waitForGCCycles: @Sendable (Int) async -> Void = { cycles in + let gcEvents = internallyTypedObjects.testsOnly_proxied.testsOnly_completedGarbageCollectionEvents + + var gcCalledTimes = 0 + for await _ in gcEvents { + gcCalledTimes += 1 + if gcCalledTimes >= cycles { + break + } + } + } + + try await testCase.scenario.action( + .init( + root: root, + objectsHelper: objectsHelper, + channelName: testCase.channelName, + channel: channel, + objects: objects, + client: client, + waitForGCCycles: waitForGCCycles, + ), + ) + } + } } // swiftlint:enable trailing_closure From 9bae7f365819abed85816ef62c1f4904b8cf7bce Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 Aug 2025 16:26:57 +0100 Subject: [PATCH 123/225] Remove PrimitiveObjectValue I copied this type blindly from JS in ce8c022, but in JS it doesn't introduce an annoying extra layer of indirection at the point of usage like it does here. Get rid of it. --- .../Internal/InternalDefaultLiveMap.swift | 12 +- .../Internal/InternalLiveMapValue.swift | 132 +++++++++------- .../InternalLiveMapValue+ToPublic.swift | 14 +- .../AblyLiveObjects/Public/PublicTypes.swift | 141 ++++++------------ .../AblyLiveObjectsTests.swift | 14 +- .../InternalDefaultLiveMapTests.swift | 16 +- .../InternalDefaultRealtimeObjectsTests.swift | 8 +- .../ObjectsIntegrationTests.swift | 50 +++---- .../ObjectCreationHelpersTests.swift | 12 +- 9 files changed, 194 insertions(+), 205 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 53321db97..312affaa9 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -887,31 +887,31 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM5d2b: If ObjectsMapEntry.data.boolean exists, return it if let boolean = entry.data?.boolean { - return .primitive(.bool(boolean)) + return .bool(boolean) } // RTLM5d2c: If ObjectsMapEntry.data.bytes exists, return it if let bytes = entry.data?.bytes { - return .primitive(.data(bytes)) + return .data(bytes) } // RTLM5d2d: If ObjectsMapEntry.data.number exists, return it if let number = entry.data?.number { - return .primitive(.number(number.doubleValue)) + return .number(number.doubleValue) } // RTLM5d2e: If ObjectsMapEntry.data.string exists, return it if let string = entry.data?.string { - return .primitive(.string(string)) + return .string(string) } // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) if let json = entry.data?.json { switch json { case let .array(array): - return .primitive(.jsonArray(array)) + return .jsonArray(array) case let .object(object): - return .primitive(.jsonObject(object)) + return .jsonObject(object) } } diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift index 657a37f37..9ebeca85e 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -2,7 +2,12 @@ import Foundation /// Same as the public ``LiveMapValue`` type but with associated values of internal type. internal enum InternalLiveMapValue: Sendable, Equatable { - case primitive(PrimitiveObjectValue) + 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) @@ -13,8 +18,18 @@ internal enum InternalLiveMapValue: Sendable, Equatable { /// 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 .primitive(primitiveValue): - self = .primitive(primitiveValue) + 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-cocoa-liveobjects-plugin/issues/37 @@ -36,21 +51,18 @@ internal enum InternalLiveMapValue: Sendable, Equatable { internal var toObjectData: ObjectData { // RTO11f4c1: Create an ObjectsMapEntry for the current value switch self { - case let .primitive(primitiveValue): - switch primitiveValue { - 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 .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): // RTO11f4c1a: If the value is of type LiveMap, set ObjectsMapEntry.data.objectId to the objectId of that object .init(objectId: liveMap.objectID) @@ -62,14 +74,6 @@ internal enum InternalLiveMapValue: Sendable, Equatable { // MARK: - Convenience getters for associated values - /// If this `InternalLiveMapValue` has case `primitive`, this returns the associated value. Else, it returns `nil`. - internal var primitiveValue: PrimitiveObjectValue? { - if case let .primitive(value) = self { - return value - } - return nil - } - /// 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 { @@ -86,54 +90,76 @@ internal enum InternalLiveMapValue: Sendable, Equatable { return nil } - /// If this `InternalLiveMapValue` has case `primitive` with a string value, this returns that value. Else, it returns `nil`. + /// If this `InternalLiveMapValue` has case `string`, this returns that value. Else, it returns `nil`. internal var stringValue: String? { - primitiveValue?.stringValue + if case let .string(value) = self { + return value + } + return nil } - /// If this `InternalLiveMapValue` has case `primitive` with a number value, this returns that value. Else, it returns `nil`. + /// If this `InternalLiveMapValue` has case `number`, this returns that value. Else, it returns `nil`. internal var numberValue: Double? { - primitiveValue?.numberValue + if case let .number(value) = self { + return value + } + return nil } - /// If this `InternalLiveMapValue` has case `primitive` with a boolean value, this returns that value. Else, it returns `nil`. + /// If this `InternalLiveMapValue` has case `bool`, this returns that value. Else, it returns `nil`. internal var boolValue: Bool? { - primitiveValue?.boolValue + if case let .bool(value) = self { + return value + } + return nil } - /// If this `InternalLiveMapValue` has case `primitive` with a data value, this returns that value. Else, it returns `nil`. + /// If this `InternalLiveMapValue` has case `data`, this returns that value. Else, it returns `nil`. internal var dataValue: Data? { - primitiveValue?.dataValue + if case let .data(value) = self { + return value + } + return nil } - /// If this `InternalLiveMapValue` has case `primitive` with a JSON array value, this returns that value. Else, it returns `nil`. + /// If this `InternalLiveMapValue` has case `jsonArray`, this returns that value. Else, it returns `nil`. internal var jsonArrayValue: [JSONValue]? { - primitiveValue?.jsonArrayValue + if case let .jsonArray(value) = self { + return value + } + return nil } - /// If this `InternalLiveMapValue` has case `primitive` with a JSON object value, this returns that value. Else, it returns `nil`. + /// If this `InternalLiveMapValue` has case `jsonObject`, this returns that value. Else, it returns `nil`. internal var jsonObjectValue: [String: JSONValue]? { - primitiveValue?.jsonObjectValue + if case let .jsonObject(value) = self { + return value + } + return nil } // MARK: - Equatable Implementation internal static func == (lhs: InternalLiveMapValue, rhs: InternalLiveMapValue) -> Bool { - switch lhs { - case let .primitive(lhsValue): - if case let .primitive(rhsValue) = rhs, lhsValue == rhsValue { - return true - } - case let .liveMap(lhsMap): - if case let .liveMap(rhsMap) = rhs, lhsMap === rhsMap { - return true - } - case let .liveCounter(lhsCounter): - if case let .liveCounter(rhsCounter) = rhs, lhsCounter === rhsCounter { - return true - } + 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 } - - return false } } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift index e5b599d00..bd88d2fbd 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift @@ -20,8 +20,18 @@ internal extension InternalLiveMapValue { /// 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 .primitive(primitive): - .primitive(primitive) + case let .string(value): + .string(value) + case let .number(value): + .number(value) + case let .bool(value): + .bool(value) + case let .data(value): + .data(value) + case let .jsonArray(value): + .jsonArray(value) + case let .jsonObject(value): + .jsonObject(value) case let .liveMap(internalLiveMap): .liveMap( PublicObjectsStore.shared.getOrCreateMap( diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index be64bcac5..de0201c7d 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -82,22 +82,19 @@ public protocol RealtimeObjects: Sendable { } /// Represents the type of data stored for a given key in a ``LiveMap``. -/// It may be a primitive value (``PrimitiveObjectValue``), or another ``LiveObject``. +/// It may be a primitive value (string, number, boolean, binary data, JSON array, or JSON object), or another ``LiveObject``. public enum LiveMapValue: Sendable, Equatable { - case primitive(PrimitiveObjectValue) + 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 `primitive`, this returns the associated value. Else, it returns `nil`. - public var primitiveValue: PrimitiveObjectValue? { - if case let .primitive(value) = self { - return value - } - return nil - } - /// If this `LiveMapValue` has case `liveMap`, this returns the associated value. Else, it returns `nil`. public var liveMapValue: (any LiveMap)? { if case let .liveMap(value) = self { @@ -114,45 +111,61 @@ public enum LiveMapValue: Sendable, Equatable { return nil } - /// If this `LiveMapValue` has case `primitive` with a string value, this returns that value. Else, it returns `nil`. + /// If this `LiveMapValue` has case `string`, this returns the associated value. Else, it returns `nil`. public var stringValue: String? { - primitiveValue?.stringValue + if case let .string(value) = self { + return value + } + return nil } - /// If this `LiveMapValue` has case `primitive` with a number value, this returns that value. Else, it returns `nil`. + /// If this `LiveMapValue` has case `number`, this returns the associated value. Else, it returns `nil`. public var numberValue: Double? { - primitiveValue?.numberValue + if case let .number(value) = self { + return value + } + return nil } - /// If this `LiveMapValue` has case `primitive` with a boolean value, this returns that value. Else, it returns `nil`. + /// If this `LiveMapValue` has case `bool`, this returns the associated value. Else, it returns `nil`. public var boolValue: Bool? { - primitiveValue?.boolValue + if case let .bool(value) = self { + return value + } + return nil } - /// If this `LiveMapValue` has case `primitive` with a data value, this returns that value. Else, it returns `nil`. + /// If this `LiveMapValue` has case `data`, this returns the associated value. Else, it returns `nil`. public var dataValue: Data? { - primitiveValue?.dataValue + if case let .data(value) = self { + return value + } + return nil } // MARK: - Equatable Implementation public static func == (lhs: LiveMapValue, rhs: LiveMapValue) -> Bool { - switch lhs { - case let .primitive(lhsValue): - if case let .primitive(rhsValue) = rhs, lhsValue == rhsValue { - return true - } - case let .liveMap(lhsMap): - if case let .liveMap(rhsMap) = rhs, lhsMap === rhsMap { - return true - } - case let .liveCounter(lhsCounter): - if case let .liveCounter(rhsCounter) = rhs, lhsCounter === rhsCounter { - return true - } + 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 } - - return false } } @@ -226,7 +239,7 @@ public protocol BatchContextLiveCounter: AnyObject, Sendable { /// 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 (see ``PrimitiveObjectValue``). +/// Keys must be strings. Values can be another ``LiveObject``, or a primitive type, such as a string, number, boolean, JSON-serializable object or array, or binary data. public protocol LiveMap: LiveObject where Update == LiveMapUpdate { /// Returns the value associated with a given key. Returns `nil` if the key doesn't exist in a map or if the associated ``LiveObject`` has been deleted. /// @@ -285,66 +298,6 @@ public protocol LiveMapUpdate: Sendable { var update: [String: LiveMapUpdateAction] { get } } -/// Represents a primitive value that can be stored in a ``LiveMap``. -public enum PrimitiveObjectValue: Sendable, Equatable { - case string(String) - case number(Double) - case bool(Bool) - case data(Data) - case jsonArray([JSONValue]) - case jsonObject([String: JSONValue]) - - // MARK: - Convenience getters for associated values - - /// If this `PrimitiveObjectValue` 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 `PrimitiveObjectValue` 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 `PrimitiveObjectValue` 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 `PrimitiveObjectValue` 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 `PrimitiveObjectValue` 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 `PrimitiveObjectValue` 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 - } -} - /// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { /// Returns the current value of the counter. diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index c9cb540b2..d5bd807a2 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -139,13 +139,13 @@ struct AblyLiveObjectsTests { // Create a map and check its initial entries let map = try await channel.objects.createMap(entries: [ - "boolKey": .primitive(.bool(true)), - "numberKey": .primitive(.number(10)), + "boolKey": .bool(true), + "numberKey": .number(10), ]) #expect( try Dictionary(uniqueKeysWithValues: map.entries) == [ - "boolKey": .primitive(.bool(true)), - "numberKey": .primitive(.number(10)), + "boolKey": .bool(true), + "numberKey": .number(10), ], ) let mapSubscription = try map.updates() @@ -162,8 +162,8 @@ struct AblyLiveObjectsTests { #expect(mapUpdate.update == ["counterKey": .updated]) #expect( try Dictionary(uniqueKeysWithValues: map.entries) == [ - "boolKey": .primitive(.bool(true)), - "numberKey": .primitive(.number(10)), + "boolKey": .bool(true), + "numberKey": .number(10), "counterKey": .liveCounter(counter), ], ) @@ -180,7 +180,7 @@ struct AblyLiveObjectsTests { #expect(mapRemoveUpdate.update == ["boolKey": .removed]) #expect( try Dictionary(uniqueKeysWithValues: map.entries) == [ - "numberKey": .primitive(.number(10)), + "numberKey": .number(10), "counterKey": .liveCounter(counter), ], ) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index daa2f503e..9d982e99a 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -1237,7 +1237,7 @@ struct InternalDefaultLiveMapTests { let coreSDK = MockCoreSDK(channelState: channelState) await #expect { - try await map.set(key: "test", value: .primitive(.string("value")), coreSDK: coreSDK) + try await map.set(key: "test", value: .string("value"), coreSDK: coreSDK) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false @@ -1264,16 +1264,16 @@ struct InternalDefaultLiveMapTests { (value: .liveMap(.createZeroValued(objectID: "map:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock())), expectedData: .init(objectId: "map:test@123")), (value: .liveCounter(.createZeroValued(objectID: "map:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock())), expectedData: .init(objectId: "map:test@123")), // RTLM20e5b - (value: .primitive(.jsonArray(["test"])), expectedData: .init(json: .array(["test"]))), - (value: .primitive(.jsonObject(["foo": "bar"])), expectedData: .init(json: .object(["foo": "bar"]))), + (value: .jsonArray(["test"]), expectedData: .init(json: .array(["test"]))), + (value: .jsonObject(["foo": "bar"]), expectedData: .init(json: .object(["foo": "bar"]))), // RTLM20e5c - (value: .primitive(.string("test")), expectedData: .init(string: "test")), + (value: .string("test"), expectedData: .init(string: "test")), // RTLM20e5d - (value: .primitive(.number(42.5)), expectedData: .init(number: NSNumber(value: 42.5))), + (value: .number(42.5), expectedData: .init(number: NSNumber(value: 42.5))), // RTLM20e5e - (value: .primitive(.bool(true)), expectedData: .init(boolean: true)), + (value: .bool(true), expectedData: .init(boolean: true)), // RTLM20e5f - (value: .primitive(.data(Data([0x01, 0x02]))), expectedData: .init(bytes: Data([0x01, 0x02]))), + (value: .data(Data([0x01, 0x02])), expectedData: .init(bytes: Data([0x01, 0x02]))), ] as [(value: InternalLiveMapValue, expectedData: ObjectData)]) func publishesCorrectObjectMessageForDifferentValueTypes(value: InternalLiveMapValue, expectedData: ObjectData) async throws { let logger = TestLogger() @@ -1317,7 +1317,7 @@ struct InternalDefaultLiveMapTests { } await #expect { - try await map.set(key: "testKey", value: .primitive(.string("testValue")), coreSDK: coreSDK) + try await map.set(key: "testKey", value: .string("testValue"), coreSDK: coreSDK) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index fee10e3c5..b4bedee96 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -1048,7 +1048,7 @@ struct InternalDefaultRealtimeObjectsTests { func throwsIfChannelIsInInvalidState(channelState: ARTRealtimeChannelState) async throws { let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let coreSDK = MockCoreSDK(channelState: channelState) - let entries: [String: InternalLiveMapValue] = ["testKey": .primitive(.string("testValue"))] + let entries: [String: InternalLiveMapValue] = ["testKey": .string("testValue")] await #expect { _ = try await realtimeObjects.createMap(entries: entries, coreSDK: coreSDK) @@ -1078,7 +1078,7 @@ struct InternalDefaultRealtimeObjectsTests { // Call createMap let returnedMap = try await realtimeObjects.createMap( entries: [ - "stringKey": .primitive(.string("stringValue")), + "stringKey": .string("stringValue"), ], coreSDK: coreSDK, ) @@ -1098,7 +1098,7 @@ struct InternalDefaultRealtimeObjectsTests { ]) // Verify initial value was merged per RTO11h3a - #expect(returnedMap.testsOnly_data == ["stringKey": .init(data: .init(string: "stringValue"))]) + #expect(returnedMap.testsOnly_data == ["stringKey": InternalObjectsMapEntry(data: ObjectData(string: "stringValue"))]) // Verify object was added to pool per RTO11h3b #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.mapValue === returnedMap) @@ -1156,7 +1156,7 @@ struct InternalDefaultRealtimeObjectsTests { } // Call createMap - the publishHandler will create the object with the generated ID - let result = try await realtimeObjects.createMap(entries: ["testKey": .primitive(.string("testValue"))], coreSDK: coreSDK) + let result = try await realtimeObjects.createMap(entries: ["testKey": .string("testValue")], coreSDK: coreSDK) // Verify ObjectMessage was published #expect(publishedMessages.count == 1) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 7f0e984e7..03457d6e1 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -137,52 +137,52 @@ private let primitiveKeyData: [(key: String, data: [String: JSONValue], liveMapV ( key: "stringKey", data: ["string": .string("stringValue")], - liveMapValue: .primitive(.string("stringValue")) + liveMapValue: .string("stringValue") ), ( key: "emptyStringKey", data: ["string": .string("")], - liveMapValue: .primitive(.string("")) + liveMapValue: .string("") ), ( key: "bytesKey", data: ["bytes": .string("eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")], - liveMapValue: .primitive(.data(Data(base64Encoded: "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")!)) + liveMapValue: .data(Data(base64Encoded: "eyJwcm9kdWN0SWQiOiAiMDAxIiwgInByb2R1Y3ROYW1lIjogImNhciJ9")!) ), ( key: "emptyBytesKey", data: ["bytes": .string("")], - liveMapValue: .primitive(.data(Data(base64Encoded: "")!)) + liveMapValue: .data(Data(base64Encoded: "")!) ), ( key: "maxSafeIntegerKey", data: ["number": .number(.init(value: Int.max))], - liveMapValue: .primitive(.number(Double(Int.max))) + liveMapValue: .number(Double(Int.max)) ), ( key: "negativeMaxSafeIntegerKey", data: ["number": .number(.init(value: -Int.max))], - liveMapValue: .primitive(.number(-Double(Int.max))) + liveMapValue: .number(-Double(Int.max)) ), ( key: "numberKey", data: ["number": .number(1)], - liveMapValue: .primitive(.number(1)) + liveMapValue: .number(1) ), ( key: "zeroKey", data: ["number": .number(0)], - liveMapValue: .primitive(.number(0)) + liveMapValue: .number(0) ), ( key: "trueKey", data: ["boolean": .bool(true)], - liveMapValue: .primitive(.bool(true)) + liveMapValue: .bool(true) ), ( key: "falseKey", data: ["boolean": .bool(false)], - liveMapValue: .primitive(.bool(false)) + liveMapValue: .bool(false) ), ] @@ -422,7 +422,7 @@ private struct ObjectsIntegrationTests { } // MAP_CREATE - let map = try await objects.createMap(entries: ["shouldStay": .primitive(.string("foo")), "shouldDelete": .primitive(.string("bar"))]) + let map = try await objects.createMap(entries: ["shouldStay": .string("foo"), "shouldDelete": .string("bar")]) // COUNTER_CREATE let counter = try await objects.createCounter(count: 1) @@ -449,7 +449,7 @@ private struct ObjectsIntegrationTests { } // Perform the operations and await the promise - async let setAnotherKeyPromise: Void = map.set(key: "anotherKey", value: .primitive(.string("baz"))) + async let setAnotherKeyPromise: Void = map.set(key: "anotherKey", value: .string("baz")) async let removeKeyPromise: Void = map.remove(key: "shouldDelete") async let incrementPromise: Void = counter.increment(amount: 10) _ = try await (setAnotherKeyPromise, removeKeyPromise, incrementPromise, operationsAppliedPromise) @@ -2555,16 +2555,16 @@ private struct ObjectsIntegrationTests { let actualValue = try #require(try root.get(key: keyData.key)) switch keyData.liveMapValue { - case let .primitive(.data(expectedData)): + 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 .primitive(.string(expectedString)): + 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 .primitive(.number(expectedNumber)): + 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 .primitive(.bool(expectedBool)): + 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: @@ -2947,19 +2947,19 @@ private struct ObjectsIntegrationTests { let actualValue = try map.get(key: key) switch expectedValue { - case let .primitive(.data(expectedData)): + 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 .primitive(.string(expectedString)): + 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 .primitive(.number(expectedNumber)): + 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 .primitive(.bool(expectedBool)): + 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 .primitive(.jsonArray), .primitive(.jsonObject): + 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") @@ -3032,7 +3032,7 @@ private struct ObjectsIntegrationTests { async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") let counter = try await objects.createCounter() - let map = try await objects.createMap(entries: ["foo": .primitive(.string("bar")), "baz": .liveCounter(counter)]) + let map = try await objects.createMap(entries: ["foo": .string("bar"), "baz": .liveCounter(counter)]) try await root.set(key: "map", value: .liveMap(map)) _ = await mapCreatedPromise @@ -3058,7 +3058,7 @@ private struct ObjectsIntegrationTests { internallyTypedObjects.testsOnly_overridePublish(with: { _ in }) // prevent publishing of ops to realtime so we guarantee that the initial value doesn't come from a CREATE op - let map = try await objects.createMap(entries: ["foo": .primitive(.string("bar"))]) + let map = try await objects.createMap(entries: ["foo": .string("bar")]) #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check map has expected initial value") }, ), @@ -3103,7 +3103,7 @@ private struct ObjectsIntegrationTests { } }) - let map = try await objects.createMap(entries: ["foo": .primitive(.string("bar"))]) + let map = try await objects.createMap(entries: ["foo": .string("bar")]) // Map should be created with forged initial value instead of the actual one #expect(try map.get(key: "foo") == nil, "Check key \"foo\" was not set on a map client-side") @@ -3127,7 +3127,7 @@ private struct ObjectsIntegrationTests { }) // Create map locally, should have an initial value set - let map = try await objects.createMap(entries: ["foo": .primitive(.string("bar"))]) + let map = try await objects.createMap(entries: ["foo": .string("bar")]) let internalMap = try #require(map as? PublicDefaultLiveMap) let mapId = internalMap.proxied.objectID diff --git a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift index 126b94264..d51efec38 100644 --- a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift @@ -36,16 +36,16 @@ struct ObjectCreationHelpersTests { // RTO11f4c1a "counterRef": .liveCounter(referencedCounter), // RTO11f4c1b - "jsonArrayKey": .primitive(.jsonArray([.string("arrayItem1"), .string("arrayItem2")])), - "jsonObjectKey": .primitive(.jsonObject(["nestedKey": .string("nestedValue")])), + "jsonArrayKey": .jsonArray([.string("arrayItem1"), .string("arrayItem2")]), + "jsonObjectKey": .jsonObject(["nestedKey": .string("nestedValue")]), // RTO11f4c1c - "stringKey": .primitive(.string("stringValue")), + "stringKey": .string("stringValue"), // RTO11f4c1d - "numberKey": .primitive(.number(42.5)), + "numberKey": .number(42.5), // RTO11f4c1e - "booleanKey": .primitive(.bool(true)), + "booleanKey": .bool(true), // RTO11f4c1f - "dataKey": .primitive(.data(Data([0x01, 0x02, 0x03]))), + "dataKey": .data(Data([0x01, 0x02, 0x03])), ], timestamp: timestamp, ) From 59045db3b2ec526986e061839b738b7590c1600b Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 Aug 2025 16:34:03 +0100 Subject: [PATCH 124/225] Add missing JSON getters to LiveMapValue Missed this in cb9a11c. --- Sources/AblyLiveObjects/Public/PublicTypes.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index de0201c7d..4d2abb338 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -143,6 +143,22 @@ public enum LiveMapValue: Sendable, Equatable { 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 { From 714988dbd12c2e749f0ee2950a7150630245eab3 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 Aug 2025 16:45:25 +0100 Subject: [PATCH 125/225] Add ExpressibleBy*Literal conformance to LiveMapValue. The conversion to use literal syntax was done by Cursor; I've vaguely satisfied myself that it's caught most of the usages but there may be some it's missed. Resolves #65. --- .../AblyLiveObjects/Public/PublicTypes.swift | 59 +++++++++++++++++++ .../AblyLiveObjectsTests.swift | 14 ++--- .../ObjectsIntegrationTests.swift | 24 ++++---- 3 files changed, 78 insertions(+), 19 deletions(-) diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 4d2abb338..6e79b290a 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -83,6 +83,27 @@ public protocol RealtimeObjects: Sendable { /// Represents the type of data stored for a given key in a ``LiveMap``. /// It may be a primitive value (string, number, boolean, binary data, JSON array, or JSON object), or another ``LiveObject``. +/// +/// `LiveMapValue` implements Swift's `ExpressibleBy*Literal` protocols. This, in combination with `JSONValue`'s conformance to these protocols, allows you to write type-safe map values using familiar syntax. For example: +/// +/// ```swift +/// let map = try await channel.objects.createMap(entries: [ +/// "someStringKey": "someString", +/// "someIntegerKey": 123, +/// "someFloatKey": 123.456, +/// "someTrueKey": true, +/// "someFalseKey": false, +/// "someJSONObjectKey": [ +/// "someNestedJSONObjectKey": [ +/// "someOtherKey": "someOtherValue", +/// ], +/// ], +/// "someJSONArrayKey": [ +/// "foo", +/// 42, +/// ], +/// ]) +/// ``` public enum LiveMapValue: Sendable, Equatable { case string(String) case number(Double) @@ -185,6 +206,44 @@ public enum LiveMapValue: Sendable, Equatable { } } +// MARK: - ExpressibleBy*Literal conformances + +extension LiveMapValue: ExpressibleByDictionaryLiteral { + public init(dictionaryLiteral elements: (String, JSONValue)...) { + self = .jsonObject(.init(uniqueKeysWithValues: elements)) + } +} + +extension LiveMapValue: ExpressibleByArrayLiteral { + public init(arrayLiteral elements: JSONValue...) { + self = .jsonArray(elements) + } +} + +extension LiveMapValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension LiveMapValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +extension LiveMapValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} + +extension LiveMapValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} + /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. public protocol OnObjectsEventResponse: Sendable { /// Deregisters the listener passed to the `on` call. diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index d5bd807a2..e71959dea 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -139,13 +139,13 @@ struct AblyLiveObjectsTests { // Create a map and check its initial entries let map = try await channel.objects.createMap(entries: [ - "boolKey": .bool(true), - "numberKey": .number(10), + "boolKey": true, + "numberKey": 10, ]) #expect( try Dictionary(uniqueKeysWithValues: map.entries) == [ - "boolKey": .bool(true), - "numberKey": .number(10), + "boolKey": true, + "numberKey": 10, ], ) let mapSubscription = try map.updates() @@ -162,8 +162,8 @@ struct AblyLiveObjectsTests { #expect(mapUpdate.update == ["counterKey": .updated]) #expect( try Dictionary(uniqueKeysWithValues: map.entries) == [ - "boolKey": .bool(true), - "numberKey": .number(10), + "boolKey": true, + "numberKey": 10, "counterKey": .liveCounter(counter), ], ) @@ -180,7 +180,7 @@ struct AblyLiveObjectsTests { #expect(mapRemoveUpdate.update == ["boolKey": .removed]) #expect( try Dictionary(uniqueKeysWithValues: map.entries) == [ - "numberKey": .number(10), + "numberKey": 10, "counterKey": .liveCounter(counter), ], ) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 03457d6e1..016f06c06 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -137,12 +137,12 @@ private let primitiveKeyData: [(key: String, data: [String: JSONValue], liveMapV ( key: "stringKey", data: ["string": .string("stringValue")], - liveMapValue: .string("stringValue") + liveMapValue: "stringValue" ), ( key: "emptyStringKey", data: ["string": .string("")], - liveMapValue: .string("") + liveMapValue: "" ), ( key: "bytesKey", @@ -167,22 +167,22 @@ private let primitiveKeyData: [(key: String, data: [String: JSONValue], liveMapV ( key: "numberKey", data: ["number": .number(1)], - liveMapValue: .number(1) + liveMapValue: 1 ), ( key: "zeroKey", data: ["number": .number(0)], - liveMapValue: .number(0) + liveMapValue: 0 ), ( key: "trueKey", data: ["boolean": .bool(true)], - liveMapValue: .bool(true) + liveMapValue: true ), ( key: "falseKey", data: ["boolean": .bool(false)], - liveMapValue: .bool(false) + liveMapValue: false ), ] @@ -422,7 +422,7 @@ private struct ObjectsIntegrationTests { } // MAP_CREATE - let map = try await objects.createMap(entries: ["shouldStay": .string("foo"), "shouldDelete": .string("bar")]) + let map = try await objects.createMap(entries: ["shouldStay": "foo", "shouldDelete": "bar"]) // COUNTER_CREATE let counter = try await objects.createCounter(count: 1) @@ -449,7 +449,7 @@ private struct ObjectsIntegrationTests { } // Perform the operations and await the promise - async let setAnotherKeyPromise: Void = map.set(key: "anotherKey", value: .string("baz")) + 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, operationsAppliedPromise) @@ -3032,7 +3032,7 @@ private struct ObjectsIntegrationTests { async let mapCreatedPromise: Void = waitForMapKeyUpdate(mapCreatedPromiseUpdates, "map") let counter = try await objects.createCounter() - let map = try await objects.createMap(entries: ["foo": .string("bar"), "baz": .liveCounter(counter)]) + let map = try await objects.createMap(entries: ["foo": "bar", "baz": .liveCounter(counter)]) try await root.set(key: "map", value: .liveMap(map)) _ = await mapCreatedPromise @@ -3058,7 +3058,7 @@ private struct ObjectsIntegrationTests { internallyTypedObjects.testsOnly_overridePublish(with: { _ in }) // prevent publishing of ops to realtime so we guarantee that the initial value doesn't come from a CREATE op - let map = try await objects.createMap(entries: ["foo": .string("bar")]) + let map = try await objects.createMap(entries: ["foo": "bar"]) #expect(try #require(map.get(key: "foo")?.stringValue) == "bar", "Check map has expected initial value") }, ), @@ -3103,7 +3103,7 @@ private struct ObjectsIntegrationTests { } }) - let map = try await objects.createMap(entries: ["foo": .string("bar")]) + let map = try await objects.createMap(entries: ["foo": "bar"]) // Map should be created with forged initial value instead of the actual one #expect(try map.get(key: "foo") == nil, "Check key \"foo\" was not set on a map client-side") @@ -3127,7 +3127,7 @@ private struct ObjectsIntegrationTests { }) // Create map locally, should have an initial value set - let map = try await objects.createMap(entries: ["foo": .string("bar")]) + let map = try await objects.createMap(entries: ["foo": "bar"]) let internalMap = try #require(map as? PublicDefaultLiveMap) let mapId = internalMap.proxied.objectID From 1ae1dee2864bfe11561be32688f5d6f45a891c7c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 19 Aug 2025 11:45:08 -0300 Subject: [PATCH 126/225] DRY up usage of PluginAPI.sharedInstance() Should have done this in b69a40d. --- .../AblyLiveObjects/Internal/ARTClientOptions+Objects.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift b/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift index dac94d1ad..e1f251a9f 100644 --- a/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift +++ b/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift @@ -14,7 +14,7 @@ internal extension ARTClientOptions { /// Can be overriden for testing purposes. var garbageCollectionOptions: InternalDefaultRealtimeObjects.GarbageCollectionOptions? { get { - let optionsValue = PluginAPI.sharedInstance().pluginOptionsValue( + let optionsValue = Plugin.defaultPluginAPI.pluginOptionsValue( forKey: Self.garbageCollectionOptionsKey, clientOptions: self, ) @@ -35,7 +35,7 @@ internal extension ARTClientOptions { preconditionFailure("Not implemented the ability to un-set GC options") } - PluginAPI.sharedInstance().setPluginOptionsValue( + Plugin.defaultPluginAPI.setPluginOptionsValue( Box(boxed: newValue), forKey: Self.garbageCollectionOptionsKey, clientOptions: self, From cec94ed123d60e39e3f8df665c30a57482d37612 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 19 Aug 2025 16:43:47 -0300 Subject: [PATCH 127/225] Initial commit The majority of the code here is taken from ably-cocoa at 9f1a651. I have subsequently made the following changes, to support this library becoming a dependency of ably-cocoa: 1. Remove all references to ably-cocoa's public types and replace them with equivalents that belong to this library, to eliminate circular dependencies. 2. Remove instance methods from APLogger and APRealtimeChannel (moving these to methods on APPluginAPI), so that ably-cocoa can make its internal ARTInternalLog and ARTRealtimeChannelInternal types conform to these protocols without having to worry about return or parameter type clashes due to the divergence introduced in 1. 3. Provide a mechanism for ably-cocoa to register an instance of PluginAPI (that is, the interface through which plugins access ably-cocoa's internals) so that it can be fetched by plugins. The motivation for moving _AblyPluginSupportPrivate out of ably-cocoa is so that this library product does not appear in Xcode's "Add Package Dependencies" UI when a user tries to add ably-cocoa to their app. This UI's initial state offers to add all of ably-cocoa's library products, which would likely lead to many users accidentally adding this private library as a dependency of their app. The creation of this repository is quite a last-minute change to the plugin architecture for ably-cocoa, with time pressure to release the LiveObjects plugin imminently, and thus there is some technical debt here that will need to be addressed later (documentation, CI). --- .gitignore | 11 ++ Package.swift | 22 ++++ README.md | 8 ++ .../APDependencyStore.m | 35 ++++++ .../include/APDecodingContext.h | 24 ++++ .../include/APDependencyStore.h | 43 ++++++++ .../include/APEncodingFormat.h | 16 +++ .../include/APLiveObjectsPlugin.h | 103 ++++++++++++++++++ .../include/APLogLevel.h | 9 ++ .../include/APLogger.h | 11 ++ .../include/APPluginAPI.h | 84 ++++++++++++++ .../include/APPublicClientOptions.h | 16 +++ .../include/APPublicErrorInfo.h | 16 +++ .../include/APPublicRealtimeChannel.h | 16 +++ ...APPublicRealtimeChannelUnderlyingObjects.h | 18 +++ .../include/APRealtimeChannel.h | 15 +++ .../include/APRealtimeChannelState.h | 10 ++ .../include/APRealtimeClient.h | 15 +++ 18 files changed, 472 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 README.md create mode 100644 Sources/_AblyPluginSupportPrivate/APDependencyStore.m create mode 100644 Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h create mode 100644 Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h create mode 100644 Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h create mode 100644 Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h create mode 100644 Sources/_AblyPluginSupportPrivate/include/APLogLevel.h create mode 100644 Sources/_AblyPluginSupportPrivate/include/APLogger.h create mode 100644 Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h create mode 100644 Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h create mode 100644 Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h create mode 100644 Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h create mode 100644 Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h create mode 100644 Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h create mode 100644 Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h create mode 100644 Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..f761f995d --- /dev/null +++ b/.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/Package.swift b/Package.swift new file mode 100644 index 000000000..787d8e52c --- /dev/null +++ b/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/README.md b/README.md new file mode 100644 index 000000000..191061660 --- /dev/null +++ b/README.md @@ -0,0 +1,8 @@ +# 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. diff --git a/Sources/_AblyPluginSupportPrivate/APDependencyStore.m b/Sources/_AblyPluginSupportPrivate/APDependencyStore.m new file mode 100644 index 000000000..9d4e2d2a3 --- /dev/null +++ b/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/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h b/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h new file mode 100644 index 000000000..77b3a84d3 --- /dev/null +++ b/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/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h b/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h new file mode 100644 index 000000000..1c7a95008 --- /dev/null +++ b/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/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h b/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h new file mode 100644 index 000000000..f8b3da1ea --- /dev/null +++ b/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/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h new file mode 100644 index 000000000..011c352f4 --- /dev/null +++ b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h @@ -0,0 +1,103 @@ +#import +#import "APEncodingFormat.h" + +@protocol APLiveObjectsInternalPluginProtocol; +@protocol APObjectMessageProtocol; +@protocol APDecodingContextProtocol; +@protocol APRealtimeChannel; +@protocol APRealtimeClient; +@protocol APPublicErrorInfo; + +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 + +/// 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)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 `-sendStateWithObjectMessages: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.) +/// +/// TODO: what thread is this called on, and does it matter? Decide in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3 +/// +/// Parameters: +/// - channel: The channel that received the `ProtocolMessage`. +/// - hasObjects: Whether the `ProtocolMessage` has the `HAS_OBJECTS` flag set. +- (void)onChannelAttached:(id)channel + hasObjects:(BOOL)hasObjects; + +/// Processes a received `OBJECT` `ProtocolMessage`. +/// +/// TODO: what thread is this called on, and does it matter? Decide in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3 +/// +/// Parameters: +/// - objectMessages: The contents of the `ProtocolMessage`'s `state` property. +/// - channel: The channel on which the `ProtocolMessage` was received. +- (void)handleObjectProtocolMessageWithObjectMessages:(NSArray> *)objectMessages + channel:(id)channel; + +/// Processes a received `OBJECT_SYNC` `ProtocolMessage`. +/// +/// TODO: what thread is this called on, and does it matter? Decide in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3 +/// +/// Parameters: +/// - objectMessages: The contents of the `ProtocolMessage`'s `state` property. +/// - channel: The channel on which the `ProtocolMessage` was received. +- (void)handleObjectSyncProtocolMessageWithObjectMessages:(NSArray> *)objectMessages + protocolMessageChannelSerial:(nullable NSString *)protocolMessageChannelSerial + channel:(id)channel; + +@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/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h b/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h new file mode 100644 index 000000000..26c8322cb --- /dev/null +++ b/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/Sources/_AblyPluginSupportPrivate/include/APLogger.h b/Sources/_AblyPluginSupportPrivate/include/APLogger.h new file mode 100644 index 000000000..4d46a9d3a --- /dev/null +++ b/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/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h new file mode 100644 index 000000000..2c9463d8c --- /dev/null +++ b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h @@ -0,0 +1,84 @@ +@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; + +/// `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 + +/// 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)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)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. +/// +/// Certain `APPluginAPIProtocol` methods must be called on this queue (the method will document when this is the case). +- (dispatch_queue_t)internalQueueForClient:(id)client; + +/// Sends an `OBJECT` `ProtocolMessage` on a channel and indicates the result of waiting for an `ACK`. TODO there is still some deciding to be done about the exact contract of this method. +/// +/// This method must be called on the client's internal queue (see `-internalQueueForClient:`). +- (void)sendObjectWithObjectMessages:(NSArray> *)objectMessages + channel:(id)channel + completion:(void (^ _Nullable)(_Nullable id error))completion; + +/// Returns a realtime channel's current state. +- (APRealtimeChannelState)stateForChannel:(id)channel; + +/// 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/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h b/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h new file mode 100644 index 000000000..ed87ad31d --- /dev/null +++ b/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/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h b/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h new file mode 100644 index 000000000..b9911ffb4 --- /dev/null +++ b/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/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h b/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h new file mode 100644 index 000000000..9588e8143 --- /dev/null +++ b/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/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h b/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h new file mode 100644 index 000000000..a6ef97e37 --- /dev/null +++ b/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/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h b/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h new file mode 100644 index 000000000..4ee93fd26 --- /dev/null +++ b/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/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h b/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h new file mode 100644 index 000000000..619a48055 --- /dev/null +++ b/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/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h b/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h new file mode 100644 index 000000000..70a3974ff --- /dev/null +++ b/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h @@ -0,0 +1,15 @@ +@import Foundation; + +NS_ASSUME_NONNULL_BEGIN + +/// The interface that plugins use to interact with a realtime client. +/// +/// This exists so that plugins do not need to make use of the public `ARTRealtime` class, which allows the internal components of ably-cocoa to continue the (existing before we introduced plugins) pattern of also not making use of this public class. +/// +/// Note that `_AblyPluginSupportPrivate` does not allow you to pass it arbitrary objects that conform to this protocol; rather you must pass it an object which it previously passed to the plugin (e.g. via TODO we don't have an example yet; will come later). +NS_SWIFT_NAME(RealtimeClient) +NS_SWIFT_SENDABLE +@protocol APRealtimeClient +@end + +NS_ASSUME_NONNULL_END From 5795e7cac5870ae644ee346d12032a692923728e Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 19 Aug 2025 18:25:33 -0300 Subject: [PATCH 128/225] Update for renamed AblyPlugin -> _AblyPluginSupportPrivate As required by the accompanying ably-cocoa submodule bump. --- .cursor/rules/swift.mdc | 2 +- .cursor/rules/testing.mdc | 2 +- Package.swift | 4 +- .../Internal/ARTClientOptions+Objects.swift | 2 +- .../AblyLiveObjects/Internal/CoreSDK.swift | 16 +++--- .../Internal/DefaultInternalPlugin.swift | 38 ++++++------- .../Internal/InternalDefaultLiveCounter.swift | 10 ++-- .../Internal/InternalDefaultLiveMap.swift | 18 +++--- .../InternalDefaultRealtimeObjects.swift | 6 +- .../Internal/InternalLiveObject.swift | 2 +- .../Internal/LiveObjectMutableState.swift | 2 +- .../Internal/ObjectCreationHelpers.swift | 2 +- .../Internal/ObjectsPool.swift | 14 ++--- .../Protocol/ObjectMessage.swift | 34 +++++------ .../Protocol/WireObjectMessage.swift | 4 +- .../Public/ARTRealtimeChannel+Objects.swift | 2 +- Sources/AblyLiveObjects/Public/Plugin.swift | 10 ++-- .../InternalLiveMapValue+ToPublic.swift | 4 +- .../PublicDefaultLiveCounter.swift | 6 +- .../PublicDefaultLiveMap.swift | 6 +- .../PublicDefaultRealtimeObjects.swift | 6 +- .../PublicObjectsStore.swift | 12 ++-- .../Utility/APLogger+Swift.swift | 4 +- .../AblyLiveObjects/Utility/WireValue.swift | 30 +++++----- .../AblyLiveObjectsTests.swift | 2 +- .../Helpers/TestFactories.swift | 2 +- .../Helpers/TestLogger.swift | 6 +- .../InternalDefaultLiveCounterTests.swift | 2 +- .../InternalDefaultLiveMapTests.swift | 2 +- .../InternalDefaultRealtimeObjectsTests.swift | 2 +- .../JS Integration Tests/ObjectsHelper.swift | 4 +- .../LiveObjectMutableStateTests.swift | 2 +- .../ObjectMessageTests.swift | 2 +- .../ObjectsPoolTests.swift | 2 +- .../WireObjectMessageTests.swift | 4 +- .../AblyLiveObjectsTests/WireValueTests.swift | 56 +++++++++---------- ably-cocoa | 2 +- 37 files changed, 162 insertions(+), 162 deletions(-) diff --git a/.cursor/rules/swift.mdc b/.cursor/rules/swift.mdc index 3fa734a61..b4a23b5bc 100644 --- a/.cursor/rules/swift.mdc +++ b/.cursor/rules/swift.mdc @@ -14,7 +14,7 @@ When writing Swift: - When writing a JSON string, favour using Swift raw string literals instead of escaping double quotes. - When you need to import the following modules inside the AblyLiveObjects library code (that is, in non-test code), do so in the following way: - Ably: use `import Ably` - - AblyPlugin: use `internal import AblyPlugin` + - `_AblyPluginSupportPrivate`: use `internal import _AblyPluginSupportPrivate` - When writing an array literal that starts with an initializer expression, start the initializer expression on the line after the opening square bracket of the array literal. That is, instead of writing: ```swift objectMessages: [InboundObjectMessage( diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc index 073629399..f21ef642f 100644 --- a/.cursor/rules/testing.mdc +++ b/.cursor/rules/testing.mdc @@ -14,7 +14,7 @@ When writing tests: - When you need to import the following modules in the tests, do so in the following way: - Ably: use `import Ably` - AblyLiveObjects: use `@testable import AblyLiveObjects` - - AblyPlugin: use `import AblyPlugin`; _do not_ do `internal import` + - `_AblyPluginSupportPrivate`: use `import _AblyPluginSupportPrivate`; _do not_ do `internal import` - When you need to pass a logger to internal components in the tests, pass `TestLogger()`. - When you need to unwrap an optional value in the tests, favour using `#require` instead of `guard let`. - When creating `testsOnly_` property declarations, do not write generic comments of the form "Test-only access to the private createOperationIsMerged property"; the meaning of these properties is already well understood. diff --git a/Package.swift b/Package.swift index 25fd4fce0..742aa4b59 100644 --- a/Package.swift +++ b/Package.swift @@ -47,7 +47,7 @@ let package = Package( package: "ably-cocoa", ), .product( - name: "AblyPlugin", + name: "_AblyPluginSupportPrivate", package: "ably-cocoa", ), ], @@ -61,7 +61,7 @@ let package = Package( package: "ably-cocoa", ), .product( - name: "AblyPlugin", + name: "_AblyPluginSupportPrivate", package: "ably-cocoa", ), ], diff --git a/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift b/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift index e1f251a9f..87973817d 100644 --- a/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift +++ b/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift @@ -1,4 +1,4 @@ -internal import AblyPlugin +internal import _AblyPluginSupportPrivate internal extension ARTClientOptions { private class Box { diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 51f054602..0c93078c6 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -1,9 +1,9 @@ +internal import _AblyPluginSupportPrivate import Ably -internal import AblyPlugin /// 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 AblyPlugin's Objective-C API (i.e. boxing). +/// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to `_AblyPluginSupportPrivate`'s Objective-C API (i.e. boxing). internal protocol CoreSDK: AnyObject, Sendable { /// Implements the internal `#publish` method of RTO15. func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) @@ -21,10 +21,10 @@ internal final class DefaultCoreSDK: CoreSDK { /// Used to synchronize access to internal mutable state. private let mutex = NSLock() - private let channel: AblyPlugin.RealtimeChannel - private let client: AblyPlugin.RealtimeClient + private let channel: _AblyPluginSupportPrivate.RealtimeChannel + private let client: _AblyPluginSupportPrivate.RealtimeClient private let pluginAPI: PluginAPIProtocol - private let logger: AblyPlugin.Logger + private let logger: _AblyPluginSupportPrivate.Logger /// If set to true, ``publish(objectMessages:)`` will behave like a no-op. /// @@ -34,10 +34,10 @@ internal final class DefaultCoreSDK: CoreSDK { private nonisolated(unsafe) var overriddenPublishImplementation: (([OutboundObjectMessage]) async throws -> Void)? internal init( - channel: AblyPlugin.RealtimeChannel, - client: AblyPlugin.RealtimeClient, + channel: _AblyPluginSupportPrivate.RealtimeChannel, + client: _AblyPluginSupportPrivate.RealtimeClient, pluginAPI: PluginAPIProtocol, - logger: AblyPlugin.Logger + logger: _AblyPluginSupportPrivate.Logger ) { self.channel = channel self.client = client diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 207ffd803..970157eea 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -1,14 +1,14 @@ -internal import AblyPlugin +internal import _AblyPluginSupportPrivate -// We explicitly import the NSObject class, else it seems to get transitively imported from `internal import AblyPlugin`, leading to the error "Class cannot be declared public because its superclass is internal". +// 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 `AblyPlugin`'s `LiveObjectsInternalPluginProtocol`. Implements the interface that ably-cocoa uses to access the functionality provided by the LiveObjects plugin. +/// The default implementation of `_AblyPluginSupportPrivate`'s `LiveObjectsInternalPluginProtocol`. Implements the interface that ably-cocoa uses to access the functionality provided by the LiveObjects plugin. @objc -internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInternalPluginProtocol { - private let pluginAPI: AblyPlugin.PluginAPIProtocol +internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate.LiveObjectsInternalPluginProtocol { + private let pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol - internal init(pluginAPI: AblyPlugin.PluginAPIProtocol) { + internal init(pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol) { self.pluginAPI = pluginAPI } @@ -20,7 +20,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte /// Retrieves the `RealtimeObjects` for this channel. /// /// We expect this value to have been previously set by ``prepare(_:)``. - internal static func realtimeObjects(for channel: AblyPlugin.RealtimeChannel, pluginAPI: AblyPlugin.PluginAPIProtocol) -> InternalDefaultRealtimeObjects { + internal static func realtimeObjects(for channel: _AblyPluginSupportPrivate.RealtimeChannel, pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol) -> InternalDefaultRealtimeObjects { guard let pluginData = pluginAPI.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]`") @@ -33,7 +33,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte // MARK: - LiveObjectsInternalPluginProtocol // Populates the channel's `objects` property. - internal func prepare(_ channel: AblyPlugin.RealtimeChannel, client: AblyPlugin.RealtimeClient) { + internal func prepare(_ channel: _AblyPluginSupportPrivate.RealtimeChannel, client: _AblyPluginSupportPrivate.RealtimeClient) { let logger = pluginAPI.logger(for: channel) let callbackQueue = pluginAPI.callbackQueue(for: client) let options = pluginAPI.options(for: client) @@ -49,14 +49,14 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } /// Retrieves the internally-typed `objects` property for the channel. - private func realtimeObjects(for channel: AblyPlugin.RealtimeChannel) -> InternalDefaultRealtimeObjects { + private func realtimeObjects(for channel: _AblyPluginSupportPrivate.RealtimeChannel) -> InternalDefaultRealtimeObjects { Self.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 `AblyPlugin.ObjectMessageProtocol`. - private final class ObjectMessageBox: AblyPlugin.ObjectMessageProtocol where T: Sendable { + /// 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`. + private final class ObjectMessageBox: _AblyPluginSupportPrivate.ObjectMessageProtocol where T: Sendable { internal let objectMessage: T init(objectMessage: T) { @@ -70,7 +70,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte format: EncodingFormat, error errorPtr: AutoreleasingUnsafeMutablePointer?, ) -> (any ObjectMessageProtocol)? { - let wireObject = WireValue.objectFromAblyPluginData(serialized) + let wireObject = WireValue.objectFrom_AblyPluginSupportPrivateData(serialized) do { let wireObjectMessage = try InboundWireObjectMessage( @@ -89,7 +89,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } internal func encodeObjectMessage( - _ publicObjectMessage: any AblyPlugin.ObjectMessageProtocol, + _ publicObjectMessage: any _AblyPluginSupportPrivate.ObjectMessageProtocol, format: EncodingFormat, ) -> [String: Any] { guard let outboundObjectMessageBox = publicObjectMessage as? ObjectMessageBox else { @@ -97,14 +97,14 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte } let wireObjectMessage = outboundObjectMessageBox.objectMessage.toWire(format: format) - return wireObjectMessage.toWireObject.toAblyPluginDataDictionary + return wireObjectMessage.toWireObject.toPluginSupportDataDictionary } - internal func onChannelAttached(_ channel: AblyPlugin.RealtimeChannel, hasObjects: Bool) { + internal func onChannelAttached(_ channel: _AblyPluginSupportPrivate.RealtimeChannel, hasObjects: Bool) { realtimeObjects(for: channel).onChannelAttached(hasObjects: hasObjects) } - internal func handleObjectProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], channel: AblyPlugin.RealtimeChannel) { + internal func 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") } @@ -116,7 +116,7 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte ) } - internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any AblyPlugin.ObjectMessageProtocol], protocolMessageChannelSerial: String?, channel: AblyPlugin.RealtimeChannel) { + internal func 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") } @@ -133,8 +133,8 @@ internal final class DefaultInternalPlugin: NSObject, AblyPlugin.LiveObjectsInte internal static func sendObject( objectMessages: [OutboundObjectMessage], - channel: AblyPlugin.RealtimeChannel, - client: AblyPlugin.RealtimeClient, + channel: _AblyPluginSupportPrivate.RealtimeChannel, + client: _AblyPluginSupportPrivate.RealtimeClient, pluginAPI: PluginAPIProtocol, ) async throws(InternalError) { let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index a5d7058ad..4169912f7 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -1,5 +1,5 @@ +internal import _AblyPluginSupportPrivate import Ably -internal import AblyPlugin import Foundation /// This provides the implementation behind ``PublicDefaultLiveCounter``, via internal versions of the ``LiveCounter`` API. @@ -21,7 +21,7 @@ internal final class InternalDefaultLiveCounter: Sendable { } } - private let logger: AblyPlugin.Logger + private let logger: _AblyPluginSupportPrivate.Logger private let userCallbackQueue: DispatchQueue private let clock: SimpleClock @@ -30,7 +30,7 @@ internal final class InternalDefaultLiveCounter: Sendable { internal convenience init( testsOnly_data data: Double, objectID: String, - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock ) { @@ -40,7 +40,7 @@ internal final class InternalDefaultLiveCounter: Sendable { private init( data: Double, objectID: String, - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock ) { @@ -56,7 +56,7 @@ internal final class InternalDefaultLiveCounter: Sendable { /// - objectID: The value for the "private objectId field" of RTO5c1b1a. internal static func createZeroValued( objectID: String, - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> Self { diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 312affaa9..e952cdb10 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -1,5 +1,5 @@ +internal import _AblyPluginSupportPrivate import Ably -internal import AblyPlugin /// Protocol for accessing objects from the ObjectsPool. This is used by a LiveMap when it needs to return an object given an object ID. internal protocol LiveMapObjectPoolDelegate: AnyObject, Sendable { @@ -38,7 +38,7 @@ internal final class InternalDefaultLiveMap: Sendable { } } - private let logger: AblyPlugin.Logger + private let logger: _AblyPluginSupportPrivate.Logger private let userCallbackQueue: DispatchQueue private let clock: SimpleClock @@ -48,7 +48,7 @@ internal final class InternalDefaultLiveMap: Sendable { testsOnly_data data: [String: InternalObjectsMapEntry], objectID: String, testsOnly_semantics semantics: WireEnum? = nil, - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) { @@ -66,7 +66,7 @@ internal final class InternalDefaultLiveMap: Sendable { data: [String: InternalObjectsMapEntry], objectID: String, semantics: WireEnum?, - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) { @@ -84,7 +84,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal static func createZeroValued( objectID: String, semantics: WireEnum? = nil, - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> Self { @@ -398,7 +398,7 @@ internal final class InternalDefaultLiveMap: Sendable { using state: ObjectState, objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, clock: SimpleClock, userCallbackQueue: DispatchQueue, ) -> LiveObjectUpdate { @@ -467,7 +467,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal mutating func mergeInitialValue( from operation: ObjectOperation, objectsPool: inout ObjectsPool, - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> LiveObjectUpdate { @@ -621,7 +621,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationTimeserial: String?, operationData: ObjectData?, objectsPool: inout ObjectsPool, - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> LiveObjectUpdate { @@ -744,7 +744,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal mutating func applyMapCreateOperation( _ operation: ObjectOperation, objectsPool: inout ObjectsPool, - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> LiveObjectUpdate { diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index af12e8a5d..89770aabc 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -1,5 +1,5 @@ +internal import _AblyPluginSupportPrivate import Ably -internal import AblyPlugin /// This provides the implementation behind ``PublicDefaultRealtimeObjects``, via internal versions of the ``RealtimeObjects`` API. internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPoolDelegate { @@ -8,7 +8,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool private nonisolated(unsafe) var mutableState: MutableState! - private let logger: AblyPlugin.Logger + private let logger: _AblyPluginSupportPrivate.Logger private let userCallbackQueue: DispatchQueue private let clock: SimpleClock @@ -91,7 +91,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } } - internal init(logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, garbageCollectionOptions: GarbageCollectionOptions = .init()) { + internal init(logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, garbageCollectionOptions: GarbageCollectionOptions = .init()) { self.logger = logger self.userCallbackQueue = userCallbackQueue self.clock = clock diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift b/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift index d80d53b49..27b0d7e89 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift @@ -1,4 +1,4 @@ -internal import AblyPlugin +internal import _AblyPluginSupportPrivate /// Provides RTLO spec point functionality common to all LiveObjects. /// diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index f33262083..b47167735 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -1,4 +1,4 @@ -internal import AblyPlugin +internal import _AblyPluginSupportPrivate /// This is the equivalent of the `LiveObject` abstract class described in RTLO. /// diff --git a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift index b82f7d845..f061302ef 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift @@ -1,4 +1,4 @@ -internal import AblyPlugin +internal import _AblyPluginSupportPrivate import CryptoKit import Foundation diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index d2ff64816..a16cd24e1 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -1,4 +1,4 @@ -internal import AblyPlugin +internal import _AblyPluginSupportPrivate /// Maintains the list of objects present on a channel, as described by RTO3. /// @@ -137,7 +137,7 @@ internal struct ObjectsPool { /// Creates an `ObjectsPool` whose root is a zero-value `LiveMap`. internal init( - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, testsOnly_otherEntries otherEntries: [String: Entry]? = nil, @@ -151,7 +151,7 @@ internal struct ObjectsPool { } private init( - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, otherEntries: [String: Entry]? @@ -187,7 +187,7 @@ internal struct ObjectsPool { /// - userCallbackQueue: The callback queue to use for any created LiveObject /// - clock: The clock to use for any created LiveObject /// - Returns: The existing or newly created object - internal mutating func createZeroValueObject(forObjectID objectID: String, logger: AblyPlugin.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock) -> Entry? { + internal mutating func createZeroValueObject(forObjectID objectID: String, logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock) -> Entry? { // RTO6a: If an object with objectId exists in ObjectsPool, do not create a new object if let existingEntry = entries[objectID] { return existingEntry @@ -220,7 +220,7 @@ internal struct ObjectsPool { /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1 and RTO5c2. internal mutating func applySyncObjectsPool( _ syncObjectsPool: [SyncObjectsPoolEntry], - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) { @@ -316,7 +316,7 @@ internal struct ObjectsPool { /// - Returns: The existing or newly created counter object internal mutating func getOrCreateCounter( creationOperation: ObjectCreationHelpers.CounterCreationOperation, - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> InternalDefaultLiveCounter { @@ -360,7 +360,7 @@ internal struct ObjectsPool { /// - Returns: The existing or newly created map object internal mutating func getOrCreateMap( creationOperation: ObjectCreationHelpers.MapCreationOperation, - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> InternalDefaultLiveMap { diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 0a2401993..ac846fcd5 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -1,4 +1,4 @@ -internal import AblyPlugin +internal import _AblyPluginSupportPrivate 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. @@ -97,7 +97,7 @@ internal extension InboundObjectMessage { /// - Throws: `InternalError` if JSON or Base64 decoding fails. init( wireObjectMessage: InboundWireObjectMessage, - format: AblyPlugin.EncodingFormat + format: _AblyPluginSupportPrivate.EncodingFormat ) throws(InternalError) { id = wireObjectMessage.id clientId = wireObjectMessage.clientId @@ -121,7 +121,7 @@ internal extension OutboundObjectMessage { /// /// - Parameters: /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: AblyPlugin.EncodingFormat) -> OutboundWireObjectMessage { + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> OutboundWireObjectMessage { .init( id: id, clientId: clientId, @@ -145,7 +145,7 @@ internal extension ObjectOperation { /// - Throws: `InternalError` if JSON or Base64 decoding fails. init( wireObjectOperation: WireObjectOperation, - format: AblyPlugin.EncodingFormat + format: _AblyPluginSupportPrivate.EncodingFormat ) throws(InternalError) { // Decode the action and objectId first they're not part of PartialObjectOperation action = wireObjectOperation.action @@ -177,7 +177,7 @@ internal extension ObjectOperation { /// /// - Parameters: /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectOperation { + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectOperation { let partialWireOperation = PartialObjectOperation( mapOp: mapOp, counterOp: counterOp, @@ -209,7 +209,7 @@ internal extension PartialObjectOperation { /// - Throws: `InternalError` if JSON or Base64 decoding fails. init( partialWireObjectOperation: PartialWireObjectOperation, - format: AblyPlugin.EncodingFormat + format: _AblyPluginSupportPrivate.EncodingFormat ) throws(InternalError) { mapOp = try partialWireObjectOperation.mapOp.map { wireObjectsMapOp throws(InternalError) in try .init(wireObjectsMapOp: wireObjectsMapOp, format: format) @@ -230,7 +230,7 @@ internal extension PartialObjectOperation { /// /// - Parameters: /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: AblyPlugin.EncodingFormat) -> PartialWireObjectOperation { + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> PartialWireObjectOperation { .init( mapOp: mapOp?.toWire(format: format), counterOp: counterOp, @@ -250,7 +250,7 @@ internal extension ObjectData { /// - Throws: `InternalError` if JSON or Base64 decoding fails. init( wireObjectData: WireObjectData, - format: AblyPlugin.EncodingFormat + format: _AblyPluginSupportPrivate.EncodingFormat ) throws(InternalError) { objectId = wireObjectData.objectId boolean = wireObjectData.boolean @@ -302,7 +302,7 @@ internal extension ObjectData { /// /// - Parameters: /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectData { + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectData { // OD4: Encode data based on format let wireBytes: StringOrData? = if let bytes { switch format { @@ -354,7 +354,7 @@ internal extension ObjectsMapOp { /// - Throws: `InternalError` if JSON or Base64 decoding fails. init( wireObjectsMapOp: WireObjectsMapOp, - format: AblyPlugin.EncodingFormat + format: _AblyPluginSupportPrivate.EncodingFormat ) throws(InternalError) { key = wireObjectsMapOp.key data = try wireObjectsMapOp.data.map { wireObjectData throws(InternalError) in @@ -366,7 +366,7 @@ internal extension ObjectsMapOp { /// /// - Parameters: /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectsMapOp { + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectsMapOp { .init( key: key, data: data?.toWire(format: format), @@ -382,7 +382,7 @@ internal extension ObjectsMapEntry { /// - Throws: `InternalError` if JSON or Base64 decoding fails. init( wireObjectsMapEntry: WireObjectsMapEntry, - format: AblyPlugin.EncodingFormat + format: _AblyPluginSupportPrivate.EncodingFormat ) throws(InternalError) { tombstone = wireObjectsMapEntry.tombstone timeserial = wireObjectsMapEntry.timeserial @@ -398,7 +398,7 @@ internal extension ObjectsMapEntry { /// /// - Parameters: /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectsMapEntry { + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectsMapEntry { .init( tombstone: tombstone, timeserial: timeserial, @@ -415,7 +415,7 @@ internal extension ObjectsMap { /// - Throws: `InternalError` if JSON or Base64 decoding fails. init( wireObjectsMap: WireObjectsMap, - format: AblyPlugin.EncodingFormat + format: _AblyPluginSupportPrivate.EncodingFormat ) throws(InternalError) { semantics = wireObjectsMap.semantics entries = try wireObjectsMap.entries?.ablyLiveObjects_mapValuesWithTypedThrow { wireMapEntry throws(InternalError) in @@ -427,7 +427,7 @@ internal extension ObjectsMap { /// /// - Parameters: /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectsMap { + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectsMap { .init( semantics: semantics, entries: entries?.mapValues { $0.toWire(format: format) }, @@ -443,7 +443,7 @@ internal extension ObjectState { /// - Throws: `InternalError` if JSON or Base64 decoding fails. init( wireObjectState: WireObjectState, - format: AblyPlugin.EncodingFormat + format: _AblyPluginSupportPrivate.EncodingFormat ) throws(InternalError) { objectId = wireObjectState.objectId siteTimeserials = wireObjectState.siteTimeserials @@ -461,7 +461,7 @@ internal extension ObjectState { /// /// - Parameters: /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: AblyPlugin.EncodingFormat) -> WireObjectState { + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectState { .init( objectId: objectId, siteTimeserials: siteTimeserials, diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index fad740a3d..a8c55d536 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -1,4 +1,4 @@ -internal import AblyPlugin +internal import _AblyPluginSupportPrivate 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. @@ -61,7 +61,7 @@ internal extension InboundWireObjectMessage { /// 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: AblyPlugin.DecodingContextProtocol + decodingContext: _AblyPluginSupportPrivate.DecodingContextProtocol ) throws(InternalError) { // OM2a if let id = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.id.rawValue) { diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index da4791781..67b8612a5 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -1,5 +1,5 @@ +internal import _AblyPluginSupportPrivate import Ably -internal import AblyPlugin public extension ARTRealtimeChannel { /// A ``RealtimeObjects`` object. diff --git a/Sources/AblyLiveObjects/Public/Plugin.swift b/Sources/AblyLiveObjects/Public/Plugin.swift index 26a377d45..870a9328a 100644 --- a/Sources/AblyLiveObjects/Public/Plugin.swift +++ b/Sources/AblyLiveObjects/Public/Plugin.swift @@ -1,6 +1,6 @@ -internal import AblyPlugin +internal import _AblyPluginSupportPrivate -// We explicitly import the NSObject class, else it seems to get transitively imported from `internal import AblyPlugin`, leading to the error "Class cannot be declared public because its superclass is internal". +// 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. @@ -22,10 +22,10 @@ import ObjectiveC.NSObject /// ``` @objc public class Plugin: NSObject { - /// The `AblyPlugin.PluginAPIProtocol` that the LiveObjects plugin should use by default (i.e. when one hasn't been injected for test purposes). - internal static let defaultPluginAPI: AblyPlugin.PluginAPIProtocol = AblyPlugin.PluginAPI.sharedInstance() + /// 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.PluginAPIProtocol = _AblyPluginSupportPrivate.PluginAPI.sharedInstance() - // MARK: - Informal conformance to AblyPlugin.LiveObjectsPluginProtocol + // MARK: - Informal conformance to _AblyPluginSupportPrivate.LiveObjectsPluginProtocol @objc private static let internalPlugin = DefaultInternalPlugin(pluginAPI: defaultPluginAPI) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift index bd88d2fbd..91fe81ebc 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift @@ -1,4 +1,4 @@ -internal import AblyPlugin +internal import _AblyPluginSupportPrivate internal extension InternalLiveMapValue { // MARK: - Mapping to public types @@ -6,7 +6,7 @@ internal extension InternalLiveMapValue { struct PublicValueCreationArgs { internal var coreSDK: CoreSDK internal var mapDelegate: LiveMapObjectPoolDelegate - internal var logger: AblyPlugin.Logger + internal var logger: _AblyPluginSupportPrivate.Logger internal var toCounterCreationArgs: PublicObjectsStore.CounterCreationArgs { .init(coreSDK: coreSDK, logger: logger) diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 146d05e67..6388aa555 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -1,5 +1,5 @@ +internal import _AblyPluginSupportPrivate import Ably -internal import AblyPlugin /// Our default implementation of ``LiveCounter``. /// @@ -10,9 +10,9 @@ internal final class PublicDefaultLiveCounter: LiveCounter { // MARK: - Dependencies that hold a strong reference to `proxied` private let coreSDK: CoreSDK - private let logger: AblyPlugin.Logger + private let logger: _AblyPluginSupportPrivate.Logger - internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK, logger: AblyPlugin.Logger) { + internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK, logger: _AblyPluginSupportPrivate.Logger) { self.proxied = proxied self.coreSDK = coreSDK self.logger = logger diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 52e207d20..a6674a422 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -1,5 +1,5 @@ +internal import _AblyPluginSupportPrivate import Ably -internal import AblyPlugin /// Our default implementation of ``LiveMap``. /// @@ -11,9 +11,9 @@ internal final class PublicDefaultLiveMap: LiveMap { private let coreSDK: CoreSDK private let delegate: LiveMapObjectPoolDelegate - private let logger: AblyPlugin.Logger + private let logger: _AblyPluginSupportPrivate.Logger - internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate, logger: AblyPlugin.Logger) { + internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate, logger: _AblyPluginSupportPrivate.Logger) { self.proxied = proxied self.coreSDK = coreSDK self.delegate = delegate diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index 3d97b8b5e..19a723c7b 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -1,5 +1,5 @@ +internal import _AblyPluginSupportPrivate import Ably -internal import AblyPlugin /// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. /// @@ -13,9 +13,9 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { // MARK: - Dependencies that hold a strong reference to `proxied` private let coreSDK: CoreSDK - private let logger: AblyPlugin.Logger + private let logger: _AblyPluginSupportPrivate.Logger - internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK, logger: AblyPlugin.Logger) { + internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK, logger: _AblyPluginSupportPrivate.Logger) { self.proxied = proxied self.coreSDK = coreSDK self.logger = logger diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift index 4be075345..a6e703319 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift @@ -1,4 +1,4 @@ -internal import AblyPlugin +internal import _AblyPluginSupportPrivate import Foundation /// Stores the public objects that wrap the SDK's internal components. @@ -21,7 +21,7 @@ internal final class PublicObjectsStore: Sendable { internal struct RealtimeObjectsCreationArgs { internal var coreSDK: CoreSDK - internal var logger: AblyPlugin.Logger + internal var logger: _AblyPluginSupportPrivate.Logger } /// Fetches the cached `PublicDefaultRealtimeObjects` that wraps a given `InternalDefaultRealtimeObjects`, creating a new public object if there isn't already one. @@ -33,7 +33,7 @@ internal final class PublicObjectsStore: Sendable { internal struct CounterCreationArgs { internal var coreSDK: CoreSDK - internal var logger: AblyPlugin.Logger + internal var logger: _AblyPluginSupportPrivate.Logger } /// Fetches the cached `PublicDefaultLiveCounter` that wraps a given `InternalDefaultLiveCounter`, creating a new public object if there isn't already one. @@ -46,7 +46,7 @@ internal final class PublicObjectsStore: Sendable { internal struct MapCreationArgs { internal var coreSDK: CoreSDK internal var delegate: LiveMapObjectPoolDelegate - internal var logger: AblyPlugin.Logger + internal var logger: _AblyPluginSupportPrivate.Logger } /// Fetches the cached `PublicDefaultLiveMap` that wraps a given `InternalDefaultLiveMap`, creating a new public object if there isn't already one. @@ -68,7 +68,7 @@ internal final class PublicObjectsStore: Sendable { /// Fetches the proxy that wraps `proxied`, creating a new proxy if there isn't already one. Stores a weak reference to the proxy. mutating func getOrCreate( proxying proxied: some AnyObject, - logger: AblyPlugin.Logger, + logger: _AblyPluginSupportPrivate.Logger, logObjectType: String, createProxy: () -> Proxy, ) -> Proxy { @@ -90,7 +90,7 @@ internal final class PublicObjectsStore: Sendable { return created } - private mutating func removeDeallocatedEntries(logger: AblyPlugin.Logger, logObjectType: String) { + private mutating func removeDeallocatedEntries(logger: _AblyPluginSupportPrivate.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) diff --git a/Sources/AblyLiveObjects/Utility/APLogger+Swift.swift b/Sources/AblyLiveObjects/Utility/APLogger+Swift.swift index 276621d1f..9236155e4 100644 --- a/Sources/AblyLiveObjects/Utility/APLogger+Swift.swift +++ b/Sources/AblyLiveObjects/Utility/APLogger+Swift.swift @@ -1,6 +1,6 @@ -internal import AblyPlugin +internal import _AblyPluginSupportPrivate -internal extension AblyPlugin.Logger { +internal extension _AblyPluginSupportPrivate.Logger { /// A convenience method that provides default values for `file` and `line`. func log(_ message: String, level: ARTLogLevel, fileID: String = #fileID, line: Int = #line) { log(message, with: level, file: fileID, line: line) diff --git a/Sources/AblyLiveObjects/Utility/WireValue.swift b/Sources/AblyLiveObjects/Utility/WireValue.swift index e2e6f5d8a..e5d2996e7 100644 --- a/Sources/AblyLiveObjects/Utility/WireValue.swift +++ b/Sources/AblyLiveObjects/Utility/WireValue.swift @@ -1,7 +1,7 @@ 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 AblyPlugin, or which we may put inside a serialized wire object that we send to AblyPlugin. +/// 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). internal indirect enum WireValue: Sendable, Equatable { @@ -118,27 +118,27 @@ extension WireValue: ExpressibleByBooleanLiteral { // MARK: - Bridging with ably-cocoa internal extension WireValue { - /// Creates a `WireValue` from an AblyPlugin deserialized wire object. + /// Creates a `WireValue` from an `_AblyPluginSupportPrivate` deserialized wire object. /// - /// Specifically, `ablyPluginData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. - init(ablyPluginData: Any) { + /// Specifically, `pluginSupportData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. + init(pluginSupportData: Any) { // swiftlint:disable:next trailing_closure - let extendedJSONValue = ExtendedJSONValue(deserialized: ablyPluginData, createExtraValue: { deserializedExtraValue in + let extendedJSONValue = ExtendedJSONValue(deserialized: pluginSupportData, 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(ablyPluginData:) was given unsupported value \(deserializedExtraValue)") + preconditionFailure("WireValue(pluginSupportData:) was given unsupported value \(deserializedExtraValue)") }) self.init(extendedJSONValue: extendedJSONValue) } - /// Creates a `WireValue` from an AblyPlugin deserialized wire object. Specifically, `ablyPluginData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. - static func objectFromAblyPluginData(_ ablyPluginData: [String: Any]) -> [String: WireValue] { - let wireValue = WireValue(ablyPluginData: ablyPluginData) + /// Creates a `WireValue` from an `_AblyPluginSupportPrivate` deserialized wire object. Specifically, `pluginSupportData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. + static func objectFrom_AblyPluginSupportPrivateData(_ pluginSupportData: [String: Any]) -> [String: WireValue] { + let wireValue = WireValue(pluginSupportData: pluginSupportData) guard case let .object(wireObject) = wireValue else { preconditionFailure() } @@ -146,10 +146,10 @@ internal extension WireValue { return wireObject } - /// Creates an AblyPlugin deserialized wire object from a `WireValue`. + /// Creates an `_AblyPluginSupportPrivate` deserialized wire object from a `WireValue`. /// - /// Used by `[String: WireValue].toAblyPluginDataDictionary`. - var toAblyPluginData: Any { + /// Used by `[String: WireValue].toPluginSupportDataDictionary`. + var toPluginSupportData: Any { // swiftlint:disable:next trailing_closure toExtendedJSONValue.serialized(serializeExtraValue: { extendedValue in switch extendedValue { @@ -161,11 +161,11 @@ internal extension WireValue { } internal extension [String: WireValue] { - /// Creates an AblyPlugin deserialized wire object from a dictionary that has string keys and `WireValue` values. + /// 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 toAblyPluginDataDictionary: [String: Any] { - mapValues(\.toAblyPluginData) + var toPluginSupportDataDictionary: [String: Any] { + mapValues(\.toPluginSupportData) } } diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index e71959dea..feaa87194 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -1,6 +1,6 @@ +import _AblyPluginSupportPrivate import Ably @testable import AblyLiveObjects -import AblyPlugin import Testing struct AblyLiveObjectsTests { diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index 441bf12fe..89b2ddb59 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -1,5 +1,5 @@ +import _AblyPluginSupportPrivate @testable import AblyLiveObjects -import AblyPlugin 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. diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift b/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift index 59c51e17f..3b6d6f5f8 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift @@ -1,8 +1,8 @@ -import AblyPlugin +import _AblyPluginSupportPrivate import os -/// An implementation of `AblyPlugin.Logger` to use when testing internal components of the LiveObjects plugin. -final class TestLogger: NSObject, AblyPlugin.Logger { +/// An implementation of `_AblyPluginSupportPrivate.Logger` to use when testing internal components of the LiveObjects plugin. +final class TestLogger: NSObject, _AblyPluginSupportPrivate.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 diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 039ec5171..a47473885 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -1,5 +1,5 @@ +import _AblyPluginSupportPrivate @testable import AblyLiveObjects -import AblyPlugin import Foundation import Testing diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 9d982e99a..0f3321184 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -1,5 +1,5 @@ +import _AblyPluginSupportPrivate @testable import AblyLiveObjects -import AblyPlugin import Foundation import Testing diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index b4bedee96..7d64d9632 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -1,6 +1,6 @@ +import _AblyPluginSupportPrivate import Ably @testable import AblyLiveObjects -import AblyPlugin import Testing /// Tests for `InternalDefaultRealtimeObjects`. diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift index 9f6671e54..77b78e16c 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift @@ -1,6 +1,6 @@ +import _AblyPluginSupportPrivate import Ably @testable import AblyLiveObjects -import AblyPlugin import Foundation // This file is copied from the file objects.test.js in ably-js. @@ -330,7 +330,7 @@ final class ObjectsHelper: Sendable { logger: channel.internal.logger, ) - let foundationObject = deserialized.toAblyPluginDataDictionary + let foundationObject = deserialized.toPluginSupportDataDictionary let protocolMessage = withExtendedLifetime(jsonLikeEncoderDelegate) { encoder.protocolMessage(from: foundationObject)! } diff --git a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift index ff39d7d4b..06cae4bff 100644 --- a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift +++ b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift @@ -1,6 +1,6 @@ +import _AblyPluginSupportPrivate import Ably @testable import AblyLiveObjects -import AblyPlugin import Testing /// Tests for `LiveObjectMutableState`. diff --git a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift index a973dea74..85fd9f49c 100644 --- a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift @@ -1,5 +1,5 @@ +import _AblyPluginSupportPrivate @testable import AblyLiveObjects -import AblyPlugin import Foundation import Testing diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 89514d5f3..8a350029b 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -1,5 +1,5 @@ +import _AblyPluginSupportPrivate @testable import AblyLiveObjects -import AblyPlugin import Testing struct ObjectsPoolTests { diff --git a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift index 16c28f127..d2228142a 100644 --- a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -1,11 +1,11 @@ +import _AblyPluginSupportPrivate @testable import AblyLiveObjects -import AblyPlugin import Foundation import Testing enum WireObjectMessageTests { // Helper: Fake decoding context - final class FakeDecodingContext: AblyPlugin.DecodingContextProtocol, @unchecked Sendable { + final class FakeDecodingContext: _AblyPluginSupportPrivate.DecodingContextProtocol, @unchecked Sendable { let parentID: String? let parentConnectionID: String? let parentTimestamp: Date? diff --git a/Tests/AblyLiveObjectsTests/WireValueTests.swift b/Tests/AblyLiveObjectsTests/WireValueTests.swift index 91e557e09..74a31d32f 100644 --- a/Tests/AblyLiveObjectsTests/WireValueTests.swift +++ b/Tests/AblyLiveObjectsTests/WireValueTests.swift @@ -4,35 +4,35 @@ import Foundation import Testing struct WireValueTests { - // MARK: Conversion from AblyPlugin data + // MARK: Conversion from _AblyPluginSupportPrivate data @Test(arguments: [ // object - (ablyPluginData: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), + (pluginSupportData: ["someKey": "someValue"], expectedResult: ["someKey": "someValue"]), // array - (ablyPluginData: ["someElement"], expectedResult: ["someElement"]), + (pluginSupportData: ["someElement"], expectedResult: ["someElement"]), // string - (ablyPluginData: "someString", expectedResult: "someString"), + (pluginSupportData: "someString", expectedResult: "someString"), // number - (ablyPluginData: NSNumber(value: 0), expectedResult: 0), - (ablyPluginData: NSNumber(value: 1), expectedResult: 1), - (ablyPluginData: NSNumber(value: 123), expectedResult: 123), - (ablyPluginData: NSNumber(value: 123.456), expectedResult: 123.456), + (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 - (ablyPluginData: NSNumber(value: true), expectedResult: true), - (ablyPluginData: NSNumber(value: false), expectedResult: false), + (pluginSupportData: NSNumber(value: true), expectedResult: true), + (pluginSupportData: NSNumber(value: false), expectedResult: false), // null - (ablyPluginData: NSNull(), expectedResult: .null), + (pluginSupportData: NSNull(), expectedResult: .null), // data - (ablyPluginData: Data([0x01, 0x02, 0x03]), expectedResult: .data(Data([0x01, 0x02, 0x03]))), - ] as[(ablyPluginData: Sendable, expectedResult: WireValue?)]) - func initWithAblyPluginData(ablyPluginData: Sendable, expectedResult: WireValue?) { - #expect(WireValue(ablyPluginData: ablyPluginData) == expectedResult) + (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 initWithAblyPluginData_endToEnd_json() throws { + func initWithPluginSupportData_endToEnd_json() throws { let jsonString = """ { "someArray": [ @@ -54,7 +54,7 @@ struct WireValueTests { } """ - let ablyPluginData = try JSONSerialization.jsonObject(with: #require(jsonString.data(using: .utf8))) + let pluginSupportData = try JSONSerialization.jsonObject(with: #require(jsonString.data(using: .utf8))) let expected: WireValue = [ "someArray": [ @@ -75,12 +75,12 @@ struct WireValueTests { ], ] - #expect(WireValue(ablyPluginData: ablyPluginData) == expected) + #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 initWithAblyPluginData_endToEnd_msgpack() throws { + func initWithPluginSupportData_endToEnd_msgpack() throws { // MessagePack representation of the same data structure as in the JSON test above, plus binary data // This represents: // { @@ -167,7 +167,7 @@ struct WireValueTests { 0xAE, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x56, 0x61, 0x6C, 0x75, 0x65, // value (14 chars) ]) - let ablyPluginData = try ARTMsgPackEncoder().decode(msgpackData) + let pluginSupportData = try ARTMsgPackEncoder().decode(msgpackData) let expected: WireValue = [ "someArray": [ @@ -189,10 +189,10 @@ struct WireValueTests { ], ] - #expect(WireValue(ablyPluginData: ablyPluginData) == expected) + #expect(WireValue(pluginSupportData: pluginSupportData) == expected) } - // MARK: Conversion to AblyPlugin data + // MARK: Conversion to _AblyPluginSupportPrivate data @Test(arguments: [ // object @@ -214,15 +214,15 @@ struct WireValueTests { // data (value: .data(Data([0x01, 0x02, 0x03])), expectedResult: Data([0x01, 0x02, 0x03])), ] as[(value: WireValue, expectedResult: Sendable)]) - func toAblyPluginData(value: WireValue, expectedResult: Sendable) throws { - let resultAsNSObject = try #require(value.toAblyPluginData as? NSObject) + 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 toAblyPluginData_endToEnd_json() throws { + func toPluginSupportData_endToEnd_json() throws { let value: WireValue = [ "someArray": [ [ @@ -265,7 +265,7 @@ struct WireValueTests { let jsonSerializationOptions: JSONSerialization.WritingOptions = [.sortedKeys] - let valueData = try JSONSerialization.data(withJSONObject: value.toAblyPluginData, options: jsonSerializationOptions) + 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) @@ -276,7 +276,7 @@ struct WireValueTests { // 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 toAblyPluginData_endToEnd_msgpack() throws { + func toPluginSupportData_endToEnd_msgpack() throws { let value: WireValue = [ "someArray": [ [ @@ -365,7 +365,7 @@ struct WireValueTests { 0xAE, 0x73, 0x6F, 0x6D, 0x65, 0x4F, 0x74, 0x68, 0x65, 0x72, 0x56, 0x61, 0x6C, 0x75, 0x65, // value (14 chars) ]) - let actualMsgPackData = try ARTMsgPackEncoder().encode(value.toAblyPluginData) + let actualMsgPackData = try ARTMsgPackEncoder().encode(value.toPluginSupportData) // Verify that both decode to the same Foundation object structure let expectedDecoded = try ARTMsgPackEncoder().decode(expectedMsgPackData) diff --git a/ably-cocoa b/ably-cocoa index 5096ca37c..9f1a65179 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 5096ca37c6c39f8f33e261f49faf2a6f9d03e529 +Subproject commit 9f1a65179b69b6041b3a65664c5fa6c48d722fec From b4e1899a86b063bf670f4b83407ba20f27458eb9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 19 Aug 2025 09:00:59 -0300 Subject: [PATCH 129/225] Get ably-cocoa plugin APIs from an external repo As required by the accompanying ably-cocoa submodule bump (see the ably-cocoa commit message for explanation of why we're making this change). --- .../xcshareddata/swiftpm/Package.resolved | 11 +++- Package.resolved | 11 +++- Package.swift | 8 ++- .../Internal/ARTClientOptions+Objects.swift | 5 +- .../AblyLiveObjects/Internal/CoreSDK.swift | 12 ++--- .../Internal/DefaultInternalPlugin.swift | 12 +++-- .../Internal/InternalDefaultLiveCounter.swift | 8 +-- .../Internal/InternalDefaultLiveMap.swift | 16 +++--- .../InternalDefaultRealtimeObjects.swift | 4 +- .../Internal/LiveObjectMutableState.swift | 1 + .../Internal/ObjectsPool.swift | 12 ++--- .../Public/ARTRealtimeChannel+Objects.swift | 5 +- Sources/AblyLiveObjects/Public/Plugin.swift | 2 +- .../InternalLiveMapValue+ToPublic.swift | 2 +- .../PublicDefaultLiveCounter.swift | 4 +- .../PublicDefaultLiveMap.swift | 4 +- .../PublicDefaultRealtimeObjects.swift | 4 +- .../PublicObjectsStore.swift | 10 ++-- .../Utility/APLogger+Swift.swift | 8 --- Sources/AblyLiveObjects/Utility/Errors.swift | 3 +- .../Utility/InternalError.swift | 7 +++ Sources/AblyLiveObjects/Utility/Logger.swift | 41 ++++++++++++++ .../Utility/MarkerProtocolHelpers.swift | 54 +++++++++++++++++++ .../AblyLiveObjects/Utility/WireValue.swift | 2 +- .../Helpers/TestLogger.swift | 11 ++-- .../InternalDefaultLiveCounterTests.swift | 9 ++-- .../InternalDefaultLiveMapTests.swift | 17 +++--- .../InternalDefaultRealtimeObjectsTests.swift | 12 ++--- .../LiveObjectMutableStateTests.swift | 4 +- .../Mocks/MockCoreSDK.swift | 7 +-- ably-cocoa | 2 +- 31 files changed, 217 insertions(+), 91 deletions(-) delete mode 100644 Sources/AblyLiveObjects/Utility/APLogger+Swift.swift create mode 100644 Sources/AblyLiveObjects/Utility/Logger.swift create mode 100644 Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index bfe1d5811..cd28d5baf 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "6884be3a1fb838d3f9a3288b0cb994d0dfce4e90c7d648bca08e61fb802c9cda", + "originHash" : "7ffc8893e3a0652bc31d38d048052def20308c84ae9888411b48b5c89c2ec6c6", "pins" : [ + { + "identity" : "ably-cocoa-plugin-support", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/ably-cocoa-plugin-support", + "state" : { + "revision" : "cec94ed123d60e39e3f8df665c30a57482d37612", + "version" : "0.1.0" + } + }, { "identity" : "delta-codec-cocoa", "kind" : "remoteSourceControl", diff --git a/Package.resolved b/Package.resolved index 773dea53b..44dc3735f 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,15 @@ { - "originHash" : "9d42be7ef9d81adeb6ac28ccc2a7a4dd43dbf0d952b6f8331e73ab665d36df3a", + "originHash" : "cf81894c95bf31f3c45009841b1a7ee58b50fdcf93ceeecdade367eef5e57c58", "pins" : [ + { + "identity" : "ably-cocoa-plugin-support", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/ably-cocoa-plugin-support", + "state" : { + "revision" : "cec94ed123d60e39e3f8df665c30a57482d37612", + "version" : "0.1.0" + } + }, { "identity" : "delta-codec-cocoa", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 742aa4b59..ce7b62f26 100644 --- a/Package.swift +++ b/Package.swift @@ -21,6 +21,10 @@ let package = Package( .package( path: "ably-cocoa", ), + .package( + url: "https://github.com/ably/ably-cocoa-plugin-support", + from: "0.1.0", + ), .package( url: "https://github.com/apple/swift-argument-parser", from: "1.5.0", @@ -48,7 +52,7 @@ let package = Package( ), .product( name: "_AblyPluginSupportPrivate", - package: "ably-cocoa", + package: "ably-cocoa-plugin-support", ), ], ), @@ -62,7 +66,7 @@ let package = Package( ), .product( name: "_AblyPluginSupportPrivate", - package: "ably-cocoa", + package: "ably-cocoa-plugin-support", ), ], resources: [ diff --git a/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift b/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift index 87973817d..685543452 100644 --- a/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift +++ b/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift @@ -1,4 +1,5 @@ internal import _AblyPluginSupportPrivate +import Ably internal extension ARTClientOptions { private class Box { @@ -16,7 +17,7 @@ internal extension ARTClientOptions { get { let optionsValue = Plugin.defaultPluginAPI.pluginOptionsValue( forKey: Self.garbageCollectionOptionsKey, - clientOptions: self, + clientOptions: asPluginPublicClientOptions, ) guard let optionsValue else { @@ -38,7 +39,7 @@ internal extension ARTClientOptions { Plugin.defaultPluginAPI.setPluginOptionsValue( Box(boxed: newValue), forKey: Self.garbageCollectionOptionsKey, - clientOptions: self, + clientOptions: asPluginPublicClientOptions, ) } } diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 0c93078c6..1e99994e1 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -14,7 +14,7 @@ internal protocol CoreSDK: AnyObject, Sendable { func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(InternalError) -> Void) /// Returns the current state of the Realtime channel that this wraps. - var channelState: ARTRealtimeChannelState { get } + var channelState: _AblyPluginSupportPrivate.RealtimeChannelState { get } } internal final class DefaultCoreSDK: CoreSDK { @@ -24,7 +24,7 @@ internal final class DefaultCoreSDK: CoreSDK { private let channel: _AblyPluginSupportPrivate.RealtimeChannel private let client: _AblyPluginSupportPrivate.RealtimeClient private let pluginAPI: PluginAPIProtocol - private let logger: _AblyPluginSupportPrivate.Logger + private let logger: Logger /// If set to true, ``publish(objectMessages:)`` will behave like a no-op. /// @@ -37,7 +37,7 @@ internal final class DefaultCoreSDK: CoreSDK { channel: _AblyPluginSupportPrivate.RealtimeChannel, client: _AblyPluginSupportPrivate.RealtimeClient, pluginAPI: PluginAPIProtocol, - logger: _AblyPluginSupportPrivate.Logger + logger: Logger ) { self.channel = channel self.client = client @@ -81,8 +81,8 @@ internal final class DefaultCoreSDK: CoreSDK { } } - internal var channelState: ARTRealtimeChannelState { - channel.state + internal var channelState: _AblyPluginSupportPrivate.RealtimeChannelState { + pluginAPI.state(for: channel) } } @@ -97,7 +97,7 @@ internal extension CoreSDK { /// - operationDescription: A description of the operation being performed, used in error messages /// - Throws: `ARTErrorInfo` with code 90001 and statusCode 400 if the channel is in any of the invalid states func validateChannelState( - notIn invalidStates: [ARTRealtimeChannelState], + notIn invalidStates: [_AblyPluginSupportPrivate.RealtimeChannelState], operationDescription: String, ) throws(ARTErrorInfo) { let currentChannelState = channelState diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 970157eea..ac081fc2b 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -1,4 +1,5 @@ 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 @@ -34,10 +35,11 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. // Populates the channel's `objects` property. internal func prepare(_ channel: _AblyPluginSupportPrivate.RealtimeChannel, client: _AblyPluginSupportPrivate.RealtimeClient) { - let logger = pluginAPI.logger(for: channel) + let pluginLogger = pluginAPI.logger(for: channel) let callbackQueue = pluginAPI.callbackQueue(for: client) - let options = pluginAPI.options(for: client) + let options = ARTClientOptions.castPluginPublicClientOptions(pluginAPI.options(for: client)) + let logger = DefaultLogger(pluginLogger: pluginLogger, pluginAPI: pluginAPI) logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) let liveObjects = InternalDefaultRealtimeObjects( logger: logger, @@ -68,9 +70,9 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. _ serialized: [String: Any], context: DecodingContextProtocol, format: EncodingFormat, - error errorPtr: AutoreleasingUnsafeMutablePointer?, + error errorPtr: AutoreleasingUnsafeMutablePointer<_AblyPluginSupportPrivate.PublicErrorInfo?>?, ) -> (any ObjectMessageProtocol)? { - let wireObject = WireValue.objectFrom_AblyPluginSupportPrivateData(serialized) + let wireObject = WireValue.objectFromPluginSupportData(serialized) do { let wireObjectMessage = try InboundWireObjectMessage( @@ -83,7 +85,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. ) return ObjectMessageBox(objectMessage: objectMessage) } catch { - errorPtr?.pointee = error.toARTErrorInfo() + errorPtr?.pointee = error.toARTErrorInfo().asPluginPublicErrorInfo return nil } } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 4169912f7..9c314bc07 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -21,7 +21,7 @@ internal final class InternalDefaultLiveCounter: Sendable { } } - private let logger: _AblyPluginSupportPrivate.Logger + private let logger: Logger private let userCallbackQueue: DispatchQueue private let clock: SimpleClock @@ -30,7 +30,7 @@ internal final class InternalDefaultLiveCounter: Sendable { internal convenience init( testsOnly_data data: Double, objectID: String, - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock ) { @@ -40,7 +40,7 @@ internal final class InternalDefaultLiveCounter: Sendable { private init( data: Double, objectID: String, - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock ) { @@ -56,7 +56,7 @@ internal final class InternalDefaultLiveCounter: Sendable { /// - objectID: The value for the "private objectId field" of RTO5c1b1a. internal static func createZeroValued( objectID: String, - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> Self { diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index e952cdb10..84a94c253 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -38,7 +38,7 @@ internal final class InternalDefaultLiveMap: Sendable { } } - private let logger: _AblyPluginSupportPrivate.Logger + private let logger: Logger private let userCallbackQueue: DispatchQueue private let clock: SimpleClock @@ -48,7 +48,7 @@ internal final class InternalDefaultLiveMap: Sendable { testsOnly_data data: [String: InternalObjectsMapEntry], objectID: String, testsOnly_semantics semantics: WireEnum? = nil, - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) { @@ -66,7 +66,7 @@ internal final class InternalDefaultLiveMap: Sendable { data: [String: InternalObjectsMapEntry], objectID: String, semantics: WireEnum?, - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) { @@ -84,7 +84,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal static func createZeroValued( objectID: String, semantics: WireEnum? = nil, - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> Self { @@ -398,7 +398,7 @@ internal final class InternalDefaultLiveMap: Sendable { using state: ObjectState, objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, clock: SimpleClock, userCallbackQueue: DispatchQueue, ) -> LiveObjectUpdate { @@ -467,7 +467,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal mutating func mergeInitialValue( from operation: ObjectOperation, objectsPool: inout ObjectsPool, - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> LiveObjectUpdate { @@ -621,7 +621,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationTimeserial: String?, operationData: ObjectData?, objectsPool: inout ObjectsPool, - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> LiveObjectUpdate { @@ -744,7 +744,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal mutating func applyMapCreateOperation( _ operation: ObjectOperation, objectsPool: inout ObjectsPool, - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> LiveObjectUpdate { diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 89770aabc..c989756c3 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -8,7 +8,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool private nonisolated(unsafe) var mutableState: MutableState! - private let logger: _AblyPluginSupportPrivate.Logger + private let logger: Logger private let userCallbackQueue: DispatchQueue private let clock: SimpleClock @@ -91,7 +91,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } } - internal init(logger: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, garbageCollectionOptions: GarbageCollectionOptions = .init()) { + internal init(logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, garbageCollectionOptions: GarbageCollectionOptions = .init()) { self.logger = logger self.userCallbackQueue = userCallbackQueue self.clock = clock diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index b47167735..191d312ca 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -1,4 +1,5 @@ internal import _AblyPluginSupportPrivate +import Ably /// This is the equivalent of the `LiveObject` abstract class described in RTLO. /// diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index a16cd24e1..3cef1deac 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -137,7 +137,7 @@ internal struct ObjectsPool { /// Creates an `ObjectsPool` whose root is a zero-value `LiveMap`. internal init( - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, testsOnly_otherEntries otherEntries: [String: Entry]? = nil, @@ -151,7 +151,7 @@ internal struct ObjectsPool { } private init( - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, otherEntries: [String: Entry]? @@ -187,7 +187,7 @@ internal struct ObjectsPool { /// - 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: _AblyPluginSupportPrivate.Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock) -> Entry? { + internal mutating func createZeroValueObject(forObjectID objectID: String, logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock) -> Entry? { // RTO6a: If an object with objectId exists in ObjectsPool, do not create a new object if let existingEntry = entries[objectID] { return existingEntry @@ -220,7 +220,7 @@ internal struct ObjectsPool { /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1 and RTO5c2. internal mutating func applySyncObjectsPool( _ syncObjectsPool: [SyncObjectsPoolEntry], - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) { @@ -316,7 +316,7 @@ internal struct ObjectsPool { /// - Returns: The existing or newly created counter object internal mutating func getOrCreateCounter( creationOperation: ObjectCreationHelpers.CounterCreationOperation, - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> InternalDefaultLiveCounter { @@ -360,7 +360,7 @@ internal struct ObjectsPool { /// - Returns: The existing or newly created map object internal mutating func getOrCreateMap( creationOperation: ObjectCreationHelpers.MapCreationOperation, - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> InternalDefaultLiveMap { diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index 67b8612a5..cec5ca942 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -9,10 +9,11 @@ public extension ARTRealtimeChannel { private var nonTypeErasedObjects: PublicDefaultRealtimeObjects { let pluginAPI = Plugin.defaultPluginAPI - let underlyingObjects = pluginAPI.underlyingObjects(forPublicRealtimeChannel: self) + let underlyingObjects = pluginAPI.underlyingObjects(for: asPluginPublicRealtimeChannel) let internalObjects = DefaultInternalPlugin.realtimeObjects(for: underlyingObjects.channel, pluginAPI: pluginAPI) - let logger = pluginAPI.logger(for: underlyingObjects.channel) + let pluginLogger = pluginAPI.logger(for: underlyingObjects.channel) + let logger = DefaultLogger(pluginLogger: pluginLogger, pluginAPI: pluginAPI) let coreSDK = DefaultCoreSDK( channel: underlyingObjects.channel, diff --git a/Sources/AblyLiveObjects/Public/Plugin.swift b/Sources/AblyLiveObjects/Public/Plugin.swift index 870a9328a..799c3542e 100644 --- a/Sources/AblyLiveObjects/Public/Plugin.swift +++ b/Sources/AblyLiveObjects/Public/Plugin.swift @@ -23,7 +23,7 @@ import ObjectiveC.NSObject @objc public class Plugin: NSObject { /// The `_AblyPluginSupportPrivate.PluginAPIProtocol` that the LiveObjects plugin should use by default (i.e. when one hasn't been injected for test purposes). - internal static let defaultPluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol = _AblyPluginSupportPrivate.PluginAPI.sharedInstance() + internal static let defaultPluginAPI = _AblyPluginSupportPrivate.DependencyStore.sharedInstance().fetchPluginAPI() // MARK: - Informal conformance to _AblyPluginSupportPrivate.LiveObjectsPluginProtocol diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift index 91fe81ebc..fa7e7bb3b 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift @@ -6,7 +6,7 @@ internal extension InternalLiveMapValue { struct PublicValueCreationArgs { internal var coreSDK: CoreSDK internal var mapDelegate: LiveMapObjectPoolDelegate - internal var logger: _AblyPluginSupportPrivate.Logger + internal var logger: Logger internal var toCounterCreationArgs: PublicObjectsStore.CounterCreationArgs { .init(coreSDK: coreSDK, logger: logger) diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 6388aa555..90a408da1 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -10,9 +10,9 @@ internal final class PublicDefaultLiveCounter: LiveCounter { // MARK: - Dependencies that hold a strong reference to `proxied` private let coreSDK: CoreSDK - private let logger: _AblyPluginSupportPrivate.Logger + private let logger: Logger - internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK, logger: _AblyPluginSupportPrivate.Logger) { + internal init(proxied: InternalDefaultLiveCounter, coreSDK: CoreSDK, logger: Logger) { self.proxied = proxied self.coreSDK = coreSDK self.logger = logger diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index a6674a422..5aad18fa4 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -11,9 +11,9 @@ internal final class PublicDefaultLiveMap: LiveMap { private let coreSDK: CoreSDK private let delegate: LiveMapObjectPoolDelegate - private let logger: _AblyPluginSupportPrivate.Logger + private let logger: Logger - internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate, logger: _AblyPluginSupportPrivate.Logger) { + internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate, logger: Logger) { self.proxied = proxied self.coreSDK = coreSDK self.delegate = delegate diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index 19a723c7b..34221ad6c 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -13,9 +13,9 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { // MARK: - Dependencies that hold a strong reference to `proxied` private let coreSDK: CoreSDK - private let logger: _AblyPluginSupportPrivate.Logger + private let logger: Logger - internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK, logger: _AblyPluginSupportPrivate.Logger) { + internal init(proxied: InternalDefaultRealtimeObjects, coreSDK: CoreSDK, logger: Logger) { self.proxied = proxied self.coreSDK = coreSDK self.logger = logger diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift index a6e703319..264a92cc3 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift @@ -21,7 +21,7 @@ internal final class PublicObjectsStore: Sendable { internal struct RealtimeObjectsCreationArgs { internal var coreSDK: CoreSDK - internal var logger: _AblyPluginSupportPrivate.Logger + internal var logger: Logger } /// Fetches the cached `PublicDefaultRealtimeObjects` that wraps a given `InternalDefaultRealtimeObjects`, creating a new public object if there isn't already one. @@ -33,7 +33,7 @@ internal final class PublicObjectsStore: Sendable { internal struct CounterCreationArgs { internal var coreSDK: CoreSDK - internal var logger: _AblyPluginSupportPrivate.Logger + internal var logger: Logger } /// Fetches the cached `PublicDefaultLiveCounter` that wraps a given `InternalDefaultLiveCounter`, creating a new public object if there isn't already one. @@ -46,7 +46,7 @@ internal final class PublicObjectsStore: Sendable { internal struct MapCreationArgs { internal var coreSDK: CoreSDK internal var delegate: LiveMapObjectPoolDelegate - internal var logger: _AblyPluginSupportPrivate.Logger + internal var logger: Logger } /// Fetches the cached `PublicDefaultLiveMap` that wraps a given `InternalDefaultLiveMap`, creating a new public object if there isn't already one. @@ -68,7 +68,7 @@ internal final class PublicObjectsStore: Sendable { /// Fetches the proxy that wraps `proxied`, creating a new proxy if there isn't already one. Stores a weak reference to the proxy. mutating func getOrCreate( proxying proxied: some AnyObject, - logger: _AblyPluginSupportPrivate.Logger, + logger: Logger, logObjectType: String, createProxy: () -> Proxy, ) -> Proxy { @@ -90,7 +90,7 @@ internal final class PublicObjectsStore: Sendable { return created } - private mutating func removeDeallocatedEntries(logger: _AblyPluginSupportPrivate.Logger, logObjectType: String) { + 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) diff --git a/Sources/AblyLiveObjects/Utility/APLogger+Swift.swift b/Sources/AblyLiveObjects/Utility/APLogger+Swift.swift deleted file mode 100644 index 9236155e4..000000000 --- a/Sources/AblyLiveObjects/Utility/APLogger+Swift.swift +++ /dev/null @@ -1,8 +0,0 @@ -internal import _AblyPluginSupportPrivate - -internal extension _AblyPluginSupportPrivate.Logger { - /// A convenience method that provides default values for `file` and `line`. - func log(_ message: String, level: ARTLogLevel, fileID: String = #fileID, line: Int = #line) { - log(message, with: level, file: fileID, line: line) - } -} diff --git a/Sources/AblyLiveObjects/Utility/Errors.swift b/Sources/AblyLiveObjects/Utility/Errors.swift index 941d3e8ba..1ab2e6619 100644 --- a/Sources/AblyLiveObjects/Utility/Errors.swift +++ b/Sources/AblyLiveObjects/Utility/Errors.swift @@ -1,3 +1,4 @@ +internal import _AblyPluginSupportPrivate import Ably /** @@ -5,7 +6,7 @@ import Ably */ 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: ARTRealtimeChannelState) + case objectsOperationFailedInvalidChannelState(operationDescription: String, channelState: _AblyPluginSupportPrivate.RealtimeChannelState) case counterInitialValueInvalid(value: Double) case counterIncrementAmountInvalid(amount: Double) diff --git a/Sources/AblyLiveObjects/Utility/InternalError.swift b/Sources/AblyLiveObjects/Utility/InternalError.swift index e9fbf7058..4492cf6aa 100644 --- a/Sources/AblyLiveObjects/Utility/InternalError.swift +++ b/Sources/AblyLiveObjects/Utility/InternalError.swift @@ -1,3 +1,4 @@ +internal import _AblyPluginSupportPrivate import Ably /// An error thrown by the internals of the LiveObjects SDK. @@ -35,3 +36,9 @@ internal extension ARTErrorInfo { .errorInfo(self) } } + +internal extension _AblyPluginSupportPrivate.PublicErrorInfo { + func toInternalError() -> InternalError { + ARTErrorInfo.castPluginPublicErrorInfo(self).toInternalError() + } +} diff --git a/Sources/AblyLiveObjects/Utility/Logger.swift b/Sources/AblyLiveObjects/Utility/Logger.swift new file mode 100644 index 000000000..94cfca9ae --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/Logger.swift @@ -0,0 +1,41 @@ +internal import _AblyPluginSupportPrivate + +/// A reference to a line within a source code file. +internal struct CodeLocation: Equatable { + /// A file identifier in the format used by Swift’s `#fileID` macro. For example, `"AblyChat/Room.swift"`. + internal var fileID: String + /// The line number in the source code file referred to by ``fileID``. + internal var line: Int +} + +internal protocol Logger: Sendable { + func log(_ message: String, level: _AblyPluginSupportPrivate.LogLevel, codeLocation: CodeLocation) +} + +internal extension AblyLiveObjects.Logger { + /// A convenience method that provides default values for `file` and `line`. + func log(_ message: String, level: _AblyPluginSupportPrivate.LogLevel, fileID: String = #fileID, line: Int = #line) { + let codeLocation = CodeLocation(fileID: fileID, line: line) + log(message, level: level, codeLocation: codeLocation) + } +} + +internal final class DefaultLogger: Logger { + private let pluginLogger: _AblyPluginSupportPrivate.Logger + private let pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol + + internal init(pluginLogger: _AblyPluginSupportPrivate.Logger, pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol) { + self.pluginLogger = pluginLogger + self.pluginAPI = pluginAPI + } + + internal func log(_ message: String, level: LogLevel, codeLocation: CodeLocation) { + pluginAPI.log( + message, + with: level, + file: codeLocation.fileID, + line: codeLocation.line, + logger: pluginLogger, + ) + } +} diff --git a/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift b/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift new file mode 100644 index 000000000..28bf619c3 --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift @@ -0,0 +1,54 @@ +internal import _AblyPluginSupportPrivate +import Ably + +/// Upcasts an instance of an `_AblyPluginSupportPrivate` marker protocol to the concrete type that this marker protocol represents. +internal func castPluginPublicMarkerProtocolValue(_ pluginMarkerProtocolValue: Any, to _: T.Type) -> T { + guard let actualPublicValue = pluginMarkerProtocolValue as? T else { + preconditionFailure("Expected \(T.self), got \(type(of: pluginMarkerProtocolValue))") + } + + return actualPublicValue +} + +internal extension ARTRealtimeChannel { + /// Downcasts this `ARTRealtimeChannel` to its `_AblyPluginSupportPrivate` equivalent type `PublicRealtimeChannel`. + /// + /// - Note: Swift compiler restrictions prevent us from declaring `ARTRealtimeChannel` as conforming to `PublicRealtimeChannel` (this is due to our use of `internal import`). + var asPluginPublicRealtimeChannel: _AblyPluginSupportPrivate.PublicRealtimeChannel { + // In order for this cast to succeed, we rely on the fact that ably-cocoa internally declares ARTRealtimeChannel as conforming to PublicRealtimeChannel. + // swiftlint:disable:next force_cast + self as! _AblyPluginSupportPrivate.PublicRealtimeChannel + } +} + +internal extension ARTClientOptions { + /// Downcasts this `ARTClientOptions` to its `_AblyPluginSupportPrivate` marker protocol type `PublicClientOptions`. + /// + /// - Note: Swift compiler restrictions prevent us from declaring `ARTClientOptions` as conforming to `PublicClientOptions` (this is due to our use of `internal import`). + var asPluginPublicClientOptions: _AblyPluginSupportPrivate.PublicClientOptions { + // In order for this cast to succeed, we rely on the fact that ably-cocoa internally declares ARTClientOptions as conforming to PublicClientOptions. + // swiftlint:disable:next force_cast + self as! _AblyPluginSupportPrivate.PublicClientOptions + } + + /// Upcasts an instance of `_AblyPluginSupportPrivate`'s `PublicClientOptions`, which is the marker protocol that it uses to represent an `ARTClientOptions`, to an `ARTClientOptions`. + static func castPluginPublicClientOptions(_ pluginPublicClientOptions: PublicClientOptions) -> Self { + castPluginPublicMarkerProtocolValue(pluginPublicClientOptions, to: Self.self) + } +} + +internal extension ARTErrorInfo { + /// Downcasts this `ARTErrorInfo` to its `_AblyPluginSupportPrivate` marker protocol type `PublicErrorInfo`. + /// + /// - Note: Swift compiler restrictions prevent us from declaring `ARTErrorInfo` as conforming to `PublicErrorInfo` (this is due to our use of `internal import`). + var asPluginPublicErrorInfo: _AblyPluginSupportPrivate.PublicErrorInfo { + // In order for this cast to succeed, we rely on the fact that ably-cocoa internally declares ARTErrorInfo as conforming to PublicErrorInfo. + // swiftlint:disable:next force_cast + self as! _AblyPluginSupportPrivate.PublicErrorInfo + } + + /// Upcasts an instance of `_AblyPluginSupportPrivate`'s `PublicErrorInfo`, which is the marker protocol that it uses to represent an `ARTErrorInfo`, to an `ARTErrorInfo`. + static func castPluginPublicErrorInfo(_ pluginPublicErrorInfo: PublicErrorInfo) -> Self { + castPluginPublicMarkerProtocolValue(pluginPublicErrorInfo, to: Self.self) + } +} diff --git a/Sources/AblyLiveObjects/Utility/WireValue.swift b/Sources/AblyLiveObjects/Utility/WireValue.swift index e5d2996e7..6e82a258b 100644 --- a/Sources/AblyLiveObjects/Utility/WireValue.swift +++ b/Sources/AblyLiveObjects/Utility/WireValue.swift @@ -137,7 +137,7 @@ internal extension WireValue { } /// Creates a `WireValue` from an `_AblyPluginSupportPrivate` deserialized wire object. Specifically, `pluginSupportData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. - static func objectFrom_AblyPluginSupportPrivateData(_ pluginSupportData: [String: Any]) -> [String: WireValue] { + static func objectFromPluginSupportData(_ pluginSupportData: [String: Any]) -> [String: WireValue] { let wireValue = WireValue(pluginSupportData: pluginSupportData) guard case let .object(wireObject) = wireValue else { preconditionFailure() diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift b/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift index 3b6d6f5f8..3d53bdad9 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift @@ -1,23 +1,24 @@ import _AblyPluginSupportPrivate +@testable import AblyLiveObjects import os -/// An implementation of `_AblyPluginSupportPrivate.Logger` to use when testing internal components of the LiveObjects plugin. -final class TestLogger: NSObject, _AblyPluginSupportPrivate.Logger { +/// 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, with level: ARTLogLevel, file fileName: UnsafePointer, line: Int) { + func log(_ message: String, level: LogLevel, codeLocation: CodeLocation) { guard Self.loggingEnabled else { return } - underlyingLogger.log(level: level.toOSLogType, "(\(String(cString: fileName)):\(line)): \(message)") + underlyingLogger.log(level: level.toOSLogType, "(\(codeLocation.fileID):\(codeLocation.line)): \(message)") } } -private extension ARTLogLevel { +private extension _AblyPluginSupportPrivate.LogLevel { var toOSLogType: OSLogType { // Not much thought has gone into this conversion switch self { diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index a47473885..048a64ceb 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -1,4 +1,5 @@ import _AblyPluginSupportPrivate +import Ably @testable import AblyLiveObjects import Foundation import Testing @@ -7,8 +8,8 @@ struct InternalDefaultLiveCounterTests { /// Tests for the `value` property, covering RTLC5 specification points struct ValueTests { // @spec RTLC5b - @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) - func valueThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + @Test(arguments: [.detached, .failed] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func valueThrowsIfChannelIsDetachedOrFailed(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let logger = TestLogger() let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: channelState) @@ -426,8 +427,8 @@ struct InternalDefaultLiveCounterTests { /// Tests for the `increment` method, covering RTLC12 specification points struct IncrementTests { // @spec RTLC12c - @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) - func throwsErrorForInvalidChannelState(channelState: ARTRealtimeChannelState) async throws { + @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let logger = TestLogger() let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: channelState) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 0f3321184..f5b889047 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -1,4 +1,5 @@ import _AblyPluginSupportPrivate +import Ably @testable import AblyLiveObjects import Foundation import Testing @@ -7,8 +8,8 @@ struct InternalDefaultLiveMapTests { /// Tests for the `get` method, covering RTLM5 specification points struct GetTests { // @spec RTLM5c - @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) - func getThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + @Test(arguments: [.detached, .failed] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func getThrowsIfChannelIsDetachedOrFailed(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let logger = TestLogger() let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -275,8 +276,8 @@ struct InternalDefaultLiveMapTests { // @spec RTLM11c // @spec RTLM12b // @spec RTLM13b - @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) - func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + @Test(arguments: [.detached, .failed] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let logger = TestLogger() let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: channelState) @@ -1230,8 +1231,8 @@ struct InternalDefaultLiveMapTests { /// Tests for the `set` method, covering RTLM20 specification points struct SetTests { // @spec RTLM20c - @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) - func throwsErrorForInvalidChannelState(channelState: ARTRealtimeChannelState) async throws { + @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let logger = TestLogger() let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: channelState) @@ -1330,8 +1331,8 @@ struct InternalDefaultLiveMapTests { /// Tests for the `remove` method, covering RTLM21 specification points struct RemoveTests { // @spec RTLM21c - @Test(arguments: [.detached, .failed, .suspended] as [ARTRealtimeChannelState]) - func throwsErrorForInvalidChannelState(channelState: ARTRealtimeChannelState) async throws { + @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func throwsErrorForInvalidChannelState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let logger = TestLogger() let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: channelState) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 7d64d9632..0406224eb 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -672,8 +672,8 @@ struct InternalDefaultRealtimeObjectsTests { // MARK: - RTO1b Tests // @spec RTO1b - @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) - func getRootThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + @Test(arguments: [.detached, .failed] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func getRootThrowsIfChannelIsDetachedOrFailed(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let coreSDK = MockCoreSDK(channelState: channelState) @@ -1044,8 +1044,8 @@ struct InternalDefaultRealtimeObjectsTests { /// 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 [ARTRealtimeChannelState]) - func throwsIfChannelIsInInvalidState(channelState: ARTRealtimeChannelState) async throws { + @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func throwsIfChannelIsInInvalidState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let coreSDK = MockCoreSDK(channelState: channelState) let entries: [String: InternalLiveMapValue] = ["testKey": .string("testValue")] @@ -1175,8 +1175,8 @@ struct InternalDefaultRealtimeObjectsTests { /// 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 [ARTRealtimeChannelState]) - func throwsIfChannelIsInInvalidState(channelState: ARTRealtimeChannelState) async throws { + @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) + func throwsIfChannelIsInInvalidState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() let coreSDK = MockCoreSDK(channelState: channelState) diff --git a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift index 06cae4bff..dbb8857e4 100644 --- a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift +++ b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift @@ -119,8 +119,8 @@ struct LiveObjectMutableStateTests { // @spec RTLO4b2 @available(iOS 17.0.0, tvOS 17.0.0, *) - @Test(arguments: [.detached, .failed] as [ARTRealtimeChannelState]) - func subscribeThrowsIfChannelIsDetachedOrFailed(channelState: ARTRealtimeChannelState) async throws { + @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) diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 3bb48bbfe..0c3980a63 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -1,3 +1,4 @@ +import _AblyPluginSupportPrivate import Ably @testable import AblyLiveObjects @@ -5,10 +6,10 @@ final class MockCoreSDK: CoreSDK { /// Synchronizes access to all of this instance's mutable state. private let mutex = NSLock() - private nonisolated(unsafe) var _channelState: ARTRealtimeChannelState + private nonisolated(unsafe) var _channelState: _AblyPluginSupportPrivate.RealtimeChannelState private nonisolated(unsafe) var _publishHandler: (([OutboundObjectMessage]) async throws(InternalError) -> Void)? - init(channelState: ARTRealtimeChannelState) { + init(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) { _channelState = channelState } @@ -24,7 +25,7 @@ final class MockCoreSDK: CoreSDK { protocolRequirementNotImplemented() } - var channelState: ARTRealtimeChannelState { + var channelState: _AblyPluginSupportPrivate.RealtimeChannelState { get { mutex.withLock { _channelState diff --git a/ably-cocoa b/ably-cocoa index 9f1a65179..9e172dd3e 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 9f1a65179b69b6041b3a65664c5fa6c48d722fec +Subproject commit 9e172dd3e744c6d8c2bb0be09a22853aa702005b From 89e63d4d1d13500cbb496c81e22a3a499420479b Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 Aug 2025 14:34:10 +0100 Subject: [PATCH 130/225] Fix code snippet in README Specify channel modes, and attach the channel (we will shortly be introducing an implicit attach on getRoot but we don't have it yet). --- README.md | 19 +++++++++++++++++-- Sources/AblyLiveObjects/Public/Plugin.swift | 19 +++++++++++++++++-- 2 files changed, 34 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 103ff6401..b6f3713ae 100644 --- a/README.md +++ b/README.md @@ -25,8 +25,23 @@ clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] let realtime = ARTRealtime(options: clientOptions) -// You can now access LiveObjects functionality via a channel's `objects` property: -let channel = realtime.channels.get("myChannel") +// 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 ``` diff --git a/Sources/AblyLiveObjects/Public/Plugin.swift b/Sources/AblyLiveObjects/Public/Plugin.swift index 26a377d45..77836b81f 100644 --- a/Sources/AblyLiveObjects/Public/Plugin.swift +++ b/Sources/AblyLiveObjects/Public/Plugin.swift @@ -15,8 +15,23 @@ import ObjectiveC.NSObject /// /// let realtime = ARTRealtime(options: clientOptions) /// -/// // You can now access LiveObjects functionality via a channel's `objects` property: -/// let channel = realtime.channels.get("myChannel") +/// // 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 /// ``` From a54eb3c76c97470562bd8d26ab15f589721da7d0 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 Aug 2025 10:46:25 -0300 Subject: [PATCH 131/225] Update for renamed repo --- .github/workflows/check.yaml | 4 ++-- CONTRIBUTING.md | 4 ++-- Sources/AblyLiveObjects/Internal/CoreSDK.swift | 2 +- .../Internal/InternalDefaultLiveCounter.swift | 2 +- .../Internal/InternalDefaultLiveMap.swift | 4 ++-- .../InternalDefaultRealtimeObjects.swift | 6 +++--- .../Internal/InternalLiveMapValue.swift | 4 ++-- .../AblyLiveObjects/Internal/ObjectsPool.swift | 4 ++-- .../Protocol/ObjectMessage.swift | 6 +++--- .../Protocol/WireObjectMessage.swift | 2 +- .../InternalDefaultLiveMapTests.swift | 8 ++++---- .../InternalDefaultRealtimeObjectsTests.swift | 4 ++-- .../LiveObjectMutableStateTests.swift | 2 +- .../ObjectLifetimesTests.swift | 6 +++--- .../ObjectMessageTests.swift | 18 +++++++++--------- ably-cocoa | 2 +- package-lock.json | 4 ++-- package.json | 4 ++-- 18 files changed, 43 insertions(+), 43 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 668bed56d..960704c9e 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -40,7 +40,7 @@ jobs: - run: swift run BuildTool lint - # TODO: Restore in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/2 once we've seen what form the LiveObjects spec takes + # TODO: Restore in https://github.com/ably/ably-liveobjects-swift-plugin/issues/2 once we've seen what form the LiveObjects spec takes # # spec-coverage: # runs-on: macos-15 @@ -254,7 +254,7 @@ jobs: uses: aws-actions/configure-aws-credentials@v4 with: aws-region: eu-west-2 - role-to-assume: arn:aws:iam::${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}:role/ably-sdk-builds-ably-cocoa-liveobjects-plugin + role-to-assume: arn:aws:iam::${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}:role/ably-sdk-builds-ably-liveobjects-swift-plugin role-session-name: "${{ github.run_id }}-${{ github.run_number }}" # Upload the generated documentation diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 896d7bc21..9eca49ca4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,7 +127,7 @@ Example: For the initial stage of development of this plugin, where we need to also iterate heavily on ably-cocoa, I've added ably-cocoa as a Git submodule, which can be found in [`ably-cocoa`](./ably-cocoa). This allows you to edit ably-cocoa from within this repo's Xcode workspace. -Nearer launch, we'll remove this submodule in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/7. +Nearer launch, we'll remove this submodule in https://github.com/ably/ably-liveobjects-swift-plugin/issues/7. ## Release process @@ -137,7 +137,7 @@ For each release, the following needs to be done: - Update the following (we have https://github.com/ably/ably-chat-swift/issues/277 for adding a script to do this): - the `version` constant in [`Sources/AblyLiveObjects/Version.swift`](Sources/AblyLiveObjects/Version.swift) - the `from: "…"` in the SPM installation instructions in [`README.md`](README.md) -- Go to [Github releases](https://github.com/ably/ably-cocoa-liveobjects-plugin/releases) and press the `Draft a new release` button. Choose your new branch as a target +- 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 diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 1e99994e1..07f3c3a82 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -66,7 +66,7 @@ internal final class DefaultCoreSDK: CoreSDK { return } - // TODO: Implement the full spec of RTO15 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/47) + // TODO: Implement the full spec of RTO15 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/47) try await DefaultInternalPlugin.sendObject( objectMessages: objectMessages, channel: channel, diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 9c314bc07..6e54d5c2a 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -4,7 +4,7 @@ import Foundation /// This provides the implementation behind ``PublicDefaultLiveCounter``, via internal versions of the ``LiveCounter`` API. internal final class InternalDefaultLiveCounter: Sendable { - // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. + // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-liveobjects-swift-plugin/issues/3. private let mutex = NSLock() private nonisolated(unsafe) var mutableState: MutableState diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 84a94c253..8b5910498 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -9,7 +9,7 @@ internal protocol LiveMapObjectPoolDelegate: AnyObject, Sendable { /// This provides the implementation behind ``PublicDefaultLiveMap``, via internal versions of the ``LiveMap`` API. internal final class InternalDefaultLiveMap: Sendable { - // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. + // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-liveobjects-swift-plugin/issues/3. private let mutex = NSLock() private nonisolated(unsafe) var mutableState: MutableState @@ -905,7 +905,7 @@ internal final class InternalDefaultLiveMap: Sendable { return .string(string) } - // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // 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): diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index c989756c3..2bd0f4007 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -3,7 +3,7 @@ import Ably /// This provides the implementation behind ``PublicDefaultRealtimeObjects``, via internal versions of the ``RealtimeObjects`` API. internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPoolDelegate { - // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3. + // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-liveobjects-swift-plugin/issues/3. private let mutex = NSLock() private nonisolated(unsafe) var mutableState: MutableState! @@ -168,7 +168,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } // RTO11f - // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) let timestamp = clock.now let creationOperation = ObjectCreationHelpers.creationOperationForLiveMap( entries: entries, @@ -213,7 +213,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool // RTO12f - // TODO: This is a stopgap; change to use server time per RTO12f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + // TODO: This is a stopgap; change to use server time per RTO12f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) let timestamp = clock.now let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter( count: count, diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift index 9ebeca85e..3b41bf061 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -32,13 +32,13 @@ internal enum InternalLiveMapValue: Sendable, Equatable { 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-cocoa-liveobjects-plugin/issues/37 + // 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-cocoa-liveobjects-plugin/issues/37 + // 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) diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 3cef1deac..f4054dcad 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -326,7 +326,7 @@ internal struct ObjectsPool { case let .counter(counter): return counter case .map: - // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/36 + // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-liveobjects-swift-plugin/issues/36 preconditionFailure("Expected counter object with ID \(creationOperation.objectID) but found map object") } } @@ -370,7 +370,7 @@ internal struct ObjectsPool { case let .map(map): return map case .counter: - // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/36 + // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-liveobjects-swift-plugin/issues/36 preconditionFailure("Expected map object with ID \(creationOperation.objectID) but found counter object") } } diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index ac846fcd5..b882d6004 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -60,7 +60,7 @@ internal struct ObjectData: Equatable { 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-cocoa-liveobjects-plugin/issues/46) + internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) } internal struct ObjectsMapOp: Equatable { @@ -289,7 +289,7 @@ internal extension ObjectData { } } - // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // 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 @@ -340,7 +340,7 @@ internal extension ObjectData { // 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-cocoa-liveobjects-plugin/issues/46) + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) json: json?.toJSONString, ) } diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index a8c55d536..828ae0a84 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -476,7 +476,7 @@ internal struct WireObjectData { 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-cocoa-liveobjects-plugin/issues/46) + internal var json: String? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) } extension WireObjectData: WireObjectCodable { diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index f5b889047..cf5c68a79 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -93,7 +93,7 @@ struct InternalDefaultLiveMapTests { #expect(result?.stringValue == "test") } - // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // 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 { @@ -105,7 +105,7 @@ struct InternalDefaultLiveMapTests { #expect(result?.jsonArrayValue == ["foo"]) } - // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // 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 { @@ -412,8 +412,8 @@ struct InternalDefaultLiveMapTests { "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-cocoa-liveobjects-plugin/issues/46) - "jsonObject": TestFactories.internalMapEntry(data: ObjectData(json: .object(["foo": "bar"]))), // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + "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 ], diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 0406224eb..566b2a0b3 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -1091,7 +1091,7 @@ struct InternalDefaultRealtimeObjectsTests { #expect(publishedMessage.operation?.action == .known(.mapCreate)) let objectID = try #require(publishedMessage.operation?.objectId) #expect(objectID.hasPrefix("map:")) - // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) #expect(objectID.contains("1754042434000")) // check contains the mock clock's timestamp in milliseconds #expect(publishedMessage.operation?.map?.entries == [ "stringKey": .init(data: .init(string: "stringValue")), @@ -1216,7 +1216,7 @@ struct InternalDefaultRealtimeObjectsTests { #expect(publishedMessage.operation?.action == .known(.counterCreate)) let objectID = try #require(publishedMessage.operation?.objectId) #expect(objectID.hasPrefix("counter:")) - // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/50) + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) #expect(objectID.contains("1754042434000")) // check contains the mock clock's timestamp in milliseconds #expect(publishedMessage.operation?.counter?.count == 10.5) diff --git a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift index dbb8857e4..5b304bc03 100644 --- a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift +++ b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift @@ -237,7 +237,7 @@ struct LiveObjectMutableStateTests { // @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-cocoa-liveobjects-plugin/issues/28")) + @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 store = MutableStateStore(stored: .init(objectID: "foo")) diff --git a/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift index 23c849802..9c83e4d8b 100644 --- a/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift @@ -68,7 +68,7 @@ struct ObjectLifetimesTests { #expect(createdObjects.weakInternalChannel != nil) #expect(createdObjects.weakInternalRealtimeObjects != nil) - // TODO: test that we can receive events on a LiveObject (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/30) + // 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( @@ -185,7 +185,7 @@ struct ObjectLifetimesTests { #expect(createdObjects.weakInternalRealtimeObjects != nil) #expect(createdObjects.weakInternalLiveObject != nil) - // TODO: test that we can receive events on a LiveObject (https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/30) + // 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( @@ -232,6 +232,6 @@ struct ObjectLifetimesTests { #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-cocoa-liveobjects-plugin/issues/30) + // 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/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift index 85fd9f49c..57a913cfc 100644 --- a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift @@ -76,7 +76,7 @@ struct ObjectMessageTests { #expect(wireData.json == nil) } - // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // 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"}"#), @@ -159,7 +159,7 @@ struct ObjectMessageTests { #expect(wireData.json == nil) } - // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // 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"}"#), @@ -255,14 +255,14 @@ struct ObjectMessageTests { #expect(objectData.json == nil) } - // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // 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-cocoa-liveobjects-plugin/issues/46) + // 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) @@ -270,7 +270,7 @@ struct ObjectMessageTests { #expect(objectData.json == ["key": "value", "number": 123]) } - // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // 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() { @@ -283,7 +283,7 @@ struct ObjectMessageTests { } } - // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // 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 @@ -380,7 +380,7 @@ struct ObjectMessageTests { } } - // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) @Test func json() throws { let jsonString = "{\"key\":\"value\",\"number\":123}" @@ -394,7 +394,7 @@ struct ObjectMessageTests { #expect(objectData.json == ["key": "value", "number": 123]) } - // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // 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() { @@ -407,7 +407,7 @@ struct ObjectMessageTests { } } - // TODO: Needs specification (see https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/46) + // 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 diff --git a/ably-cocoa b/ably-cocoa index 9e172dd3e..a64683548 160000 --- a/ably-cocoa +++ b/ably-cocoa @@ -1 +1 @@ -Subproject commit 9e172dd3e744c6d8c2bb0be09a22853aa702005b +Subproject commit a64683548c4147d8de06afbdd89bb2d8540f547e diff --git a/package-lock.json b/package-lock.json index 59e4ef29a..7ac4a172f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,11 +1,11 @@ { - "name": "ably-cocoa-liveobjects-plugin-dev-tooling", + "name": "ably-liveobjects-swift-plugin-dev-tooling", "version": "0.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { - "name": "ably-cocoa-liveobjects-plugin-dev-tooling", + "name": "ably-liveobjects-swift-plugin-dev-tooling", "version": "0.1.0", "devDependencies": { "prettier": "^3.3.3" diff --git a/package.json b/package.json index fb991e127..94eb34922 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { - "name": "ably-cocoa-liveobjects-plugin-dev-tooling", + "name": "ably-liveobjects-swift-plugin-dev-tooling", "version": "0.1.0", - "description": "Development tooling for the ably-cocoa-liveobjects-plugin repo", + "description": "Development tooling for the ably-liveobjects-swift-plugin repo", "devDependencies": { "prettier": "^3.3.3" }, From 2b1677b096d2dbf9790f1519fb1388c91f72534c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 Aug 2025 11:25:22 -0300 Subject: [PATCH 132/225] Remove declaration of batch API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We have not yet implemented this and will not be doing so before our initial release. Also the API is not fully correct — as things stand, if you extract a LiveObject from a map inside the batch callback (see first example on [1]) you have to use an async API to interact with it, which is not what we want. [1] https://ably.com/docs/liveobjects/batch --- .../InternalDefaultRealtimeObjects.swift | 4 - .../PublicDefaultRealtimeObjects.swift | 4 - .../AblyLiveObjects/Public/PublicTypes.swift | 77 ------------------- 3 files changed, 85 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 2bd0f4007..4a9c2831a 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -242,10 +242,6 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool try await createCounter(count: 0, coreSDK: coreSDK) } - internal func batch(callback _: sending BatchCallback) async throws { - notYetImplemented() - } - @discardableResult internal func on(event _: ObjectsEvent, callback _: ObjectsEventCallback) -> any OnObjectsEventResponse { notYetImplemented() diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index 34221ad6c..eec2508a6 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -86,10 +86,6 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { ) } - internal func batch(callback: sending BatchCallback) async throws { - try await proxied.batch(callback: callback) - } - internal func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { proxied.on(event: event, callback: callback) } diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/Sources/AblyLiveObjects/Public/PublicTypes.swift index 6e79b290a..086ee9863 100644 --- a/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -16,11 +16,6 @@ public typealias ObjectsEventCallback = @Sendable (_ subscription: OnObjectsEven /// - Parameter subscription: A ``OnLiveObjectLifecycleEventResponse`` object that allows the provided listener to deregister itself from future updates. public typealias LiveObjectLifecycleEventCallback = @Sendable (_ subscription: OnLiveObjectLifecycleEventResponse) -> Void -/// A function passed to ``RealtimeObjects/batch(callback:)`` to group multiple Objects operations into a single channel message. -/// -/// - Parameter batchContext: A ``BatchContext`` object that allows grouping Objects operations for this batch. -public typealias BatchCallback = (_ batchContext: sending BatchContext) -> Void - /// Describes the events emitted by an ``RealtimeObjects`` object. public enum ObjectsEvent: Sendable { /// The local copy of Objects on a channel is currently being synchronized with the Ably service. @@ -56,18 +51,6 @@ public protocol RealtimeObjects: Sendable { /// Creates a new ``LiveCounter`` object instance with a value of zero. func createCounter() async throws(ARTErrorInfo) -> any LiveCounter - /// Allows you to group multiple operations together and send them to the Ably service in a single channel message. - /// As a result, other clients will receive the changes as a single channel message after the batch function has completed. - /// - /// This method accepts a synchronous callback, which is provided with a ``BatchContext`` object. - /// Use the context object to access Objects on a channel and batch operations for them. - /// - /// The objects' data is not modified inside the callback function. Instead, the objects will be updated - /// when the batched operations are applied by the Ably service and echoed back to the client. - /// - /// - Parameter callback: A batch callback function used to group operations together. - func batch(callback: sending BatchCallback) async throws - /// 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: @@ -250,66 +233,6 @@ public protocol OnObjectsEventResponse: Sendable { func off() } -/// Enables grouping multiple Objects operations together by providing `BatchContext*` wrapper objects. -public protocol BatchContext: Sendable { - /// Mirrors the ``RealtimeObjects/getRoot()`` method and returns a ``BatchContextLiveMap`` wrapper for the root object on a channel. - /// - /// - Returns: A ``BatchContextLiveMap`` object. - func getRoot() -> BatchContextLiveMap -} - -/// A wrapper around the ``LiveMap`` object that enables batching operations inside a ``BatchCallback``. -public protocol BatchContextLiveMap: AnyObject, Sendable { - /// Mirrors the ``LiveMap/get(key:)`` method and returns the value associated with a key in the map. - /// - /// - 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) -> LiveMapValue? - - /// Returns the number of key-value pairs in the map. - var size: Int { get } - - /// Similar to the ``LiveMap/set(key:value:)`` method, but instead, it adds an operation to set a key in the map with the provided value to the current batch, to be sent in a single message to the Ably service. - /// - /// 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?) - - /// Similar to the ``LiveMap/remove(key:)`` method, but instead, it adds an operation to remove a key from the map to the current batch, to be sent in a single message to the Ably service. - /// - /// 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 set the value for. - func remove(key: String) -} - -/// A wrapper around the ``LiveCounter`` object that enables batching operations inside a ``BatchCallback``. -public protocol BatchContextLiveCounter: AnyObject, Sendable { - /// Returns the current value of the counter. - var value: Double { get } - - /// Similar to the ``LiveCounter/increment(amount:)`` method, but instead, it adds an operation to increment the counter value to the current batch, to be sent in a single message to the Ably service. - /// - /// 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) - - /// An alias for calling [`increment(-amount)`](doc:BatchContextLiveCounter/increment(amount:)). - /// - /// - Parameter amount: The amount by which to decrease the counter value. - func decrement(amount: Double) -} - /// 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. From 8ff88a0974115fc9ffcd5d004bf4dd52cffd6a9c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 Aug 2025 14:50:12 -0300 Subject: [PATCH 133/225] Fix ordering of maps created from fixtures in test Mistake in 70306a0. Correct approach copied from "Objects.createCounter sends COUNTER_CREATE operation" test. --- .../ObjectsIntegrationTests.swift | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 016f06c06..32eb76b12 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -2916,22 +2916,23 @@ private struct ObjectsIntegrationTests { action: { ctx in let objects = ctx.objects - let maps = try await withThrowingTaskGroup(of: (any LiveMap).self, returning: [any LiveMap].self) { group in - for mapFixture in primitiveMapsFixtures { + 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 { - if let entries = mapFixture.liveMapEntries { + 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: [any LiveMap] = [] - while let map = try await group.next() { - results.append(map) + var results: [(index: Int, map: any LiveMap)] = [] + while let result = try await group.next() { + results.append(result) } - return results + return results.sorted { $0.index < $1.index }.map(\.map) } for (i, map) in maps.enumerated() { From dad690e9e5718ffd51e380f8a1297c24cd5bc885 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 Aug 2025 15:51:41 -0300 Subject: [PATCH 134/225] Make waiting for GC more robust in integration tests Wait until we're sure that GC of an entry should have occurred, regardless of clock skew. Also fix what may have been a bug in 792683d where we may not wait for as many GC cycles as we intended to (because of counting AsyncStream buffered values). --- .../InternalDefaultRealtimeObjects.swift | 18 ++++---- .../ObjectsIntegrationTests.swift | 46 ++++++++++--------- 2 files changed, 33 insertions(+), 31 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index af12e8a5d..455bd7a58 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -98,7 +98,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool (receivedObjectProtocolMessages, receivedObjectProtocolMessagesContinuation) = AsyncStream.makeStream() (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() - (completedGarbageCollectionEvents, completedGarbageCollectionsEventsContinuation) = AsyncStream.makeStream() + (completedGarbageCollectionEventsWithoutBuffering, completedGarbageCollectionEventsWithoutBufferingContinuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(0)) mutableState = .init(objectsPool: .init(logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) garbageCollectionInterval = garbageCollectionOptions.interval garbageCollectionGracePeriod = garbageCollectionOptions.gracePeriod @@ -332,17 +332,17 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool gracePeriod: garbageCollectionGracePeriod, clock: clock, logger: logger, - eventsContinuation: completedGarbageCollectionsEventsContinuation, + eventsContinuation: completedGarbageCollectionEventsWithoutBufferingContinuation, ) } } - // These drive the testsOnly_completedGarbageCollectionEvents property that informs the test suite when a garbage collection cycle has completed. - private let completedGarbageCollectionEvents: AsyncStream - private let completedGarbageCollectionsEventsContinuation: AsyncStream.Continuation + // 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_completedGarbageCollectionEvents: AsyncStream { - completedGarbageCollectionEvents + internal var testsOnly_completedGarbageCollectionEventsWithoutBuffering: AsyncStream { + completedGarbageCollectionEventsWithoutBuffering } // MARK: - Testing @@ -352,12 +352,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool /// - testsOnly_receivedObjectProtocolMessages /// - testsOnly_receivedObjectStateProtocolMessages /// - testsOnly_waitingForSyncEvents - /// - testsOnly_completedGarbageCollectionEvents + /// - testsOnly_completedGarbageCollectionEventsWithoutBuffering internal func testsOnly_finishAllTestHelperStreams() { receivedObjectProtocolMessagesContinuation.finish() receivedObjectSyncProtocolMessagesContinuation.finish() waitingForSyncEventsContinuation.finish() - completedGarbageCollectionsEventsContinuation.finish() + completedGarbageCollectionEventsWithoutBufferingContinuation.finish() } // MARK: - Mutable state and the operations that affect it diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 32eb76b12..c0506801a 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -3729,7 +3729,7 @@ private struct ObjectsIntegrationTests { var channel: ARTRealtimeChannel var objects: any RealtimeObjects var client: ARTRealtime - var waitForGCCycles: @Sendable (Int) async -> Void + var waitForTombstonedObjectsToBeCollected: @Sendable (Date) async throws -> Void } static let scenarios: [TestScenario] = [ @@ -3742,7 +3742,7 @@ private struct ObjectsIntegrationTests { let channelName = ctx.channelName let channel = ctx.channel let objects = ctx.objects - let waitForGCCycles = ctx.waitForGCCycles + let waitForTombstonedObjectsToBeCollected = ctx.waitForTombstonedObjectsToBeCollected // Wait for counter creation async let counterCreatedPromise: Void = waitForObjectOperation(ctx.objects, .counterCreate) @@ -3780,9 +3780,10 @@ private struct ObjectsIntegrationTests { "Check object's \"tombstone\" flag is set to \"true\" after OBJECT_DELETE", ) - // We expect 2 cycles to guarantee that grace period has expired, which will always be - // true based on the test config used - await waitForGCCycles(2) + let tombstonedAt = try #require(poolEntry.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( @@ -3799,7 +3800,7 @@ private struct ObjectsIntegrationTests { let root = ctx.root let objectsHelper = ctx.objectsHelper let channelName = ctx.channelName - let waitForGCCycles = ctx.waitForGCCycles + let waitForTombstonedObjectsToBeCollected = ctx.waitForTombstonedObjectsToBeCollected let keyUpdatedPromise = try root.updates() async let keyUpdatedWait: Void = { @@ -3854,9 +3855,10 @@ private struct ObjectsIntegrationTests { "Check map entry for \"foo\" on root has \"tombstone\" flag set to \"true\" after MAP_REMOVE", ) - // We expect 2 cycles to guarantee that grace period has expired, which will always be - // true based on the test config used - await waitForGCCycles(2) + 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 @@ -3880,10 +3882,11 @@ private struct ObjectsIntegrationTests { // Configure GC options with shorter intervals for testing var options = testCase.options - options.garbageCollectionOptions = .init( - interval: 2.0, // JS uses 0.5s but I've found that, at least testing locally, this was not enough to compensate for the clock skew between my local clock and whatever was used to generate the tombstonedAt timestamps server-side. + let garbageCollectionOptions = InternalDefaultRealtimeObjects.GarbageCollectionOptions( + interval: 0.5, gracePeriod: 0.25, ) + options.garbageCollectionOptions = garbageCollectionOptions let objectsHelper = try await ObjectsHelper() let client = try await realtimeWithObjects(options: options) @@ -3895,18 +3898,17 @@ private struct ObjectsIntegrationTests { try await channel.attachAsync() let root = try await objects.getRoot() - // Helper function to wait for a specific number of GC cycles + // 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 waitForGCCycles: @Sendable (Int) async -> Void = { cycles in - let gcEvents = internallyTypedObjects.testsOnly_proxied.testsOnly_completedGarbageCollectionEvents - - var gcCalledTimes = 0 - for await _ in gcEvents { - gcCalledTimes += 1 - if gcCalledTimes >= cycles { - break - } + 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).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( @@ -3917,7 +3919,7 @@ private struct ObjectsIntegrationTests { channel: channel, objects: objects, client: client, - waitForGCCycles: waitForGCCycles, + waitForTombstonedObjectsToBeCollected: waitForTombstonedObjectsToBeCollected, ), ) } From 7df42d271bfd19150bc99eaea69f0634973b4c51 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 21 Aug 2025 18:04:31 -0300 Subject: [PATCH 135/225] Run integration tests in serial I've been observing a bunch of failures and hangs etc in these tests; see the comment and the referenced issue. In order to unblock CI whilst we prepare for a release, run the tests in serial, which seems to improve reliability at the unfortunate cost of making them a _lot_ slower to run (from ~15s on my machine up to 4 minutes). --- .../JS Integration Tests/ObjectsIntegrationTests.swift | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index c0506801a..1e18d42ad 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -334,7 +334,15 @@ class MainActorStorage { // MARK: - Test suite -@Suite(.objectsFixtures) +@Suite( + .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 From 5f181ba61ac40781033e2c5401ce864b64fa757d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 Aug 2025 10:17:45 -0300 Subject: [PATCH 136/225] Remove ably-cocoa submodule This reverts 29c6aa8, which pulled ably-cocoa in as a submodule to make it easier to heavily iterate on ably-cocoa whilst building this plugin. We're near release now so it's time to get rid of it. Resolves #7. --- .gitmodules | 3 --- .../xcshareddata/swiftpm/Package.resolved | 10 +++++++++- CONTRIBUTING.md | 4 ++-- Package.resolved | 10 +++++++++- Package.swift | 4 +++- ably-cocoa | 1 - 6 files changed, 23 insertions(+), 9 deletions(-) delete mode 160000 ably-cocoa diff --git a/.gitmodules b/.gitmodules index 007962dae..aecc76733 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ [submodule "Tests/AblyLiveObjectsTests/ably-common"] path = Tests/AblyLiveObjectsTests/ably-common url = https://github.com/ably/ably-common -[submodule "ably-cocoa"] - path = ably-cocoa - url = git@github.com:ably/ably-cocoa.git diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index cd28d5baf..567d6fb25 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,6 +1,14 @@ { - "originHash" : "7ffc8893e3a0652bc31d38d048052def20308c84ae9888411b48b5c89c2ec6c6", + "originHash" : "f4612ea7dd07d68ee1d23785f1de05ad64a31d2a149fdfcaa4f8750623547c09", "pins" : [ + { + "identity" : "ably-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/ably-cocoa", + "state" : { + "revision" : "b0c2ecf9da993736297b6986be5610b375440a3f" + } + }, { "identity" : "ably-cocoa-plugin-support", "kind" : "remoteSourceControl", diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9eca49ca4..0e679a91e 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -125,9 +125,9 @@ Example: ## Developing ably-cocoa alongside this plugin -For the initial stage of development of this plugin, where we need to also iterate heavily on ably-cocoa, I've added ably-cocoa as a Git submodule, which can be found in [`ably-cocoa`](./ably-cocoa). This allows you to edit ably-cocoa from within this repo's Xcode workspace. +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). -Nearer launch, we'll remove this submodule in https://github.com/ably/ably-liveobjects-swift-plugin/issues/7. +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 diff --git a/Package.resolved b/Package.resolved index 44dc3735f..9ed348780 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,6 +1,14 @@ { - "originHash" : "cf81894c95bf31f3c45009841b1a7ee58b50fdcf93ceeecdade367eef5e57c58", + "originHash" : "749e7e5e2f29aba400061df38c5d997e141d8ee721a9082edb01112579f3487f", "pins" : [ + { + "identity" : "ably-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/ably-cocoa", + "state" : { + "revision" : "b0c2ecf9da993736297b6986be5610b375440a3f" + } + }, { "identity" : "ably-cocoa-plugin-support", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index ce7b62f26..3cd42133f 100644 --- a/Package.swift +++ b/Package.swift @@ -19,7 +19,9 @@ let package = Package( ], dependencies: [ .package( - path: "ably-cocoa", + url: "https://github.com/ably/ably-cocoa", + // TODO: Unpin before launch (https://github.com/ably/ably-liveobjects-swift-plugin/issues/75) + revision: "b0c2ecf9da993736297b6986be5610b375440a3f", ), .package( url: "https://github.com/ably/ably-cocoa-plugin-support", diff --git a/ably-cocoa b/ably-cocoa deleted file mode 160000 index a64683548..000000000 --- a/ably-cocoa +++ /dev/null @@ -1 +0,0 @@ -Subproject commit a64683548c4147d8de06afbdd89bb2d8540f547e From 54cbfa2de0623936def479894c6ffb74d02d3593 Mon Sep 17 00:00:00 2001 From: Marat Al Date: Fri, 22 Aug 2025 14:39:16 +0200 Subject: [PATCH 137/225] Example app based on https://ably.com/examples/liveobjects-live-counter?lang=javascript --- .../AblyLiveObjectsExampleApp.swift | 15 +- .../AblyLiveObjectsExample/ContentView.swift | 49 +++- .../Helpers/ARTRealtimeChannel+Async.swift | 27 +++ .../ViewModels/LiveCounterViewModel.swift | 218 ++++++++++++++++++ .../ViewModels/TaskBoardViewModel.swift | 180 +++++++++++++++ .../Views/LiveCounterView.swift | 117 ++++++++++ .../Views/TaskBoardView.swift | 177 ++++++++++++++ 7 files changed, 770 insertions(+), 13 deletions(-) create mode 100644 Example/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift create mode 100644 Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift create mode 100644 Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift create mode 100644 Example/AblyLiveObjectsExample/Views/LiveCounterView.swift create mode 100644 Example/AblyLiveObjectsExample/Views/TaskBoardView.swift diff --git a/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift b/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift index cfeb88ce2..6e3cd99a3 100644 --- a/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift +++ b/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift @@ -4,16 +4,23 @@ import SwiftUI @main struct AblyLiveObjectsExampleApp: App { - @State private var realtime = { + 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 { - ContentView(realtime: realtime) + #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/Example/AblyLiveObjectsExample/ContentView.swift b/Example/AblyLiveObjectsExample/ContentView.swift index faa2db05b..62b93c009 100644 --- a/Example/AblyLiveObjectsExample/ContentView.swift +++ b/Example/AblyLiveObjectsExample/ContentView.swift @@ -3,18 +3,49 @@ import AblyLiveObjects import SwiftUI struct ContentView: View { - var realtime: ARTRealtime + @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 { - VStack { - Image(systemName: "globe") - .imageScale(.large) - .foregroundStyle(.tint) - Text("Hello, world!") + 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") + } - let channel = realtime.channels.get("myChannel") - Text("`channel.objects`: `\(String(describing: channel.objects))`") + // 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") + } } - .padding() } } diff --git a/Example/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift b/Example/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift new file mode 100644 index 000000000..38308a103 --- /dev/null +++ b/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/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift b/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift new file mode 100644 index 000000000..6009eb514 --- /dev/null +++ b/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/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift b/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift new file mode 100644 index 000000000..dfd1b4da1 --- /dev/null +++ b/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/Example/AblyLiveObjectsExample/Views/LiveCounterView.swift b/Example/AblyLiveObjectsExample/Views/LiveCounterView.swift new file mode 100644 index 000000000..93516d16a --- /dev/null +++ b/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/Example/AblyLiveObjectsExample/Views/TaskBoardView.swift b/Example/AblyLiveObjectsExample/Views/TaskBoardView.swift new file mode 100644 index 000000000..a3c5ce9fb --- /dev/null +++ b/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") + } + } +} From 1f85b13e2c34b0ddbd0550a959580aad36d095c3 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 Aug 2025 14:04:20 -0300 Subject: [PATCH 138/225] DRY up a signature --- Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index 191d312ca..3202af128 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -83,7 +83,7 @@ internal struct LiveObjectMutableState { /// 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 `LiveObjectMutableState` will store a copy of this function and thus this function should be careful not to introduce a strong reference cycle. - internal typealias UpdateLiveObject = @Sendable (_ action: (inout LiveObjectMutableState) -> Void) -> Void + internal typealias UpdateLiveObject = @Sendable (_ action: (inout Self) -> Void) -> Void private struct SubscribeResponse: AblyLiveObjects.SubscribeResponse { var subscriptionID: Subscription.ID From 77a4c455157430980df9cf619eddd7aa3d36202d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 Aug 2025 13:53:13 -0300 Subject: [PATCH 139/225] Split subscription bookkeeping out of LiveObjectMutableState Preparation for implementing LiveObject and RealtimeObjects' on(event:callback:) method. This was largely done by Cursor. Doing this in a bit of a rush and didn't have time to split out the tests; will do in #81. --- .../Internal/LiveObjectMutableState.swift | 50 ++++------------ .../Internal/SubscriptionStorage.swift | 57 +++++++++++++++++++ 2 files changed, 68 insertions(+), 39 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index 3202af128..e0eddc7f6 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -20,8 +20,8 @@ internal struct LiveObjectMutableState { // RTLO3e internal var tombstonedAt: Date? - /// Internal bookkeeping for subscriptions. - private var subscriptionsByID: [Subscription.ID: Subscription] = [:] + /// Internal subscription storage. + private var subscriptionStorage = SubscriptionStorage() internal init( objectID: String, @@ -72,47 +72,24 @@ internal struct LiveObjectMutableState { // MARK: - Subscriptions - private struct Subscription: Identifiable { - var id = UUID() - var listener: LiveObjectUpdateCallback - var updateLiveObject: UpdateLiveObject - } - - /// A function that allows a `LiveObjectMutableState` 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 `LiveObjectMutableState` will store a copy of this function and thus this function should be careful not to introduce a strong reference cycle. internal typealias UpdateLiveObject = @Sendable (_ action: (inout Self) -> Void) -> Void - private struct SubscribeResponse: AblyLiveObjects.SubscribeResponse { - var subscriptionID: Subscription.ID - var updateLiveObject: UpdateLiveObject - - func unsubscribe() { - updateLiveObject { liveObject in - liveObject.unsubscribe(subscriptionID: subscriptionID) - } - } - } - @discardableResult internal mutating func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK, updateSelfLater: @escaping UpdateLiveObject) throws(ARTErrorInfo) -> any AblyLiveObjects.SubscribeResponse { // RTLO4b2 try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "subscribe") - let subscription = Subscription(listener: listener, updateLiveObject: updateSelfLater) - subscriptionsByID[subscription.id] = subscription - return SubscribeResponse(subscriptionID: subscription.id, updateLiveObject: updateSelfLater) - } + let updateSubscriptionStorage: SubscriptionStorage.UpdateSubscriptionStorage = { action in + updateSelfLater { liveObject in + action(&liveObject.subscriptionStorage) + } + } - internal mutating func unsubscribeAll() { - subscriptionsByID.removeAll() + return subscriptionStorage.subscribe(listener: listener, updateSelfLater: updateSubscriptionStorage) } - private mutating func unsubscribe(subscriptionID: Subscription.ID) { - // RTLO4d - subscriptionsByID.removeValue(forKey: subscriptionID) + internal mutating func unsubscribeAll() { + subscriptionStorage.unsubscribeAll() } internal func emit(_ update: LiveObjectUpdate, on queue: DispatchQueue) { @@ -122,12 +99,7 @@ internal struct LiveObjectMutableState { return case let .update(update): // RTLO4b4c2 - for subscription in subscriptionsByID.values { - queue.async { - let response = SubscribeResponse(subscriptionID: subscription.id, updateLiveObject: subscription.updateLiveObject) - subscription.listener(update, response) - } - } + subscriptionStorage.emit(update, on: queue) } } } diff --git a/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift b/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift new file mode 100644 index 000000000..f4aabebd0 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Handles subscription bookkeeping, providing methods for subscribing and emitting events. +internal struct SubscriptionStorage { + /// Internal bookkeeping for subscriptions. + private var subscriptionsByID: [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 updateSubscriptionStorage: UpdateSubscriptionStorage + + func unsubscribe() { + updateSubscriptionStorage { subscriptionStorage in + subscriptionStorage.unsubscribe(subscriptionID: subscriptionID) + } + } + } + + @discardableResult + internal mutating func subscribe(listener: @escaping LiveObjectUpdateCallback, updateSelfLater: @escaping UpdateSubscriptionStorage) -> any AblyLiveObjects.SubscribeResponse { + let subscription = Subscription(listener: listener, updateSubscriptionStorage: updateSelfLater) + subscriptionsByID[subscription.id] = subscription + return SubscribeResponse(subscriptionID: subscription.id, updateSubscriptionStorage: updateSelfLater) + } + + internal mutating func unsubscribeAll() { + subscriptionsByID.removeAll() + } + + private mutating func unsubscribe(subscriptionID: Subscription.ID) { + subscriptionsByID.removeValue(forKey: subscriptionID) + } + + internal func emit(_ update: Update, on queue: DispatchQueue) { + for subscription in subscriptionsByID.values { + queue.async { + let response = SubscribeResponse(subscriptionID: subscription.id, updateSubscriptionStorage: subscription.updateSubscriptionStorage) + subscription.listener(update, response) + } + } + } +} From c7d7477ef9122d4dab831145616d8198360b4246 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 Aug 2025 14:02:43 -0300 Subject: [PATCH 140/225] Add event name support to SubscriptionStorage This allows a user to register a listener for a specific event name. We'll use this for the LiveObject and RealtimeObjects `on(event:callback:)` methods. This code was largely generated by Cursor. --- .../Internal/LiveObjectMutableState.swift | 16 ++++-- .../Internal/SubscriptionStorage.swift | 51 ++++++++++++++----- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index e0eddc7f6..10d42e483 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -20,8 +20,12 @@ internal struct LiveObjectMutableState { // RTLO3e internal var tombstonedAt: Date? + private enum EventName { + case update + } + /// Internal subscription storage. - private var subscriptionStorage = SubscriptionStorage() + private var subscriptionStorage = SubscriptionStorage() internal init( objectID: String, @@ -79,13 +83,17 @@ internal struct LiveObjectMutableState { // RTLO4b2 try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "subscribe") - let updateSubscriptionStorage: SubscriptionStorage.UpdateSubscriptionStorage = { action in + let updateSubscriptionStorage: SubscriptionStorage.UpdateSubscriptionStorage = { action in updateSelfLater { liveObject in action(&liveObject.subscriptionStorage) } } - return subscriptionStorage.subscribe(listener: listener, updateSelfLater: updateSubscriptionStorage) + return subscriptionStorage.subscribe( + listener: listener, + eventName: .update, + updateSelfLater: updateSubscriptionStorage, + ) } internal mutating func unsubscribeAll() { @@ -99,7 +107,7 @@ internal struct LiveObjectMutableState { return case let .update(update): // RTLO4b4c2 - subscriptionStorage.emit(update, on: queue) + subscriptionStorage.emit(update, eventName: .update, on: queue) } } } diff --git a/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift b/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift index f4aabebd0..5443e6cf3 100644 --- a/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift +++ b/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift @@ -1,9 +1,10 @@ import Foundation /// Handles subscription bookkeeping, providing methods for subscribing and emitting events. -internal struct SubscriptionStorage { - /// Internal bookkeeping for subscriptions. - private var subscriptionsByID: [Subscription.ID: Subscription] = [:] +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 @@ -22,34 +23,58 @@ internal struct SubscriptionStorage { private struct SubscribeResponse: AblyLiveObjects.SubscribeResponse { var subscriptionID: Subscription.ID + var eventName: EventName var updateSubscriptionStorage: UpdateSubscriptionStorage func unsubscribe() { updateSubscriptionStorage { subscriptionStorage in - subscriptionStorage.unsubscribe(subscriptionID: subscriptionID) + subscriptionStorage.unsubscribe(subscriptionID: subscriptionID, eventName: eventName) } } } @discardableResult - internal mutating func subscribe(listener: @escaping LiveObjectUpdateCallback, updateSelfLater: @escaping UpdateSubscriptionStorage) -> any AblyLiveObjects.SubscribeResponse { + internal mutating func subscribe( + listener: @escaping LiveObjectUpdateCallback, + eventName: EventName, + updateSelfLater: @escaping UpdateSubscriptionStorage, + ) -> any AblyLiveObjects.SubscribeResponse { let subscription = Subscription(listener: listener, updateSubscriptionStorage: updateSelfLater) - subscriptionsByID[subscription.id] = subscription - return SubscribeResponse(subscriptionID: subscription.id, 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() { - subscriptionsByID.removeAll() + subscriptionsByEventName.removeAll() } - private mutating func unsubscribe(subscriptionID: Subscription.ID) { - subscriptionsByID.removeValue(forKey: subscriptionID) + 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, on queue: DispatchQueue) { - for subscription in subscriptionsByID.values { + 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, updateSubscriptionStorage: subscription.updateSubscriptionStorage) + let response = SubscribeResponse(subscriptionID: subscription.id, eventName: eventName, updateSubscriptionStorage: subscription.updateSubscriptionStorage) subscription.listener(update, response) } } From 40b2d86a5c15ad482ef611f4ee46315b7fa53a72 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 22 Aug 2025 14:48:22 -0300 Subject: [PATCH 141/225] Emit the .deleted LiveObject lifecycle event Want to get this in last-minute before we do our 0.1 release. There's no spec for this yet so I've taken it from JS. We'll do the tests in #52. Code largely generated by Cursor with my guidance. --- .../Internal/InternalDefaultLiveCounter.swift | 22 +++++++++-- .../Internal/InternalDefaultLiveMap.swift | 20 ++++++++-- .../Internal/InternalLiveObject.swift | 8 ++++ .../Internal/LiveObjectMutableState.swift | 39 +++++++++++++++++++ .../Internal/ObjectsPool.swift | 2 + .../Internal/SubscriptionStorage.swift | 9 +++++ 6 files changed, 94 insertions(+), 6 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 6e54d5c2a..4971c2c3f 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -150,12 +150,24 @@ internal final class InternalDefaultLiveCounter: Sendable { } @discardableResult - internal func on(event _: LiveObjectLifecycleEvent, callback _: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { - notYetImplemented() + internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + mutex.withLock { + mutableState.liveObjectMutableState.on(event: event, callback: callback) { [weak self] action in + guard let self else { + return + } + + mutex.withLock { + action(&mutableState.liveObjectMutableState) + } + } + } } internal func offAll() { - notYetImplemented() + mutex.withLock { + mutableState.liveObjectMutableState.offAll() + } } // MARK: - Emitting update from external sources @@ -185,6 +197,7 @@ internal final class InternalDefaultLiveCounter: Sendable { objectMessageSerialTimestamp: objectMessageSerialTimestamp, logger: logger, clock: clock, + userCallbackQueue: userCallbackQueue, ) } } @@ -266,6 +279,7 @@ internal final class InternalDefaultLiveCounter: Sendable { objectMessageSerialTimestamp: Date?, logger: Logger, clock: SimpleClock, + userCallbackQueue: DispatchQueue, ) -> LiveObjectUpdate { // RTLC6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials liveObjectMutableState.siteTimeserials = state.siteTimeserials @@ -283,6 +297,7 @@ internal final class InternalDefaultLiveCounter: Sendable { objectMessageSerialTimestamp: objectMessageSerialTimestamp, logger: logger, clock: clock, + userCallbackQueue: userCallbackQueue, ) // RTLC6f1 @@ -372,6 +387,7 @@ internal final class InternalDefaultLiveCounter: Sendable { objectMessageSerialTimestamp: objectMessageSerialTimestamp, logger: logger, clock: clock, + userCallbackQueue: userCallbackQueue, ) // RTLC7d4a diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 8b5910498..27493e29a 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -219,12 +219,24 @@ internal final class InternalDefaultLiveMap: Sendable { } @discardableResult - internal func on(event _: LiveObjectLifecycleEvent, callback _: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { - notYetImplemented() + internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { + mutex.withLock { + mutableState.liveObjectMutableState.on(event: event, callback: callback) { [weak self] action in + guard let self else { + return + } + + mutex.withLock { + action(&mutableState.liveObjectMutableState) + } + } + } } internal func offAll() { - notYetImplemented() + mutex.withLock { + mutableState.liveObjectMutableState.offAll() + } } // MARK: - Emitting update from external sources @@ -418,6 +430,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectMessageSerialTimestamp: objectMessageSerialTimestamp, logger: logger, clock: clock, + userCallbackQueue: userCallbackQueue, ) // RTLM6f1 @@ -605,6 +618,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectMessageSerialTimestamp: objectMessageSerialTimestamp, logger: logger, clock: clock, + userCallbackQueue: userCallbackQueue, ) // RTLM15d5a diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift b/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift index 27b0d7e89..0a308efb9 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift @@ -18,6 +18,7 @@ internal extension InternalLiveObject { objectMessageSerialTimestamp: Date?, logger: Logger, clock: SimpleClock, + userCallbackQueue: DispatchQueue, ) { // RTLO4e2, RTLO4e3 if let objectMessageSerialTimestamp { @@ -32,6 +33,11 @@ internal extension InternalLiveObject { // RTLO4e4 resetDataToZeroValued() + + // Emit the deleted lifecycle event + // Taken from https://github.com/ably/ably-js/blob/0c5baa9273ca87aec6ca594833d59c4c4d2dddbb/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. @@ -39,12 +45,14 @@ internal extension InternalLiveObject { objectMessageSerialTimestamp: Date?, logger: Logger, clock: SimpleClock, + userCallbackQueue: DispatchQueue, ) { // RTLO5b tombstone( objectMessageSerialTimestamp: objectMessageSerialTimestamp, logger: logger, clock: clock, + userCallbackQueue: userCallbackQueue, ) } } diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index 10d42e483..3a120bb65 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -27,6 +27,9 @@ internal struct LiveObjectMutableState { /// 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, @@ -96,10 +99,42 @@ internal struct LiveObjectMutableState { ) } + @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: @@ -110,4 +145,8 @@ internal struct LiveObjectMutableState { subscriptionStorage.emit(update, eventName: .update, on: queue) } } + + internal func emitLifecycleEvent(_ event: LiveObjectLifecycleEvent, on queue: DispatchQueue) { + lifecycleEventSubscriptionStorage.emit(eventName: event, on: queue) + } } diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index f4054dcad..7174f49a1 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -83,6 +83,7 @@ internal struct ObjectsPool { using state: ObjectState, objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, + userCallbackQueue: DispatchQueue, ) -> DeferredUpdate { switch self { case let .map(map): @@ -245,6 +246,7 @@ internal struct ObjectsPool { using: syncObjectsPoolEntry.state, objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, objectsPool: &self, + userCallbackQueue: userCallbackQueue, ) // RTO5c1a2: Store this update to emit at end updatesToExistingObjects.append(deferredUpdate) diff --git a/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift b/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift index 5443e6cf3..e03ac2a21 100644 --- a/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift +++ b/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift @@ -80,3 +80,12 @@ internal struct SubscriptionStorage Date: Fri, 22 Aug 2025 15:32:49 -0300 Subject: [PATCH 142/225] Emit the channel object sync-related events Want to get this in last-minute before we do our 0.1 release. There's no spec for this yet so I've taken it from JS (I _think_ that my state changes are functionally equivalent to theirs). Also didn't have time to write tests for it; will also do those in #80. --- .../InternalDefaultRealtimeObjects.swift | 105 +++++++++++++++++- 1 file changed, 102 insertions(+), 3 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index b70b42895..f652d9e8b 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -243,12 +243,24 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } @discardableResult - internal func on(event _: ObjectsEvent, callback _: ObjectsEventCallback) -> any OnObjectsEventResponse { - notYetImplemented() + internal func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { + mutex.withLock { + mutableState.on(event: event, callback: callback) { [weak self] action in + guard let self else { + return + } + + mutex.withLock { + action(&mutableState) + } + } + } } internal func offAll() { - notYetImplemented() + mutex.withLock { + mutableState.offAll() + } } // MARK: Handling channel events @@ -264,6 +276,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool mutableState.onChannelAttached( hasObjects: hasObjects, logger: logger, + userCallbackQueue: userCallbackQueue, ) } } @@ -364,15 +377,61 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool internal var syncSequence: SyncSequence? internal var syncStatus = SyncStatus() internal var onChannelAttachedHasObjects: Bool? + internal var objectsEventSubscriptionStorage = SubscriptionStorage() + + /// The state that drives the emission of the `syncing` and `synced` events. + /// + /// This manipulation of this value is based on https://github.com/ably/ably-js/blob/0c5baa9273ca87aec6ca594833d59c4c4d2dddbb/src/plugins/objects/objects.ts. + /// TODO: Bring in line with spec once it exists (https://github.com/ably/ably-liveobjects-swift-plugin/issues/80) and reconcile it with the existing state that we have + internal var state = State.initialized + + /// The state that drives the emission of the `syncing` and `synced` events. + /// + /// This type is copied from https://github.com/ably/ably-js/blob/0c5baa9273ca87aec6ca594833d59c4c4d2dddbb/src/plugins/objects/objects.ts. + /// TODO: Bring in line with spec once it exists (https://github.com/ably/ably-liveobjects-swift-plugin/issues/80) + internal enum State { + case initialized + case syncing + case synced + + var toEvent: ObjectsEvent? { + switch self { + case .initialized: + nil + case .syncing: + .syncing + case .synced: + .synced + } + } + } + + mutating func transition( + to newState: State, + userCallbackQueue: DispatchQueue, + ) { + guard newState != state else { + return + } + state = newState + guard let event = newState.toEvent else { + return + } + emitObjectsEvent(event, on: userCallbackQueue) + } internal mutating func 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 + transition(to: .syncing, 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 @@ -386,6 +445,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool // RTO4b3, RTO4b4, RTO4b5, RTO5c3, RTO5c4, RTO5c5 syncSequence = nil syncStatus.signalSyncComplete() + transition(to: .synced, userCallbackQueue: userCallbackQueue) } /// Implements the `OBJECT_SYNC` handling of RTO5. @@ -484,6 +544,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool syncSequence = nil syncStatus.signalSyncComplete() + transition(to: .synced, userCallbackQueue: userCallbackQueue) } } @@ -568,5 +629,43 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool return } } + + 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) + } + + 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) + } } } From 4afef3005e682cf2681f7b7ad47e7291dd720dc7 Mon Sep 17 00:00:00 2001 From: umair Date: Tue, 26 Aug 2025 14:14:32 +0100 Subject: [PATCH 143/225] update readme --- README.md | 81 +++++++++++++++++++++++++++++-------------------------- 1 file changed, 43 insertions(+), 38 deletions(-) diff --git a/README.md b/README.md index b6f3713ae..5bea5df6e 100644 --- a/README.md +++ b/README.md @@ -1,54 +1,47 @@ -# Ably LiveObjects plugin for ably-cocoa SDK ++[![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) -This is a work in progress plugin that enables LiveObjects functionality in the [ably-cocoa](https://github.com/ably/ably-cocoa/) SDK. It is not yet ready to be used. +--- -## Supported Platforms +# Ably LiveObjects Swift Plugin -- macOS 11 and above -- iOS 14 and above -- tvOS 14 and above +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. -## Requirements +> [!WARNING] +> This plugin is currently experimental and the public API may change. -Xcode 16.3 or later. +--- -## Installation +## Getting started -For now, here is a code snippet demonstrating how, after installing this package and ably-cocoa using Swift Package Manager, you can set up the LiveObjects plugin and access its functionality. +Everything you need to get started with Ably LiveObjects: -```swift -import Ably -import AblyLiveObjects +- [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. -let clientOptions = ARTClientOptions(key: /* */) -clientOptions.plugins = [.liveObjects: AblyLiveObjects.Plugin.self] +--- -let realtime = ARTRealtime(options: clientOptions) +## Supported platforms -// Fetch a channel, specifying the `.objectPublish` and `.objectSubscribe` modes -let channelOptions = ARTRealtimeChannelOptions() -channelOptions.modes = [.objectPublish, .objectSubscribe] -let channel = realtime.channels.get("myChannel", options: channelOptions) +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). -// Attach the channel -try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in - channel.attach { error in - if let error { - continuation.resume(throwing: error) - } else { - continuation.resume() - } - } -} +This plugin supports the following platforms: -// You can now access LiveObjects functionality via the channel's `objects` property: -let rootObject = try await channel.objects.getRoot() -// …and so on -``` +| 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 SDK. The code for this app is in the [`Example`](Example) directory. +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: @@ -60,8 +53,20 @@ 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. +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). -## Contributing +## Support, feedback and troubleshooting -For guidance on how to contribute to this project, see the [contributing guidelines](CONTRIBUTING.md). +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. From 403d1c0ee7c49700bd22d7abdb021906a0346f31 Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 28 Aug 2025 12:38:33 +0100 Subject: [PATCH 144/225] unpins ably-cocoa version. now points to the min required version with liveobjects support --- .../xcshareddata/swiftpm/Package.resolved | 5 +++-- Package.resolved | 5 +++-- Package.swift | 3 +-- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index 567d6fb25..59ad104db 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,13 @@ { - "originHash" : "f4612ea7dd07d68ee1d23785f1de05ad64a31d2a149fdfcaa4f8750623547c09", + "originHash" : "cc7d6e4ea33d6e05a1fb86591bd0c5b32a1ff113f1780cda71779ad7c1a85d9a", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "b0c2ecf9da993736297b6986be5610b375440a3f" + "revision" : "1b378f0baffa04920d2e52567acfc8810d769af4", + "version" : "1.2.44" } }, { diff --git a/Package.resolved b/Package.resolved index 9ed348780..c0edfc960 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,13 @@ { - "originHash" : "749e7e5e2f29aba400061df38c5d997e141d8ee721a9082edb01112579f3487f", + "originHash" : "916630c8f8adb7f7f0a7c80e192b06035e4720a902e2c91dcee7965e5f37c0f3", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "b0c2ecf9da993736297b6986be5610b375440a3f" + "revision" : "1b378f0baffa04920d2e52567acfc8810d769af4", + "version" : "1.2.44" } }, { diff --git a/Package.swift b/Package.swift index 3cd42133f..df214e976 100644 --- a/Package.swift +++ b/Package.swift @@ -20,8 +20,7 @@ let package = Package( dependencies: [ .package( url: "https://github.com/ably/ably-cocoa", - // TODO: Unpin before launch (https://github.com/ably/ably-liveobjects-swift-plugin/issues/75) - revision: "b0c2ecf9da993736297b6986be5610b375440a3f", + from: "1.2.44", ), .package( url: "https://github.com/ably/ably-cocoa-plugin-support", From 61a15bb18f77174df33786c7b0c7769286bd0d78 Mon Sep 17 00:00:00 2001 From: umair Date: Thu, 28 Aug 2025 13:23:01 +0100 Subject: [PATCH 145/225] 0.1.0 release PR --- CHANGELOG.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a56de9840..d4bef7fc5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ # Change Log -This will be updated once we've done our first release. +## [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) From 29818ab1dfef29ba599c0b3e38601828e94d27d8 Mon Sep 17 00:00:00 2001 From: Francis Roberts <111994975+franrob-projects@users.noreply.github.com> Date: Mon, 1 Sep 2025 18:11:43 +0200 Subject: [PATCH 146/225] EDU-2053: Adds new header --- README.md | 3 ++- images/SwiftSDK-LiveObjects-github v1.0.png | Bin 0 -> 935881 bytes 2 files changed, 2 insertions(+), 1 deletion(-) create mode 100644 images/SwiftSDK-LiveObjects-github v1.0.png diff --git a/README.md b/README.md index 5bea5df6e..9865ee25a 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,10 @@ +![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 +# 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. diff --git a/images/SwiftSDK-LiveObjects-github v1.0.png b/images/SwiftSDK-LiveObjects-github v1.0.png new file mode 100644 index 0000000000000000000000000000000000000000..ed504e0d3983e4024d00eb197f4d4b6f4690d5be GIT binary patch literal 935881 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

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

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

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

2!c;!x==wrp&<&fzjQ{ZwPrw-Cq6YXBVI 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 a& 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 VQa`B{nZp18002ovPDHLkV1nHwNBRH& literal 0 HcmV?d00001 From 2ce1058ed4430cc3563dcead0299e92a81d2774b Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 1 Sep 2025 16:46:49 -0300 Subject: [PATCH 147/225] Tighten up threading requirements Restrict more things to the ably-cocoa internal queue, and adopt the same naming convention as ably-cocoa to indicate this. Relates to [1], in which I'm going to isolate all of the LiveObject plugin's internal state to the ably-cocoa internal queue. [1] https://github.com/ably/ably-liveobjects-swift-plugin/issues/3 --- README.md | 4 ++++ .../include/APLiveObjectsPlugin.h | 23 ++++++++----------- .../include/APPluginAPI.h | 22 +++++++++--------- 3 files changed, 24 insertions(+), 25 deletions(-) diff --git a/README.md b/README.md index 191061660..5fe437efd 100644 --- a/README.md +++ b/README.md @@ -6,3 +6,7 @@ This is an Ably-internal library that defines the private APIs that the [Ably Pu > 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. diff --git a/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h index 011c352f4..7ca44cbab 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h +++ b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h @@ -32,7 +32,7 @@ NS_SWIFT_SENDABLE /// 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)prepareChannel:(id)channel client:(id)client; +- (void)nosync_prepareChannel:(id)channel client:(id)client; /// Decodes an `ObjectMessage` received over the wire. /// @@ -59,34 +59,29 @@ NS_SWIFT_SENDABLE /// Called when a channel received an `ATTACHED` `ProtocolMessage`. (This is copied from ably-js, will document this method properly once exact meaning decided.) /// -/// TODO: what thread is this called on, and does it matter? Decide in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3 -/// /// Parameters: /// - channel: The channel that received the `ProtocolMessage`. /// - hasObjects: Whether the `ProtocolMessage` has the `HAS_OBJECTS` flag set. -- (void)onChannelAttached:(id)channel - hasObjects:(BOOL)hasObjects; +- (void)nosync_onChannelAttached:(id)channel + hasObjects:(BOOL)hasObjects + NS_SWIFT_NAME(nosync_onChannelAttached(_:hasObjects:)); /// Processes a received `OBJECT` `ProtocolMessage`. /// -/// TODO: what thread is this called on, and does it matter? Decide in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3 -/// /// Parameters: /// - objectMessages: The contents of the `ProtocolMessage`'s `state` property. /// - channel: The channel on which the `ProtocolMessage` was received. -- (void)handleObjectProtocolMessageWithObjectMessages:(NSArray> *)objectMessages - channel:(id)channel; +- (void)nosync_handleObjectProtocolMessageWithObjectMessages:(NSArray> *)objectMessages + channel:(id)channel; /// Processes a received `OBJECT_SYNC` `ProtocolMessage`. /// -/// TODO: what thread is this called on, and does it matter? Decide in https://github.com/ably/ably-cocoa-liveobjects-plugin/issues/3 -/// /// Parameters: /// - objectMessages: The contents of the `ProtocolMessage`'s `state` property. /// - channel: The channel on which the `ProtocolMessage` was received. -- (void)handleObjectSyncProtocolMessageWithObjectMessages:(NSArray> *)objectMessages - protocolMessageChannelSerial:(nullable NSString *)protocolMessageChannelSerial - channel:(id)channel; +- (void)nosync_handleObjectSyncProtocolMessageWithObjectMessages:(NSArray> *)objectMessages + protocolMessageChannelSerial:(nullable NSString *)protocolMessageChannelSerial + channel:(id)channel; @end diff --git a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h index 2c9463d8c..745915fd2 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h +++ b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h @@ -25,13 +25,13 @@ NS_SWIFT_SENDABLE /// Allows a plugin to store arbitrary key-value data on a channel. /// /// The channel stores a strong reference to `value`. -- (void)setPluginDataValue:(id)value - forKey:(NSString *)key - channel:(id)channel; +- (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)pluginDataValueForKey:(NSString *)key - channel:(id)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. /// @@ -59,18 +59,18 @@ NS_SWIFT_SENDABLE /// Provides plugins with the queue which a given client uses to synchronize its internal state. /// -/// Certain `APPluginAPIProtocol` methods must be called on this queue (the method will document when this is the case). +/// All `_AblyPluginSupportPrivate` methods whose names begin with `nosync_` must be called on this queue. - (dispatch_queue_t)internalQueueForClient:(id)client; /// Sends an `OBJECT` `ProtocolMessage` on a channel and indicates the result of waiting for an `ACK`. TODO there is still some deciding to be done about the exact contract of this method. /// -/// This method must be called on the client's internal queue (see `-internalQueueForClient:`). -- (void)sendObjectWithObjectMessages:(NSArray> *)objectMessages - channel:(id)channel - completion:(void (^ _Nullable)(_Nullable id error))completion; +/// The completion handler will be called on the client's internal queue (see `-internalQueueForClient:`). +- (void)nosync_sendObjectWithObjectMessages:(NSArray> *)objectMessages + channel:(id)channel + completion:(void (^ _Nullable)(_Nullable id error))completion; /// Returns a realtime channel's current state. -- (APRealtimeChannelState)stateForChannel:(id)channel; +- (APRealtimeChannelState)nosync_stateForChannel:(id)channel; /// Logs a message to a logger. - (void)log:(NSString *)message From 7a2379c8ef636c15b02576b4bcfea73a8eb5589d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 3 Sep 2025 09:05:04 -0300 Subject: [PATCH 148/225] Suppress deletion of updateSelfLater argument label Should have done this in ad60504 and 40b2d86. We do this for clarity so that this closure doesn't look like a listener, same as in the existing `subscribe` methods. --- .../Internal/InternalDefaultLiveCounter.swift | 5 +++-- .../AblyLiveObjects/Internal/InternalDefaultLiveMap.swift | 5 +++-- .../Internal/InternalDefaultRealtimeObjects.swift | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 4971c2c3f..71d54daa8 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -152,7 +152,8 @@ internal final class InternalDefaultLiveCounter: Sendable { @discardableResult internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { mutex.withLock { - mutableState.liveObjectMutableState.on(event: event, callback: callback) { [weak self] action in + // swiftlint:disable:next trailing_closure + mutableState.liveObjectMutableState.on(event: event, callback: callback, updateSelfLater: { [weak self] action in guard let self else { return } @@ -160,7 +161,7 @@ internal final class InternalDefaultLiveCounter: Sendable { mutex.withLock { action(&mutableState.liveObjectMutableState) } - } + }) } } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 27493e29a..a25b5421c 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -221,7 +221,8 @@ internal final class InternalDefaultLiveMap: Sendable { @discardableResult internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { mutex.withLock { - mutableState.liveObjectMutableState.on(event: event, callback: callback) { [weak self] action in + // swiftlint:disable:next trailing_closure + mutableState.liveObjectMutableState.on(event: event, callback: callback, updateSelfLater: { [weak self] action in guard let self else { return } @@ -229,7 +230,7 @@ internal final class InternalDefaultLiveMap: Sendable { mutex.withLock { action(&mutableState.liveObjectMutableState) } - } + }) } } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index f652d9e8b..179b472a2 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -245,7 +245,8 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool @discardableResult internal func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { mutex.withLock { - mutableState.on(event: event, callback: callback) { [weak self] action in + // swiftlint:disable:next trailing_closure + mutableState.on(event: event, callback: callback, updateSelfLater: { [weak self] action in guard let self else { return } @@ -253,7 +254,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool mutex.withLock { action(&mutableState) } - } + }) } } From ae7d3ebab90bf913d6ed451347c81068904ce99b Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 3 Sep 2025 09:18:13 -0300 Subject: [PATCH 149/225] Add missing @Test Mistake in d9c327e. --- Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index cf5c68a79..a2ee2cb10 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -1354,6 +1354,7 @@ struct InternalDefaultLiveMapTests { // @spec RTLM21e3 // @spec RTLM21e4 // @spec RTLM21f + @Test func publishesCorrectObjectMessage() async throws { let logger = TestLogger() let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) From 6404b523b043123b8cb9a4a8f2046efd7f1516e0 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 3 Sep 2025 09:51:59 -0300 Subject: [PATCH 150/225] Document the approach for using ably-cocoa internals in tests Resolves #10. This is the simplest of the potential options outlined in that issue but it seems sufficient for now. The only downside so far has been the duplication of TestProxyTransport but I think we can live with it. --- CONTRIBUTING.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e679a91e..63874fc21 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -123,6 +123,10 @@ Example: // @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). From eadbf846cae6391920263f554a6aa9bd0c612e8a Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 5 Sep 2025 10:26:02 -0300 Subject: [PATCH 151/225] Remove stray gitignore entry The Chat gitignore that I copied in b38ce55 had a mistake (fixed in [1]). [1] https://github.com/ably/ably-chat-swift/pull/353 --- .gitignore | 1 - 1 file changed, 1 deletion(-) diff --git a/.gitignore b/.gitignore index c62449f92..9383ec368 100644 --- a/.gitignore +++ b/.gitignore @@ -7,7 +7,6 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc -docs-coverage-report # End of .gitignore created by Swift Package Manager /node_modules From a766b7924954308846733e8128cc3ccfec1b66b8 Mon Sep 17 00:00:00 2001 From: Francis Roberts <111994975+franrob-projects@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:01:25 +0200 Subject: [PATCH 152/225] fixup! EDU-2053: Adds new header --- README.md | 2 +- ...hub v1.0.png => SwiftSDK-LiveObjects-github.png} | Bin 2 files changed, 1 insertion(+), 1 deletion(-) rename images/{SwiftSDK-LiveObjects-github v1.0.png => SwiftSDK-LiveObjects-github.png} (100%) diff --git a/README.md b/README.md index 9865ee25a..6ad00fb22 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ ![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) +[![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) --- diff --git a/images/SwiftSDK-LiveObjects-github v1.0.png b/images/SwiftSDK-LiveObjects-github.png similarity index 100% rename from images/SwiftSDK-LiveObjects-github v1.0.png rename to images/SwiftSDK-LiveObjects-github.png From 71810d5d185f94e9b0bd06295b1ce28b4e71c0ad Mon Sep 17 00:00:00 2001 From: Francis Roberts <111994975+franrob-projects@users.noreply.github.com> Date: Thu, 18 Sep 2025 10:28:31 +0200 Subject: [PATCH 153/225] fixup! EDU-2053: Adds new header --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 6ad00fb22..6ed7a235d 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ --- -# LiveObjects Swift plugin +# 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. From b86e09a7061e13661f38a9fa2455132a8dc31cfa Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 1 Sep 2025 14:52:18 -0300 Subject: [PATCH 154/225] Finalise and document threading approach Isolate the plugin's mutable state to the ARTRealtime instance's internal queue, for consistency with ably-cocoa. Will unpin ably-cocoa and ably-cocoa-plugin-support before our next release. Resolves #3. --- .../xcshareddata/swiftpm/Package.resolved | 8 +- CONTRIBUTING.md | 12 + Package.resolved | 8 +- Package.swift | 6 +- .../AblyLiveObjects/Internal/CoreSDK.swift | 10 +- .../Internal/DefaultInternalPlugin.swift | 31 +- .../Internal/InternalDefaultLiveCounter.swift | 152 ++-- .../Internal/InternalDefaultLiveMap.swift | 247 +++--- .../InternalDefaultRealtimeObjects.swift | 154 ++-- .../Internal/InternalLiveMapValue.swift | 6 +- .../Internal/LiveObjectMutableState.swift | 4 +- .../Internal/ObjectCreationHelpers.swift | 4 +- .../Internal/ObjectsPool.swift | 143 +++- .../Public/ARTRealtimeChannel+Objects.swift | 5 +- .../Utility/DispatchQueue+Extensions.swift | 15 + .../Utility/DispatchQueueMutex.swift | 48 ++ .../Helpers/TestFactories.swift | 9 + .../InternalDefaultLiveCounterTests.swift | 256 ++++--- .../InternalDefaultLiveMapTests.swift | 531 +++++++------ .../InternalDefaultRealtimeObjectsTests.swift | 703 +++++++++++------- .../ObjectsIntegrationTests.swift | 8 +- .../LiveObjectMutableStateTests.swift | 58 +- .../Mocks/MockCoreSDK.swift | 23 +- .../Mocks/MockLiveMapObjectPoolDelegate.swift | 21 +- .../ObjectCreationHelpersTests.swift | 47 +- .../ObjectsPoolTests.swift | 131 ++-- 26 files changed, 1634 insertions(+), 1006 deletions(-) create mode 100644 Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift create mode 100644 Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index 59ad104db..b2ca14c1f 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,12 @@ { - "originHash" : "cc7d6e4ea33d6e05a1fb86591bd0c5b32a1ff113f1780cda71779ad7c1a85d9a", + "originHash" : "36e98bdea0539e16fb3606593afdd71398955c0c0a1f8bd5549e20f660a9cd3f", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "1b378f0baffa04920d2e52567acfc8810d769af4", - "version" : "1.2.44" + "revision" : "8dde3e841aa1f861176c1341cf44e92014b95857" } }, { @@ -15,8 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "cec94ed123d60e39e3f8df665c30a57482d37612", - "version" : "0.1.0" + "revision" : "2ce1058ed4430cc3563dcead0299e92a81d2774b" } }, { diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0e679a91e..25a9410f3 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -70,6 +70,18 @@ The `Public…` classes all follow the same pattern and are not very interesting `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 diff --git a/Package.resolved b/Package.resolved index c0edfc960..a9ec8ce2d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,12 @@ { - "originHash" : "916630c8f8adb7f7f0a7c80e192b06035e4720a902e2c91dcee7965e5f37c0f3", + "originHash" : "bf537c63896d70fc4c726aee2c32003715644223b53ac9f8144267e14c5bd9a7", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "1b378f0baffa04920d2e52567acfc8810d769af4", - "version" : "1.2.44" + "revision" : "8dde3e841aa1f861176c1341cf44e92014b95857" } }, { @@ -15,8 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "cec94ed123d60e39e3f8df665c30a57482d37612", - "version" : "0.1.0" + "revision" : "2ce1058ed4430cc3563dcead0299e92a81d2774b" } }, { diff --git a/Package.swift b/Package.swift index df214e976..4702890f3 100644 --- a/Package.swift +++ b/Package.swift @@ -20,11 +20,13 @@ let package = Package( dependencies: [ .package( url: "https://github.com/ably/ably-cocoa", - from: "1.2.44", + // TODO: Unpin before next release + revision: "8dde3e841aa1f861176c1341cf44e92014b95857", ), .package( url: "https://github.com/ably/ably-cocoa-plugin-support", - from: "0.1.0", + // TODO: Unpin before next release + revision: "2ce1058ed4430cc3563dcead0299e92a81d2774b", ), .package( url: "https://github.com/apple/swift-argument-parser", diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 07f3c3a82..83c466b4c 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -14,7 +14,7 @@ internal protocol CoreSDK: AnyObject, Sendable { func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(InternalError) -> Void) /// Returns the current state of the Realtime channel that this wraps. - var channelState: _AblyPluginSupportPrivate.RealtimeChannelState { get } + var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { get } } internal final class DefaultCoreSDK: CoreSDK { @@ -81,8 +81,8 @@ internal final class DefaultCoreSDK: CoreSDK { } } - internal var channelState: _AblyPluginSupportPrivate.RealtimeChannelState { - pluginAPI.state(for: channel) + internal var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { + pluginAPI.nosync_state(for: channel) } } @@ -96,11 +96,11 @@ internal extension CoreSDK { /// - invalidStates: Array of channel states that are considered invalid for the operation /// - operationDescription: A description of the operation being performed, used in error messages /// - Throws: `ARTErrorInfo` with code 90001 and statusCode 400 if the channel is in any of the invalid states - func validateChannelState( + func nosync_validateChannelState( notIn invalidStates: [_AblyPluginSupportPrivate.RealtimeChannelState], operationDescription: String, ) throws(ARTErrorInfo) { - let currentChannelState = channelState + let currentChannelState = nosync_channelState if invalidStates.contains(currentChannelState) { throw LiveObjectsError.objectsOperationFailedInvalidChannelState( operationDescription: operationDescription, diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index ac081fc2b..b9e817210 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -21,8 +21,8 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. /// Retrieves the `RealtimeObjects` for this channel. /// /// We expect this value to have been previously set by ``prepare(_:)``. - internal static func realtimeObjects(for channel: _AblyPluginSupportPrivate.RealtimeChannel, pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol) -> InternalDefaultRealtimeObjects { - guard let pluginData = pluginAPI.pluginDataValue(forKey: pluginDataKey, channel: channel) else { + 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]`") } @@ -34,8 +34,9 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. // MARK: - LiveObjectsInternalPluginProtocol // Populates the channel's `objects` property. - internal func prepare(_ channel: _AblyPluginSupportPrivate.RealtimeChannel, client: _AblyPluginSupportPrivate.RealtimeClient) { + 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)) @@ -43,16 +44,17 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. logger.log("LiveObjects.DefaultInternalPlugin received prepare(_:)", level: .debug) let liveObjects = InternalDefaultRealtimeObjects( logger: logger, + internalQueue: internalQueue, userCallbackQueue: callbackQueue, clock: DefaultSimpleClock(), garbageCollectionOptions: options.garbageCollectionOptions ?? .init(), ) - pluginAPI.setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) + pluginAPI.nosync_setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } /// Retrieves the internally-typed `objects` property for the channel. - private func realtimeObjects(for channel: _AblyPluginSupportPrivate.RealtimeChannel) -> InternalDefaultRealtimeObjects { - Self.realtimeObjects(for: channel, pluginAPI: pluginAPI) + private func nosync_realtimeObjects(for channel: _AblyPluginSupportPrivate.RealtimeChannel) -> InternalDefaultRealtimeObjects { + Self.nosync_realtimeObjects(for: channel, pluginAPI: pluginAPI) } /// A class that wraps an object message. @@ -102,30 +104,30 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. return wireObjectMessage.toWireObject.toPluginSupportDataDictionary } - internal func onChannelAttached(_ channel: _AblyPluginSupportPrivate.RealtimeChannel, hasObjects: Bool) { - realtimeObjects(for: channel).onChannelAttached(hasObjects: hasObjects) + internal func nosync_onChannelAttached(_ channel: _AblyPluginSupportPrivate.RealtimeChannel, hasObjects: Bool) { + nosync_realtimeObjects(for: channel).nosync_onChannelAttached(hasObjects: hasObjects) } - internal func handleObjectProtocolMessage(withObjectMessages publicObjectMessages: [any _AblyPluginSupportPrivate.ObjectMessageProtocol], channel: _AblyPluginSupportPrivate.RealtimeChannel) { + 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) - realtimeObjects(for: channel).handleObjectProtocolMessage( + nosync_realtimeObjects(for: channel).nosync_handleObjectProtocolMessage( objectMessages: objectMessages, ) } - internal func handleObjectSyncProtocolMessage(withObjectMessages publicObjectMessages: [any _AblyPluginSupportPrivate.ObjectMessageProtocol], protocolMessageChannelSerial: String?, channel: _AblyPluginSupportPrivate.RealtimeChannel) { + 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) - realtimeObjects(for: channel).handleObjectSyncProtocolMessage( + nosync_realtimeObjects(for: channel).nosync_handleObjectSyncProtocolMessage( objectMessages: objectMessages, protocolMessageChannelSerial: protocolMessageChannelSerial, ) @@ -145,10 +147,13 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. let internalQueue = pluginAPI.internalQueue(for: client) internalQueue.async { - pluginAPI.sendObject( + pluginAPI.nosync_sendObject( withObjectMessages: objectMessageBoxes, channel: channel, ) { error in + // We don't currently rely on this documented behaviour of `nosync_sendObject` but we may do later, so assert it to be sure it's happening. + dispatchPrecondition(condition: .onQueue(internalQueue)) + if let error { continuation.resume(returning: .failure(error.toInternalError())) } else { diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 71d54daa8..c521bff94 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -4,19 +4,16 @@ import Foundation /// This provides the implementation behind ``PublicDefaultLiveCounter``, via internal versions of the ``LiveCounter`` API. internal final class InternalDefaultLiveCounter: Sendable { - // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-liveobjects-swift-plugin/issues/3. - private let mutex = NSLock() - - private nonisolated(unsafe) var mutableState: MutableState + private let mutableStateMutex: DispatchQueueMutex internal var testsOnly_siteTimeserials: [String: String] { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.liveObjectMutableState.siteTimeserials } } internal var testsOnly_createOperationIsMerged: Bool { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.liveObjectMutableState.createOperationIsMerged } } @@ -31,20 +28,32 @@ internal final class InternalDefaultLiveCounter: Sendable { testsOnly_data data: Double, objectID: String, logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock ) { - self.init(data: data, objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) + 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 ) { - mutableState = .init(liveObjectMutableState: .init(objectID: objectID), data: data) + mutableStateMutex = .init( + dispatchQueue: internalQueue, + initialValue: .init(liveObjectMutableState: .init(objectID: objectID), data: data), + ) self.logger = logger self.userCallbackQueue = userCallbackQueue self.clock = clock @@ -57,6 +66,7 @@ internal final class InternalDefaultLiveCounter: Sendable { internal static func createZeroValued( objectID: String, logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> Self { @@ -64,6 +74,7 @@ internal final class InternalDefaultLiveCounter: Sendable { data: 0, objectID: objectID, logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -71,8 +82,15 @@ internal final class InternalDefaultLiveCounter: Sendable { // MARK: - Data access - internal var objectID: String { - mutex.withLock { + 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 } } @@ -80,40 +98,42 @@ internal final class InternalDefaultLiveCounter: Sendable { // MARK: - Internal methods that back LiveCounter conformance internal func value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { - try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in - try mutableState.value(coreSDK: coreSDK) + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + try mutableState.nosync_value(coreSDK: coreSDK) } } internal func increment(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { do throws(InternalError) { - // RTLC12c - do { - try coreSDK.validateChannelState( - notIn: [.detached, .failed, .suspended], - operationDescription: "LiveCounter.increment", - ) - } catch { - throw error.toInternalError() - } + let objectMessage = try mutableStateMutex.withSync { mutableState throws(InternalError) in + // RTLC12c + do throws(ARTErrorInfo) { + try coreSDK.nosync_validateChannelState( + notIn: [.detached, .failed, .suspended], + operationDescription: "LiveCounter.increment", + ) + } catch { + throw error.toInternalError() + } - // RTLC12e1 - if !amount.isFinite { - throw LiveObjectsError.counterIncrementAmountInvalid(amount: amount).toARTErrorInfo().toInternalError() - } + // RTLC12e1 + if !amount.isFinite { + throw LiveObjectsError.counterIncrementAmountInvalid(amount: amount).toARTErrorInfo().toInternalError() + } - let objectMessage = OutboundObjectMessage( - operation: .init( - // RTLC12e2 - action: .known(.counterInc), - // RTLC12e3 - objectId: objectID, - counterOp: .init( - // RTLC12e4 - amount: .init(value: amount), + return OutboundObjectMessage( + operation: .init( + // RTLC12e2 + action: .known(.counterInc), + // RTLC12e3 + objectId: mutableState.liveObjectMutableState.objectID, + counterOp: .init( + // RTLC12e4 + amount: .init(value: amount), + ), ), - ), - ) + ) + } // RTLC12f try await coreSDK.publish(objectMessages: [objectMessage]) @@ -129,14 +149,14 @@ internal final class InternalDefaultLiveCounter: Sendable { @discardableResult internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { - try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in // swiftlint:disable:next trailing_closure - try mutableState.liveObjectMutableState.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + try mutableState.liveObjectMutableState.nosync_subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in guard let self else { return } - mutex.withLock { + mutableStateMutex.withSync { mutableState in action(&mutableState.liveObjectMutableState) } }) @@ -144,21 +164,21 @@ internal final class InternalDefaultLiveCounter: Sendable { } internal func unsubscribeAll() { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.liveObjectMutableState.unsubscribeAll() } } @discardableResult internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { - mutex.withLock { + 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 } - mutex.withLock { + mutableStateMutex.withSync { mutableState in action(&mutableState.liveObjectMutableState) } }) @@ -166,7 +186,7 @@ internal final class InternalDefaultLiveCounter: Sendable { } internal func offAll() { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.liveObjectMutableState.offAll() } } @@ -176,8 +196,8 @@ internal final class InternalDefaultLiveCounter: Sendable { /// Emit an event from this `LiveCounter`. /// /// This is used to instruct this counter to emit updates during an `OBJECT_SYNC`. - internal func emit(_ update: LiveObjectUpdate) { - mutex.withLock { + internal func nosync_emit(_ update: LiveObjectUpdate) { + mutableStateMutex.withoutSync { mutableState in mutableState.liveObjectMutableState.emit(update, on: userCallbackQueue) } } @@ -188,11 +208,11 @@ internal final class InternalDefaultLiveCounter: Sendable { /// /// - Parameters: /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this counter. - internal func replaceData( + internal func nosync_replaceData( using state: ObjectState, objectMessageSerialTimestamp: Date?, ) -> LiveObjectUpdate { - mutex.withLock { + mutableStateMutex.withoutSync { mutableState in mutableState.replaceData( using: state, objectMessageSerialTimestamp: objectMessageSerialTimestamp, @@ -204,35 +224,35 @@ internal final class InternalDefaultLiveCounter: Sendable { } /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC10. - internal func mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { - mutex.withLock { + 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 { - mutex.withLock { + 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: WireObjectsCounterOp?) -> LiveObjectUpdate { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.applyCounterIncOperation(operation) } } /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. - internal func apply( + internal func nosync_apply( _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, ) { - mutex.withLock { + mutableStateMutex.withoutSync { mutableState in mutableState.apply( operation, objectMessageSerial: objectMessageSerial, @@ -249,15 +269,29 @@ internal final class InternalDefaultLiveCounter: Sendable { // MARK: - LiveObject /// Returns the object's RTLO3d `isTombstone` property. - internal var isTombstone: Bool { - mutex.withLock { + 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 tombstonedAt: Date? { - mutex.withLock { + 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 } } @@ -433,9 +467,9 @@ internal final class InternalDefaultLiveCounter: Sendable { data = 0 } - internal func value(coreSDK: CoreSDK) throws(ARTErrorInfo) -> Double { + 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.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveCounter.value") + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveCounter.value") // RTLC5c return data diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index a25b5421c..f0266508c 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -4,36 +4,33 @@ import Ably /// Protocol for accessing objects from the ObjectsPool. This is used by a LiveMap when it needs to return an object given an object ID. internal protocol LiveMapObjectPoolDelegate: AnyObject, Sendable { /// Fetches an object from the pool by its ID - func getObjectFromPool(id: String) -> ObjectsPool.Entry? + func nosync_getObjectFromPool(id: String) -> ObjectsPool.Entry? } /// This provides the implementation behind ``PublicDefaultLiveMap``, via internal versions of the ``LiveMap`` API. internal final class InternalDefaultLiveMap: Sendable { - // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-liveobjects-swift-plugin/issues/3. - private let mutex = NSLock() - - private nonisolated(unsafe) var mutableState: MutableState + private let mutableStateMutex: DispatchQueueMutex internal var testsOnly_data: [String: InternalObjectsMapEntry] { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.data } } internal var testsOnly_semantics: WireEnum? { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.semantics } } internal var testsOnly_siteTimeserials: [String: String] { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.liveObjectMutableState.siteTimeserials } } internal var testsOnly_createOperationIsMerged: Bool { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.liveObjectMutableState.createOperationIsMerged } } @@ -49,6 +46,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectID: String, testsOnly_semantics semantics: WireEnum? = nil, logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) { @@ -57,6 +55,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectID: objectID, semantics: semantics, logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -67,10 +66,14 @@ internal final class InternalDefaultLiveMap: Sendable { objectID: String, semantics: WireEnum?, logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) { - mutableState = .init(liveObjectMutableState: .init(objectID: objectID), data: data, semantics: semantics) + mutableStateMutex = .init( + dispatchQueue: internalQueue, + initialValue: .init(liveObjectMutableState: .init(objectID: objectID), data: data, semantics: semantics), + ) self.logger = logger self.userCallbackQueue = userCallbackQueue self.clock = clock @@ -85,6 +88,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectID: String, semantics: WireEnum? = nil, logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> Self { @@ -93,6 +97,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectID: objectID, semantics: semantics, logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -100,8 +105,15 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - Data access - internal var objectID: String { - mutex.withLock { + 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 } } @@ -110,20 +122,20 @@ internal final class InternalDefaultLiveMap: Sendable { /// Returns the value associated with a given key, following RTLM5d specification. internal func get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { - try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in - try mutableState.get(key: key, coreSDK: coreSDK, delegate: delegate) + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + try mutableState.nosync_get(key: key, coreSDK: coreSDK, delegate: delegate) } } internal func size(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> Int { - try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in - try mutableState.size(coreSDK: coreSDK, delegate: delegate) + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + try mutableState.nosync_size(coreSDK: coreSDK, delegate: delegate) } } internal func entries(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { - try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in - try mutableState.entries(coreSDK: coreSDK, delegate: delegate) + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + try mutableState.nosync_entries(coreSDK: coreSDK, delegate: delegate) } } @@ -139,27 +151,29 @@ internal final class InternalDefaultLiveMap: Sendable { internal func set(key: String, value: InternalLiveMapValue, coreSDK: CoreSDK) async throws(ARTErrorInfo) { do throws(InternalError) { - // RTLM20c - do { - try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.set") - } catch { - throw error.toInternalError() - } - - let objectMessage = OutboundObjectMessage( - operation: .init( - // RTLM20e2 - action: .known(.mapSet), - // RTLM20e3 - objectId: objectID, - mapOp: .init( - // RTLM20e4 - key: key, - // RTLM20e5 - data: value.toObjectData, + let objectMessage = try mutableStateMutex.withSync { mutableState throws(InternalError) in + // RTLM20c + do throws(ARTErrorInfo) { + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.set") + } catch { + throw error.toInternalError() + } + + return OutboundObjectMessage( + operation: .init( + // RTLM20e2 + action: .known(.mapSet), + // RTLM20e3 + objectId: mutableState.liveObjectMutableState.objectID, + mapOp: .init( + // RTLM20e4 + key: key, + // RTLM20e5 + data: value.nosync_toObjectData, + ), ), - ), - ) + ) + } try await coreSDK.publish(objectMessages: [objectMessage]) } catch { @@ -169,25 +183,27 @@ internal final class InternalDefaultLiveMap: Sendable { internal func remove(key: String, coreSDK: CoreSDK) async throws(ARTErrorInfo) { do throws(InternalError) { - // RTLM21c - do { - try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.remove") - } catch { - throw error.toInternalError() - } - - let objectMessage = OutboundObjectMessage( - operation: .init( - // RTLM21e2 - action: .known(.mapRemove), - // RTLM21e3 - objectId: objectID, - mapOp: .init( - // RTLM21e4 - key: key, + let objectMessage = try mutableStateMutex.withSync { mutableState throws(InternalError) in + // RTLM21c + do throws(ARTErrorInfo) { + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.remove") + } catch { + throw error.toInternalError() + } + + return OutboundObjectMessage( + operation: .init( + // RTLM21e2 + action: .known(.mapRemove), + // RTLM21e3 + objectId: mutableState.liveObjectMutableState.objectID, + mapOp: .init( + // RTLM21e4 + key: key, + ), ), - ), - ) + ) + } // RTLM21f try await coreSDK.publish(objectMessages: [objectMessage]) @@ -198,14 +214,14 @@ internal final class InternalDefaultLiveMap: Sendable { @discardableResult internal func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> any SubscribeResponse { - try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in + try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in // swiftlint:disable:next trailing_closure - try mutableState.liveObjectMutableState.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + try mutableState.liveObjectMutableState.nosync_subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in guard let self else { return } - mutex.withLock { + mutableStateMutex.withSync { mutableState in action(&mutableState.liveObjectMutableState) } }) @@ -213,21 +229,21 @@ internal final class InternalDefaultLiveMap: Sendable { } internal func unsubscribeAll() { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.liveObjectMutableState.unsubscribeAll() } } @discardableResult internal func on(event: LiveObjectLifecycleEvent, callback: @escaping LiveObjectLifecycleEventCallback) -> any OnLiveObjectLifecycleEventResponse { - mutex.withLock { + 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 } - mutex.withLock { + mutableStateMutex.withSync { mutableState in action(&mutableState.liveObjectMutableState) } }) @@ -235,7 +251,7 @@ internal final class InternalDefaultLiveMap: Sendable { } internal func offAll() { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.liveObjectMutableState.offAll() } } @@ -245,8 +261,8 @@ internal final class InternalDefaultLiveMap: Sendable { /// Emit an event from this `LiveMap`. /// /// This is used to instruct this map to emit updates during an `OBJECT_SYNC`. - internal func emit(_ update: LiveObjectUpdate) { - mutex.withLock { + internal func nosync_emit(_ update: LiveObjectUpdate) { + mutableStateMutex.withoutSync { mutableState in mutableState.liveObjectMutableState.emit(update, on: userCallbackQueue) } } @@ -258,30 +274,32 @@ internal final class InternalDefaultLiveMap: Sendable { /// - Parameters: /// - objectsPool: The pool into which should be inserted any objects created by a `MAP_SET` operation. /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone this map. - internal func replaceData( + internal func nosync_replaceData( using state: ObjectState, objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, ) -> LiveObjectUpdate { - mutex.withLock { + 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 RTLM17. - internal func mergeInitialValue(from operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { - mutex.withLock { + 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, ) @@ -290,11 +308,12 @@ internal final class InternalDefaultLiveMap: Sendable { /// Test-only method to apply a MAP_CREATE operation, per RTLM16. internal func testsOnly_applyMapCreateOperation(_ operation: ObjectOperation, objectsPool: inout ObjectsPool) -> LiveObjectUpdate { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.applyMapCreateOperation( operation, objectsPool: &objectsPool, logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -302,14 +321,14 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLM15. - internal func apply( + internal func nosync_apply( _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, ) { - mutex.withLock { + mutableStateMutex.withoutSync { mutableState in mutableState.apply( operation, objectMessageSerial: objectMessageSerial, @@ -317,6 +336,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -332,13 +352,14 @@ internal final class InternalDefaultLiveMap: Sendable { operationData: ObjectData, objectsPool: inout ObjectsPool, ) -> LiveObjectUpdate { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.applyMapSetOperation( key: key, operationTimeserial: operationTimeserial, operationData: operationData, objectsPool: &objectsPool, logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -349,7 +370,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// /// 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 { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.applyMapRemoveOperation( key: key, operationTimeserial: operationTimeserial, @@ -361,15 +382,15 @@ internal final class InternalDefaultLiveMap: Sendable { } /// 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 resetData() { - mutex.withLock { + 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 releaseTombstonedEntries(gracePeriod: TimeInterval, clock: SimpleClock) { - mutex.withLock { + internal func nosync_releaseTombstonedEntries(gracePeriod: TimeInterval, clock: SimpleClock) { + mutableStateMutex.withoutSync { mutableState in mutableState.releaseTombstonedEntries(gracePeriod: gracePeriod, logger: logger, clock: clock) } } @@ -377,15 +398,29 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - LiveObject /// Returns the object's RTLO3d `isTombstone` property. - internal var isTombstone: Bool { - mutex.withLock { + 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 tombstonedAt: Date? { - mutex.withLock { + 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 } } @@ -413,6 +448,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectsPool: inout ObjectsPool, logger: Logger, clock: SimpleClock, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, ) -> LiveObjectUpdate { // RTLM6a: Replace the private siteTimeserials with the value from ObjectState.siteTimeserials @@ -468,6 +504,7 @@ internal final class InternalDefaultLiveMap: Sendable { from: createOp, objectsPool: &objectsPool, logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -482,6 +519,7 @@ internal final class InternalDefaultLiveMap: Sendable { from operation: ObjectOperation, objectsPool: inout ObjectsPool, logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> LiveObjectUpdate { @@ -507,6 +545,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationData: entry.data, objectsPool: &objectsPool, logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -544,6 +583,7 @@ internal final class InternalDefaultLiveMap: Sendable { objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) { @@ -569,6 +609,7 @@ internal final class InternalDefaultLiveMap: Sendable { operation, objectsPool: &objectsPool, logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -591,6 +632,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationData: data, objectsPool: &objectsPool, logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -637,6 +679,7 @@ internal final class InternalDefaultLiveMap: Sendable { operationData: ObjectData?, objectsPool: inout ObjectsPool, logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> LiveObjectUpdate { @@ -665,7 +708,13 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute if let objectId = operationData?.objectId, !objectId.isEmpty { // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 - _ = objectsPool.createZeroValueObject(forObjectID: objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) + _ = objectsPool.createZeroValueObject( + forObjectID: objectId, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) } // RTLM7f @@ -760,6 +809,7 @@ internal final class InternalDefaultLiveMap: Sendable { _ operation: ObjectOperation, objectsPool: inout ObjectsPool, logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> LiveObjectUpdate { @@ -776,6 +826,7 @@ internal final class InternalDefaultLiveMap: Sendable { from: operation, objectsPool: &objectsPool, logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -824,9 +875,9 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Returns the value associated with a given key, following RTLM5d specification. - internal func get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { + internal func nosync_get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { // RTLM5c: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.get") + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.get") // RTLM5e - Return nil if self is tombstone if liveObjectMutableState.isTombstone { @@ -839,30 +890,30 @@ internal final class InternalDefaultLiveMap: Sendable { } // RTLM5d2: If a ObjectsMapEntry exists at the key, convert it using the shared logic - return convertEntryToLiveMapValue(entry, delegate: delegate) + return nosync_convertEntryToLiveMapValue(entry, delegate: delegate) } - internal func size(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> Int { + internal func nosync_size(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) 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.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.size") + 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.isEntryTombstoned(entry, delegate: delegate) + !Self.nosync_isEntryTombstoned(entry, delegate: delegate) } } - internal func entries(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { + internal func nosync_entries(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { // RTLM11c: If the channel is in the DETACHED or FAILED state, the library should throw an ErrorInfo error with statusCode 400 and code 90001 - try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "LiveMap.entries") + 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.isEntryTombstoned(entry, delegate: delegate) { + for (key, entry) in data where !Self.nosync_isEntryTombstoned(entry, delegate: delegate) { // Convert entry to LiveMapValue using the same logic as get(key:) - if let value = convertEntryToLiveMapValue(entry, delegate: delegate) { + if let value = nosync_convertEntryToLiveMapValue(entry, delegate: delegate) { result.append((key: key, value: value)) } } @@ -873,7 +924,7 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - Helper Methods /// Returns whether a map entry should be considered tombstoned, per the check described in RTLM14. - private static func isEntryTombstoned(_ entry: InternalObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> Bool { + private static func nosync_isEntryTombstoned(_ entry: InternalObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> Bool { // RTLM14a if entry.tombstone { return true @@ -881,7 +932,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM14c if let objectId = entry.data?.objectId { - if let poolEntry = delegate.getObjectFromPool(id: objectId), poolEntry.isTombstone { + if let poolEntry = delegate.nosync_getObjectFromPool(id: objectId), poolEntry.nosync_isTombstone { return true } } @@ -892,7 +943,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// Converts an InternalObjectsMapEntry to LiveMapValue using the same logic as get(key:) /// This is used by entries to ensure consistent value conversion - private func convertEntryToLiveMapValue(_ entry: InternalObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> InternalLiveMapValue? { + private func nosync_convertEntryToLiveMapValue(_ entry: InternalObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> InternalLiveMapValue? { // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null if entry.tombstone == true { return nil @@ -933,12 +984,12 @@ internal final class InternalDefaultLiveMap: Sendable { // 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 = delegate.getObjectFromPool(id: objectId) else { + guard let poolEntry = delegate.nosync_getObjectFromPool(id: objectId) else { return nil } // RTLM5d2f3: If referenced object is tombstoned, return nil - if poolEntry.isTombstone { + if poolEntry.nosync_isTombstone { return nil } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 179b472a2..ff7069f71 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -3,10 +3,7 @@ import Ably /// This provides the implementation behind ``PublicDefaultRealtimeObjects``, via internal versions of the ``RealtimeObjects`` API. internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPoolDelegate { - // Used for synchronizing access to all of this instance's mutable state. This is a temporary solution just to allow us to implement `Sendable`, and we'll revisit it in https://github.com/ably/ably-liveobjects-swift-plugin/issues/3. - private let mutex = NSLock() - - private nonisolated(unsafe) var mutableState: MutableState! + private let mutableStateMutex: DispatchQueueMutex private let logger: Logger private let userCallbackQueue: DispatchQueue @@ -39,14 +36,14 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } internal var testsOnly_objectsPool: ObjectsPool { - mutex.withLock { + 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 { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.syncSequence != nil } } @@ -91,7 +88,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } } - internal init(logger: Logger, userCallbackQueue: DispatchQueue, clock: SimpleClock, garbageCollectionOptions: GarbageCollectionOptions = .init()) { + internal init( + logger: Logger, + internalQueue: DispatchQueue, + userCallbackQueue: DispatchQueue, + clock: SimpleClock, + garbageCollectionOptions: GarbageCollectionOptions = .init() + ) { self.logger = logger self.userCallbackQueue = userCallbackQueue self.clock = clock @@ -99,7 +102,17 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool (receivedObjectSyncProtocolMessages, receivedObjectSyncProtocolMessagesContinuation) = AsyncStream.makeStream() (waitingForSyncEvents, waitingForSyncEventsContinuation) = AsyncStream.makeStream() (completedGarbageCollectionEventsWithoutBuffering, completedGarbageCollectionEventsWithoutBufferingContinuation) = AsyncStream.makeStream(bufferingPolicy: .bufferingNewest(0)) - mutableState = .init(objectsPool: .init(logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) + mutableStateMutex = .init( + dispatchQueue: internalQueue, + initialValue: .init( + objectsPool: .init( + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ), + ), + ) garbageCollectionInterval = garbageCollectionOptions.interval garbageCollectionGracePeriod = garbageCollectionOptions.gracePeriod @@ -128,8 +141,8 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool // MARK: - LiveMapObjectPoolDelegate - internal func getObjectFromPool(id: String) -> ObjectsPool.Entry? { - mutex.withLock { + internal func nosync_getObjectFromPool(id: String) -> ObjectsPool.Entry? { + mutableStateMutex.withoutSync { mutableState in mutableState.objectsPool.entries[id] } } @@ -137,11 +150,11 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool // MARK: - Internal methods that power RealtimeObjects conformance internal func getRoot(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { - // RTO1b: If the channel is in the DETACHED or FAILED state, the library should indicate an error with code 90001 - try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "getRoot") + let syncStatus = 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") - let syncStatus = mutex.withLock { - mutableState.syncStatus + return mutableState.syncStatus } if !syncStatus.isSyncComplete { @@ -152,7 +165,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool logger.log("getRoot completed waiting for sync sequence to complete", level: .debug) } - return mutex.withLock { + return mutableStateMutex.withSync { mutableState in // RTO1d mutableState.objectsPool.root } @@ -160,29 +173,32 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool internal func createMap(entries: [String: InternalLiveMapValue], coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { do throws(InternalError) { - // RTO11d - do { - try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createMap") - } catch { - throw error.toInternalError() - } + let creationOperation = try mutableStateMutex.withSync { _ throws(InternalError) in + // RTO11d + do throws(ARTErrorInfo) { + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createMap") + } catch { + throw error.toInternalError() + } - // RTO11f - // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) - let timestamp = clock.now - let creationOperation = ObjectCreationHelpers.creationOperationForLiveMap( - entries: entries, - timestamp: timestamp, - ) + // RTO11f + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) + let timestamp = clock.now + return ObjectCreationHelpers.nosync_creationOperationForLiveMap( + entries: entries, + timestamp: timestamp, + ) + } // RTO11g try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) // RTO11h - return mutex.withLock { - mutableState.objectsPool.getOrCreateMap( + return mutableStateMutex.withSync { mutableState in + mutableState.objectsPool.nosync_getOrCreateMap( creationOperation: creationOperation, logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -200,8 +216,10 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool internal func createCounter(count: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { do throws(InternalError) { // RTO12d - do { - try coreSDK.validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createCounter") + do throws(ARTErrorInfo) { + try mutableStateMutex.withSync { _ throws(ARTErrorInfo) in + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createCounter") + } } catch { throw error.toInternalError() } @@ -224,10 +242,11 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) // RTO12h - return mutex.withLock { - mutableState.objectsPool.getOrCreateCounter( + return mutableStateMutex.withSync { mutableState in + mutableState.objectsPool.nosync_getOrCreateCounter( creationOperation: creationOperation, logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -244,14 +263,14 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool @discardableResult internal func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { - mutex.withLock { + 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 } - mutex.withLock { + mutableStateMutex.withSync { mutableState in action(&mutableState) } }) @@ -259,7 +278,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } internal func offAll() { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.offAll() } } @@ -267,14 +286,14 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool // MARK: Handling channel events internal var testsOnly_onChannelAttachedHasObjects: Bool? { - mutex.withLock { + mutableStateMutex.withSync { mutableState in mutableState.onChannelAttachedHasObjects } } - internal func onChannelAttached(hasObjects: Bool) { - mutex.withLock { - mutableState.onChannelAttached( + internal func nosync_onChannelAttached(hasObjects: Bool) { + mutableStateMutex.withoutSync { mutableState in + mutableState.nosync_onChannelAttached( hasObjects: hasObjects, logger: logger, userCallbackQueue: userCallbackQueue, @@ -287,11 +306,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } /// Implements the `OBJECT` handling of RTO8. - internal func handleObjectProtocolMessage(objectMessages: [InboundObjectMessage]) { - mutex.withLock { - mutableState.handleObjectProtocolMessage( + 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, @@ -304,12 +324,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } /// Implements the `OBJECT_SYNC` handling of RTO5. - internal func handleObjectSyncProtocolMessage(objectMessages: [InboundObjectMessage], protocolMessageChannelSerial: String?) { - mutex.withLock { - mutableState.handleObjectSyncProtocolMessage( + 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, @@ -321,8 +342,14 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool /// /// Intended as a way for tests to populate the object pool. internal func testsOnly_createZeroValueLiveObject(forObjectID objectID: String) -> ObjectsPool.Entry? { - mutex.withLock { - mutableState.objectsPool.createZeroValueObject(forObjectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) + mutableStateMutex.withSync { mutableState in + mutableState.objectsPool.createZeroValueObject( + forObjectID: objectID, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) } } @@ -337,8 +364,8 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool /// Performs garbage collection of tombstoned objects and map entries, per RTO10c. internal func performGarbageCollection() { - mutex.withLock { - mutableState.objectsPool.performGarbageCollection( + mutableStateMutex.withSync { mutableState in + mutableState.objectsPool.nosync_performGarbageCollection( gracePeriod: garbageCollectionGracePeriod, clock: clock, logger: logger, @@ -421,7 +448,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool emitObjectsEvent(event, on: userCallbackQueue) } - internal mutating func onChannelAttached( + internal mutating func nosync_onChannelAttached( hasObjects: Bool, logger: Logger, userCallbackQueue: DispatchQueue, @@ -439,7 +466,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } // RTO4b1, RTO4b2: Reset the ObjectsPool to have a single empty root object - objectsPool.reset() + 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. @@ -450,10 +477,11 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } /// Implements the `OBJECT_SYNC` handling of RTO5. - internal mutating func handleObjectSyncProtocolMessage( + internal mutating func nosync_handleObjectSyncProtocolMessage( objectMessages: [InboundObjectMessage], protocolMessageChannelSerial: String?, logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, receivedObjectSyncProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, @@ -521,9 +549,10 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool if let completedSyncObjectsPool { // RTO5c - objectsPool.applySyncObjectsPool( + objectsPool.nosync_applySyncObjectsPool( completedSyncObjectsPool, logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -532,9 +561,10 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool if let completedSyncBufferedObjectOperations, !completedSyncBufferedObjectOperations.isEmpty { logger.log("Applying \(completedSyncBufferedObjectOperations.count) buffered OBJECT ObjectMessages", level: .debug) for objectMessage in completedSyncBufferedObjectOperations { - applyObjectProtocolMessageObjectMessage( + nosync_applyObjectProtocolMessageObjectMessage( objectMessage, logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -550,9 +580,10 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } /// Implements the `OBJECT` handling of RTO8. - internal mutating func handleObjectProtocolMessage( + internal mutating func nosync_handleObjectProtocolMessage( objectMessages: [InboundObjectMessage], logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, receivedObjectProtocolMessagesContinuation: AsyncStream<[InboundObjectMessage]>.Continuation, @@ -570,9 +601,10 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } else { // RTO8b: Handle the OBJECT message immediately for objectMessage in objectMessages { - applyObjectProtocolMessageObjectMessage( + nosync_applyObjectProtocolMessageObjectMessage( objectMessage, logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) @@ -581,9 +613,10 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } /// Implements the `OBJECT` application of RTO9. - private mutating func applyObjectProtocolMessageObjectMessage( + private mutating func nosync_applyObjectProtocolMessageObjectMessage( _ objectMessage: InboundObjectMessage, logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) { @@ -601,6 +634,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool guard let newEntry = objectsPool.createZeroValueObject( forObjectID: operation.objectId, logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) else { @@ -616,7 +650,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool switch action { case .mapCreate, .mapSet, .mapRemove, .counterCreate, .counterInc, .objectDelete: // RTO9a2a3 - entry.apply( + entry.nosync_apply( operation, objectMessageSerial: objectMessage.serial, objectMessageSiteCode: objectMessage.siteCode, diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift index 3b41bf061..7bf39f144 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -48,7 +48,7 @@ internal enum InternalLiveMapValue: Sendable, Equatable { // 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 RTO11f4 and RTLM20e4. - internal var toObjectData: ObjectData { + internal var nosync_toObjectData: ObjectData { // RTO11f4c1: Create an ObjectsMapEntry for the current value switch self { case let .bool(value): @@ -65,10 +65,10 @@ internal enum InternalLiveMapValue: Sendable, Equatable { .init(json: .object(value)) case let .liveMap(liveMap): // RTO11f4c1a: If the value is of type LiveMap, set ObjectsMapEntry.data.objectId to the objectId of that object - .init(objectId: liveMap.objectID) + .init(objectId: liveMap.nosync_objectID) case let .liveCounter(liveCounter): // RTO11f4c1a: If the value is of type LiveCounter, set ObjectsMapEntry.data.objectId to the objectId of that object - .init(objectId: liveCounter.objectID) + .init(objectId: liveCounter.nosync_objectID) } } diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift index 3a120bb65..8978b9ea2 100644 --- a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -82,9 +82,9 @@ internal struct LiveObjectMutableState { internal typealias UpdateLiveObject = @Sendable (_ action: (inout Self) -> Void) -> Void @discardableResult - internal mutating func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK, updateSelfLater: @escaping UpdateLiveObject) throws(ARTErrorInfo) -> any AblyLiveObjects.SubscribeResponse { + internal mutating func nosync_subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK, updateSelfLater: @escaping UpdateLiveObject) throws(ARTErrorInfo) -> any AblyLiveObjects.SubscribeResponse { // RTLO4b2 - try coreSDK.validateChannelState(notIn: [.detached, .failed], operationDescription: "subscribe") + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed], operationDescription: "subscribe") let updateSubscriptionStorage: SubscriptionStorage.UpdateSubscriptionStorage = { action in updateSelfLater { liveObject in diff --git a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift index f061302ef..e823ab030 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift @@ -100,13 +100,13 @@ internal enum ObjectCreationHelpers { /// - Parameters: /// - entries: The initial entries for the new LiveMap object /// - timestamp: The timestamp to use for the generated object ID. - internal static func creationOperationForLiveMap( + internal static func nosync_creationOperationForLiveMap( entries: [String: InternalLiveMapValue], timestamp: Date, ) -> MapCreationOperation { // RTO11f4: Create initial value for the new LiveMap let mapEntries = entries.mapValues { liveMapValue -> ObjectsMapEntry in - ObjectsMapEntry(data: liveMapValue.toObjectData) + ObjectsMapEntry(data: liveMapValue.nosync_toObjectData) } let initialValue = PartialObjectOperation( diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 7174f49a1..addfe833e 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -30,7 +30,7 @@ internal struct ObjectsPool { } /// Applies an operation to a LiveObject, per RTO9a2a3. - internal func apply( + internal func nosync_apply( _ operation: ObjectOperation, objectMessageSerial: String?, objectMessageSiteCode: String?, @@ -39,7 +39,7 @@ internal struct ObjectsPool { ) { switch self { case let .map(map): - map.apply( + map.nosync_apply( operation, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, @@ -47,7 +47,7 @@ internal struct ObjectsPool { objectsPool: &objectsPool, ) case let .counter(counter): - counter.apply( + counter.nosync_apply( operation, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, @@ -63,12 +63,12 @@ internal struct ObjectsPool { case counter(InternalDefaultLiveCounter, LiveObjectUpdate) /// Causes the referenced `LiveObject` to emit the stored event to its subscribers. - internal func emit() { + internal func nosync_emit() { switch self { case let .map(map, update): - map.emit(update) + map.nosync_emit(update) case let .counter(counter, update): - counter.emit(update) + counter.nosync_emit(update) } } } @@ -79,7 +79,7 @@ internal struct ObjectsPool { /// /// - Parameters: /// - objectMessageSerialTimestamp: The `serialTimestamp` of the containing `ObjectMessage`. Used if we need to tombstone the object. - fileprivate func replaceData( + fileprivate func nosync_replaceData( using state: ObjectState, objectMessageSerialTimestamp: Date?, objectsPool: inout ObjectsPool, @@ -89,7 +89,7 @@ internal struct ObjectsPool { case let .map(map): .map( map, - map.replaceData( + map.nosync_replaceData( using: state, objectMessageSerialTimestamp: objectMessageSerialTimestamp, objectsPool: &objectsPool, @@ -98,7 +98,7 @@ internal struct ObjectsPool { case let .counter(counter): .counter( counter, - counter.replaceData( + counter.nosync_replaceData( using: state, objectMessageSerialTimestamp: objectMessageSerialTimestamp, ), @@ -107,21 +107,41 @@ internal struct ObjectsPool { } /// Returns the object's RTLO3d `isTombstone` property. - internal var isTombstone: Bool { + internal var nosync_isTombstone: Bool { switch self { case let .counter(counter): - counter.isTombstone + counter.nosync_isTombstone case let .map(map): - map.isTombstone + map.nosync_isTombstone } } - internal var tombstonedAt: Date? { + internal var nosync_tombstonedAt: Date? { switch self { case let .counter(counter): - counter.tombstonedAt + counter.nosync_tombstonedAt case let .map(map): - map.tombstonedAt + 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 } } } @@ -139,12 +159,14 @@ internal struct ObjectsPool { /// 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, @@ -153,13 +175,22 @@ internal struct ObjectsPool { 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, userCallbackQueue: userCallbackQueue, clock: clock)) + entries[Self.rootKey] = .map( + .createZeroValued( + objectID: Self.rootKey, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ), + ) } // MARK: - Typed root @@ -188,7 +219,13 @@ internal struct ObjectsPool { /// - 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, userCallbackQueue: DispatchQueue, clock: SimpleClock) -> Entry? { + 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 @@ -206,9 +243,25 @@ internal struct ObjectsPool { let entry: Entry switch typeString { case "map": - entry = .map(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) + entry = .map( + .createZeroValued( + objectID: objectID, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ), + ) case "counter": - entry = .counter(.createZeroValued(objectID: objectID, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock)) + entry = .counter( + .createZeroValued( + objectID: objectID, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ), + ) default: return nil } @@ -219,9 +272,10 @@ internal struct ObjectsPool { } /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1 and RTO5c2. - internal mutating func applySyncObjectsPool( + internal mutating func nosync_applySyncObjectsPool( _ syncObjectsPool: [SyncObjectsPoolEntry], logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) { @@ -242,7 +296,7 @@ internal struct ObjectsPool { logger.log("Updating existing object with ID: \(syncObjectsPoolEntry.state.objectId)", level: .debug) // RTO5c1a1: Override the internal data for the object as per RTLC6, RTLM6 - let deferredUpdate = existingEntry.replaceData( + let deferredUpdate = existingEntry.nosync_replaceData( using: syncObjectsPoolEntry.state, objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, objectsPool: &self, @@ -260,8 +314,14 @@ internal struct ObjectsPool { if syncObjectsPoolEntry.state.counter != nil { // RTO5c1b1a: If ObjectState.counter is present, create a zero-value LiveCounter, // set its private objectId equal to ObjectState.objectId and override its internal data per RTLC6 - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: syncObjectsPoolEntry.state.objectId, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) - _ = counter.replaceData( + let counter = InternalDefaultLiveCounter.createZeroValued( + objectID: syncObjectsPoolEntry.state.objectId, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + _ = counter.nosync_replaceData( using: syncObjectsPoolEntry.state, objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, ) @@ -270,8 +330,15 @@ internal struct ObjectsPool { // RTO5c1b1b: If ObjectState.map is present, create a zero-value LiveMap, // set its private objectId equal to ObjectState.objectId, set its private semantics // equal to ObjectState.map.semantics and override its internal data per RTLM6 - let map = InternalDefaultLiveMap.createZeroValued(objectID: syncObjectsPoolEntry.state.objectId, semantics: objectsMap.semantics, logger: logger, userCallbackQueue: userCallbackQueue, clock: clock) - _ = map.replaceData( + let map = InternalDefaultLiveMap.createZeroValued( + objectID: syncObjectsPoolEntry.state.objectId, + semantics: objectsMap.semantics, + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + _ = map.nosync_replaceData( using: syncObjectsPoolEntry.state, objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, objectsPool: &self, @@ -302,7 +369,7 @@ internal struct ObjectsPool { // RTO5c7: Emit the updates to existing objects for deferredUpdate in updatesToExistingObjects { - deferredUpdate.emit() + deferredUpdate.nosync_emit() } logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) @@ -313,12 +380,14 @@ internal struct ObjectsPool { /// - Parameters: /// - creationOperation: The CounterCreationOperation containing the object ID and operation to merge /// - logger: The logger to use for any created LiveObject + /// - internalQueue: The internal queue 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 counter object - internal mutating func getOrCreateCounter( + internal mutating func nosync_getOrCreateCounter( creationOperation: ObjectCreationHelpers.CounterCreationOperation, logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> InternalDefaultLiveCounter { @@ -338,12 +407,13 @@ internal struct ObjectsPool { let counter = InternalDefaultLiveCounter.createZeroValued( objectID: creationOperation.objectID, logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) // Merge the initial value from the creation operation - _ = counter.mergeInitialValue(from: creationOperation.operation) + _ = counter.nosync_mergeInitialValue(from: creationOperation.operation) // RTO12h3b: Add the created LiveCounter instance to the internal ObjectsPool entries[creationOperation.objectID] = .counter(counter) @@ -357,12 +427,14 @@ internal struct ObjectsPool { /// - Parameters: /// - creationOperation: The MapCreationOperation containing the object ID and operation to merge /// - logger: The logger to use for any created LiveObject + /// - internalQueue: The internal queue 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 map object - internal mutating func getOrCreateMap( + internal mutating func nosync_getOrCreateMap( creationOperation: ObjectCreationHelpers.MapCreationOperation, logger: Logger, + internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> InternalDefaultLiveMap { @@ -383,12 +455,13 @@ internal struct ObjectsPool { objectID: creationOperation.objectID, semantics: .known(creationOperation.semantics), logger: logger, + internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) // Merge the initial value from the creation operation - _ = map.mergeInitialValue(from: creationOperation.operation, objectsPool: &self) + _ = map.nosync_mergeInitialValue(from: creationOperation.operation, objectsPool: &self) // RTO11h3b: Add the created LiveMap instance to the internal ObjectsPool entries[creationOperation.objectID] = .map(map) @@ -398,7 +471,7 @@ internal struct ObjectsPool { } /// Removes all entries except the root, and clears the root's data. This is to be used when an `ATTACHED` ProtocolMessage indicates that the only object in a channel is an empty root map, per RTO4b. - internal mutating func reset() { + internal mutating func nosync_reset() { let root = root // RTO4b1 @@ -406,11 +479,11 @@ internal struct ObjectsPool { // RTO4b2 // TODO: this one is unclear (are we meant to replace the root or just clear its data?) https://github.com/ably/specification/pull/333/files#r2183493458. I believe that the answer is that we should just clear its data but the spec point needs to be clearer, see https://github.com/ably/specification/pull/346/files#r2201434895. - root.resetData() + root.nosync_resetData() } /// Performs garbage collection of tombstoned objects and map entries, per RTO10c. - internal mutating func performGarbageCollection( + internal mutating func nosync_performGarbageCollection( gracePeriod: TimeInterval, clock: SimpleClock, logger: Logger, @@ -423,12 +496,12 @@ internal struct ObjectsPool { entries = entries.filter { key, entry in if case let .map(map) = entry { // RTO10c1a - map.releaseTombstonedEntries(gracePeriod: gracePeriod, clock: clock) + map.nosync_releaseTombstonedEntries(gracePeriod: gracePeriod, clock: clock) } // RTO10c1b let shouldRelease = { - guard let tombstonedAt = entry.tombstonedAt else { + guard let tombstonedAt = entry.nosync_tombstonedAt else { return false } diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift index cec5ca942..0beb04b02 100644 --- a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift +++ b/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift @@ -10,7 +10,10 @@ public extension ARTRealtimeChannel { private var nonTypeErasedObjects: PublicDefaultRealtimeObjects { let pluginAPI = Plugin.defaultPluginAPI let underlyingObjects = pluginAPI.underlyingObjects(for: asPluginPublicRealtimeChannel) - let internalObjects = DefaultInternalPlugin.realtimeObjects(for: underlyingObjects.channel, pluginAPI: pluginAPI) + 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) diff --git a/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift b/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift new file mode 100644 index 000000000..fc188589d --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift @@ -0,0 +1,15 @@ +import Foundation + +internal extension DispatchQueue { + /// Same as `sync(execute:)` but with a runtime precondition that we are not already on this queue. + func ably_syncNoDeadlock(execute block: () -> Void) { + dispatchPrecondition(condition: .notOnQueue(self)) + sync(execute: block) + } + + /// Same as `sync(execute:)` but with a runtime precondition that we are not already on this queue. + func ably_syncNoDeadlock(execute work: () throws -> T) rethrows -> T { + dispatchPrecondition(condition: .notOnQueue(self)) + return try sync(execute: work) + } +} diff --git a/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift b/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift new file mode 100644 index 000000000..5be97eb46 --- /dev/null +++ b/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift @@ -0,0 +1,48 @@ +import Foundation + +/// A class that provides mutually exclusive access to a value using a serial dispatch queue. +/// +/// In order to access or mutate the mutex's value, it is expected that you know whether or not you are already executing on the mutex's queue. If you are, then use ``withoutSync(_:)``, which simply performs a runtime check that the current queue is correct. If not, then use ``withSync(_:)``, which synchronously dispatches to the queue. ``withSync(_:)`` must not be called from the queue, as doing so would cause a deadlock (there is a runtime check which terminates execution in this case). +/// +/// This class is styled on Swift's built-in `Mutex` type. +internal final class DispatchQueueMutex: Sendable { + /// The queue that this mutex uses to synchronise access to the wrapped value. + internal let dispatchQueue: DispatchQueue + + private nonisolated(unsafe) var value: T + + internal init(dispatchQueue: DispatchQueue, initialValue: T) { + self.dispatchQueue = dispatchQueue + value = initialValue + } + + /// Provides access to the wrapped value by dispatching synchronously to the dispatch queue. + /// + /// - Parameters: + /// - body: The action to perform. It can read and/or mutate the wrapped value. + /// + /// - Warning: This must only be called when not already on the dispatch queue. Violating this precondition will result in a runtime error. + internal func withSync(_ body: (inout T) throws(E) -> R) throws(E) -> R { + let result: Result = dispatchQueue.ably_syncNoDeadlock { + do throws(E) { + return try .success(body(&value)) + } catch { + return .failure(error) + } + } + + return try result.get() + } + + /// Provides access to the wrapped value without dispatching to the dispatch queue. + /// + /// - Parameters: + /// - body: The action to perform. It can read and/or mutate the wrapped value. + /// + /// - Warning: This must only be called when already on the dispatch queue. Violating this precondition will result in a runtime error. + internal func withoutSync(_ body: (inout T) throws(E) -> R) throws(E) -> R { + dispatchPrecondition(condition: .onQueue(dispatchQueue)) + + return try body(&value) + } +} diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index 89b2ddb59..6199bf272 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -680,4 +680,13 @@ struct TestFactories { } 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/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 048a64ceb..9669d4393 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -11,8 +11,9 @@ struct InternalDefaultLiveCounterTests { @Test(arguments: [.detached, .failed] as [_AblyPluginSupportPrivate.RealtimeChannelState]) func valueThrowsIfChannelIsDetachedOrFailed(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: channelState) + 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) @@ -29,11 +30,14 @@ struct InternalDefaultLiveCounterTests { @Test func valueReturnsCurrentDataWhenChannelIsValid() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attached) + 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 - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 42), objectMessageSerialTimestamp: nil) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 42), objectMessageSerialTimestamp: nil) + } #expect(try counter.value(coreSDK: coreSDK) == 42) } @@ -45,11 +49,14 @@ struct InternalDefaultLiveCounterTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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 ) - _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) } @@ -60,15 +67,18 @@ struct InternalDefaultLiveCounterTests { 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, userCallbackQueue: .main, clock: MockSimpleClock()) + 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), ), ) - _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } #expect(counter.testsOnly_createOperationIsMerged) return counter @@ -78,7 +88,9 @@ struct InternalDefaultLiveCounterTests { let state = TestFactories.counterObjectState( createOp: nil, // Test value - must be nil to test RTLC6b ) - _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } // Then: #expect(!counter.testsOnly_createOperationIsMerged) @@ -88,12 +100,15 @@ struct InternalDefaultLiveCounterTests { @Test func setsDataToCounterCount() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attaching) + 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 ) - _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil) + } #expect(try counter.value(coreSDK: coreSDK) == 42) } @@ -101,11 +116,14 @@ struct InternalDefaultLiveCounterTests { @Test func setsDataToZeroWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attaching) - _ = counter.replaceData(using: TestFactories.counterObjectState( - count: nil, // Test value - must be nil - ), objectMessageSerialTimestamp: nil) + 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) } @@ -117,13 +135,16 @@ struct InternalDefaultLiveCounterTests { @Test func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attaching) + 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 ) - _ = counter.replaceData(using: state, objectMessageSerialTimestamp: nil) + 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 (RTLC10a) #expect(counter.testsOnly_createOperationIsMerged) } @@ -137,16 +158,21 @@ struct InternalDefaultLiveCounterTests { @Test func addsCounterCountToData() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attaching) + 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 - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) + 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 = counter.mergeInitialValue(from: operation) + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_mergeInitialValue(from: operation) + } #expect(try counter.value(coreSDK: coreSDK) == 15) // 5 + 10 @@ -159,11 +185,14 @@ struct InternalDefaultLiveCounterTests { @Test func doesNotModifyDataWhenCounterCountDoesNotExist() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attaching) + 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 - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) + 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 @@ -171,7 +200,9 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterCreate), counter: nil, // Test value - must be nil ) - let update = counter.mergeInitialValue(from: operation) + let update = internalQueue.ably_syncNoDeadlock { + counter.nosync_mergeInitialValue(from: operation) + } #expect(try counter.value(coreSDK: coreSDK) == 5) // Unchanged @@ -183,11 +214,14 @@ struct InternalDefaultLiveCounterTests { @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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 - _ = counter.mergeInitialValue(from: operation) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_mergeInitialValue(from: operation) + } #expect(counter.testsOnly_createOperationIsMerged) } @@ -199,12 +233,15 @@ struct InternalDefaultLiveCounterTests { @Test func discardsOperationWhenCreateOperationIsMerged() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attaching) + 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 - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) - _ = counter.mergeInitialValue(from: TestFactories.counterCreateOperation(count: 10)) + 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 @@ -223,11 +260,14 @@ struct InternalDefaultLiveCounterTests { @Test func mergesInitialValue() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attaching) + 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 - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) + } #expect(!counter.testsOnly_createOperationIsMerged) // Apply COUNTER_CREATE operation @@ -264,11 +304,14 @@ struct InternalDefaultLiveCounterTests { ) func addsAmountToData(operation: WireObjectsCounterOp?, expectedValue: Double, expectedUpdate: LiveObjectUpdate) throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attaching) + 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 - _ = counter.replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) + internalQueue.ably_syncNoDeadlock { + _ = counter.nosync_replaceData(using: TestFactories.counterObjectState(count: 5), objectMessageSerialTimestamp: nil) + } #expect(try counter.value(coreSDK: coreSDK) == 5) // Apply COUNTER_INC operation @@ -288,29 +331,34 @@ struct InternalDefaultLiveCounterTests { @Test func discardsOperationWhenCannotBeApplied() throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attaching) + 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 - _ = counter.replaceData(using: TestFactories.counterObjectState( - siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" - count: 5, - ), objectMessageSerialTimestamp: nil) + 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), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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) - counter.apply( - operation, - objectMessageSerial: "ts1", // Less than existing "ts2" - objectMessageSiteCode: "site1", - objectMessageSerialTimestamp: nil, - objectsPool: &pool, - ) + internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + operation, + objectMessageSerial: "ts1", // Less than existing "ts2" + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } // Check that the COUNTER_INC side-effects didn't happen: // Verify the operation was discarded - data unchanged (should still be 5 from creation) @@ -326,23 +374,26 @@ struct InternalDefaultLiveCounterTests { @Test func appliesCounterCreateOperation() async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attaching) + 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, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply COUNTER_CREATE operation - counter.apply( - operation, - objectMessageSerial: "ts1", - objectMessageSiteCode: "site1", - objectMessageSerialTimestamp: nil, - objectsPool: &pool, - ) + internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } // 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) @@ -362,30 +413,35 @@ struct InternalDefaultLiveCounterTests { @Test func appliesCounterIncOperation() async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attaching) + 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 - _ = counter.replaceData(using: TestFactories.counterObjectState(siteTimeserials: [:], count: 5), objectMessageSerialTimestamp: nil) + 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), counterOp: TestFactories.counterOp(amount: 10), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply COUNTER_INC operation - counter.apply( - operation, - objectMessageSerial: "ts1", - objectMessageSiteCode: "site1", - objectMessageSerialTimestamp: nil, - objectsPool: &pool, - ) + internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } // 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 @@ -402,21 +458,24 @@ struct InternalDefaultLiveCounterTests { @Test func noOpForOtherOperation() async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attaching) + 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, userCallbackQueue: .main, clock: MockSimpleClock()) - counter.apply( - TestFactories.mapCreateOperation(), - objectMessageSerial: "ts1", - objectMessageSiteCode: "site1", - objectMessageSerialTimestamp: nil, - objectsPool: &pool, - ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + counter.nosync_apply( + TestFactories.mapCreateOperation(), + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } // Check no update was emitted let subscriberInvocations = await subscriber.getInvocations() @@ -430,8 +489,9 @@ struct InternalDefaultLiveCounterTests { @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) func throwsErrorForInvalidChannelState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: channelState) + 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) await #expect { try await counter.increment(amount: 10, coreSDK: coreSDK) @@ -452,8 +512,9 @@ struct InternalDefaultLiveCounterTests { ] as [Double]) func throwsErrorForInvalidAmount(amount: Double) async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attached) + 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) await #expect { try await counter.increment(amount: amount, coreSDK: coreSDK) @@ -472,8 +533,9 @@ struct InternalDefaultLiveCounterTests { // @spec RTLC12f func publishesCorrectObjectMessage() async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attached) + 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) var publishedMessages: [OutboundObjectMessage] = [] coreSDK.setPublishHandler { messages in @@ -500,8 +562,9 @@ struct InternalDefaultLiveCounterTests { @Test func throwsErrorWhenPublishFails() async throws { let logger = TestLogger() - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attached) + 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) coreSDK.setPublishHandler { _ throws(InternalError) in throw InternalError.other(.generic(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]))) @@ -524,8 +587,9 @@ struct InternalDefaultLiveCounterTests { @Test func isOppositeOfIncrement() async throws { // This is just a smoke test; we assume that this just calls `increment`, which is tested elsewhere. - let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attached) + 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) var publishedMessages: [OutboundObjectMessage] = [] coreSDK.setPublishHandler { messages in diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index a2ee2cb10..e4670f3c4 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -11,10 +11,11 @@ struct InternalDefaultLiveMapTests { @Test(arguments: [.detached, .failed] as [_AblyPluginSupportPrivate.RealtimeChannelState]) func getThrowsIfChannelIsDetachedOrFailed(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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), delegate: MockLiveMapObjectPoolDelegate()) + _ = try map.get(key: "test", coreSDK: MockCoreSDK(channelState: channelState, internalQueue: internalQueue), delegate: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false @@ -30,9 +31,10 @@ struct InternalDefaultLiveMapTests { @Test func returnsNilWhenNoEntryExists() throws { let logger = TestLogger() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - #expect(try map.get(key: "nonexistent", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) + 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) == nil) } // @spec RTLM5d2a @@ -43,19 +45,21 @@ struct InternalDefaultLiveMapTests { tombstonedAt: Date(), data: ObjectData(boolean: true), // Value doesn't matter as it's tombstoned ) - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) == nil) + 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: MockLiveMapObjectPoolDelegate(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) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) + 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) #expect(result?.boolValue == true) } @@ -63,11 +67,12 @@ struct InternalDefaultLiveMapTests { @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) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) + 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) #expect(result?.dataValue == bytes) } @@ -75,10 +80,11 @@ struct InternalDefaultLiveMapTests { @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) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) + 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) #expect(result?.numberValue == 123.456) } @@ -86,10 +92,11 @@ struct InternalDefaultLiveMapTests { @Test func returnsStringValue() throws { let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() let entry = TestFactories.internalMapEntry(data: ObjectData(string: "test")) - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) + 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) #expect(result?.stringValue == "test") } @@ -98,10 +105,11 @@ struct InternalDefaultLiveMapTests { @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) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) + 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) #expect(result?.jsonArrayValue == ["foo"]) } @@ -110,10 +118,11 @@ struct InternalDefaultLiveMapTests { @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) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectPoolDelegate()) + 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) #expect(result?.jsonObjectValue == ["foo": "bar"]) } @@ -122,9 +131,10 @@ struct InternalDefaultLiveMapTests { func returnsNilWhenReferencedObjectDoesNotExist() throws { let logger = TestLogger() let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: "missing")) - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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) } @@ -132,13 +142,14 @@ struct InternalDefaultLiveMapTests { @Test func returnsReferencedMap() throws { let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() let objectId = "map1" let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let delegate = MockLiveMapObjectPoolDelegate(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, userCallbackQueue: .main, clock: MockSimpleClock()) + 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) @@ -148,13 +159,14 @@ struct InternalDefaultLiveMapTests { @Test func returnsReferencedCounter() throws { let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() let objectId = "counter1" let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let delegate = MockLiveMapObjectPoolDelegate(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, userCallbackQueue: .main, clock: MockSimpleClock()) + 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) @@ -164,10 +176,11 @@ struct InternalDefaultLiveMapTests { @Test func returnsNullOtherwise() throws { let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() let entry = TestFactories.internalMapEntry(data: ObjectData()) - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap(testsOnly_data: ["key": entry], objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let delegate = MockLiveMapObjectPoolDelegate(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) } } @@ -178,13 +191,16 @@ struct InternalDefaultLiveMapTests { @Test func replacesSiteTimeserials() { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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, userCallbackQueue: .main, clock: MockSimpleClock()) - _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + 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"]) } @@ -193,15 +209,18 @@ struct InternalDefaultLiveMapTests { func setsCreateOperationIsMergedToFalseWhenCreateOpAbsent() { // Given: let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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, userCallbackQueue: .main, clock: MockSimpleClock()) + 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"), ) - _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } #expect(map.testsOnly_createOperationIsMerged) return map @@ -209,7 +228,9 @@ struct InternalDefaultLiveMapTests { // When: let state = TestFactories.objectState(objectId: "arbitrary-id", createOp: nil) - _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + } // Then: #expect(!map.testsOnly_createOperationIsMerged) @@ -219,16 +240,19 @@ struct InternalDefaultLiveMapTests { @Test func setsDataToMapEntries() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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, userCallbackQueue: .main, clock: MockSimpleClock()) - _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + 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"]) @@ -240,9 +264,10 @@ struct InternalDefaultLiveMapTests { @Test func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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( @@ -258,8 +283,10 @@ struct InternalDefaultLiveMapTests { ], ), ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - _ = map.replaceData(using: state, objectMessageSerialTimestamp: nil, objectsPool: &pool) + 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; RTLM17 is tested in more detail elsewhere // Check that it contains the data from the entries (per RTLM6c) and also the createOp (per RTLM6d) #expect(try map.get(key: "keyFromMapEntries", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromMapEntries") @@ -279,9 +306,10 @@ struct InternalDefaultLiveMapTests { @Test(arguments: [.detached, .failed] as [_AblyPluginSupportPrivate.RealtimeChannelState]) func allPropertiesThrowIfChannelIsDetachedOrFailed(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: channelState) - let delegate = MockLiveMapObjectPoolDelegate() + 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 = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) // Define actions to test let actions: [(String, () throws -> Any)] = [ @@ -314,8 +342,9 @@ struct InternalDefaultLiveMapTests { @Test func allPropertiesFilterOutTombstonedEntries() throws { let logger = TestLogger() - let coreSDK = MockCoreSDK(channelState: .attaching) - let delegate = MockLiveMapObjectPoolDelegate() + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) let map = InternalDefaultLiveMap( testsOnly_data: [ // tombstonedAt is nil, so not considered tombstoned @@ -326,6 +355,7 @@ struct InternalDefaultLiveMapTests { ], objectID: "arbitrary", logger: logger, + internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock(), ) @@ -359,8 +389,9 @@ struct InternalDefaultLiveMapTests { @Test func allAccessPropertiesReturnExpectedValuesAndAreConsistentWithEachOther() throws { let logger = TestLogger() - let coreSDK = MockCoreSDK(channelState: .attaching) - let delegate = MockLiveMapObjectPoolDelegate() + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) + let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) let map = InternalDefaultLiveMap( testsOnly_data: [ "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), @@ -369,6 +400,7 @@ struct InternalDefaultLiveMapTests { ], objectID: "arbitrary", logger: logger, + internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock(), ) @@ -397,12 +429,13 @@ struct InternalDefaultLiveMapTests { @Test func entriesHandlesAllValueTypes() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) // Create referenced objects for testing - let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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) @@ -419,6 +452,7 @@ struct InternalDefaultLiveMapTests { ], objectID: "arbitrary", logger: logger, + internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock(), ) @@ -463,16 +497,18 @@ struct InternalDefaultLiveMapTests { @Test func discardsOperationWhenCannotBeApplied() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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, 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( @@ -503,16 +539,18 @@ struct InternalDefaultLiveMapTests { ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) func appliesOperationWhenCanBeApplied(operationData: ObjectData, expectedCreatedObjectID: String?) throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let update = map.testsOnly_applyMapSetOperation( key: "key1", @@ -576,10 +614,11 @@ struct InternalDefaultLiveMapTests { ] as [(operationData: ObjectData, expectedCreatedObjectID: String?)]) func createsNewEntryWhenNoExistingEntry(operationData: ObjectData, expectedCreatedObjectID: String?) throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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", @@ -626,9 +665,10 @@ struct InternalDefaultLiveMapTests { @Test func doesNotReplaceExistingObjectWhenReferencedByMapSet() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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" @@ -636,11 +676,13 @@ struct InternalDefaultLiveMapTests { 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)], @@ -675,12 +717,14 @@ struct InternalDefaultLiveMapTests { @Test func discardsOperationWhenCannotBeApplied() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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(), ) @@ -701,12 +745,14 @@ struct InternalDefaultLiveMapTests { @Test func appliesOperationWhenCanBeApplied() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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(), ) @@ -739,7 +785,8 @@ struct InternalDefaultLiveMapTests { @Test func createsNewEntryWhenNoExistingEntry() throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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) @@ -757,7 +804,8 @@ struct InternalDefaultLiveMapTests { @Test func setsNewEntryTombstoneToTrue() throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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) @@ -817,16 +865,18 @@ struct InternalDefaultLiveMapTests { ] as [(entrySerial: String?, operationSerial: String?, shouldApply: Bool)]) func mapOperationApplicability(entrySerial: String?, operationSerial: String?, shouldApply: Bool) throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) _ = map.testsOnly_applyMapSetOperation( key: "key1", @@ -853,10 +903,11 @@ struct InternalDefaultLiveMapTests { @Test func appliesMapSetOperationsFromOperation() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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( @@ -865,7 +916,9 @@ struct InternalDefaultLiveMapTests { "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, ], ) - _ = map.mergeInitialValue(from: operation, objectsPool: &pool) + 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 RTLM17a1) @@ -876,16 +929,18 @@ struct InternalDefaultLiveMapTests { @Test func appliesMapRemoveOperationsFromOperation() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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, 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) @@ -900,7 +955,9 @@ struct InternalDefaultLiveMapTests { objectId: "arbitrary-id", entries: ["key1": entry], ) - _ = map.mergeInitialValue(from: operation, objectsPool: &pool) + 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) @@ -910,6 +967,7 @@ struct InternalDefaultLiveMapTests { @Test func returnedUpdateMergesOperationUpdates() throws { let logger = TestLogger() + let internalQueue = TestFactories.createInternalQueue() let map = InternalDefaultLiveMap( testsOnly_data: [ "keyThatWillBeRemoved": TestFactories.internalStringMapEntry(timeserial: "ts1").entry, @@ -917,10 +975,11 @@ struct InternalDefaultLiveMapTests { ], objectID: "arbitrary", logger: logger, + internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock(), ) - var pool = ObjectsPool(logger: logger, 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 RTLM17a1 and RTLM17a2 test cases) let operation = TestFactories.mapCreateOperation( @@ -939,7 +998,9 @@ struct InternalDefaultLiveMapTests { "keyFromCreateOp": TestFactories.stringMapEntry(key: "keyFromCreateOp", value: "valueFromCreateOp").entry, ], ) - let update = map.mergeInitialValue(from: operation, objectsPool: &pool) + let update = internalQueue.ably_syncNoDeadlock { + map.nosync_mergeInitialValue(from: operation, objectsPool: &pool) + } // Verify merged return value per RTLM17c #expect(try #require(update.update).update == ["keyThatWillBeRemoved": .removed, "keyFromCreateOp": .updated]) @@ -949,12 +1010,15 @@ struct InternalDefaultLiveMapTests { @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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") - _ = map.mergeInitialValue(from: operation, objectsPool: &pool) + internalQueue.ably_syncNoDeadlock { + _ = map.nosync_mergeInitialValue(from: operation, objectsPool: &pool) + } #expect(map.testsOnly_createOperationIsMerged) } @@ -966,14 +1030,19 @@ struct InternalDefaultLiveMapTests { @Test func discardsOperationWhenCreateOperationIsMerged() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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 - _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectMessageSerialTimestamp: nil, objectsPool: &pool) - _ = map.mergeInitialValue(from: TestFactories.mapCreateOperation(entries: ["key2": TestFactories.stringMapEntry(key: "key2", value: "value2").entry]), objectsPool: &pool) + 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 @@ -994,13 +1063,16 @@ struct InternalDefaultLiveMapTests { @Test func mergesInitialValue() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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 - _ = map.replaceData(using: TestFactories.mapObjectState(entries: ["key1": TestFactories.stringMapEntry().entry]), objectMessageSerialTimestamp: nil, objectsPool: &pool) + 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 @@ -1023,21 +1095,24 @@ struct InternalDefaultLiveMapTests { @Test func discardsOperationWhenCannotBeApplied() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - _ = map.replaceData( - using: TestFactories.mapObjectState( - siteTimeserials: ["site1": "ts2"], // Existing serial "ts2" - entries: [key1: entry1], - ), - objectMessageSerialTimestamp: nil, - objectsPool: &pool, - ) + 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), @@ -1045,13 +1120,15 @@ struct InternalDefaultLiveMapTests { ) // 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) - map.apply( - operation, - objectMessageSerial: "ts1", // Less than existing "ts2" - objectMessageSiteCode: "site1", - objectMessageSerialTimestamp: nil, - objectsPool: &pool, - ) + internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + objectMessageSerial: "ts1", // Less than existing "ts2" + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } // Check that the MAP_SET side-effects didn't happen: // Verify the operation was discarded - data unchanged (should still be "existing" from creation) @@ -1067,9 +1144,10 @@ struct InternalDefaultLiveMapTests { @Test func appliesMapCreateOperation() async throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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) @@ -1077,16 +1155,18 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.mapCreateOperation( entries: ["key1": TestFactories.stringMapEntry(key: "key1", value: "value1").entry], ) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply MAP_CREATE operation - map.apply( - operation, - objectMessageSerial: "ts1", - objectMessageSiteCode: "site1", - objectMessageSerialTimestamp: nil, - objectsPool: &pool, - ) + internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } // 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") @@ -1106,24 +1186,27 @@ struct InternalDefaultLiveMapTests { @Test func appliesMapSetOperation() async throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - _ = map.replaceData( - using: TestFactories.mapObjectState( - siteTimeserials: [:], - entries: [key1: entry1], - ), - objectMessageSerialTimestamp: nil, - objectsPool: &pool, - ) + 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( @@ -1132,13 +1215,15 @@ struct InternalDefaultLiveMapTests { ) // Apply MAP_SET operation - map.apply( - operation, - objectMessageSerial: "ts1", - objectMessageSiteCode: "site1", - objectMessageSerialTimestamp: nil, - objectsPool: &pool, - ) + internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } // 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") @@ -1157,24 +1242,27 @@ struct InternalDefaultLiveMapTests { @Test func appliesMapRemoveOperation() async throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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, userCallbackQueue: .main, clock: MockSimpleClock()) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let (key1, entry1) = TestFactories.stringMapEntry(key: "key1", value: "existing", timeserial: nil) - _ = map.replaceData( - using: TestFactories.mapObjectState( - siteTimeserials: [:], - entries: [key1: entry1], - ), - objectMessageSerialTimestamp: nil, - objectsPool: &pool, - ) + 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( @@ -1183,13 +1271,15 @@ struct InternalDefaultLiveMapTests { ) // Apply MAP_REMOVE operation - map.apply( - operation, - objectMessageSerial: "ts1", - objectMessageSiteCode: "site1", - objectMessageSerialTimestamp: nil, - objectsPool: &pool, - ) + internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + operation, + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } // 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) @@ -1206,21 +1296,24 @@ struct InternalDefaultLiveMapTests { @Test func noOpForOtherOperation() async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attaching) + 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, userCallbackQueue: .main, clock: MockSimpleClock()) - map.apply( - TestFactories.counterCreateOperation(), - objectMessageSerial: "ts1", - objectMessageSiteCode: "site1", - objectMessageSerialTimestamp: nil, - objectsPool: &pool, - ) + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + map.nosync_apply( + TestFactories.counterCreateOperation(), + objectMessageSerial: "ts1", + objectMessageSiteCode: "site1", + objectMessageSerialTimestamp: nil, + objectsPool: &pool, + ) + } // Check no update was emitted let subscriberInvocations = await subscriber.getInvocations() @@ -1234,8 +1327,9 @@ struct InternalDefaultLiveMapTests { @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) func throwsErrorForInvalidChannelState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: channelState) + 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) await #expect { try await map.set(key: "test", value: .string("value"), coreSDK: coreSDK) @@ -1262,31 +1356,32 @@ struct InternalDefaultLiveMapTests { // @spec RTLM20f @Test(arguments: [ // RTLM20e5a - (value: .liveMap(.createZeroValued(objectID: "map:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock())), expectedData: .init(objectId: "map:test@123")), - (value: .liveCounter(.createZeroValued(objectID: "map:test@123", logger: TestLogger(), userCallbackQueue: .main, clock: MockSimpleClock())), expectedData: .init(objectId: "map:test@123")), + (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")), // RTLM20e5b - (value: .jsonArray(["test"]), expectedData: .init(json: .array(["test"]))), - (value: .jsonObject(["foo": "bar"]), expectedData: .init(json: .object(["foo": "bar"]))), + (value: { @Sendable _ in .jsonArray(["test"]) }, expectedData: .init(json: .array(["test"]))), + (value: { @Sendable _ in .jsonObject(["foo": "bar"]) }, expectedData: .init(json: .object(["foo": "bar"]))), // RTLM20e5c - (value: .string("test"), expectedData: .init(string: "test")), + (value: { @Sendable _ in .string("test") }, expectedData: .init(string: "test")), // RTLM20e5d - (value: .number(42.5), expectedData: .init(number: NSNumber(value: 42.5))), + (value: { @Sendable _ in .number(42.5) }, expectedData: .init(number: NSNumber(value: 42.5))), // RTLM20e5e - (value: .bool(true), expectedData: .init(boolean: true)), + (value: { @Sendable _ in .bool(true) }, expectedData: .init(boolean: true)), // RTLM20e5f - (value: .data(Data([0x01, 0x02])), expectedData: .init(bytes: Data([0x01, 0x02]))), - ] as [(value: InternalLiveMapValue, expectedData: ObjectData)]) - func publishesCorrectObjectMessageForDifferentValueTypes(value: InternalLiveMapValue, expectedData: ObjectData) async throws { + (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 map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attached) + 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) var publishedMessage: OutboundObjectMessage? coreSDK.setPublishHandler { messages in publishedMessage = messages.first } - try await map.set(key: "testKey", value: value, coreSDK: coreSDK) + try await map.set(key: "testKey", value: value(internalQueue), coreSDK: coreSDK) let expectedMessage = OutboundObjectMessage( operation: ObjectOperation( @@ -1310,8 +1405,9 @@ struct InternalDefaultLiveMapTests { @Test func throwsErrorWhenPublishFails() async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attached) + 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) coreSDK.setPublishHandler { _ throws(InternalError) in throw NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]).toInternalError() @@ -1334,8 +1430,9 @@ struct InternalDefaultLiveMapTests { @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) func throwsErrorForInvalidChannelState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: channelState) + 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) await #expect { try await map.remove(key: "test", coreSDK: coreSDK) @@ -1357,8 +1454,9 @@ struct InternalDefaultLiveMapTests { @Test func publishesCorrectObjectMessage() async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attached) + 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) var publishedMessages: [OutboundObjectMessage] = [] coreSDK.setPublishHandler { messages in @@ -1388,8 +1486,9 @@ struct InternalDefaultLiveMapTests { @Test func throwsErrorWhenPublishFails() async throws { let logger = TestLogger() - let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let coreSDK = MockCoreSDK(channelState: .attached) + 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) coreSDK.setPublishHandler { _ throws(InternalError) in throw NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]).toInternalError() diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 566b2a0b3..c4588a7ce 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -8,9 +8,17 @@ struct InternalDefaultRealtimeObjectsTests { // MARK: - Test Helpers /// Creates a InternalDefaultRealtimeObjects instance for testing - static func createDefaultRealtimeObjects(clock: SimpleClock = MockSimpleClock()) -> InternalDefaultRealtimeObjects { + static func createDefaultRealtimeObjects( + clock: SimpleClock = MockSimpleClock(), + internalQueue: DispatchQueue = TestFactories.createInternalQueue(), + ) -> InternalDefaultRealtimeObjects { let logger = TestLogger() - return InternalDefaultRealtimeObjects(logger: logger, userCallbackQueue: .main, clock: clock) + return InternalDefaultRealtimeObjects( + logger: logger, + internalQueue: internalQueue, + userCallbackQueue: .main, + clock: clock, + ) } /// Tests for `InternalDefaultRealtimeObjects.handleObjectSyncProtocolMessage`, covering RTO5 specification points. @@ -20,7 +28,8 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO5a5 @Test func handlesSingleProtocolMessageSync() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) let objectMessages = [ TestFactories.simpleMapMessage(objectId: "map:1@123"), TestFactories.simpleMapMessage(objectId: "map:2@456"), @@ -30,10 +39,12 @@ struct InternalDefaultRealtimeObjectsTests { #expect(!realtimeObjects.testsOnly_hasSyncSequence) // Call with no channelSerial (RTO5a5 case) - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: objectMessages, - protocolMessageChannelSerial: nil, - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: nil, + ) + } // Verify sync was applied immediately and sequence was cleared (RTO5c3) #expect(!realtimeObjects.testsOnly_hasSyncSequence) @@ -55,15 +66,18 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO5c5 @Test func handlesMultiProtocolMessageSync() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + 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")] - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: firstMessages, - protocolMessageChannelSerial: "\(sequenceId):cursor1", - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: firstMessages, + protocolMessageChannelSerial: "\(sequenceId):cursor1", + ) + } // Verify sync sequence is active (RTO5a1, RTO5a3) #expect(realtimeObjects.testsOnly_hasSyncSequence) @@ -74,10 +88,12 @@ struct InternalDefaultRealtimeObjectsTests { // Second message in sequence let secondMessages = [TestFactories.simpleMapMessage(objectId: "map:2@456")] - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: secondMessages, - protocolMessageChannelSerial: "\(sequenceId):cursor2", - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: secondMessages, + protocolMessageChannelSerial: "\(sequenceId):cursor2", + ) + } // Verify sync sequence still active #expect(realtimeObjects.testsOnly_hasSyncSequence) @@ -89,10 +105,12 @@ struct InternalDefaultRealtimeObjectsTests { // Final message in sequence (end of sequence per RTO5a4) let finalMessages = [TestFactories.simpleMapMessage(objectId: "map:3@789")] - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: finalMessages, - protocolMessageChannelSerial: "\(sequenceId):", // Empty cursor indicates end - ) + 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) @@ -111,39 +129,48 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO5a2b @Test func newSequenceIdDiscardsInFlightSync() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + 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")] - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: firstMessages, - protocolMessageChannelSerial: "\(firstSequenceId):cursor1", - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: firstMessages, + protocolMessageChannelSerial: "\(firstSequenceId):cursor1", + ) + } #expect(realtimeObjects.testsOnly_hasSyncSequence) // Inject an OBJECT; it will get buffered per RTO8a and subsequently discarded per RTO5a2b - realtimeObjects.handleObjectProtocolMessage(objectMessages: [ - TestFactories.mapCreateOperationMessage(objectId: "map:3@789"), - ]) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [ + TestFactories.mapCreateOperationMessage(objectId: "map:3@789"), + ]) + } // Start new sequence with different ID (RTO5a2) let secondMessages = [TestFactories.simpleMapMessage(objectId: "map:2@456")] - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: secondMessages, - protocolMessageChannelSerial: "\(secondSequenceId):cursor1", - ) + 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 - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [], - protocolMessageChannelSerial: "\(secondSequenceId):", - ) + 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 @@ -158,14 +185,17 @@ struct InternalDefaultRealtimeObjectsTests { // A smoke test that the RTO5c post-sync behaviours get performed. They are tested in more detail in the ObjectsPool.applySyncObjectsPool tests. @Test func performsPostSyncSteps() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) // Perform sync with only one object (RTO5a5 case) let syncMessages = [TestFactories.mapObjectMessage(objectId: "map:synced@1")] - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: syncMessages, - protocolMessageChannelSerial: nil, - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: syncMessages, + protocolMessageChannelSerial: nil, + ) + } // Verify root is preserved (RTO5c2a) and sync completed (RTO5c3) #expect(!realtimeObjects.testsOnly_hasSyncSequence) @@ -179,14 +209,17 @@ struct InternalDefaultRealtimeObjectsTests { /// Test handling of invalid channelSerial format @Test func handlesInvalidChannelSerialFormat() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) let objectMessages = [TestFactories.mapObjectMessage(objectId: "map:1@123")] // Call with invalid channelSerial (missing colon) - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: objectMessages, - protocolMessageChannelSerial: "invalid_format_no_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) @@ -201,22 +234,27 @@ struct InternalDefaultRealtimeObjectsTests { /// Test with empty sequence ID @Test func handlesEmptySequenceId() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) let objectMessages = [TestFactories.mapObjectMessage(objectId: "map:1@123")] // Start sequence with empty sequence ID - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: objectMessages, - protocolMessageChannelSerial: ":cursor1", - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: objectMessages, + protocolMessageChannelSerial: ":cursor1", + ) + } #expect(realtimeObjects.testsOnly_hasSyncSequence) // End sequence with empty sequence ID - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [], - protocolMessageChannelSerial: ":", - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [], + protocolMessageChannelSerial: ":", + ) + } // Verify sequence completed successfully #expect(!realtimeObjects.testsOnly_hasSyncSequence) @@ -227,7 +265,8 @@ struct InternalDefaultRealtimeObjectsTests { /// Test mixed object types in single sync @Test func handlesMixedObjectTypesInSync() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) let mixedMessages = [ TestFactories.mapObjectMessage(objectId: "map:1@123"), @@ -235,10 +274,12 @@ struct InternalDefaultRealtimeObjectsTests { TestFactories.mapObjectMessage(objectId: "map:2@789"), ] - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: mixedMessages, - protocolMessageChannelSerial: nil, // Single message sync - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: mixedMessages, + protocolMessageChannelSerial: nil, // Single message sync + ) + } // Verify all object types were processed let pool = realtimeObjects.testsOnly_objectsPool @@ -251,35 +292,44 @@ struct InternalDefaultRealtimeObjectsTests { /// Test continuation of sync after interruption by new sequence @Test func handlesSequenceInterruptionCorrectly() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) // Start first sequence - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [TestFactories.mapObjectMessage(objectId: "map:old@1")], - protocolMessageChannelSerial: "oldSeq:cursor1", - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [TestFactories.mapObjectMessage(objectId: "map:old@1")], + protocolMessageChannelSerial: "oldSeq:cursor1", + ) + } #expect(realtimeObjects.testsOnly_hasSyncSequence) // Interrupt with new sequence - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [TestFactories.mapObjectMessage(objectId: "map:new@1")], - protocolMessageChannelSerial: "newSeq:cursor1", - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [TestFactories.mapObjectMessage(objectId: "map:new@1")], + protocolMessageChannelSerial: "newSeq:cursor1", + ) + } #expect(realtimeObjects.testsOnly_hasSyncSequence) // Continue new sequence - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [TestFactories.mapObjectMessage(objectId: "map:new@2")], - protocolMessageChannelSerial: "newSeq:cursor2", - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [TestFactories.mapObjectMessage(objectId: "map:new@2")], + protocolMessageChannelSerial: "newSeq:cursor2", + ) + } // Complete new sequence - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [], - protocolMessageChannelSerial: "newSeq:", - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [], + protocolMessageChannelSerial: "newSeq:", + ) + } // Verify only new sequence objects were applied let pool = realtimeObjects.testsOnly_objectsPool @@ -301,7 +351,8 @@ struct InternalDefaultRealtimeObjectsTests { // @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 any internal state @Test func doesNotModifyStateWhenHasObjectsIsTrue() { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + 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 @@ -309,17 +360,21 @@ struct InternalDefaultRealtimeObjectsTests { _ = realtimeObjects.testsOnly_createZeroValueLiveObject(forObjectID: "map:test@123") // Set up an in-progress sync sequence - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.mapObjectMessage(objectId: "map:sync@456"), - ], - protocolMessageChannelSerial: "seq1:cursor1", - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:sync@456"), + ], + protocolMessageChannelSerial: "seq1:cursor1", + ) + } #expect(realtimeObjects.testsOnly_hasSyncSequence) // When: onChannelAttached is called with hasObjects = true - realtimeObjects.onChannelAttached(hasObjects: true) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: true) + } // Then: Nothing should be modified #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == true) @@ -345,41 +400,48 @@ struct InternalDefaultRealtimeObjectsTests { @available(iOS 17.0.0, tvOS 17.0.0, *) @Test func handlesHasObjectsFalse() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) // Set up initial state with additional objects in the pool using sync - realtimeObjects.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 - ) + 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"]) let rootSubscriber = Subscriber(callbackQueue: .main) - let coreSDK = MockCoreSDK(channelState: .attached) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) try originalPool.root.subscribe(listener: rootSubscriber.createListener(), coreSDK: coreSDK) // Set up an in-progress sync sequence - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.mapObjectMessage(objectId: "map:sync@789"), - ], - protocolMessageChannelSerial: "seq1:cursor1", - ) + 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 - realtimeObjects.onChannelAttached(hasObjects: false) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } // Then: Verify the expected behavior per RTO4b #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == false) @@ -409,23 +471,30 @@ struct InternalDefaultRealtimeObjectsTests { /// Test that multiple calls to onChannelAttached work correctly @Test func handlesMultipleCallsCorrectly() { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) // First call with hasObjects = true (should do nothing) - realtimeObjects.onChannelAttached(hasObjects: true) + 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) - realtimeObjects.onChannelAttached(hasObjects: false) + 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) - realtimeObjects.onChannelAttached(hasObjects: true) + 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 @@ -434,28 +503,35 @@ struct InternalDefaultRealtimeObjectsTests { /// Test that sync sequence is properly discarded even with complex sync state @Test func discardsComplexSyncSequence() { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + 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) - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.mapObjectMessage(objectId: "map:sync1@123"), - ], - protocolMessageChannelSerial: "seq1:cursor1", - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:sync1@123"), + ], + protocolMessageChannelSerial: "seq1:cursor1", + ) + } - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.counterObjectMessage(objectId: "counter:sync1@456"), - ], - protocolMessageChannelSerial: "seq1:cursor2", - ) + 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 - realtimeObjects.onChannelAttached(hasObjects: false) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } // Then: All sync data should be discarded #expect(!realtimeObjects.testsOnly_hasSyncSequence) @@ -468,16 +544,19 @@ struct InternalDefaultRealtimeObjectsTests { /// Test behavior when there's no sync sequence in progress @Test func handlesNoSyncSequenceCorrectly() { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + 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) - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.mapObjectMessage(objectId: "map:test@123"), - ], - protocolMessageChannelSerial: nil, // Complete sync immediately - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:test@123"), + ], + protocolMessageChannelSerial: nil, // Complete sync immediately + ) + } let pool = realtimeObjects.testsOnly_objectsPool @@ -485,7 +564,9 @@ struct InternalDefaultRealtimeObjectsTests { #expect(pool.entries.count == 2) // root + additional map // When: onChannelAttached is called with hasObjects = false - realtimeObjects.onChannelAttached(hasObjects: false) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } // Then: Should still reset the pool correctly let newPool = realtimeObjects.testsOnly_objectsPool @@ -497,10 +578,13 @@ struct InternalDefaultRealtimeObjectsTests { /// Test that the root object's delegate is correctly set after reset @Test func setsCorrectDelegateOnNewRoot() { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) // When: onChannelAttached is called with hasObjects = false - realtimeObjects.onChannelAttached(hasObjects: false) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } // Then: The new root should be properly initialized let newRoot = realtimeObjects.testsOnly_objectsPool.root @@ -515,8 +599,9 @@ struct InternalDefaultRealtimeObjectsTests { // @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 realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() - let coreSDK = MockCoreSDK(channelState: .attached) + 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) @@ -525,7 +610,9 @@ struct InternalDefaultRealtimeObjectsTests { _ = try #require(await realtimeObjects.testsOnly_waitingForSyncEvents.first { _ in true }) // Complete sync via ATTACHED with HAS_OBJECTS false (RTO4b) - realtimeObjects.onChannelAttached(hasObjects: false) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } // getRoot should now complete _ = try await getRootTask @@ -534,8 +621,9 @@ struct InternalDefaultRealtimeObjectsTests { // @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 realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() - let coreSDK = MockCoreSDK(channelState: .attached) + 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) @@ -546,16 +634,18 @@ struct InternalDefaultRealtimeObjectsTests { // 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") - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.rootObjectMessage(entries: [ - testKey: testEntry, - referencedKey: referencedEntry, - ]), - TestFactories.mapObjectMessage(objectId: "map:test@123"), - ], - protocolMessageChannelSerial: nil, // RTO5a5 case - ) + 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 @@ -572,8 +662,9 @@ struct InternalDefaultRealtimeObjectsTests { // @specOneOf(3/4) RTO1c - getRoot waits for sync completion when sync completes via multiple `OBJECT_SYNC` messages (RTO5a4) @Test func waitsForSyncCompletionViaMultipleObjectSync() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() - let coreSDK = MockCoreSDK(channelState: .attached) + 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 @@ -587,34 +678,40 @@ struct InternalDefaultRealtimeObjectsTests { 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") - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.rootObjectMessage(entries: [ - firstKey: firstEntry, - firstObjectKey: firstObjectEntry, - secondObjectKey: secondObjectEntry, - finalObjectKey: finalObjectEntry, - ]), - TestFactories.mapObjectMessage(objectId: "map:first@123"), - ], - protocolMessageChannelSerial: "\(sequenceId):cursor1", - ) + 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 - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.mapObjectMessage(objectId: "map:second@456"), - ], - protocolMessageChannelSerial: "\(sequenceId):cursor2", - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: [ + TestFactories.mapObjectMessage(objectId: "map:second@456"), + ], + protocolMessageChannelSerial: "\(sequenceId):cursor2", + ) + } // Complete sync sequence (RTO5a4) - add final object - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.mapObjectMessage(objectId: "map:final@789"), - ], - protocolMessageChannelSerial: "\(sequenceId):", // Empty cursor indicates end - ) + 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 @@ -633,11 +730,14 @@ struct InternalDefaultRealtimeObjectsTests { // @specOneOf(4/4) RTO1c - getRoot returns immediately when sync is already complete @Test func returnsImmediatelyWhenSyncAlreadyComplete() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() - let coreSDK = MockCoreSDK(channelState: .attached) + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) // Complete sync first - realtimeObjects.onChannelAttached(hasObjects: false) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } // getRoot should return _ = try await realtimeObjects.getRoot(coreSDK: coreSDK) @@ -655,11 +755,14 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO1d @Test func returnsRootObjectFromObjectsPool() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() - let coreSDK = MockCoreSDK(channelState: .attached) + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) // Complete sync first - realtimeObjects.onChannelAttached(hasObjects: false) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_onChannelAttached(hasObjects: false) + } // Call getRoot let root = try await realtimeObjects.getRoot(coreSDK: coreSDK) @@ -674,8 +777,9 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO1b @Test(arguments: [.detached, .failed] as [_AblyPluginSupportPrivate.RealtimeChannelState]) func getRootThrowsIfChannelIsDetachedOrFailed(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() - let coreSDK = MockCoreSDK(channelState: channelState) + 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) @@ -701,7 +805,8 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO9a2a1 - Tests that if necessary it creates an object in the ObjectsPool @Test func createsObjectInObjectsPoolWhenNecessary() { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + 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 @@ -716,7 +821,9 @@ struct InternalDefaultRealtimeObjectsTests { ) // Handle the object protocol message - realtimeObjects.handleObjectProtocolMessage(objectMessages: [operationMessage]) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [operationMessage]) + } // Verify the object was created in the ObjectsPool (RTO9a2a1) let finalPool = realtimeObjects.testsOnly_objectsPool @@ -730,25 +837,28 @@ struct InternalDefaultRealtimeObjectsTests { // @specOneOf(1/5) RTO9a2a3 - Tests MAP_CREATE operation application @Test func appliesMapCreateOperation() throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + 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") - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.mapObjectMessage( - objectId: objectId, - siteTimeserials: ["site1": "ts1"], - entries: [entryKey: entry], - ), - ], - protocolMessageChannelSerial: nil, - ) + 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) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) #expect(initialValue == "existingValue") @@ -762,7 +872,9 @@ struct InternalDefaultRealtimeObjectsTests { ) // Handle the object protocol message - realtimeObjects.handleObjectProtocolMessage(objectMessages: [operationMessage]) + 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 @@ -777,25 +889,28 @@ struct InternalDefaultRealtimeObjectsTests { // @specOneOf(2/5) RTO9a2a3 - Tests MAP_SET operation application @Test func appliesMapSetOperation() throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + 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") - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.mapObjectMessage( - objectId: objectId, - siteTimeserials: ["site1": "ts1"], - entries: [entryKey: entry], - ), - ], - protocolMessageChannelSerial: nil, - ) + 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) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) #expect(initialValue == "existingValue") @@ -809,7 +924,9 @@ struct InternalDefaultRealtimeObjectsTests { ) // Handle the object protocol message - realtimeObjects.handleObjectProtocolMessage(objectMessages: [operationMessage]) + 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 @@ -823,25 +940,28 @@ struct InternalDefaultRealtimeObjectsTests { // @specOneOf(3/5) RTO9a2a3 - Tests MAP_REMOVE operation application @Test func appliesMapRemoveOperation() throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + 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") - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.mapObjectMessage( - objectId: objectId, - siteTimeserials: ["site1": "ts1"], - entries: [entryKey: entry], - ), - ], - protocolMessageChannelSerial: nil, - ) + 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) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let initialValue = try #require(map.get(key: "existingKey", coreSDK: coreSDK, delegate: realtimeObjects)?.stringValue) #expect(initialValue == "existingValue") @@ -854,7 +974,9 @@ struct InternalDefaultRealtimeObjectsTests { ) // Handle the object protocol message - realtimeObjects.handleObjectProtocolMessage(objectMessages: [operationMessage]) + 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 @@ -868,24 +990,27 @@ struct InternalDefaultRealtimeObjectsTests { // @specOneOf(4/5) RTO9a2a3 - Tests COUNTER_CREATE operation application @Test func appliesCounterCreateOperation() throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) let objectId = "counter:test@123" // Create a counter object in the pool first - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.counterObjectMessage( - objectId: objectId, - siteTimeserials: ["site1": "ts1"], - count: 5, - ), - ], - protocolMessageChannelSerial: nil, - ) + 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) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let initialValue = try counter.value(coreSDK: coreSDK) #expect(initialValue == 5) @@ -898,7 +1023,9 @@ struct InternalDefaultRealtimeObjectsTests { ) // Handle the object protocol message - realtimeObjects.handleObjectProtocolMessage(objectMessages: [operationMessage]) + 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 @@ -912,24 +1039,27 @@ struct InternalDefaultRealtimeObjectsTests { // @specOneOf(5/5) RTO9a2a3 - Tests COUNTER_INC operation application @Test func appliesCounterIncOperation() throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) let objectId = "counter:test@123" // Create a counter object in the pool first - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: [ - TestFactories.counterObjectMessage( - objectId: objectId, - siteTimeserials: ["site1": "ts1"], - count: 5, - ), - ], - protocolMessageChannelSerial: nil, - ) + 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) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let initialValue = try counter.value(coreSDK: coreSDK) #expect(initialValue == 5) @@ -942,7 +1072,9 @@ struct InternalDefaultRealtimeObjectsTests { ) // Handle the object protocol message - realtimeObjects.handleObjectProtocolMessage(objectMessages: [operationMessage]) + 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 @@ -958,7 +1090,8 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO5c6 @Test func buffersObjectOperationsDuringSyncAndAppliesAfterCompletion() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) let sequenceId = "seq123" // Start sync sequence with first OBJECT_SYNC message @@ -970,10 +1103,12 @@ struct InternalDefaultRealtimeObjectsTests { entries: [entryKey: entry], ), ] - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: firstSyncMessages, - protocolMessageChannelSerial: "\(sequenceId):cursor1", - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: firstSyncMessages, + protocolMessageChannelSerial: "\(sequenceId):cursor1", + ) + } // Verify sync sequence is active #expect(realtimeObjects.testsOnly_hasSyncSequence) @@ -986,7 +1121,9 @@ struct InternalDefaultRealtimeObjectsTests { serial: "ts3", // Higher than sync data "ts1" siteCode: "site1", ) - realtimeObjects.handleObjectProtocolMessage(objectMessages: [firstObjectMessage]) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [firstObjectMessage]) + } // Verify the operation was buffered and not applied yet let poolAfterFirstObject = realtimeObjects.testsOnly_objectsPool @@ -999,7 +1136,9 @@ struct InternalDefaultRealtimeObjectsTests { serial: "ts4", // Higher than sync data "ts2" siteCode: "site1", ) - realtimeObjects.handleObjectProtocolMessage(objectMessages: [secondObjectMessage]) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [secondObjectMessage]) + } // Verify the second operation was also buffered and not applied yet let poolAfterSecondObject = realtimeObjects.testsOnly_objectsPool @@ -1013,10 +1152,12 @@ struct InternalDefaultRealtimeObjectsTests { count: 5, ), ] - realtimeObjects.handleObjectSyncProtocolMessage( - objectMessages: finalSyncMessages, - protocolMessageChannelSerial: "\(sequenceId):", // Empty cursor indicates end - ) + internalQueue.ably_syncNoDeadlock { + realtimeObjects.nosync_handleObjectSyncProtocolMessage( + objectMessages: finalSyncMessages, + protocolMessageChannelSerial: "\(sequenceId):", // Empty cursor indicates end + ) + } // Verify sync sequence is cleared #expect(!realtimeObjects.testsOnly_hasSyncSequence) @@ -1028,7 +1169,7 @@ struct InternalDefaultRealtimeObjectsTests { // 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) + 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") @@ -1046,8 +1187,9 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO11d @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) func throwsIfChannelIsInInvalidState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() - let coreSDK = MockCoreSDK(channelState: channelState) + 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 { @@ -1065,9 +1207,10 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO11h3b @Test func publishesObjectMessageAndCreatesMap() async throws { + let internalQueue = TestFactories.createInternalQueue() let clock = MockSimpleClock(currentTime: .init(timeIntervalSince1970: 1_754_042_434)) - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(clock: clock) - let coreSDK = MockCoreSDK(channelState: .attached) + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(clock: clock, internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) // Track published messages var publishedMessages: [OutboundObjectMessage] = [] @@ -1107,8 +1250,9 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO11f4b @Test func withNoEntriesArgumentCreatesEmptyMap() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() - let coreSDK = MockCoreSDK(channelState: .attached) + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) // Track published messages var publishedMessages: [OutboundObjectMessage] = [] @@ -1134,8 +1278,9 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO11h2 @Test func returnsExistingObjectIfAlreadyInPool() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() - let coreSDK = MockCoreSDK(channelState: .attached) + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) // Track published messages and the generated objectId var publishedMessages: [OutboundObjectMessage] = [] @@ -1177,8 +1322,9 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO12d @Test(arguments: [.detached, .failed, .suspended] as [_AblyPluginSupportPrivate.RealtimeChannelState]) func throwsIfChannelIsInInvalidState(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() - let coreSDK = MockCoreSDK(channelState: channelState) + 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) @@ -1195,9 +1341,10 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO12h3b @Test func publishesObjectMessageAndCreatesCounter() async throws { + let internalQueue = TestFactories.createInternalQueue() let clock = MockSimpleClock(currentTime: .init(timeIntervalSince1970: 1_754_042_434)) - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(clock: clock) - let coreSDK = MockCoreSDK(channelState: .attached) + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(clock: clock, internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) // Track published messages var publishedMessages: [OutboundObjectMessage] = [] @@ -1230,8 +1377,9 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO12f2a @Test func withNoEntriesArgumentCreatesWithZeroValue() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() - let coreSDK = MockCoreSDK(channelState: .attached) + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) // Track published messages var publishedMessages: [OutboundObjectMessage] = [] @@ -1258,8 +1406,9 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO12h2 @Test func returnsExistingObjectIfAlreadyInPool() async throws { - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects() - let coreSDK = MockCoreSDK(channelState: .attached) + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) // Track published messages and the generated objectId var publishedMessages: [OutboundObjectMessage] = [] diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 1e18d42ad..d5a79c7ed 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -2881,7 +2881,7 @@ private struct ObjectsIntegrationTests { // Create counter locally, should have an initial value set let counter = try await objects.createCounter(count: 1) let internalCounter = try #require(counter as? PublicDefaultLiveCounter) - let counterId = internalCounter.proxied.objectID + 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( @@ -3138,7 +3138,7 @@ private struct ObjectsIntegrationTests { // Create map locally, should have an initial value set let map = try await objects.createMap(entries: ["foo": "bar"]) let internalMap = try #require(map as? PublicDefaultLiveMap) - let mapId = internalMap.proxied.objectID + 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( @@ -3784,11 +3784,11 @@ private struct ObjectsIntegrationTests { let poolEntry = try #require(internallyTypedObjects.testsOnly_proxied.testsOnly_objectsPool.entries[objectId]) #expect( - poolEntry.isTombstone == true, + poolEntry.testsOnly_isTombstone == true, "Check object's \"tombstone\" flag is set to \"true\" after OBJECT_DELETE", ) - let tombstonedAt = try #require(poolEntry.tombstonedAt) + let tombstonedAt = try #require(poolEntry.testsOnly_tombstonedAt) // Wait for objects tombstoned at this time to be garbage collected try await waitForTombstonedObjectsToBeCollected(tombstonedAt) diff --git a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift index 5b304bc03..981f1d30e 100644 --- a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift +++ b/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift @@ -124,10 +124,13 @@ struct LiveObjectMutableStateTests { var mutableState = LiveObjectMutableState(objectID: "foo") let queue = DispatchQueue.main let subscriber = Subscriber(callbackQueue: queue) - let coreSDK = MockCoreSDK(channelState: channelState) + let internalQueue = TestFactories.createInternalQueue() + let coreSDK = MockCoreSDK(channelState: channelState, internalQueue: internalQueue) #expect { - try mutableState.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK, updateSelfLater: { _ in fatalError("Not expected") }) + 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 @@ -146,8 +149,11 @@ struct LiveObjectMutableStateTests { var mutableState = LiveObjectMutableState(objectID: "foo") let queue = DispatchQueue.main let subscriber = Subscriber(callbackQueue: queue) - let coreSDK = MockCoreSDK(channelState: .attached) - try mutableState.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK, updateSelfLater: { _ in fatalError("Not expected") }) + 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) @@ -165,8 +171,11 @@ struct LiveObjectMutableStateTests { var mutableState = LiveObjectMutableState(objectID: "foo") let queue = DispatchQueue.main let subscriber = Subscriber(callbackQueue: queue) - let coreSDK = MockCoreSDK(channelState: .attached) - try mutableState.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK, updateSelfLater: { _ in fatalError("Not expected") }) + 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) @@ -179,22 +188,21 @@ struct LiveObjectMutableStateTests { struct UnsubscribeTests { final class MutableStateStore: Sendable { - private let mutex = NSLock() - private nonisolated(unsafe) var stored: LiveObjectMutableState + private let mutex: DispatchQueueMutex> - init(stored: LiveObjectMutableState) { - self.stored = stored + init(stored: LiveObjectMutableState, internalQueue: DispatchQueue) { + mutex = .init(dispatchQueue: internalQueue, initialValue: stored) } @discardableResult func subscribe(listener: @escaping LiveObjectUpdateCallback, coreSDK: CoreSDK) throws(ARTErrorInfo) -> SubscribeResponse { - try mutex.ablyLiveObjects_withLockWithTypedThrow { () throws(ARTErrorInfo) in - try stored.subscribe(listener: listener, coreSDK: coreSDK, updateSelfLater: { [weak self] action in + 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.withLock { + mutex.withSync { stored in action(&stored) } }) @@ -202,13 +210,13 @@ struct LiveObjectMutableStateTests { } func emit(_ update: LiveObjectUpdate, on queue: DispatchQueue) { - mutex.withLock { + mutex.withSync { stored in stored.emit(update, on: queue) } } func unsubscribeAll() { - mutex.withLock { + mutex.withSync { stored in stored.unsubscribeAll() } } @@ -219,10 +227,11 @@ struct LiveObjectMutableStateTests { @Test func unsubscribeFromReturnValue() async throws { // Given - let store = MutableStateStore(stored: .init(objectID: "foo")) 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) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) let subscription = try store.subscribe(listener: subscriber.createListener(), coreSDK: coreSDK) // When @@ -240,10 +249,11 @@ struct LiveObjectMutableStateTests { @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 store = MutableStateStore(stored: .init(objectID: "foo")) 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) + 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() @@ -265,10 +275,11 @@ struct LiveObjectMutableStateTests { @Test func unsubscribeInsideCallback_nonBackToBackUpdates() async throws { // Given - let store = MutableStateStore(stored: .init(objectID: "foo")) 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) + 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() @@ -291,9 +302,10 @@ struct LiveObjectMutableStateTests { @Test func unsubscribeAll() async throws { // Given - let store = MutableStateStore(stored: .init(objectID: "foo")) let queue = DispatchQueue.main - let coreSDK = MockCoreSDK(channelState: .attached) + 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), diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 0c3980a63..8aacf19db 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -3,14 +3,14 @@ import Ably @testable import AblyLiveObjects final class MockCoreSDK: CoreSDK { - /// Synchronizes access to all of this instance's mutable state. + /// Synchronizes access to `_publishHandler`. private let mutex = NSLock() - - private nonisolated(unsafe) var _channelState: _AblyPluginSupportPrivate.RealtimeChannelState private nonisolated(unsafe) var _publishHandler: (([OutboundObjectMessage]) async throws(InternalError) -> Void)? - init(channelState: _AblyPluginSupportPrivate.RealtimeChannelState) { - _channelState = channelState + private let channelStateMutex: DispatchQueueMutex<_AblyPluginSupportPrivate.RealtimeChannelState> + + init(channelState: _AblyPluginSupportPrivate.RealtimeChannelState, internalQueue: DispatchQueue) { + channelStateMutex = DispatchQueueMutex(dispatchQueue: internalQueue, initialValue: channelState) } func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { @@ -25,17 +25,8 @@ final class MockCoreSDK: CoreSDK { protocolRequirementNotImplemented() } - var channelState: _AblyPluginSupportPrivate.RealtimeChannelState { - get { - mutex.withLock { - _channelState - } - } - set { - mutex.withLock { - _channelState = newValue - } - } + var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { + channelStateMutex.withoutSync { $0 } } /// Sets a custom publish handler for testing diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift b/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift index b094f2e97..f3af88333 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift @@ -3,23 +3,22 @@ import Foundation /// A mock delegate that can return predefined objects final class MockLiveMapObjectPoolDelegate: LiveMapObjectPoolDelegate { - private let mutex = NSLock() - private nonisolated(unsafe) var _objects: [String: ObjectsPool.Entry] = [:] + private let objectsMutex: DispatchQueueMutex<[String: ObjectsPool.Entry]> + + init(internalQueue: DispatchQueue) { + objectsMutex = DispatchQueueMutex(dispatchQueue: internalQueue, initialValue: [:]) + } + var objects: [String: ObjectsPool.Entry] { get { - mutex.withLock { - _objects - } + objectsMutex.withSync { $0 } } - set { - mutex.withLock { - _objects = newValue - } + objectsMutex.withSync { $0 = newValue } } } - func getObjectFromPool(id: String) -> ObjectsPool.Entry? { - objects[id] + func nosync_getObjectFromPool(id: String) -> ObjectsPool.Entry? { + objectsMutex.withoutSync { $0[id] } } } diff --git a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift index d51efec38..b58cfb39d 100644 --- a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift @@ -25,30 +25,33 @@ struct ObjectCreationHelpersTests { let timestamp = Date(timeIntervalSince1970: 1_754_042_434) let logger = TestLogger() let clock = MockSimpleClock() - let referencedMap = InternalDefaultLiveMap.createZeroValued(objectID: "referencedMapID", logger: logger, userCallbackQueue: .main, clock: clock) - let referencedCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "referencedCounterID", logger: logger, userCallbackQueue: .main, clock: clock) + 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 = ObjectCreationHelpers.creationOperationForLiveMap( - entries: [ - // RTO11f4c1a - "mapRef": .liveMap(referencedMap), - // RTO11f4c1a - "counterRef": .liveCounter(referencedCounter), - // RTO11f4c1b - "jsonArrayKey": .jsonArray([.string("arrayItem1"), .string("arrayItem2")]), - "jsonObjectKey": .jsonObject(["nestedKey": .string("nestedValue")]), - // RTO11f4c1c - "stringKey": .string("stringValue"), - // RTO11f4c1d - "numberKey": .number(42.5), - // RTO11f4c1e - "booleanKey": .bool(true), - // RTO11f4c1f - "dataKey": .data(Data([0x01, 0x02, 0x03])), - ], - timestamp: timestamp, - ) + let creationOperation = internalQueue.ably_syncNoDeadlock { + ObjectCreationHelpers.nosync_creationOperationForLiveMap( + entries: [ + // RTO11f4c1a + "mapRef": .liveMap(referencedMap), + // RTO11f4c1a + "counterRef": .liveCounter(referencedCounter), + // RTO11f4c1b + "jsonArrayKey": .jsonArray([.string("arrayItem1"), .string("arrayItem2")]), + "jsonObjectKey": .jsonObject(["nestedKey": .string("nestedValue")]), + // RTO11f4c1c + "stringKey": .string("stringValue"), + // RTO11f4c1d + "numberKey": .number(42.5), + // RTO11f4c1e + "booleanKey": .bool(true), + // RTO11f4c1f + "dataKey": .data(Data([0x01, 0x02, 0x03])), + ], + timestamp: timestamp, + ) + } // Then diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 8a350029b..61fc2ee90 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -9,10 +9,11 @@ struct ObjectsPoolTests { @Test func returnsExistingObject() throws { let logger = TestLogger() - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["map:123@456": .map(existingMap)]) + let 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, 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) #expect(map as AnyObject === existingMap as AnyObject) } @@ -21,42 +22,45 @@ struct ObjectsPoolTests { @Test func createsZeroValueMap() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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, 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.objectID == "map:123@456") + #expect(map.testsOnly_objectID == "map:123@456") } // @spec RTO6b3 @Test func createsZeroValueCounter() throws { let logger = TestLogger() - let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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, 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.objectID == "counter:123@456") + #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() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) - let result = pool.createZeroValueObject(forObjectID: "invalid", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let result = pool.createZeroValueObject(forObjectID: "invalid", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) #expect(result == nil) } @@ -64,9 +68,10 @@ struct ObjectsPoolTests { @Test func returnsNilForUnknownType() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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, 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) } @@ -83,12 +88,13 @@ struct ObjectsPoolTests { @Test func updatesExistingMapObject() async throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(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, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["map:hash@123": .map(existingMap)]) + 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( @@ -100,7 +106,9 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: 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) @@ -122,11 +130,12 @@ struct ObjectsPoolTests { @Test func updatesExistingCounterObject() async throws { let logger = TestLogger() - let coreSDK = MockCoreSDK(channelState: .attaching) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["counter:hash@123": .counter(existingCounter)]) + 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", @@ -135,7 +144,9 @@ struct ObjectsPoolTests { count: 10, ) - pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: 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) @@ -154,8 +165,9 @@ struct ObjectsPoolTests { @Test func createsNewCounterObject() throws { let logger = TestLogger() - let coreSDK = MockCoreSDK(channelState: .attaching) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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", @@ -163,7 +175,9 @@ struct ObjectsPoolTests { count: 100, ) - pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: 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) @@ -172,17 +186,18 @@ struct ObjectsPoolTests { // Checking site timeserials to verify they were set by replaceData #expect(newCounter.testsOnly_siteTimeserials == ["site2": "ts2"]) // Verify the objectID is correctly set per RTO5c1b1a - #expect(newCounter.objectID == "counter:hash@456") + #expect(newCounter.testsOnly_objectID == "counter:hash@456") } // @spec RTO5c1b1b @Test func createsNewMapObject() throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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( @@ -191,7 +206,9 @@ struct ObjectsPoolTests { entries: [key: entry], ) - pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: 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) @@ -200,7 +217,7 @@ struct ObjectsPoolTests { // Checking site timeserials to verify they were set by replaceData #expect(newMap.testsOnly_siteTimeserials == ["site3": "ts3"]) // Verify the objectID and semantics are correctly set per RTO5c1b1b - #expect(newMap.objectID == "map:hash@789") + #expect(newMap.testsOnly_objectID == "map:hash@789") #expect(newMap.testsOnly_semantics == .known(.lww)) } @@ -208,7 +225,8 @@ struct ObjectsPoolTests { @Test func ignoresNonMapOrCounterObject() throws { let logger = TestLogger() - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let internalQueue = TestFactories.createInternalQueue() + var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let validObjectState = TestFactories.counterObjectState( objectId: "counter:hash@456", @@ -218,7 +236,9 @@ struct ObjectsPoolTests { let invalidObjectState = TestFactories.objectState(objectId: "invalid") - pool.applySyncObjectsPool([invalidObjectState, validObjectState].map { .init(state: $0, objectMessageSerialTimestamp: nil) }, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool([invalidObjectState, validObjectState].map { .init(state: $0, objectMessageSerialTimestamp: nil) }, logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } // Check that there's no entry for the key that we don't know how to handle, and that it didn't interfere with the insertion of the we one that we do know how to handle #expect(Set(pool.entries.keys) == ["root", "counter:hash@456"]) @@ -230,11 +250,12 @@ struct ObjectsPoolTests { @Test func removesObjectsNotInSync() throws { let logger = TestLogger() - let existingMap1 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let existingMap2 = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + 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, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: [ + 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), @@ -243,7 +264,9 @@ struct ObjectsPoolTests { // Only sync one of the existing objects let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") - pool.applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: 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 @@ -257,11 +280,14 @@ struct ObjectsPoolTests { @Test func doesNotRemoveRootObject() throws { let logger = TestLogger() - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - var pool = ObjectsPool(logger: logger, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: ["map:hash@1": .map(existingMap)]) + 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) - pool.applySyncObjectsPool([], logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool([], logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } // Verify root is preserved but other objects are removed #expect(pool.entries.count == 1) // Only root @@ -274,19 +300,20 @@ struct ObjectsPoolTests { @Test func handlesComplexSyncScenario() async throws { let logger = TestLogger() - let delegate = MockLiveMapObjectPoolDelegate() - let coreSDK = MockCoreSDK(channelState: .attaching) + let internalQueue = TestFactories.createInternalQueue() + let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) - let existingMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let existingCounter = InternalDefaultLiveCounter.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) - let toBeRemovedMap = InternalDefaultLiveMap.createZeroValued(objectID: "arbitrary", logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + let 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, userCallbackQueue: .main, clock: MockSimpleClock(), testsOnly_otherEntries: [ + 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), @@ -324,7 +351,9 @@ struct ObjectsPoolTests { // Note: "map:toremove@1" is not in sync, so it should be removed ] - pool.applySyncObjectsPool(syncObjects.map { .init(state: $0, objectMessageSerialTimestamp: nil) }, logger: logger, userCallbackQueue: .main, clock: MockSimpleClock()) + internalQueue.ably_syncNoDeadlock { + pool.nosync_applySyncObjectsPool(syncObjects.map { .init(state: $0, objectMessageSerialTimestamp: nil) }, logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + } // Verify final state #expect(pool.entries.count == 5) // root + 4 synced objects @@ -354,14 +383,14 @@ struct ObjectsPoolTests { // Checking map data to verify the new map was created and replaceData was called #expect(try newMap.get(key: "new", coreSDK: coreSDK, delegate: delegate)?.stringValue == "new") // Verify the objectID and semantics are correctly set per RTO5c1b1b - #expect(newMap.objectID == "map:new@1") + #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.objectID == "counter:new@1") + #expect(newCounter.testsOnly_objectID == "counter:new@1") // Removed object #expect(pool.entries["map:toremove@1"] == nil) From 5a63313d29dddace58ff80c7322d21b450dc35d7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 29 Sep 2025 08:43:29 -0300 Subject: [PATCH 155/225] Add missing @Test Mistake in d9c327e. --- Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift | 1 + 1 file changed, 1 insertion(+) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 9669d4393..2860d441a 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -531,6 +531,7 @@ struct InternalDefaultLiveCounterTests { // @spec RTLC12e3 // @spec RTLC12e4 // @spec RTLC12f + @Test func publishesCorrectObjectMessage() async throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() From 5ba838bffc41e5329cd559898260dfb196a092e9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 3 Sep 2025 10:30:54 -0300 Subject: [PATCH 156/225] Remove InternalError It wasn't serving a useful purpose and was just another layer of indirection. Throw ARTErrorInfo directly inside the codebase and let it bubble up to the public API (but still keep the richer underlying errors for checking in tests). Resolves #48. --- .../xcshareddata/swiftpm/Package.resolved | 4 +- Package.resolved | 4 +- Package.swift | 2 +- .../AblyLiveObjects/Internal/CoreSDK.swift | 16 +-- .../Internal/DefaultInternalPlugin.swift | 8 +- .../Internal/InternalDefaultLiveCounter.swift | 56 ++++----- .../Internal/InternalDefaultLiveMap.swift | 90 ++++++-------- .../InternalDefaultRealtimeObjects.swift | 114 ++++++++---------- .../Protocol/ObjectMessage.swift | 49 ++++---- .../AblyLiveObjects/Protocol/SyncCursor.swift | 5 +- .../Protocol/WireObjectMessage.swift | 35 +++--- .../PublicDefaultRealtimeObjects.swift | 4 +- .../Utility/Data+Extensions.swift | 6 +- Sources/AblyLiveObjects/Utility/Errors.swift | 93 +++++++++++++- .../Utility/InternalError.swift | 44 ------- .../AblyLiveObjects/Utility/JSONValue.swift | 8 +- .../AblyLiveObjects/Utility/WireCodable.swift | 90 +++++++------- .../AblyLiveObjects/Utility/WireValue.swift | 6 +- .../AblyLiveObjectsTests.swift | 10 +- .../InternalDefaultLiveCounterTests.swift | 4 +- .../InternalDefaultLiveMapTests.swift | 8 +- .../InternalErrorTests.swift | 18 --- .../ObjectsIntegrationTests.swift | 8 +- .../Mocks/MockCoreSDK.swift | 8 +- .../ObjectMessageTests.swift | 11 +- .../SyncCursorTests.swift | 11 +- 26 files changed, 350 insertions(+), 362 deletions(-) delete mode 100644 Sources/AblyLiveObjects/Utility/InternalError.swift delete mode 100644 Tests/AblyLiveObjectsTests/InternalErrorTests.swift diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index b2ca14c1f..129efb5e1 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "36e98bdea0539e16fb3606593afdd71398955c0c0a1f8bd5549e20f660a9cd3f", + "originHash" : "db81b5b63dd3c181dd6b5858443bec813293f75bfb7d44371053c1cc7bf30d70", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "8dde3e841aa1f861176c1341cf44e92014b95857" + "revision" : "6bcbf5faaa7b577f4fe8129d895be0f24258aa27" } }, { diff --git a/Package.resolved b/Package.resolved index a9ec8ce2d..e4441c1ba 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "bf537c63896d70fc4c726aee2c32003715644223b53ac9f8144267e14c5bd9a7", + "originHash" : "1e8978bd03f19fa4f0f7ec194d9281e28f2f5c2292ac744452a40ae531b4fb56", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "8dde3e841aa1f861176c1341cf44e92014b95857" + "revision" : "6bcbf5faaa7b577f4fe8129d895be0f24258aa27" } }, { diff --git a/Package.swift b/Package.swift index 4702890f3..5674241b2 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,7 @@ let package = Package( .package( url: "https://github.com/ably/ably-cocoa", // TODO: Unpin before next release - revision: "8dde3e841aa1f861176c1341cf44e92014b95857", + revision: "6bcbf5faaa7b577f4fe8129d895be0f24258aa27", ), .package( url: "https://github.com/ably/ably-cocoa-plugin-support", diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 83c466b4c..cc05671ae 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -6,12 +6,12 @@ import Ably /// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to `_AblyPluginSupportPrivate`'s Objective-C API (i.e. boxing). internal protocol CoreSDK: AnyObject, Sendable { /// Implements the internal `#publish` method of RTO15. - func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) + func publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) /// Replaces the implementation of ``publish(objectMessages:)``. /// /// 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(InternalError) -> Void) + func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void) /// Returns the current state of the Realtime channel that this wraps. var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { get } @@ -30,7 +30,7 @@ internal final class DefaultCoreSDK: CoreSDK { /// /// This enables the `testsOnly_overridePublish(with:)` test hook. /// - /// - Note: This should be `throws(InternalError)` but that causes a compilation error of "Runtime support for typed throws function types is only available in macOS 15.0.0 or newer". + /// - 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 -> Void)? internal init( @@ -47,7 +47,7 @@ internal final class DefaultCoreSDK: CoreSDK { // MARK: - CoreSDK conformance - internal func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + internal func publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) { logger.log("publish(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) // Use the overridden implementation if supplied @@ -58,10 +58,10 @@ internal final class DefaultCoreSDK: CoreSDK { do { try await overriddenImplementation(objectMessages) } catch { - guard let internalError = error as? InternalError else { - preconditionFailure("Expected InternalError, got \(error)") + guard let artErrorInfo = error as? ARTErrorInfo else { + preconditionFailure("Expected ARTErrorInfo, got \(error)") } - throw internalError + throw artErrorInfo } return } @@ -75,7 +75,7 @@ internal final class DefaultCoreSDK: CoreSDK { ) } - internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(InternalError) -> Void) { + internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void) { mutex.withLock { overriddenPublishImplementation = newImplementation } diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index b9e817210..42b7597e0 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -87,7 +87,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. ) return ObjectMessageBox(objectMessage: objectMessage) } catch { - errorPtr?.pointee = error.toARTErrorInfo().asPluginPublicErrorInfo + errorPtr?.pointee = error.asPluginPublicErrorInfo return nil } } @@ -140,10 +140,10 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. channel: _AblyPluginSupportPrivate.RealtimeChannel, client: _AblyPluginSupportPrivate.RealtimeClient, pluginAPI: PluginAPIProtocol, - ) async throws(InternalError) { + ) async throws(ARTErrorInfo) { let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } - try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in let internalQueue = pluginAPI.internalQueue(for: client) internalQueue.async { @@ -155,7 +155,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. dispatchPrecondition(condition: .onQueue(internalQueue)) if let error { - continuation.resume(returning: .failure(error.toInternalError())) + continuation.resume(returning: .failure(ARTErrorInfo.castPluginPublicErrorInfo(error))) } else { continuation.resume(returning: .success(())) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index c521bff94..15f12a10f 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -104,42 +104,34 @@ internal final class InternalDefaultLiveCounter: Sendable { } internal func increment(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { - do throws(InternalError) { - let objectMessage = try mutableStateMutex.withSync { mutableState throws(InternalError) in - // RTLC12c - do throws(ARTErrorInfo) { - try coreSDK.nosync_validateChannelState( - notIn: [.detached, .failed, .suspended], - operationDescription: "LiveCounter.increment", - ) - } catch { - throw error.toInternalError() - } - - // RTLC12e1 - if !amount.isFinite { - throw LiveObjectsError.counterIncrementAmountInvalid(amount: amount).toARTErrorInfo().toInternalError() - } + let objectMessage = try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + // RTLC12c + try coreSDK.nosync_validateChannelState( + notIn: [.detached, .failed, .suspended], + operationDescription: "LiveCounter.increment", + ) - return OutboundObjectMessage( - operation: .init( - // RTLC12e2 - action: .known(.counterInc), - // RTLC12e3 - objectId: mutableState.liveObjectMutableState.objectID, - counterOp: .init( - // RTLC12e4 - amount: .init(value: amount), - ), - ), - ) + // RTLC12e1 + if !amount.isFinite { + throw LiveObjectsError.counterIncrementAmountInvalid(amount: amount).toARTErrorInfo() } - // RTLC12f - try await coreSDK.publish(objectMessages: [objectMessage]) - } catch { - throw error.toARTErrorInfo() + return OutboundObjectMessage( + operation: .init( + // RTLC12e2 + action: .known(.counterInc), + // RTLC12e3 + objectId: mutableState.liveObjectMutableState.objectID, + counterOp: .init( + // RTLC12e4 + amount: .init(value: amount), + ), + ), + ) } + + // RTLC12f + try await coreSDK.publish(objectMessages: [objectMessage]) } internal func decrement(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index f0266508c..48668f08d 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -150,66 +150,50 @@ internal final class InternalDefaultLiveMap: Sendable { } internal func set(key: String, value: InternalLiveMapValue, coreSDK: CoreSDK) async throws(ARTErrorInfo) { - do throws(InternalError) { - let objectMessage = try mutableStateMutex.withSync { mutableState throws(InternalError) in - // RTLM20c - do throws(ARTErrorInfo) { - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.set") - } catch { - throw error.toInternalError() - } - - return OutboundObjectMessage( - operation: .init( - // RTLM20e2 - action: .known(.mapSet), - // RTLM20e3 - objectId: mutableState.liveObjectMutableState.objectID, - mapOp: .init( - // RTLM20e4 - key: key, - // RTLM20e5 - data: value.nosync_toObjectData, - ), + let objectMessage = try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + // RTLM20c + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.set") + + return OutboundObjectMessage( + operation: .init( + // RTLM20e2 + action: .known(.mapSet), + // RTLM20e3 + objectId: mutableState.liveObjectMutableState.objectID, + mapOp: .init( + // RTLM20e4 + key: key, + // RTLM20e5 + data: value.nosync_toObjectData, ), - ) - } - - try await coreSDK.publish(objectMessages: [objectMessage]) - } catch { - throw error.toARTErrorInfo() + ), + ) } + + try await coreSDK.publish(objectMessages: [objectMessage]) } internal func remove(key: String, coreSDK: CoreSDK) async throws(ARTErrorInfo) { - do throws(InternalError) { - let objectMessage = try mutableStateMutex.withSync { mutableState throws(InternalError) in - // RTLM21c - do throws(ARTErrorInfo) { - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.remove") - } catch { - throw error.toInternalError() - } - - return OutboundObjectMessage( - operation: .init( - // RTLM21e2 - action: .known(.mapRemove), - // RTLM21e3 - objectId: mutableState.liveObjectMutableState.objectID, - mapOp: .init( - // RTLM21e4 - key: key, - ), + let objectMessage = try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + // RTLM21c + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.remove") + + return OutboundObjectMessage( + operation: .init( + // RTLM21e2 + action: .known(.mapRemove), + // RTLM21e3 + objectId: mutableState.liveObjectMutableState.objectID, + mapOp: .init( + // RTLM21e4 + key: key, ), - ) - } - - // RTLM21f - try await coreSDK.publish(objectMessages: [objectMessage]) - } catch { - throw error.toARTErrorInfo() + ), + ) } + + // RTLM21f + try await coreSDK.publish(objectMessages: [objectMessage]) } @discardableResult diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index ff7069f71..ca4e1963a 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -172,39 +172,31 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } internal func createMap(entries: [String: InternalLiveMapValue], coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { - do throws(InternalError) { - let creationOperation = try mutableStateMutex.withSync { _ throws(InternalError) in - // RTO11d - do throws(ARTErrorInfo) { - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createMap") - } catch { - throw error.toInternalError() - } + let creationOperation = try mutableStateMutex.withSync { _ throws(ARTErrorInfo) in + // RTO11d + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createMap") - // RTO11f - // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) - let timestamp = clock.now - return ObjectCreationHelpers.nosync_creationOperationForLiveMap( - entries: entries, - timestamp: timestamp, - ) - } + // RTO11f + // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) + let timestamp = clock.now + return ObjectCreationHelpers.nosync_creationOperationForLiveMap( + entries: entries, + timestamp: timestamp, + ) + } - // RTO11g - try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) + // RTO11g + try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) - // RTO11h - return mutableStateMutex.withSync { mutableState in - mutableState.objectsPool.nosync_getOrCreateMap( - creationOperation: creationOperation, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - } catch { - throw error.toARTErrorInfo() + // RTO11h + return mutableStateMutex.withSync { mutableState in + mutableState.objectsPool.nosync_getOrCreateMap( + creationOperation: creationOperation, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) } } @@ -214,45 +206,37 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool } internal func createCounter(count: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { - do throws(InternalError) { - // RTO12d - do throws(ARTErrorInfo) { - try mutableStateMutex.withSync { _ throws(ARTErrorInfo) in - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createCounter") - } - } catch { - throw error.toInternalError() - } + // RTO12d + try mutableStateMutex.withSync { _ throws(ARTErrorInfo) in + try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createCounter") + } - // RTO12f1 - if !count.isFinite { - throw LiveObjectsError.counterInitialValueInvalid(value: count).toARTErrorInfo().toInternalError() - } + // RTO12f1 + if !count.isFinite { + throw LiveObjectsError.counterInitialValueInvalid(value: count).toARTErrorInfo() + } - // RTO12f + // RTO12f - // TODO: This is a stopgap; change to use server time per RTO12f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) - let timestamp = clock.now - let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter( - count: count, - timestamp: timestamp, - ) + // TODO: This is a stopgap; change to use server time per RTO12f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) + let timestamp = clock.now + let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter( + count: count, + timestamp: timestamp, + ) - // RTO12g - try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) + // RTO12g + try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) - // RTO12h - return mutableStateMutex.withSync { mutableState in - mutableState.objectsPool.nosync_getOrCreateCounter( - creationOperation: creationOperation, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } - } catch { - throw error.toARTErrorInfo() + // RTO12h + return mutableStateMutex.withSync { mutableState in + mutableState.objectsPool.nosync_getOrCreateCounter( + creationOperation: creationOperation, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) } } @@ -356,7 +340,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool // MARK: - Sending `OBJECT` ProtocolMessage // This is currently exposed so that we can try calling it from the tests in the early days of the SDK to check that we can send an OBJECT ProtocolMessage. We'll probably make it private later on. - internal func testsOnly_publish(objectMessages: [OutboundObjectMessage], coreSDK: CoreSDK) async throws(InternalError) { + internal func testsOnly_publish(objectMessages: [OutboundObjectMessage], coreSDK: CoreSDK) async throws(ARTErrorInfo) { try await coreSDK.publish(objectMessages: objectMessages) } diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index b882d6004..f1411894f 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -1,4 +1,5 @@ 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. @@ -94,20 +95,20 @@ internal extension InboundObjectMessage { /// /// - Parameters: /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `InternalError` if JSON or Base64 decoding fails. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. init( wireObjectMessage: InboundWireObjectMessage, format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(InternalError) { + ) throws(ARTErrorInfo) { id = wireObjectMessage.id clientId = wireObjectMessage.clientId connectionId = wireObjectMessage.connectionId extras = wireObjectMessage.extras timestamp = wireObjectMessage.timestamp - operation = try wireObjectMessage.operation.map { wireObjectOperation throws(InternalError) in + operation = try wireObjectMessage.operation.map { wireObjectOperation throws(ARTErrorInfo) in try .init(wireObjectOperation: wireObjectOperation, format: format) } - object = try wireObjectMessage.object.map { wireObjectState throws(InternalError) in + object = try wireObjectMessage.object.map { wireObjectState throws(ARTErrorInfo) in try .init(wireObjectState: wireObjectState, format: format) } serial = wireObjectMessage.serial @@ -142,11 +143,11 @@ internal extension ObjectOperation { /// /// - Parameters: /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `InternalError` if JSON or Base64 decoding fails. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. init( wireObjectOperation: WireObjectOperation, format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(InternalError) { + ) throws(ARTErrorInfo) { // Decode the action and objectId first they're not part of PartialObjectOperation action = wireObjectOperation.action objectId = wireObjectOperation.objectId @@ -206,16 +207,16 @@ internal extension PartialObjectOperation { /// /// - Parameters: /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `InternalError` if JSON or Base64 decoding fails. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. init( partialWireObjectOperation: PartialWireObjectOperation, format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(InternalError) { - mapOp = try partialWireObjectOperation.mapOp.map { wireObjectsMapOp throws(InternalError) in + ) throws(ARTErrorInfo) { + mapOp = try partialWireObjectOperation.mapOp.map { wireObjectsMapOp throws(ARTErrorInfo) in try .init(wireObjectsMapOp: wireObjectsMapOp, format: format) } counterOp = partialWireObjectOperation.counterOp - map = try partialWireObjectOperation.map.map { wireMap throws(InternalError) in + map = try partialWireObjectOperation.map.map { wireMap throws(ARTErrorInfo) in try .init(wireObjectsMap: wireMap, format: format) } counter = partialWireObjectOperation.counter @@ -247,11 +248,11 @@ internal extension ObjectData { /// /// - Parameters: /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `InternalError` if JSON or Base64 decoding fails. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. init( wireObjectData: WireObjectData, format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(InternalError) { + ) throws(ARTErrorInfo) { objectId = wireObjectData.objectId boolean = wireObjectData.boolean number = wireObjectData.number @@ -351,13 +352,13 @@ internal extension ObjectsMapOp { /// /// - Parameters: /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `InternalError` if JSON or Base64 decoding fails. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. init( wireObjectsMapOp: WireObjectsMapOp, format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(InternalError) { + ) throws(ARTErrorInfo) { key = wireObjectsMapOp.key - data = try wireObjectsMapOp.data.map { wireObjectData throws(InternalError) in + data = try wireObjectsMapOp.data.map { wireObjectData throws(ARTErrorInfo) in try .init(wireObjectData: wireObjectData, format: format) } } @@ -379,11 +380,11 @@ internal extension ObjectsMapEntry { /// /// - Parameters: /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `InternalError` if JSON or Base64 decoding fails. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. init( wireObjectsMapEntry: WireObjectsMapEntry, format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(InternalError) { + ) throws(ARTErrorInfo) { tombstone = wireObjectsMapEntry.tombstone timeserial = wireObjectsMapEntry.timeserial data = if let wireObjectData = wireObjectsMapEntry.data { @@ -412,13 +413,13 @@ internal extension ObjectsMap { /// /// - Parameters: /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `InternalError` if JSON or Base64 decoding fails. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. init( wireObjectsMap: WireObjectsMap, format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(InternalError) { + ) throws(ARTErrorInfo) { semantics = wireObjectsMap.semantics - entries = try wireObjectsMap.entries?.ablyLiveObjects_mapValuesWithTypedThrow { wireMapEntry throws(InternalError) in + entries = try wireObjectsMap.entries?.ablyLiveObjects_mapValuesWithTypedThrow { wireMapEntry throws(ARTErrorInfo) in try .init(wireObjectsMapEntry: wireMapEntry, format: format) } } @@ -440,18 +441,18 @@ internal extension ObjectState { /// /// - Parameters: /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `InternalError` if JSON or Base64 decoding fails. + /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. init( wireObjectState: WireObjectState, format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(InternalError) { + ) throws(ARTErrorInfo) { objectId = wireObjectState.objectId siteTimeserials = wireObjectState.siteTimeserials tombstone = wireObjectState.tombstone - createOp = try wireObjectState.createOp.map { wireObjectOperation throws(InternalError) in + createOp = try wireObjectState.createOp.map { wireObjectOperation throws(ARTErrorInfo) in try .init(wireObjectOperation: wireObjectOperation, format: format) } - map = try wireObjectState.map.map { wireObjectsMap throws(InternalError) in + map = try wireObjectState.map.map { wireObjectsMap throws(ARTErrorInfo) in try .init(wireObjectsMap: wireObjectsMap, format: format) } counter = wireObjectState.counter diff --git a/Sources/AblyLiveObjects/Protocol/SyncCursor.swift b/Sources/AblyLiveObjects/Protocol/SyncCursor.swift index d3d92ebcf..311e8f98e 100644 --- a/Sources/AblyLiveObjects/Protocol/SyncCursor.swift +++ b/Sources/AblyLiveObjects/Protocol/SyncCursor.swift @@ -1,3 +1,4 @@ +import Ably import Foundation /// The `OBJECT_SYNC` sync cursor, as extracted from a `channelSerial` per RTO5a1 and RTO5a4. @@ -11,7 +12,7 @@ internal struct SyncCursor { } /// Creates a `SyncCursor` from the `channelSerial` of an `OBJECT_SYNC` `ProtocolMessage`. - internal init(channelSerial: String) throws(InternalError) { + internal init(channelSerial: String) throws(ARTErrorInfo) { let scanner = Scanner(string: channelSerial) scanner.charactersToBeSkipped = nil @@ -20,7 +21,7 @@ internal struct SyncCursor { // Check if we have a colon guard scanner.scanString(":") != nil else { - throw Error.channelSerialDoesNotMatchExpectedFormat(channelSerial).toInternalError() + throw Error.channelSerialDoesNotMatchExpectedFormat(channelSerial).toARTErrorInfo() } // Everything after the colon (if anything) is the cursor value diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 828ae0a84..20342724a 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -1,4 +1,5 @@ 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. @@ -62,7 +63,7 @@ internal extension InboundWireObjectMessage { init( wireObject: [String: WireValue], decodingContext: _AblyPluginSupportPrivate.DecodingContextProtocol - ) throws(InternalError) { + ) throws(ARTErrorInfo) { // OM2a if let id = try wireObject.optionalStringValueForKey(WireObjectMessageWireKey.id.rawValue) { self.id = id @@ -81,7 +82,7 @@ internal extension InboundWireObjectMessage { // Convert WireValue extras to JSONValue extras if let wireExtras = try wireObject.optionalObjectValueForKey(WireObjectMessageWireKey.extras.rawValue) { - extras = try wireExtras.ablyLiveObjects_mapValuesWithTypedThrow { wireValue throws(InternalError) in + extras = try wireExtras.ablyLiveObjects_mapValuesWithTypedThrow { wireValue throws(ARTErrorInfo) in try wireValue.toJSONValue } } else { @@ -179,7 +180,7 @@ extension PartialWireObjectOperation: WireObjectCodable { case initialValue } - internal init(wireObject: [String: WireValue]) throws(InternalError) { + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { mapOp = try wireObject.optionalDecodableValueForKey(WireKey.mapOp.rawValue) counterOp = try wireObject.optionalDecodableValueForKey(WireKey.counterOp.rawValue) map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) @@ -234,7 +235,7 @@ extension WireObjectOperation: WireObjectCodable { case objectId } - internal init(wireObject: [String: WireValue]) throws(InternalError) { + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { // Decode the action and objectId first since they're not part of PartialWireObjectOperation action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) @@ -288,11 +289,11 @@ extension WireObjectState: WireObjectCodable { case counter } - internal init(wireObject: [String: WireValue]) throws(InternalError) { + 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(InternalError) in + 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).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(WireKey.siteTimeserials.rawValue, actualValue: value).toARTErrorInfo() } return string } @@ -334,7 +335,7 @@ extension WireObjectsMapOp: WireObjectCodable { case data } - internal init(wireObject: [String: WireValue]) throws(InternalError) { + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { key = try wireObject.stringValueForKey(WireKey.key.rawValue) data = try wireObject.optionalDecodableValueForKey(WireKey.data.rawValue) } @@ -361,7 +362,7 @@ extension WireObjectsCounterOp: WireObjectCodable { case amount } - internal init(wireObject: [String: WireValue]) throws(InternalError) { + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { amount = try wireObject.numberValueForKey(WireKey.amount.rawValue) } @@ -383,11 +384,11 @@ extension WireObjectsMap: WireObjectCodable { case entries } - internal init(wireObject: [String: WireValue]) throws(InternalError) { + 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(InternalError) in + 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).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(WireKey.entries.rawValue, actualValue: value).toARTErrorInfo() } return try WireObjectsMapEntry(wireObject: object) } @@ -415,7 +416,7 @@ extension WireObjectsCounter: WireObjectCodable { case count } - internal init(wireObject: [String: WireValue]) throws(InternalError) { + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { count = try wireObject.optionalNumberValueForKey(WireKey.count.rawValue) } @@ -443,7 +444,7 @@ extension WireObjectsMapEntry: WireObjectCodable { case serialTimestamp } - internal init(wireObject: [String: WireValue]) throws(InternalError) { + 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) @@ -489,7 +490,7 @@ extension WireObjectData: WireObjectCodable { case json } - internal init(wireObject: [String: WireValue]) throws(InternalError) { + 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) @@ -536,14 +537,14 @@ internal enum StringOrData: WireCodable { case unsupportedValue(WireValue) } - internal init(wireValue: WireValue) throws(InternalError) { + 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).toInternalError() + throw DecodingError.unsupportedValue(wireValue).toARTErrorInfo() } } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index eec2508a6..e42da95a3 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -106,7 +106,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { proxied.testsOnly_receivedObjectProtocolMessages } - internal func testsOnly_publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + internal func testsOnly_publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) { try await proxied.testsOnly_publish(objectMessages: objectMessages, coreSDK: coreSDK) } @@ -119,7 +119,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { /// 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(InternalError) -> Void) { + internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void) { coreSDK.testsOnly_overridePublish(with: newImplementation) } } diff --git a/Sources/AblyLiveObjects/Utility/Data+Extensions.swift b/Sources/AblyLiveObjects/Utility/Data+Extensions.swift index 7143a329d..286e0bb67 100644 --- a/Sources/AblyLiveObjects/Utility/Data+Extensions.swift +++ b/Sources/AblyLiveObjects/Utility/Data+Extensions.swift @@ -9,10 +9,10 @@ internal enum DecodingError: Error, Equatable { 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: `DecodingError.invalidBase64String` if the string cannot be decoded as Base64 - static func fromBase64Throwing(_ base64String: String) throws(InternalError) -> Data { + /// - 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).toInternalError() + throw DecodingError.invalidBase64String(base64String).toARTErrorInfo() } return data } diff --git a/Sources/AblyLiveObjects/Utility/Errors.swift b/Sources/AblyLiveObjects/Utility/Errors.swift index 1ab2e6619..564f382fb 100644 --- a/Sources/AblyLiveObjects/Utility/Errors.swift +++ b/Sources/AblyLiveObjects/Utility/Errors.swift @@ -9,6 +9,7 @@ internal enum LiveObjectsError { case objectsOperationFailedInvalidChannelState(operationDescription: String, channelState: _AblyPluginSupportPrivate.RealtimeChannelState) case counterInitialValueInvalid(value: Double) case counterIncrementAmountInvalid(amount: Double) + case other(Error) /// The ``ARTErrorInfo/code`` that should be returned for this error. internal var code: ARTErrorCode { @@ -18,6 +19,8 @@ internal enum LiveObjectsError { case .counterInitialValueInvalid, .counterIncrementAmountInvalid: // RTO12f1, RTLC12e1 .invalidParameterValue + case .other: + .badRequest } } @@ -26,7 +29,8 @@ internal enum LiveObjectsError { switch self { case .objectsOperationFailedInvalidChannelState, .counterInitialValueInvalid, - .counterIncrementAmountInvalid: + .counterIncrementAmountInvalid, + .other: 400 } } @@ -40,6 +44,8 @@ internal enum LiveObjectsError { "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 .other(error): + "\(error)" } } @@ -48,6 +54,91 @@ internal enum LiveObjectsError { withCode: Int(code.rawValue), status: statusCode, message: localizedDescription, + additionalUserInfo: [liveObjectsErrorUserInfoKey: self], ) } } + +// MARK: - ConvertibleToLiveObjectsError Protocol + +/// Protocol for types that can be converted to a `LiveObjectsError`. +/// +/// We deliberately do not conform `ARTErrorInfo` (or its parent types `NSError` or `Error`) to this protocol, so that we do not accidentally end up flattening an `ARTErrorInfo` into the `.other` `LiveObjectsError` case; if we have an `ARTErrorInfo` then it should just be thrown directly. +/// +/// If you need to convert a non-specific `NSError` or `Error` to a `LiveObjects` error, then do so explicitly using `LiveObjectsError.other`. +internal protocol ConvertibleToLiveObjectsError { + func toLiveObjectsError() -> LiveObjectsError +} + +internal extension ConvertibleToLiveObjectsError { + /// Convenience method to convert directly to an `ARTErrorInfo`. + func toARTErrorInfo() -> ARTErrorInfo { + toLiveObjectsError().toARTErrorInfo() + } +} + +// MARK: - Conversion Extensions + +extension DecodingError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +extension WireValueDecodingError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +extension WireValue.ConversionError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +extension SyncCursor.Error: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +extension InboundWireObjectMessage.DecodingError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +extension StringOrData.DecodingError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +extension JSONObjectOrArray.ConversionError: ConvertibleToLiveObjectsError { + internal func toLiveObjectsError() -> LiveObjectsError { + .other(self) + } +} + +// MARK: - ARTErrorInfo Extension + +/// The `ARTErrorInfo.userInfo` key under which we store the underlying `LiveObjectsError`. Used by `testsOnly_underlyingLiveObjectsError`. +private let liveObjectsErrorUserInfoKey = "LiveObjectsError" + +internal extension ARTErrorInfo { + /// Retrieves the underlying `LiveObjectsError` from this `ARTErrorInfo` if it was generated from a `LiveObjectsError`. + /// + /// - Returns: The underlying `LiveObjectsError` if this error was generated from one, `nil` otherwise. + var testsOnly_underlyingLiveObjectsError: LiveObjectsError? { + guard let userInfoEntry = userInfo[liveObjectsErrorUserInfoKey] else { + return nil + } + + guard let liveObjectsError = userInfoEntry as? LiveObjectsError else { + preconditionFailure("Expected a LiveObjectsError, got \(userInfoEntry)") + } + + return liveObjectsError + } +} diff --git a/Sources/AblyLiveObjects/Utility/InternalError.swift b/Sources/AblyLiveObjects/Utility/InternalError.swift deleted file mode 100644 index 4492cf6aa..000000000 --- a/Sources/AblyLiveObjects/Utility/InternalError.swift +++ /dev/null @@ -1,44 +0,0 @@ -internal import _AblyPluginSupportPrivate -import Ably - -/// An error thrown by the internals of the LiveObjects SDK. -/// -/// Copied from ably-chat-swift; will decide what to do about it. -internal enum InternalError: Error { - case errorInfo(ARTErrorInfo) - case other(Other) - - internal enum Other { - // In ably-chat-swift we have different cases here for different types of errors thrown within the codebase, but we didn't figure out what to actually _do_ with these different types of errors (see implementation of toARTErrorInfo which squashes everything down to the same error), so let's not bother with that for now - case generic(Error) - } - - /// Returns the error that this should be converted to when exposed via the SDK's public API. - internal func toARTErrorInfo() -> ARTErrorInfo { - switch self { - case let .errorInfo(errorInfo): - errorInfo - case let .other(other): - // For now we just treat all errors that are not backed by an ARTErrorInfo as non-recoverable user errors - .create(withCode: Int(ARTErrorCode.badRequest.rawValue), message: "\(other)") - } - } -} - -internal extension Error { - func toInternalError() -> InternalError { - .other(.generic(self)) - } -} - -internal extension ARTErrorInfo { - func toInternalError() -> InternalError { - .errorInfo(self) - } -} - -internal extension _AblyPluginSupportPrivate.PublicErrorInfo { - func toInternalError() -> InternalError { - ARTErrorInfo.castPluginPublicErrorInfo(self).toInternalError() - } -} diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift index 48c33f3f2..1c6ae2ed1 100644 --- a/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -167,14 +167,14 @@ internal enum JSONObjectOrArray: Equatable { case incompatibleJSONValue(JSONValue) } - internal init(jsonValue: JSONValue) throws(InternalError) { + 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).toInternalError() + throw ConversionError.incompatibleJSONValue(jsonValue).toARTErrorInfo() } } @@ -269,13 +269,13 @@ internal extension JSONObjectOrArray { } /// Deserializes a JSON string into a `JSONObjectOrArray`. Throws an error if not given a valid JSON string. - init(jsonString: String) throws(InternalError) { + init(jsonString: String) throws(ARTErrorInfo) { let data = Data(jsonString.utf8) let jsonSerializationOutput: Any do { jsonSerializationOutput = try JSONSerialization.jsonObject(with: data) } catch { - throw error.toInternalError() + throw LiveObjectsError.other(error).toARTErrorInfo() } let jsonValue = JSONValue(jsonSerializationOutput: jsonSerializationOutput) diff --git a/Sources/AblyLiveObjects/Utility/WireCodable.swift b/Sources/AblyLiveObjects/Utility/WireCodable.swift index b00b7edd9..b68485d18 100644 --- a/Sources/AblyLiveObjects/Utility/WireCodable.swift +++ b/Sources/AblyLiveObjects/Utility/WireCodable.swift @@ -6,7 +6,7 @@ internal protocol WireEncodable { } internal protocol WireDecodable { - init(wireValue: WireValue) throws(InternalError) + init(wireValue: WireValue) throws(ARTErrorInfo) } internal typealias WireCodable = WireDecodable & WireEncodable @@ -23,7 +23,7 @@ internal extension WireObjectEncodable { } internal protocol WireObjectDecodable: WireDecodable { - init(wireObject: [String: WireValue]) throws(InternalError) + init(wireObject: [String: WireValue]) throws(ARTErrorInfo) } internal enum WireValueDecodingError: Error { @@ -35,9 +35,9 @@ internal enum WireValueDecodingError: Error { // Default implementation of `WireDecodable` conformance for `WireObjectDecodable` internal extension WireObjectDecodable { - init(wireValue: WireValue) throws(InternalError) { + init(wireValue: WireValue) throws(ARTErrorInfo) { guard case let .object(wireObject) = wireValue else { - throw WireValueDecodingError.valueIsNotObject.toInternalError() + throw WireValueDecodingError.valueIsNotObject.toARTErrorInfo() } self = try .init(wireObject: wireObject) @@ -55,13 +55,13 @@ internal extension [String: WireValue] { /// - Throws: /// - `WireValueDecodingError.noValueForKey` if the key is absent /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `object` - func objectValueForKey(_ key: String) throws(InternalError) -> [String: WireValue] { + func objectValueForKey(_ key: String) throws(ARTErrorInfo) -> [String: WireValue] { guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() } guard case let .object(objectValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() } return objectValue @@ -70,7 +70,7 @@ internal extension [String: WireValue] { /// 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(InternalError) -> [String: WireValue]? { + func optionalObjectValueForKey(_ key: String) throws(ARTErrorInfo) -> [String: WireValue]? { guard let value = self[key] else { return nil } @@ -80,7 +80,7 @@ internal extension [String: WireValue] { } guard case let .object(objectValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() } return objectValue @@ -91,13 +91,13 @@ internal extension [String: WireValue] { /// - Throws: /// - `WireValueDecodingError.noValueForKey` if the key is absent /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `array` - func arrayValueForKey(_ key: String) throws(InternalError) -> [WireValue] { + func arrayValueForKey(_ key: String) throws(ARTErrorInfo) -> [WireValue] { guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() } guard case let .array(arrayValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() } return arrayValue @@ -106,7 +106,7 @@ internal extension [String: WireValue] { /// 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(InternalError) -> [WireValue]? { + func optionalArrayValueForKey(_ key: String) throws(ARTErrorInfo) -> [WireValue]? { guard let value = self[key] else { return nil } @@ -116,7 +116,7 @@ internal extension [String: WireValue] { } guard case let .array(arrayValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() } return arrayValue @@ -127,13 +127,13 @@ internal extension [String: WireValue] { /// - Throws: /// - `WireValueDecodingError.noValueForKey` if the key is absent /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `string` - func stringValueForKey(_ key: String) throws(InternalError) -> String { + func stringValueForKey(_ key: String) throws(ARTErrorInfo) -> String { guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() } guard case let .string(stringValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() } return stringValue @@ -142,7 +142,7 @@ internal extension [String: WireValue] { /// 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(InternalError) -> String? { + func optionalStringValueForKey(_ key: String) throws(ARTErrorInfo) -> String? { guard let value = self[key] else { return nil } @@ -152,7 +152,7 @@ internal extension [String: WireValue] { } guard case let .string(stringValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() } return stringValue @@ -163,13 +163,13 @@ internal extension [String: WireValue] { /// - Throws: /// - `WireValueDecodingError.noValueForKey` if the key is absent /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` - func numberValueForKey(_ key: String) throws(InternalError) -> NSNumber { + func numberValueForKey(_ key: String) throws(ARTErrorInfo) -> NSNumber { guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() } guard case let .number(numberValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() } return numberValue @@ -178,7 +178,7 @@ internal extension [String: WireValue] { /// 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(InternalError) -> NSNumber? { + func optionalNumberValueForKey(_ key: String) throws(ARTErrorInfo) -> NSNumber? { guard let value = self[key] else { return nil } @@ -188,7 +188,7 @@ internal extension [String: WireValue] { } guard case let .number(numberValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() } return numberValue @@ -199,13 +199,13 @@ internal extension [String: WireValue] { /// - Throws: /// - `WireValueDecodingError.noValueForKey` if the key is absent /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `bool` - func boolValueForKey(_ key: String) throws(InternalError) -> Bool { + func boolValueForKey(_ key: String) throws(ARTErrorInfo) -> Bool { guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() } guard case let .bool(boolValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() } return boolValue @@ -214,7 +214,7 @@ internal extension [String: WireValue] { /// 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(InternalError) -> Bool? { + func optionalBoolValueForKey(_ key: String) throws(ARTErrorInfo) -> Bool? { guard let value = self[key] else { return nil } @@ -224,7 +224,7 @@ internal extension [String: WireValue] { } guard case let .bool(boolValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() } return boolValue @@ -235,13 +235,13 @@ internal extension [String: WireValue] { /// - Throws: /// - `WireValueDecodingError.noValueForKey` if the key is absent /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `data` - func dataValueForKey(_ key: String) throws(InternalError) -> Data { + func dataValueForKey(_ key: String) throws(ARTErrorInfo) -> Data { guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() } guard case let .data(dataValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() } return dataValue @@ -250,7 +250,7 @@ internal extension [String: WireValue] { /// 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(InternalError) -> Data? { + func optionalDataValueForKey(_ key: String) throws(ARTErrorInfo) -> Data? { guard let value = self[key] else { return nil } @@ -260,7 +260,7 @@ internal extension [String: WireValue] { } guard case let .data(dataValue) = value else { - throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toInternalError() + throw WireValueDecodingError.wrongTypeForKey(key, actualValue: value).toARTErrorInfo() } return dataValue @@ -275,7 +275,7 @@ internal extension [String: WireValue] { /// - Throws: /// - `WireValueDecodingError.noValueForKey` if the key is absent /// - `WireValueDecodingError.wrongTypeForKey` if the value does not have case `number` - func ablyProtocolDateValueForKey(_ key: String) throws(InternalError) -> Date { + func ablyProtocolDateValueForKey(_ key: String) throws(ARTErrorInfo) -> Date { let millisecondsSinceEpoch = try numberValueForKey(key).uint64Value return dateFromMillisecondsSinceEpoch(millisecondsSinceEpoch) @@ -284,7 +284,7 @@ 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). 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(InternalError) -> Date? { + func optionalAblyProtocolDateValueForKey(_ key: String) throws(ARTErrorInfo) -> Date? { guard let millisecondsSinceEpoch = try optionalNumberValueForKey(key)?.uint64Value else { return nil } @@ -305,7 +305,7 @@ internal extension [String: WireValue] { /// - `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(InternalError) -> T where T.RawValue == String { + 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) @@ -316,7 +316,7 @@ internal extension [String: WireValue] { /// - 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(InternalError) -> T? where T.RawValue == String { + 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 } @@ -324,9 +324,9 @@ internal extension [String: WireValue] { return try rawRepresentableValueFromRawValue(rawValue, type: T.self) } - private func rawRepresentableValueFromRawValue(_ rawValue: String, type _: T.Type = T.self) throws(InternalError) -> T where T.RawValue == String { + 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).toInternalError() + throw WireValueDecodingError.failedToDecodeFromRawValue(rawValue).toARTErrorInfo() } return value @@ -341,7 +341,7 @@ internal extension [String: WireValue] { /// - 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(InternalError) -> WireEnum where Known.RawValue == Int { + 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) } @@ -349,7 +349,7 @@ 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. 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(InternalError) -> WireEnum? where Known.RawValue == Int { + 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 } @@ -365,9 +365,9 @@ internal extension [String: WireValue] { /// - 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(InternalError) -> T { + func decodableValueForKey(_ key: String, type _: T.Type = T.self) throws(ARTErrorInfo) -> T { guard let value = self[key] else { - throw WireValueDecodingError.noValueForKey(key).toInternalError() + throw WireValueDecodingError.noValueForKey(key).toARTErrorInfo() } return try T(wireValue: value) @@ -376,7 +376,7 @@ 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. 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(InternalError) -> T? { + func optionalDecodableValueForKey(_ key: String, type _: T.Type = T.self) throws(ARTErrorInfo) -> T? { guard let value = self[key] else { return nil } diff --git a/Sources/AblyLiveObjects/Utility/WireValue.swift b/Sources/AblyLiveObjects/Utility/WireValue.swift index 6e82a258b..9f59db15d 100644 --- a/Sources/AblyLiveObjects/Utility/WireValue.swift +++ b/Sources/AblyLiveObjects/Utility/WireValue.swift @@ -235,11 +235,11 @@ internal extension WireValue { /// /// - Throws: `ConversionError.dataCannotBeConvertedToJSONValue` if `WireValue` represents binary data. var toJSONValue: JSONValue { - get throws(InternalError) { - let neverExtended = try toExtendedJSONValue.map { extra throws(InternalError) -> Never in + get throws(ARTErrorInfo) { + let neverExtended = try toExtendedJSONValue.map { extra throws(ARTErrorInfo) -> Never in switch extra { case .data: - throw ConversionError.dataCannotBeConvertedToJSONValue.toInternalError() + throw ConversionError.dataCannotBeConvertedToJSONValue.toARTErrorInfo() } } diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index feaa87194..bb5a419e4 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -109,13 +109,9 @@ struct AblyLiveObjectsTests { // 7. Now, send an invalid OBJECT ProtocolMessage to check that ably-cocoa correctly reports on its NACK. let invalidObjectThrownError = try await #require(throws: ARTErrorInfo.self) { - do throws(InternalError) { - try await channel.testsOnly_nonTypeErasedObjects.testsOnly_publish(objectMessages: [ - .init(), - ]) - } catch { - throw error.toARTErrorInfo() - } + try await channel.testsOnly_nonTypeErasedObjects.testsOnly_publish(objectMessages: [ + .init(), + ]) } // (These are just based on what I observed in the NACK) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 2860d441a..520ad03b2 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -567,8 +567,8 @@ struct InternalDefaultLiveCounterTests { let counter = InternalDefaultLiveCounter.createZeroValued(objectID: "counter:test@123", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) - coreSDK.setPublishHandler { _ throws(InternalError) in - throw InternalError.other(.generic(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]))) + coreSDK.setPublishHandler { _ throws(ARTErrorInfo) in + throw LiveObjectsError.other(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"])).toARTErrorInfo() } await #expect { diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index e4670f3c4..bf43d98c2 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -1409,8 +1409,8 @@ struct InternalDefaultLiveMapTests { let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) - coreSDK.setPublishHandler { _ throws(InternalError) in - throw NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]).toInternalError() + coreSDK.setPublishHandler { _ throws(ARTErrorInfo) in + throw LiveObjectsError.other(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"])).toARTErrorInfo() } await #expect { @@ -1490,8 +1490,8 @@ struct InternalDefaultLiveMapTests { let map = InternalDefaultLiveMap.createZeroValued(objectID: "map:test@123", logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) - coreSDK.setPublishHandler { _ throws(InternalError) in - throw NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"]).toInternalError() + coreSDK.setPublishHandler { _ throws(ARTErrorInfo) in + throw LiveObjectsError.other(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"])).toARTErrorInfo() } await #expect { diff --git a/Tests/AblyLiveObjectsTests/InternalErrorTests.swift b/Tests/AblyLiveObjectsTests/InternalErrorTests.swift deleted file mode 100644 index ac5c9333b..000000000 --- a/Tests/AblyLiveObjectsTests/InternalErrorTests.swift +++ /dev/null @@ -1,18 +0,0 @@ -import Ably -@testable import AblyLiveObjects -import Testing - -struct InternalErrorTests { - @Test - func artErrorInfo_toInternalError() { - let errorInfo = ARTErrorInfo(domain: "foo", code: 3) - - // Check that we get errorInfo instead of the protocol extension on Swift.Error - switch errorInfo.toInternalError() { - case .errorInfo: - break - case .other: - Issue.record("Expected .errorInfo") - } - } -} diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index d5a79c7ed..1ad75927c 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -2839,7 +2839,7 @@ private struct ObjectsIntegrationTests { let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) var capturedCounterId: String? - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages throws(InternalError) in + internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages throws(ARTErrorInfo) in do { let counterId = try #require(objectMessages[0].operation?.objectId) capturedCounterId = counterId @@ -2852,7 +2852,7 @@ private struct ObjectsIntegrationTests { state: [objectsHelper.counterCreateOp(objectId: counterId, count: 10)], ) } catch { - throw error.toInternalError() + throw LiveObjectsError.other(error).toARTErrorInfo() } }) @@ -3085,7 +3085,7 @@ private struct ObjectsIntegrationTests { let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) var capturedMapId: String? - internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages throws(InternalError) in + internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages throws(ARTErrorInfo) in do { let mapId = try #require(objectMessages[0].operation?.objectId) capturedMapId = mapId @@ -3108,7 +3108,7 @@ private struct ObjectsIntegrationTests { ], ) } catch { - throw error.toInternalError() + throw LiveObjectsError.other(error).toARTErrorInfo() } }) diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 8aacf19db..71623a79e 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -5,7 +5,7 @@ import Ably final class MockCoreSDK: CoreSDK { /// Synchronizes access to `_publishHandler`. private let mutex = NSLock() - private nonisolated(unsafe) var _publishHandler: (([OutboundObjectMessage]) async throws(InternalError) -> Void)? + private nonisolated(unsafe) var _publishHandler: (([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void)? private let channelStateMutex: DispatchQueueMutex<_AblyPluginSupportPrivate.RealtimeChannelState> @@ -13,7 +13,7 @@ final class MockCoreSDK: CoreSDK { channelStateMutex = DispatchQueueMutex(dispatchQueue: internalQueue, initialValue: channelState) } - func publish(objectMessages: [OutboundObjectMessage]) async throws(InternalError) { + func publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) { if let handler = _publishHandler { try await handler(objectMessages) } else { @@ -21,7 +21,7 @@ final class MockCoreSDK: CoreSDK { } } - func testsOnly_overridePublish(with _: @escaping ([OutboundObjectMessage]) async throws(InternalError) -> Void) { + func testsOnly_overridePublish(with _: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void) { protocolRequirementNotImplemented() } @@ -30,7 +30,7 @@ final class MockCoreSDK: CoreSDK { } /// Sets a custom publish handler for testing - func setPublishHandler(_ handler: @escaping ([OutboundObjectMessage]) async throws(InternalError) -> Void) { + func setPublishHandler(_ handler: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void) { mutex.withLock { _publishHandler = handler } diff --git a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift index 57a913cfc..3fe457bf6 100644 --- a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift @@ -1,4 +1,5 @@ import _AblyPluginSupportPrivate +import Ably @testable import AblyLiveObjects import Foundation import Testing @@ -278,7 +279,7 @@ struct ObjectMessageTests { let wireData = WireObjectData(json: invalidJsonString) // Should throw when JSON parsing fails, even in MessagePack format - #expect(throws: InternalError.self) { + #expect(throws: ARTErrorInfo.self) { _ = try ObjectData(wireObjectData: wireData, format: .messagePack) } } @@ -301,7 +302,7 @@ struct ObjectMessageTests { let wireData = WireObjectData(json: jsonString) // Should throw when JSON is valid but not an object or array - #expect(throws: InternalError.self) { + #expect(throws: ARTErrorInfo.self) { _ = try ObjectData(wireObjectData: wireData, format: .messagePack) } } @@ -375,7 +376,7 @@ struct ObjectMessageTests { let wireData = WireObjectData(bytes: .string(invalidBase64String)) // Should throw when Base64 decoding fails - #expect(throws: InternalError.self) { + #expect(throws: ARTErrorInfo.self) { _ = try ObjectData(wireObjectData: wireData, format: .json) } } @@ -402,7 +403,7 @@ struct ObjectMessageTests { let wireData = WireObjectData(json: invalidJsonString) // Should throw when JSON parsing fails - #expect(throws: InternalError.self) { + #expect(throws: ARTErrorInfo.self) { _ = try ObjectData(wireObjectData: wireData, format: .json) } } @@ -425,7 +426,7 @@ struct ObjectMessageTests { let wireData = WireObjectData(json: jsonString) // Should throw when JSON is valid but not an object or array - #expect(throws: InternalError.self) { + #expect(throws: ARTErrorInfo.self) { _ = try ObjectData(wireObjectData: wireData, format: .json) } } diff --git a/Tests/AblyLiveObjectsTests/SyncCursorTests.swift b/Tests/AblyLiveObjectsTests/SyncCursorTests.swift index 83f1e3734..be056fba1 100644 --- a/Tests/AblyLiveObjectsTests/SyncCursorTests.swift +++ b/Tests/AblyLiveObjectsTests/SyncCursorTests.swift @@ -1,3 +1,4 @@ +import Ably @testable import AblyLiveObjects import Testing @@ -42,9 +43,8 @@ struct SyncCursorTests { _ = try SyncCursor(channelSerial: channelSerial) Issue.record("Expected error was not thrown") } catch { - guard case let .other(.generic(underlyingError)) = error, - let syncError = underlyingError as? SyncCursor.Error, - case .channelSerialDoesNotMatchExpectedFormat = syncError + guard let liveObjectsError = error.testsOnly_underlyingLiveObjectsError, + case .other(SyncCursor.Error.channelSerialDoesNotMatchExpectedFormat) = liveObjectsError else { Issue.record("Expected channelSerialDoesNotMatchExpectedFormat error") return @@ -62,9 +62,8 @@ struct SyncCursorTests { _ = try SyncCursor(channelSerial: channelSerial) Issue.record("Expected error was not thrown") } catch { - guard case let .other(.generic(underlyingError)) = error, - let syncError = underlyingError as? SyncCursor.Error, - case .channelSerialDoesNotMatchExpectedFormat = syncError + guard let liveObjectsError = error.testsOnly_underlyingLiveObjectsError, + case .other(SyncCursor.Error.channelSerialDoesNotMatchExpectedFormat) = liveObjectsError else { Issue.record("Expected channelSerialDoesNotMatchExpectedFormat error") return From bb36453705dc7174a1da0f9b3f850fbfd0f9f347 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 3 Sep 2025 14:26:23 -0300 Subject: [PATCH 157/225] Change LiveMap pool delegate to return snapshot of pool This will subsequently allow us to make more use of value types internally. (The change in this commit, in isolation, actually makes things a bit messier because now we have to mock a whole ObjectsPool, with its root object that the tests don't even need. I suppose we could instead make the delegate just return a dictionary containing the pool's entries, but let's see if it actually becomes a problem.) --- .../Internal/InternalDefaultLiveMap.swift | 8 ++--- .../InternalDefaultRealtimeObjects.swift | 4 +-- .../Mocks/MockLiveMapObjectPoolDelegate.swift | 29 +++++++++++++++---- 3 files changed, 29 insertions(+), 12 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 48668f08d..b538f2855 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -3,8 +3,8 @@ import Ably /// Protocol for accessing objects from the ObjectsPool. This is used by a LiveMap when it needs to return an object given an object ID. internal protocol LiveMapObjectPoolDelegate: AnyObject, Sendable { - /// Fetches an object from the pool by its ID - func nosync_getObjectFromPool(id: String) -> ObjectsPool.Entry? + /// A snapshot of the objects pool. + var nosync_objectsPool: ObjectsPool { get } } /// This provides the implementation behind ``PublicDefaultLiveMap``, via internal versions of the ``LiveMap`` API. @@ -916,7 +916,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM14c if let objectId = entry.data?.objectId { - if let poolEntry = delegate.nosync_getObjectFromPool(id: objectId), poolEntry.nosync_isTombstone { + if let poolEntry = delegate.nosync_objectsPool.entries[objectId], poolEntry.nosync_isTombstone { return true } } @@ -968,7 +968,7 @@ internal final class InternalDefaultLiveMap: Sendable { // 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 = delegate.nosync_getObjectFromPool(id: objectId) else { + guard let poolEntry = delegate.nosync_objectsPool.entries[objectId] else { return nil } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index ca4e1963a..0bd23ddbd 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -141,9 +141,9 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool // MARK: - LiveMapObjectPoolDelegate - internal func nosync_getObjectFromPool(id: String) -> ObjectsPool.Entry? { + internal var nosync_objectsPool: ObjectsPool { mutableStateMutex.withoutSync { mutableState in - mutableState.objectsPool.entries[id] + mutableState.objectsPool } } diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift b/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift index f3af88333..c835a374f 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift @@ -3,22 +3,39 @@ import Foundation /// A mock delegate that can return predefined objects final class MockLiveMapObjectPoolDelegate: LiveMapObjectPoolDelegate { - private let objectsMutex: DispatchQueueMutex<[String: ObjectsPool.Entry]> + private let poolMutex: DispatchQueueMutex init(internalQueue: DispatchQueue) { - objectsMutex = DispatchQueueMutex(dispatchQueue: internalQueue, initialValue: [:]) + 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 { - objectsMutex.withSync { $0 } + poolMutex.withSync { $0.entries } } set { - objectsMutex.withSync { $0 = newValue } + poolMutex.withSync { $0 = Self.createPool(internalQueue: poolMutex.dispatchQueue, otherEntries: newValue) } } } - func nosync_getObjectFromPool(id: String) -> ObjectsPool.Entry? { - objectsMutex.withoutSync { $0[id] } + var nosync_objectsPool: ObjectsPool { + poolMutex.withoutSync { $0 } } } From 9bd15c828ea2c16520c056bc7be2c1a5934ccc2c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 3 Sep 2025 14:37:40 -0300 Subject: [PATCH 158/225] Directly use ObjectsPool inside LiveMap getter methods It's nicer to deal with value types instead of objects (the latter coming with synchronisation requirements to worry about). Resolves #39. --- .../Internal/InternalDefaultLiveMap.swift | 38 ++++++++++++------- 1 file changed, 24 insertions(+), 14 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index b538f2855..370594299 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -123,19 +123,29 @@ internal final class InternalDefaultLiveMap: Sendable { /// Returns the value associated with a given key, following RTLM5d specification. internal func get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - try mutableState.nosync_get(key: key, coreSDK: coreSDK, delegate: delegate) + try mutableState.nosync_get( + key: key, + coreSDK: coreSDK, + objectsPool: delegate.nosync_objectsPool, + ) } } internal func size(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> Int { try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - try mutableState.nosync_size(coreSDK: coreSDK, delegate: delegate) + try mutableState.nosync_size( + coreSDK: coreSDK, + objectsPool: delegate.nosync_objectsPool, + ) } } internal func entries(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - try mutableState.nosync_entries(coreSDK: coreSDK, delegate: delegate) + try mutableState.nosync_entries( + coreSDK: coreSDK, + objectsPool: delegate.nosync_objectsPool, + ) } } @@ -859,7 +869,7 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Returns the value associated with a given key, following RTLM5d specification. - internal func nosync_get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { + 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") @@ -874,20 +884,20 @@ internal final class InternalDefaultLiveMap: Sendable { } // RTLM5d2: If a ObjectsMapEntry exists at the key, convert it using the shared logic - return nosync_convertEntryToLiveMapValue(entry, delegate: delegate) + return nosync_convertEntryToLiveMapValue(entry, objectsPool: objectsPool) } - internal func nosync_size(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> Int { + 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, delegate: delegate) + !Self.nosync_isEntryTombstoned(entry, objectsPool: objectsPool) } } - internal func nosync_entries(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { + 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") @@ -895,9 +905,9 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM11d1: Pairs with tombstoned entries (per RTLM14) are not returned var result: [(key: String, value: InternalLiveMapValue)] = [] - for (key, entry) in data where !Self.nosync_isEntryTombstoned(entry, delegate: delegate) { + 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, delegate: delegate) { + if let value = nosync_convertEntryToLiveMapValue(entry, objectsPool: objectsPool) { result.append((key: key, value: value)) } } @@ -908,7 +918,7 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - Helper Methods /// Returns whether a map entry should be considered tombstoned, per the check described in RTLM14. - private static func nosync_isEntryTombstoned(_ entry: InternalObjectsMapEntry, delegate: LiveMapObjectPoolDelegate) -> Bool { + private static func nosync_isEntryTombstoned(_ entry: InternalObjectsMapEntry, objectsPool: ObjectsPool) -> Bool { // RTLM14a if entry.tombstone { return true @@ -916,7 +926,7 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM14c if let objectId = entry.data?.objectId { - if let poolEntry = delegate.nosync_objectsPool.entries[objectId], poolEntry.nosync_isTombstone { + if let poolEntry = objectsPool.entries[objectId], poolEntry.nosync_isTombstone { return true } } @@ -927,7 +937,7 @@ internal final class InternalDefaultLiveMap: Sendable { /// 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, delegate: LiveMapObjectPoolDelegate) -> InternalLiveMapValue? { + private func nosync_convertEntryToLiveMapValue(_ entry: InternalObjectsMapEntry, objectsPool: ObjectsPool) -> InternalLiveMapValue? { // RTLM5d2a: If ObjectsMapEntry.tombstone is true, return undefined/null if entry.tombstone == true { return nil @@ -968,7 +978,7 @@ internal final class InternalDefaultLiveMap: Sendable { // 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 = delegate.nosync_objectsPool.entries[objectId] else { + guard let poolEntry = objectsPool.entries[objectId] else { return nil } From 96799cb1c82b2ec8fce7f830db0ae86fc049b219 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 3 Sep 2025 15:15:09 -0300 Subject: [PATCH 159/225] Pluralise "ObjectPool" thus fixing the typo --- .../Internal/InternalDefaultLiveMap.swift | 12 +-- .../InternalDefaultRealtimeObjects.swift | 4 +- .../InternalLiveMapValue+ToPublic.swift | 2 +- .../PublicDefaultLiveMap.swift | 4 +- .../PublicObjectsStore.swift | 2 +- .../InternalDefaultLiveMapTests.swift | 80 +++++++++---------- ...t => MockLiveMapObjectsPoolDelegate.swift} | 2 +- .../ObjectsPoolTests.swift | 6 +- 8 files changed, 56 insertions(+), 56 deletions(-) rename Tests/AblyLiveObjectsTests/Mocks/{MockLiveMapObjectPoolDelegate.swift => MockLiveMapObjectsPoolDelegate.swift} (94%) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 370594299..d1ec8922e 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -2,7 +2,7 @@ internal import _AblyPluginSupportPrivate import Ably /// Protocol for accessing objects from the ObjectsPool. This is used by a LiveMap when it needs to return an object given an object ID. -internal protocol LiveMapObjectPoolDelegate: AnyObject, Sendable { +internal protocol LiveMapObjectsPoolDelegate: AnyObject, Sendable { /// A snapshot of the objects pool. var nosync_objectsPool: ObjectsPool { get } } @@ -121,7 +121,7 @@ internal final class InternalDefaultLiveMap: Sendable { // MARK: - Internal methods that back LiveMap conformance /// Returns the value associated with a given key, following RTLM5d specification. - internal func get(key: String, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> InternalLiveMapValue? { + 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, @@ -131,7 +131,7 @@ internal final class InternalDefaultLiveMap: Sendable { } } - internal func size(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> Int { + internal func size(coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate) throws(ARTErrorInfo) -> Int { try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in try mutableState.nosync_size( coreSDK: coreSDK, @@ -140,7 +140,7 @@ internal final class InternalDefaultLiveMap: Sendable { } } - internal func entries(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [(key: String, value: InternalLiveMapValue)] { + 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, @@ -149,12 +149,12 @@ internal final class InternalDefaultLiveMap: Sendable { } } - internal func keys(coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [String] { + 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: LiveMapObjectPoolDelegate) throws(ARTErrorInfo) -> [InternalLiveMapValue] { + 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) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 0bd23ddbd..dcc5bc679 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -2,7 +2,7 @@ internal import _AblyPluginSupportPrivate import Ably /// This provides the implementation behind ``PublicDefaultRealtimeObjects``, via internal versions of the ``RealtimeObjects`` API. -internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPoolDelegate { +internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoolDelegate { private let mutableStateMutex: DispatchQueueMutex private let logger: Logger @@ -139,7 +139,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectPool garbageCollectionTask.cancel() } - // MARK: - LiveMapObjectPoolDelegate + // MARK: - LiveMapObjectsPoolDelegate internal var nosync_objectsPool: ObjectsPool { mutableStateMutex.withoutSync { mutableState in diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift index fa7e7bb3b..d669c0cda 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift @@ -5,7 +5,7 @@ internal extension InternalLiveMapValue { struct PublicValueCreationArgs { internal var coreSDK: CoreSDK - internal var mapDelegate: LiveMapObjectPoolDelegate + internal var mapDelegate: LiveMapObjectsPoolDelegate internal var logger: Logger internal var toCounterCreationArgs: PublicObjectsStore.CounterCreationArgs { diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 5aad18fa4..58f2036d3 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -10,10 +10,10 @@ internal final class PublicDefaultLiveMap: LiveMap { // MARK: - Dependencies that hold a strong reference to `proxied` private let coreSDK: CoreSDK - private let delegate: LiveMapObjectPoolDelegate + private let delegate: LiveMapObjectsPoolDelegate private let logger: Logger - internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectPoolDelegate, logger: Logger) { + internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate, logger: Logger) { self.proxied = proxied self.coreSDK = coreSDK self.delegate = delegate diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift index 264a92cc3..d8d915b07 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift @@ -45,7 +45,7 @@ internal final class PublicObjectsStore: Sendable { internal struct MapCreationArgs { internal var coreSDK: CoreSDK - internal var delegate: LiveMapObjectPoolDelegate + internal var delegate: LiveMapObjectsPoolDelegate internal var logger: Logger } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index bf43d98c2..3fd05e224 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -15,7 +15,7 @@ struct InternalDefaultLiveMapTests { 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) + _ = 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 @@ -34,7 +34,7 @@ struct InternalDefaultLiveMapTests { 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) == nil) + #expect(try map.get(key: "nonexistent", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) == nil) } // @spec RTLM5d2a @@ -48,7 +48,7 @@ struct InternalDefaultLiveMapTests { 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) == nil) + #expect(try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) == nil) } // @spec RTLM5d2b @@ -59,7 +59,7 @@ struct InternalDefaultLiveMapTests { 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) #expect(result?.boolValue == true) } @@ -72,7 +72,7 @@ struct InternalDefaultLiveMapTests { 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) #expect(result?.dataValue == bytes) } @@ -84,7 +84,7 @@ struct InternalDefaultLiveMapTests { 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) #expect(result?.numberValue == 123.456) } @@ -96,7 +96,7 @@ struct InternalDefaultLiveMapTests { 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) #expect(result?.stringValue == "test") } @@ -109,7 +109,7 @@ struct InternalDefaultLiveMapTests { 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) #expect(result?.jsonArrayValue == ["foo"]) } @@ -122,7 +122,7 @@ struct InternalDefaultLiveMapTests { 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: MockLiveMapObjectPoolDelegate(internalQueue: internalQueue)) + let result = try map.get(key: "key", coreSDK: coreSDK, delegate: MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue)) #expect(result?.jsonObjectValue == ["foo": "bar"]) } @@ -132,7 +132,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: "missing")) let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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) @@ -145,7 +145,7 @@ struct InternalDefaultLiveMapTests { let internalQueue = TestFactories.createInternalQueue() let objectId = "map1" let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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) @@ -162,7 +162,7 @@ struct InternalDefaultLiveMapTests { let internalQueue = TestFactories.createInternalQueue() let objectId = "counter1" let entry = TestFactories.internalMapEntry(data: ObjectData(objectId: objectId)) - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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) @@ -178,7 +178,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let entry = TestFactories.internalMapEntry(data: ObjectData()) - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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) @@ -241,7 +241,7 @@ struct InternalDefaultLiveMapTests { func setsDataToMapEntries() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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") @@ -265,7 +265,7 @@ struct InternalDefaultLiveMapTests { func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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( @@ -309,7 +309,7 @@ struct InternalDefaultLiveMapTests { 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 = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) // Define actions to test let actions: [(String, () throws -> Any)] = [ @@ -344,7 +344,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let map = InternalDefaultLiveMap( testsOnly_data: [ // tombstonedAt is nil, so not considered tombstoned @@ -391,7 +391,7 @@ struct InternalDefaultLiveMapTests { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let map = InternalDefaultLiveMap( testsOnly_data: [ "key1": TestFactories.internalMapEntry(data: ObjectData(string: "value1")), @@ -430,7 +430,7 @@ struct InternalDefaultLiveMapTests { func entriesHandlesAllValueTypes() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) // Create referenced objects for testing @@ -498,7 +498,7 @@ struct InternalDefaultLiveMapTests { func discardsOperationWhenCannotBeApplied() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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"))], @@ -530,17 +530,17 @@ struct InternalDefaultLiveMapTests { // @specOneOf(1/2) RTLM7c1 // @specOneOf(1/2) RTLM7f @Test(arguments: [ - // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectPool per RTLM7c) + // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectsPool per RTLM7c) (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 ObjectPool per RTLM7c) + // Case 2: ObjectData refers to an object value but the object ID is an empty string (shouldn't modify the ObjectsPool per RTLM7c) (operationData: ObjectData(objectId: ""), expectedCreatedObjectID: nil), - // Case 3: ObjectData refers to an object value (should modify the ObjectPool per RTLM7c and RTLM7c1) + // Case 3: ObjectData refers to an object value (should modify the ObjectsPool per RTLM7c and RTLM7c1) (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 = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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"))], @@ -605,17 +605,17 @@ struct InternalDefaultLiveMapTests { // @specOneOf(2/2) RTLM7c1 // @specOneOf(2/2) RTLM7f @Test(arguments: [ - // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectPool per RTLM7c) + // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectsPool per RTLM7c) (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 ObjectPool per RTLM7c) + // Case 2: ObjectData refers to an object value but the object ID is an empty string (shouldn't modify the ObjectsPool per RTLM7c) (operationData: ObjectData(objectId: ""), expectedCreatedObjectID: nil), - // Case 3: ObjectData refers to an object value (should modify the ObjectPool per RTLM7c and RTLM7c1) + // Case 3: ObjectData refers to an object value (should modify the ObjectsPool per RTLM7c and RTLM7c1) (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 = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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()) @@ -666,7 +666,7 @@ struct InternalDefaultLiveMapTests { func doesNotReplaceExistingObjectWhenReferencedByMapSet() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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()) @@ -718,7 +718,7 @@ struct InternalDefaultLiveMapTests { func discardsOperationWhenCannotBeApplied() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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"))], @@ -746,7 +746,7 @@ struct InternalDefaultLiveMapTests { func appliesOperationWhenCanBeApplied() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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"))], @@ -866,7 +866,7 @@ struct InternalDefaultLiveMapTests { func mapOperationApplicability(entrySerial: String?, operationSerial: String?, shouldApply: Bool) throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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"))], @@ -904,7 +904,7 @@ struct InternalDefaultLiveMapTests { func appliesMapSetOperationsFromOperation() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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()) @@ -930,7 +930,7 @@ struct InternalDefaultLiveMapTests { func appliesMapRemoveOperationsFromOperation() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) let map = InternalDefaultLiveMap( testsOnly_data: ["key1": TestFactories.internalStringMapEntry().entry], @@ -1031,7 +1031,7 @@ struct InternalDefaultLiveMapTests { func discardsOperationWhenCreateOperationIsMerged() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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()) @@ -1064,7 +1064,7 @@ struct InternalDefaultLiveMapTests { func mergesInitialValue() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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()) @@ -1096,7 +1096,7 @@ struct InternalDefaultLiveMapTests { func discardsOperationWhenCannotBeApplied() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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()) @@ -1145,7 +1145,7 @@ struct InternalDefaultLiveMapTests { func appliesMapCreateOperation() async throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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()) @@ -1187,7 +1187,7 @@ struct InternalDefaultLiveMapTests { func appliesMapSetOperation() async throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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()) @@ -1243,7 +1243,7 @@ struct InternalDefaultLiveMapTests { func appliesMapRemoveOperation() async throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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()) diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift b/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift similarity index 94% rename from Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift rename to Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift index c835a374f..a012c94cf 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectPoolDelegate.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift @@ -2,7 +2,7 @@ import Foundation /// A mock delegate that can return predefined objects -final class MockLiveMapObjectPoolDelegate: LiveMapObjectPoolDelegate { +final class MockLiveMapObjectsPoolDelegate: LiveMapObjectsPoolDelegate { private let poolMutex: DispatchQueueMutex init(internalQueue: DispatchQueue) { diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 61fc2ee90..866a6cff3 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -89,7 +89,7 @@ struct ObjectsPoolTests { func updatesExistingMapObject() async throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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) @@ -194,7 +194,7 @@ struct ObjectsPoolTests { func createsNewMapObject() throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + let delegate = MockLiveMapObjectsPoolDelegate(internalQueue: internalQueue) let coreSDK = MockCoreSDK(channelState: .attaching, internalQueue: internalQueue) var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -301,7 +301,7 @@ struct ObjectsPoolTests { func handlesComplexSyncScenario() async throws { let logger = TestLogger() let internalQueue = TestFactories.createInternalQueue() - let delegate = MockLiveMapObjectPoolDelegate(internalQueue: internalQueue) + 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()) From 355497efa9722d2e9b06c27c64e5c1d94df44d68 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 25 Sep 2025 12:58:41 -0300 Subject: [PATCH 160/225] Switch JSONValue's `number` case back to Double This reverts the public API change of fd1e91c whilst preserving the internal MessagePack-related motivation for that commit. --- .../Utility/ExtendedJSONValue.swift | 41 ++++++++----------- .../AblyLiveObjects/Utility/JSONValue.swift | 17 ++++---- .../AblyLiveObjects/Utility/WireValue.swift | 23 ++++++----- .../JS Integration Tests/ObjectsHelper.swift | 4 +- .../ObjectsIntegrationTests.swift | 19 +++++---- .../ObjectCreationHelpersTests.swift | 2 +- 6 files changed, 51 insertions(+), 55 deletions(-) diff --git a/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift b/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift index 133fd8697..1f1d133d9 100644 --- a/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift @@ -1,11 +1,11 @@ import Foundation -/// Like ``JSONValue``, but provides 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 { +/// 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(NSNumber) + case number(Number) case bool(Bool) case null case extra(Extra) @@ -17,12 +17,12 @@ 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, createExtraValue: (Any) -> 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, createExtraValue: createExtraValue) }) + self = .object(dictionary.mapValues { .init(deserialized: $0, createNumberValue: createNumberValue, createExtraValue: createExtraValue) }) case let array as [Any]: - self = .array(array.map { .init(deserialized: $0, createExtraValue: createExtraValue) }) + self = .array(array.map { .init(deserialized: $0, createNumberValue: createNumberValue, createExtraValue: createExtraValue) }) case let string as String: self = .string(string) case let number as NSNumber: @@ -32,7 +32,7 @@ internal extension ExtendedJSONValue { } else if number === kCFBooleanFalse { self = .bool(false) } else { - self = .number(number) + self = .number(createNumberValue(number)) } case is NSNull: self = .null @@ -44,16 +44,16 @@ internal extension ExtendedJSONValue { /// 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(serializeExtraValue: (Extra) -> Any) -> Any { + func serialized(serializeNumberValue: (Number) -> Any, serializeExtraValue: (Extra) -> Any) -> Any { switch self { case let .object(underlying): - underlying.mapValues { $0.serialized(serializeExtraValue: serializeExtraValue) } + underlying.mapValues { $0.serialized(serializeNumberValue: serializeNumberValue, serializeExtraValue: serializeExtraValue) } case let .array(underlying): - underlying.map { $0.serialized(serializeExtraValue: serializeExtraValue) } + underlying.map { $0.serialized(serializeNumberValue: serializeNumberValue, serializeExtraValue: serializeExtraValue) } case let .string(underlying): underlying case let .number(underlying): - underlying + serializeNumberValue(underlying) case let .bool(underlying): underlying case .null: @@ -64,37 +64,30 @@ internal extension ExtendedJSONValue { } } -internal extension ExtendedJSONValue where Extra == Never { - var serialized: Any { - // swiftlint:disable:next trailing_closure - serialized(serializeExtraValue: { _ in }) - } -} - // MARK: - Transforming the extra data internal extension ExtendedJSONValue { - /// Converts this `ExtendedJSONValue` to an `ExtendedJSONValue` using a given transformation. - func map(_ transform: @escaping (Extra) throws(Failure) -> NewExtra) throws(Failure) -> 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(transform) + try value.map(number: transformNumber, extra: transformExtra) }) case let .array(underlying): try .array(underlying.map { element throws(Failure) in - try element.map(transform) + try element.map(number: transformNumber, extra: transformExtra) }) case let .string(underlying): .string(underlying) case let .number(underlying): - .number(underlying) + try .number(transformNumber(underlying)) case let .bool(underlying): .bool(underlying) case .null: .null case let .extra(extra): - try .extra(transform(extra)) + try .extra(transformExtra(extra)) } } } diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/Sources/AblyLiveObjects/Utility/JSONValue.swift index 1c6ae2ed1..02a71c907 100644 --- a/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -29,7 +29,7 @@ public indirect enum JSONValue: Sendable, Equatable { case object([String: JSONValue]) case array([JSONValue]) case string(String) - case number(NSNumber) + case number(Double) case bool(Bool) case null @@ -63,7 +63,7 @@ public indirect enum JSONValue: Sendable, Equatable { } /// If this `JSONValue` has case `number`, this returns the associated value. Else, it returns `nil`. - public var numberValue: NSNumber? { + public var numberValue: Double? { if case let .number(numberValue) = self { numberValue } else { @@ -110,13 +110,13 @@ extension JSONValue: ExpressibleByStringLiteral { extension JSONValue: ExpressibleByIntegerLiteral { public init(integerLiteral value: Int) { - self = .number(value as NSNumber) + self = .number(Double(value)) } } extension JSONValue: ExpressibleByFloatLiteral { public init(floatLiteral value: Double) { - self = .number(value as NSNumber) + self = .number(value) } } @@ -136,8 +136,7 @@ internal extension JSONValue { /// - 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) { - // swiftlint:disable:next trailing_closure - let extended = ExtendedJSONValue(deserialized: jsonSerializationOutput, createExtraValue: { deserializedExtraValue in + 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)") }) @@ -152,7 +151,7 @@ internal extension JSONValue { /// - 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 + toExtendedJSONValue.serialized(serializeNumberValue: { $0 as NSNumber }, serializeExtraValue: { _ in }) } } @@ -226,7 +225,7 @@ internal extension [JSONValue] { // MARK: - Conversion to/from ExtendedJSONValue internal extension JSONValue { - init(extendedJSONValue: ExtendedJSONValue) { + init(extendedJSONValue: ExtendedJSONValue) { switch extendedJSONValue { case let .object(underlying): self = .object(underlying.mapValues { .init(extendedJSONValue: $0) }) @@ -243,7 +242,7 @@ internal extension JSONValue { } } - var toExtendedJSONValue: ExtendedJSONValue { + var toExtendedJSONValue: ExtendedJSONValue { switch self { case let .object(underlying): .object(underlying.mapValues(\.toExtendedJSONValue)) diff --git a/Sources/AblyLiveObjects/Utility/WireValue.swift b/Sources/AblyLiveObjects/Utility/WireValue.swift index 9f59db15d..c8b75f28b 100644 --- a/Sources/AblyLiveObjects/Utility/WireValue.swift +++ b/Sources/AblyLiveObjects/Utility/WireValue.swift @@ -3,7 +3,7 @@ import Foundation /// A wire value that can be represents the kinds of data that we expect to find inside a deserialized wire object received from `_AblyPluginSupportPrivate`, or which we may put inside a serialized wire object that we send to `_AblyPluginSupportPrivate`. /// -/// Its cases are a superset of those of ``JSONValue``, adding a further `data` case for binary data (we expect to be able to send and receive binary data in the case where ably-cocoa is using the MessagePack format). +/// 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]) @@ -122,8 +122,7 @@ internal extension WireValue { /// /// Specifically, `pluginSupportData` can be a value that was passed to `LiveObjectsPlugin.decodeObjectMessage:…`. init(pluginSupportData: Any) { - // swiftlint:disable:next trailing_closure - let extendedJSONValue = ExtendedJSONValue(deserialized: pluginSupportData, createExtraValue: { deserializedExtraValue in + 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) @@ -150,8 +149,7 @@ internal extension WireValue { /// /// Used by `[String: WireValue].toPluginSupportDataDictionary`. var toPluginSupportData: Any { - // swiftlint:disable:next trailing_closure - toExtendedJSONValue.serialized(serializeExtraValue: { extendedValue in + toExtendedJSONValue.serialized(serializeNumberValue: { $0 }, serializeExtraValue: { extendedValue in switch extendedValue { case let .data(data): data @@ -176,7 +174,7 @@ internal extension WireValue { case data(Data) } - init(extendedJSONValue: ExtendedJSONValue) { + init(extendedJSONValue: ExtendedJSONValue) { switch extendedJSONValue { case let .object(underlying): self = .object(underlying.mapValues { .init(extendedJSONValue: $0) }) @@ -198,7 +196,7 @@ internal extension WireValue { } } - var toExtendedJSONValue: ExtendedJSONValue { + var toExtendedJSONValue: ExtendedJSONValue { switch self { case let .object(underlying): .object(underlying.mapValues(\.toExtendedJSONValue)) @@ -223,8 +221,9 @@ internal extension WireValue { internal extension WireValue { /// Converts a `JSONValue` to its corresponding `WireValue`. init(jsonValue: JSONValue) { - // swiftlint:disable:next array_init - self.init(extendedJSONValue: jsonValue.toExtendedJSONValue.map { (extra: Never) in extra }) + self.init(extendedJSONValue: jsonValue.toExtendedJSONValue.map(number: { (number: Double) -> NSNumber in + number as NSNumber + }, extra: { (extra: Never) in extra })) } enum ConversionError: Error { @@ -236,12 +235,14 @@ internal extension WireValue { /// - Throws: `ConversionError.dataCannotBeConvertedToJSONValue` if `WireValue` represents binary data. var toJSONValue: JSONValue { get throws(ARTErrorInfo) { - let neverExtended = try toExtendedJSONValue.map { extra throws(ARTErrorInfo) -> Never in + 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/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift index 77b78e16c..92b79a0b3 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift @@ -451,7 +451,7 @@ final class ObjectsHelper: Sendable { ] if let number { - opBody["data"] = .object(["number": .number(NSNumber(value: number))]) + opBody["data"] = .object(["number": .number(number)]) } if let objectId { @@ -467,7 +467,7 @@ final class ObjectsHelper: Sendable { [ "operation": .string(Actions.counterInc.stringValue), "objectId": .string(objectId), - "data": .object(["number": .number(NSNumber(value: number))]), + "data": .object(["number": .number(number)]), ] } diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 1ad75927c..0cfce1961 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -131,6 +131,9 @@ 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)] = [ @@ -156,13 +159,13 @@ private let primitiveKeyData: [(key: String, data: [String: JSONValue], liveMapV ), ( key: "maxSafeIntegerKey", - data: ["number": .number(.init(value: Int.max))], - liveMapValue: .number(Double(Int.max)) + data: ["number": .number(maxSafeInteger)], + liveMapValue: .number(maxSafeInteger) ), ( key: "negativeMaxSafeIntegerKey", - data: ["number": .number(.init(value: -Int.max))], - liveMapValue: .number(-Double(Int.max)) + data: ["number": .number(-maxSafeInteger)], + liveMapValue: .number(-maxSafeInteger) ), ( key: "numberKey", @@ -854,7 +857,7 @@ private struct ObjectsIntegrationTests { 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.doubleValue, "Check map \"\(mapKey)\" has correct value for \"\(key)\" key") + #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 { @@ -1070,7 +1073,7 @@ private struct ObjectsIntegrationTests { 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.doubleValue, "Check root has correct value for \"\(keyData.key)\" key after MAP_SET op") + #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 { @@ -2047,7 +2050,7 @@ private struct ObjectsIntegrationTests { } } else if let numberValue = keyData.data["number"] { if case let .number(expectedNumber) = numberValue { - #expect(try #require(root.get(key: keyData.key)?.numberValue) == expectedNumber.doubleValue, "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: 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 { @@ -2347,7 +2350,7 @@ private struct ObjectsIntegrationTests { } } else if let numberValue = keyData.data["number"] { if case let .number(expectedNumber) = numberValue { - #expect(try #require(root.get(key: keyData.key)?.numberValue) == expectedNumber.doubleValue, "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: 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 { diff --git a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift index b58cfb39d..9f88051dd 100644 --- a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift @@ -65,7 +65,7 @@ struct ObjectCreationHelpersTests { #expect(deserializedInitialValue == [ "map": [ // RTO11f4a - "semantics": .number(ObjectsMapSemantics.lww.rawValue as NSNumber), + "semantics": .number(Double(ObjectsMapSemantics.lww.rawValue)), "entries": [ // RTO11f4c1a "mapRef": [ From c9dc35a01224f446ee72b0a3111fee2fec492bd9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 2 Oct 2025 09:27:51 -0300 Subject: [PATCH 161/225] Unpin ably-cocoa and ably-cocoa-plugin-support --- .../xcshareddata/swiftpm/Package.resolved | 8 +++++--- Package.resolved | 8 +++++--- Package.swift | 6 ++---- 3 files changed, 12 insertions(+), 10 deletions(-) diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index 129efb5e1..08a8d7167 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,13 @@ { - "originHash" : "db81b5b63dd3c181dd6b5858443bec813293f75bfb7d44371053c1cc7bf30d70", + "originHash" : "a9bb12b2370d8ccfa25000a37b1301668b79689668f71059fea14431be9a0649", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "6bcbf5faaa7b577f4fe8129d895be0f24258aa27" + "revision" : "c28983fbdfee1327a4073620af255fd3b1b44b4c", + "version" : "1.2.46" } }, { @@ -14,7 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "2ce1058ed4430cc3563dcead0299e92a81d2774b" + "revision" : "8db4632b0664b7272d54f7b612ddad0a18d1758f", + "version" : "0.2.0" } }, { diff --git a/Package.resolved b/Package.resolved index e4441c1ba..e67695fd6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,13 @@ { - "originHash" : "1e8978bd03f19fa4f0f7ec194d9281e28f2f5c2292ac744452a40ae531b4fb56", + "originHash" : "6b347363915d5bce46289bd7c3815b40c1d86d54160d10dced8b6a07956652e3", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "6bcbf5faaa7b577f4fe8129d895be0f24258aa27" + "revision" : "c28983fbdfee1327a4073620af255fd3b1b44b4c", + "version" : "1.2.46" } }, { @@ -14,7 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "2ce1058ed4430cc3563dcead0299e92a81d2774b" + "revision" : "8db4632b0664b7272d54f7b612ddad0a18d1758f", + "version" : "0.2.0" } }, { diff --git a/Package.swift b/Package.swift index 5674241b2..ac1832de2 100644 --- a/Package.swift +++ b/Package.swift @@ -20,13 +20,11 @@ let package = Package( dependencies: [ .package( url: "https://github.com/ably/ably-cocoa", - // TODO: Unpin before next release - revision: "6bcbf5faaa7b577f4fe8129d895be0f24258aa27", + from: "1.2.46", ), .package( url: "https://github.com/ably/ably-cocoa-plugin-support", - // TODO: Unpin before next release - revision: "2ce1058ed4430cc3563dcead0299e92a81d2774b", + from: "0.2.0", ), .package( url: "https://github.com/apple/swift-argument-parser", From c4d615ce9cbb88d4967241a334afee61581bbc19 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 2 Oct 2025 09:52:41 -0300 Subject: [PATCH 162/225] Pin ably-cocoa-plugin-support to an exact version Users have started getting failing builds in LiveObjects 0.1.0 because it started resolving the 'from: "0.1.0"' ably-cocoa-plugin-support dependency to the recently-released 0.2.0 version. This was due to my misunderstanding of SPM dependency resolution, which I thought used exact-version matching for 0.x versions of dependencies, but that does not appear to be the case. --- Package.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Package.swift b/Package.swift index ac1832de2..6db40cbe1 100644 --- a/Package.swift +++ b/Package.swift @@ -24,7 +24,8 @@ let package = Package( ), .package( url: "https://github.com/ably/ably-cocoa-plugin-support", - from: "0.2.0", + // Be sure to use `exact` here and not `from`; SPM does not have any special handling of 0.x versions and will resolve 'from: "0.2.0"' to anything less than 1.0.0. + exact: "0.2.0", ), .package( url: "https://github.com/apple/swift-argument-parser", From 85ee6181ab4414b972d700a69fe77e803907e16f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 2 Oct 2025 10:38:09 -0300 Subject: [PATCH 163/225] Remove mis-copied steps in release process Copied these from ably-chat-swift in the initial commit, but they don't apply. --- CONTRIBUTING.md | 3 --- 1 file changed, 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 50330835b..8c26a4a07 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -150,9 +150,6 @@ If you use edit mode, Xcode will not let you edit ably-cocoa from _within_ `./Pa 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 -- Update the following (we have https://github.com/ably/ably-chat-swift/issues/277 for adding a script to do this): - - the `version` constant in [`Sources/AblyLiveObjects/Version.swift`](Sources/AblyLiveObjects/Version.swift) - - the `from: "…"` in the SPM installation instructions in [`README.md`](README.md) - 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 From 601a65137aacee253964440706584261e0f1bd43 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 2 Oct 2025 10:44:56 -0300 Subject: [PATCH 164/225] Release version 0.2.0 --- CHANGELOG.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index d4bef7fc5..e12b6ae53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Change Log +## [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 From bef4b8585fada6015468fe721e497ff8a4fee0ae Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 17 Oct 2025 11:06:01 -0300 Subject: [PATCH 165/225] Remove fancy quotes Due to LLM limitations, LLM coding assistants frequently steamroller these and convert them into straight quotes. We have two choices: fight them at every step and keep reverting their changes, or just give up on fancy quotes. I think that realistically, we wouldn't do the first one, and would instead just keep introducing noise into diffs. --- .github/workflows/check.yaml | 10 +++--- .prettierignore | 2 +- .swiftlint.yml | 8 ++--- CONTRIBUTING.md | 8 ++--- Sources/AblyLiveObjects/.swiftformat | 2 +- Sources/AblyLiveObjects/.swiftlint.yml | 2 +- Sources/AblyLiveObjects/Utility/Logger.swift | 2 +- Sources/BuildTool/BuildTool.swift | 32 +++++++++---------- Sources/BuildTool/DestinationFetcher.swift | 2 +- Sources/BuildTool/ProcessRunner.swift | 2 +- Sources/BuildTool/XcodeRunner.swift | 22 ++++++------- .../Helpers/TestLogger.swift | 2 +- .../AblyLiveObjectsTests/JSONValueTests.swift | 2 +- .../AblyLiveObjectsTests/WireValueTests.swift | 4 +-- 14 files changed, 50 insertions(+), 50 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 960704c9e..a2a046b56 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -20,7 +20,7 @@ jobs: with: submodules: true - # This step can be removed once the runners’ default version of Xcode is 16.3 or above + # This step can be removed once the runners' default version of Xcode is 16.3 or above - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: 16.3 @@ -49,7 +49,7 @@ jobs: # with: # submodules: true # - # # This step can be removed once the runners’ default version of Xcode is 16.3 or above + # # This step can be removed once the runners' default version of Xcode is 16.3 or above # - uses: maxim-lobanov/setup-xcode@v1 # with: # xcode-version: 16.3 @@ -68,7 +68,7 @@ jobs: with: submodules: true - # This step can be removed once the runners’ default version of Xcode is 16.3 or above + # This step can be removed once the runners' default version of Xcode is 16.3 or above - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: 16.3 @@ -149,7 +149,7 @@ jobs: with: submodules: true - # This step can be removed once the runners’ default version of Xcode is 16.3 or above + # This step can be removed once the runners' default version of Xcode is 16.3 or above - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: 16.3 @@ -226,7 +226,7 @@ jobs: with: submodules: true - # This step can be removed once the runners’ default version of Xcode is 16.3 or above + # This step can be removed once the runners' default version of Xcode is 16.3 or above - uses: maxim-lobanov/setup-xcode@v1 with: xcode-version: 16.3 diff --git a/.prettierignore b/.prettierignore index ad33706e8..e2e73d508 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,4 @@ -# Don’t try and format the asset catalogue JSON files, which are managed by Xcode +# Don't try and format the asset catalogue JSON files, which are managed by Xcode *.xcassets/ # Submodules diff --git a/.swiftlint.yml b/.swiftlint.yml index cd4f1384c..f6d10a1e2 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -16,7 +16,7 @@ disabled_rules: - nesting - type_body_length - # Rules of type "lint" that we’ve decided we don’t want: + # 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: @@ -34,7 +34,7 @@ opt_in_rules: - reduce_into - sorted_first_last - # Opt-in rules of type "style" that we’ve decided we want: + # Opt-in rules of type "style" that we've decided we want: - attributes - closure_end_indentation - closure_spacing @@ -64,7 +64,7 @@ opt_in_rules: - vertical_whitespace_closing_braces - vertical_whitespace_opening_braces - # Opt-in rules of type "idiomatic" that we’ve decided we want: + # Opt-in rules of type "idiomatic" that we've decided we want: - anonymous_argument_in_multiline_closure - convenience_type - fallthrough @@ -76,7 +76,7 @@ opt_in_rules: - toggle_bool - xct_specific_matcher - # Opt-in rules of type "lint" that we’ve decided we want: + # Opt-in rules of type "lint" that we've decided we want: - array_init - empty_xctest_method - override_in_extension diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8c26a4a07..8295b9964 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -36,8 +36,8 @@ To check formatting and code quality, run `swift run BuildTool lint`. Run with ` - 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.) + - 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`. @@ -112,11 +112,11 @@ func test3 { … } ``` ```swift -// @specPartial CHA-EX1h4 - Tests that we retry, but not the retry attempt limit because we’ve not implemented it yet +// @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. +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 diff --git a/Sources/AblyLiveObjects/.swiftformat b/Sources/AblyLiveObjects/.swiftformat index 6252d93b7..3dd75866c 100644 --- a/Sources/AblyLiveObjects/.swiftformat +++ b/Sources/AblyLiveObjects/.swiftformat @@ -1,2 +1,2 @@ -# To avoid clash with SwiftLint’s explicit_acl rule +# To avoid clash with SwiftLint's explicit_acl rule --disable redundantInternal diff --git a/Sources/AblyLiveObjects/.swiftlint.yml b/Sources/AblyLiveObjects/.swiftlint.yml index b3580378b..85b1c9a91 100644 --- a/Sources/AblyLiveObjects/.swiftlint.yml +++ b/Sources/AblyLiveObjects/.swiftlint.yml @@ -1,3 +1,3 @@ opt_in_rules: - # Opt-in rules of type "idiomatic" that we’ve decided we want: + # Opt-in rules of type "idiomatic" that we've decided we want: - explicit_acl diff --git a/Sources/AblyLiveObjects/Utility/Logger.swift b/Sources/AblyLiveObjects/Utility/Logger.swift index 94cfca9ae..90cb7d49a 100644 --- a/Sources/AblyLiveObjects/Utility/Logger.swift +++ b/Sources/AblyLiveObjects/Utility/Logger.swift @@ -2,7 +2,7 @@ internal import _AblyPluginSupportPrivate /// A reference to a line within a source code file. internal struct CodeLocation: Equatable { - /// A file identifier in the format used by Swift’s `#fileID` macro. For example, `"AblyChat/Room.swift"`. + /// 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 diff --git a/Sources/BuildTool/BuildTool.swift b/Sources/BuildTool/BuildTool.swift index 12e380e8d..403c501f7 100644 --- a/Sources/BuildTool/BuildTool.swift +++ b/Sources/BuildTool/BuildTool.swift @@ -128,7 +128,7 @@ 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. + 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: @@ -164,7 +164,7 @@ struct GenerateMatrices: ParsableCommand { ], ] - // I’m assuming the JSONSerialization output has no newlines + // 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) @@ -209,7 +209,7 @@ struct Lint: AsyncParsableCommand { 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./// + /// 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") @@ -234,9 +234,9 @@ struct Lint: AsyncParsableCommand { } } - /// Checks that the SPM-managed Package.resolved matches the Xcode-managed one. (I still don’t fully understand _why_ there are two files). + /// 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. + /// 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 @@ -313,7 +313,7 @@ struct SpecCoverage: AsyncParsableCommand { 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) + // (This `Testable` is a convention that's being used only in the Chat spec) let specPointLineRegex = /^\*+ @\((.*?)\)@( @\[Testable\]@ )?/ @@ -337,7 +337,7 @@ struct SpecCoverage: AsyncParsableCommand { } /** - * 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`. + * 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` { @@ -448,7 +448,7 @@ struct SpecCoverage: AsyncParsableCommand { 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). + * 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 @@ -624,12 +624,12 @@ struct SpecCoverage: AsyncParsableCommand { 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: + // 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. + // 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 @@ -689,7 +689,7 @@ struct SpecCoverage: AsyncParsableCommand { } /** - * The response from GitHub’s [“get a commit” endpoint](https://docs.github.com/en/rest/commits/commits?apiVersion=2022-11-28#get-a-commit). + * 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 @@ -769,12 +769,12 @@ struct SpecCoverage: AsyncParsableCommand { var name: String /** - * The path of this target’s sources, relative to ``PackageDescribeOutput/path``. + * 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``. + * The paths of each of this target's sources, relative to ``path``. */ var sources: [String] } @@ -783,7 +783,7 @@ struct SpecCoverage: AsyncParsableCommand { } /** - * Fetches the absolute file URLs of all of the source files for the SDK’s tests. + * 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( @@ -811,7 +811,7 @@ struct BuildDocumentation: AsyncParsableCommand { ) 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. + // 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", @@ -829,7 +829,7 @@ struct BuildDocumentation: AsyncParsableCommand { // - 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. + // 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. diff --git a/Sources/BuildTool/DestinationFetcher.swift b/Sources/BuildTool/DestinationFetcher.swift index 2d4f99c52..ceb6427fd 100644 --- a/Sources/BuildTool/DestinationFetcher.swift +++ b/Sources/BuildTool/DestinationFetcher.swift @@ -11,7 +11,7 @@ enum DestinationFetcher { 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)") + 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 { diff --git a/Sources/BuildTool/ProcessRunner.swift b/Sources/BuildTool/ProcessRunner.swift index 6219c0138..61ccb3d01 100644 --- a/Sources/BuildTool/ProcessRunner.swift +++ b/Sources/BuildTool/ProcessRunner.swift @@ -4,7 +4,7 @@ import Foundation 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. + // 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 diff --git a/Sources/BuildTool/XcodeRunner.swift b/Sources/BuildTool/XcodeRunner.swift index 16dcb9eee..379fcb237 100644 --- a/Sources/BuildTool/XcodeRunner.swift +++ b/Sources/BuildTool/XcodeRunner.swift @@ -35,30 +35,30 @@ enum XcodeRunner { > 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 + 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. + 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”. + 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 + 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 + 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 + 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 + 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 + 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.) diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift b/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift index 3d53bdad9..d38c3869b 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift @@ -4,7 +4,7 @@ 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. + // 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() diff --git a/Tests/AblyLiveObjectsTests/JSONValueTests.swift b/Tests/AblyLiveObjectsTests/JSONValueTests.swift index 02b92d485..bdbef4f32 100644 --- a/Tests/AblyLiveObjectsTests/JSONValueTests.swift +++ b/Tests/AblyLiveObjectsTests/JSONValueTests.swift @@ -101,7 +101,7 @@ struct JSONValueTests { #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. + // 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] = [ diff --git a/Tests/AblyLiveObjectsTests/WireValueTests.swift b/Tests/AblyLiveObjectsTests/WireValueTests.swift index 74a31d32f..7f29db0ee 100644 --- a/Tests/AblyLiveObjectsTests/WireValueTests.swift +++ b/Tests/AblyLiveObjectsTests/WireValueTests.swift @@ -220,7 +220,7 @@ struct WireValueTests { #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. + // 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 = [ @@ -274,7 +274,7 @@ struct WireValueTests { #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. + // 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 = [ From 140ef0646fc9dc0cc8f4f0962ca1624ac4f3940e Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 20 Oct 2025 17:16:16 -0300 Subject: [PATCH 166/225] Add LICENSE and COPYRIGHT --- COPYRIGHT | 1 + LICENSE | 176 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 COPYRIGHT create mode 100644 LICENSE diff --git a/COPYRIGHT b/COPYRIGHT new file mode 100644 index 000000000..165bec7ac --- /dev/null +++ b/COPYRIGHT @@ -0,0 +1 @@ +Copyright 2025 Ably Real-time Ltd (ably.com) diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..d9a10c0d8 --- /dev/null +++ b/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 From 37ad19df2cc6063c74ec88ecefc2ddf491db5ebf Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 1 Dec 2025 09:54:29 -0300 Subject: [PATCH 167/225] Add API for fetching server time For the LiveObjects client to use when creating object IDs, per RTO16 (see [1]). [1] https://github.com/ably/ably-liveobjects-swift-plugin/issues/50 --- Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h index 745915fd2..395aef29d 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h +++ b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h @@ -72,6 +72,14 @@ NS_SWIFT_SENDABLE /// 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; + /// Logs a message to a logger. - (void)log:(NSString *)message withLevel:(APLogLevel)level From e3940fa8d384bd3763ae7127056254e3a07ee492 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 1 Dec 2025 10:15:51 -0300 Subject: [PATCH 168/225] Use server time for object ID per spec This was skipped in dcdb350 due to time constraints. Resolves #50. --- .../xcshareddata/swiftpm/Package.resolved | 12 ++++----- Package.resolved | 12 ++++----- Package.swift | 6 +++-- .../AblyLiveObjects/Internal/CoreSDK.swift | 25 +++++++++++++++++++ .../InternalDefaultRealtimeObjects.swift | 16 +++++++----- .../InternalDefaultRealtimeObjectsTests.swift | 18 ++++++------- .../Mocks/MockCoreSDK.swift | 8 +++++- 7 files changed, 64 insertions(+), 33 deletions(-) diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index 08a8d7167..a84573016 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,12 @@ { - "originHash" : "a9bb12b2370d8ccfa25000a37b1301668b79689668f71059fea14431be9a0649", + "originHash" : "fafd5590fad90c81ba4c543a3eb57d220d0404f5b00c0fdebfcc9a899cc2ed31", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "c28983fbdfee1327a4073620af255fd3b1b44b4c", - "version" : "1.2.46" + "revision" : "23954b55bea7fa24beaa6e43beb580d501e6b6e1" } }, { @@ -15,8 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "8db4632b0664b7272d54f7b612ddad0a18d1758f", - "version" : "0.2.0" + "revision" : "37ad19df2cc6063c74ec88ecefc2ddf491db5ebf" } }, { @@ -24,8 +22,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/delta-codec-cocoa", "state" : { - "revision" : "3ee62ea40a63996b55818d44b3f0e56d8753be88", - "version" : "1.3.3" + "revision" : "d53eec08f9443c6160d941327a6f9d8bbb93cea2", + "version" : "1.3.5" } }, { diff --git a/Package.resolved b/Package.resolved index e67695fd6..27ef76b0e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,12 @@ { - "originHash" : "6b347363915d5bce46289bd7c3815b40c1d86d54160d10dced8b6a07956652e3", + "originHash" : "9002541995dc04fb561afebcaa1a0e9f7c094847cc52b4dcce9284d7664e81b7", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "c28983fbdfee1327a4073620af255fd3b1b44b4c", - "version" : "1.2.46" + "revision" : "23954b55bea7fa24beaa6e43beb580d501e6b6e1" } }, { @@ -15,8 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "8db4632b0664b7272d54f7b612ddad0a18d1758f", - "version" : "0.2.0" + "revision" : "37ad19df2cc6063c74ec88ecefc2ddf491db5ebf" } }, { @@ -24,8 +22,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/delta-codec-cocoa", "state" : { - "revision" : "3ee62ea40a63996b55818d44b3f0e56d8753be88", - "version" : "1.3.3" + "revision" : "d53eec08f9443c6160d941327a6f9d8bbb93cea2", + "version" : "1.3.5" } }, { diff --git a/Package.swift b/Package.swift index 6db40cbe1..58d36df2f 100644 --- a/Package.swift +++ b/Package.swift @@ -20,12 +20,14 @@ let package = Package( dependencies: [ .package( url: "https://github.com/ably/ably-cocoa", - from: "1.2.46", + // TODO: Unpin before next release + revision: "23954b55bea7fa24beaa6e43beb580d501e6b6e1", ), .package( url: "https://github.com/ably/ably-cocoa-plugin-support", // Be sure to use `exact` here and not `from`; SPM does not have any special handling of 0.x versions and will resolve 'from: "0.2.0"' to anything less than 1.0.0. - exact: "0.2.0", + // TODO: Unpin before next release + revision: "37ad19df2cc6063c74ec88ecefc2ddf491db5ebf", ), .package( url: "https://github.com/apple/swift-argument-parser", diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index cc05671ae..c9a6d9e76 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -8,6 +8,9 @@ internal protocol CoreSDK: AnyObject, Sendable { /// Implements the internal `#publish` method of RTO15. func publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) + /// Implements the server time fetch of RTO16, including the storing and usage of the local clock offset. + func fetchServerTime() async throws(ARTErrorInfo) -> Date + /// Replaces the implementation of ``publish(objectMessages:)``. /// /// 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. @@ -81,6 +84,28 @@ internal final class DefaultCoreSDK: CoreSDK { } } + internal func fetchServerTime() async throws(ARTErrorInfo) -> Date { + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + let internalQueue = pluginAPI.internalQueue(for: client) + + internalQueue.async { [client, pluginAPI] in + pluginAPI.nosync_fetchServerTime(for: client) { serverTime, error in + // We don't currently rely on this documented behaviour of `noSync_fetchServerTime` but we may do later, so assert it to be sure it's happening. + dispatchPrecondition(condition: .onQueue(internalQueue)) + + if let error { + continuation.resume(returning: .failure(ARTErrorInfo.castPluginPublicErrorInfo(error))) + } else { + guard let serverTime else { + preconditionFailure("nosync_fetchServerTime gave nil serverTime and nil error") + } + continuation.resume(returning: .success(serverTime)) + } + } + } + }.get() + } + internal var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { pluginAPI.nosync_state(for: channel) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index dcc5bc679..c64e80361 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -172,14 +172,17 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } internal func createMap(entries: [String: InternalLiveMapValue], coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { - let creationOperation = try mutableStateMutex.withSync { _ throws(ARTErrorInfo) in + try mutableStateMutex.withSync { _ throws(ARTErrorInfo) in // RTO11d try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createMap") + } + // RTO11f7 + let timestamp = try await coreSDK.fetchServerTime() + + let creationOperation = mutableStateMutex.withSync { _ in // RTO11f - // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) - let timestamp = clock.now - return ObjectCreationHelpers.nosync_creationOperationForLiveMap( + ObjectCreationHelpers.nosync_creationOperationForLiveMap( entries: entries, timestamp: timestamp, ) @@ -218,8 +221,9 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo // RTO12f - // TODO: This is a stopgap; change to use server time per RTO12f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) - let timestamp = clock.now + // RTO12f5 + let timestamp = try await coreSDK.fetchServerTime() + let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter( count: count, timestamp: timestamp, diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index c4588a7ce..591f2ea11 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -1202,15 +1202,15 @@ struct InternalDefaultRealtimeObjectsTests { } } + // @spec RTO11f7 // @spec RTO11g // @spec RTO11h3a // @spec RTO11h3b @Test func publishesObjectMessageAndCreatesMap() async throws { let internalQueue = TestFactories.createInternalQueue() - let clock = MockSimpleClock(currentTime: .init(timeIntervalSince1970: 1_754_042_434)) - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(clock: clock, internalQueue: internalQueue) - let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, serverTime: .init(timeIntervalSince1970: 1_754_042_434), internalQueue: internalQueue) // Track published messages var publishedMessages: [OutboundObjectMessage] = [] @@ -1234,8 +1234,7 @@ struct InternalDefaultRealtimeObjectsTests { #expect(publishedMessage.operation?.action == .known(.mapCreate)) let objectID = try #require(publishedMessage.operation?.objectId) #expect(objectID.hasPrefix("map:")) - // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) - #expect(objectID.contains("1754042434000")) // check contains the mock clock's timestamp in milliseconds + #expect(objectID.contains("1754042434000")) // check contains the server timestamp in milliseconds per RTO11f7 #expect(publishedMessage.operation?.map?.entries == [ "stringKey": .init(data: .init(string: "stringValue")), ]) @@ -1336,15 +1335,15 @@ struct InternalDefaultRealtimeObjectsTests { } } + // @spec RTO12f5 // @spec RTO12g // @spec RTO12h3a // @spec RTO12h3b @Test func publishesObjectMessageAndCreatesCounter() async throws { let internalQueue = TestFactories.createInternalQueue() - let clock = MockSimpleClock(currentTime: .init(timeIntervalSince1970: 1_754_042_434)) - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(clock: clock, internalQueue: internalQueue) - let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, serverTime: .init(timeIntervalSince1970: 1_754_042_434), internalQueue: internalQueue) // Track published messages var publishedMessages: [OutboundObjectMessage] = [] @@ -1363,8 +1362,7 @@ struct InternalDefaultRealtimeObjectsTests { #expect(publishedMessage.operation?.action == .known(.counterCreate)) let objectID = try #require(publishedMessage.operation?.objectId) #expect(objectID.hasPrefix("counter:")) - // TODO: This is a stopgap; change to use server time per RTO11f5 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/50) - #expect(objectID.contains("1754042434000")) // check contains the mock clock's timestamp in milliseconds + #expect(objectID.contains("1754042434000")) // check contains the server timestamp in milliseconds per RTO12f5 #expect(publishedMessage.operation?.counter?.count == 10.5) // Verify initial value was merged per RTO12h3a diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 71623a79e..688327f6a 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -8,9 +8,11 @@ final class MockCoreSDK: CoreSDK { private nonisolated(unsafe) var _publishHandler: (([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void)? private let channelStateMutex: DispatchQueueMutex<_AblyPluginSupportPrivate.RealtimeChannelState> + private let serverTime: Date - init(channelState: _AblyPluginSupportPrivate.RealtimeChannelState, internalQueue: DispatchQueue) { + init(channelState: _AblyPluginSupportPrivate.RealtimeChannelState, serverTime: Date = .init(), internalQueue: DispatchQueue) { channelStateMutex = DispatchQueueMutex(dispatchQueue: internalQueue, initialValue: channelState) + self.serverTime = serverTime } func publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) { @@ -35,4 +37,8 @@ final class MockCoreSDK: CoreSDK { _publishHandler = handler } } + + func fetchServerTime() async throws(ARTErrorInfo) -> Date { + serverTime + } } From c034504a5ef426f64e7b27534e86a0c547f3b1e8 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 1 Dec 2025 15:06:00 -0300 Subject: [PATCH 169/225] Add LiveObjects plugin API for receiving connection details So that the plugin can use the GC grace period when releasing tombstoned objects, per RTO10b (see [1]). [1] https://github.com/ably/ably-liveobjects-swift-plugin/issues/32 --- .../include/APConnectionDetails.h | 15 +++++++++++++++ .../include/APLiveObjectsPlugin.h | 8 ++++++++ .../include/APPluginAPI.h | 3 +++ 3 files changed, 26 insertions(+) create mode 100644 Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h b/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h new file mode 100644 index 000000000..3fdc91c01 --- /dev/null +++ b/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h @@ -0,0 +1,15 @@ +@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; + +@end + +NS_ASSUME_NONNULL_END diff --git a/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h index 7ca44cbab..f7552ec60 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h +++ b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h @@ -7,6 +7,7 @@ @protocol APRealtimeChannel; @protocol APRealtimeClient; @protocol APPublicErrorInfo; +@protocol APConnectionDetailsProtocol; NS_ASSUME_NONNULL_BEGIN @@ -83,6 +84,13 @@ NS_SWIFT_SENDABLE 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; + @end /// An `ObjectMessage`, as found in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. diff --git a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h index 395aef29d..1871e6816 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h +++ b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h @@ -80,6 +80,9 @@ NS_SWIFT_SENDABLE - (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 From 7c68c4f71b3fe38dd068a3e00e65382266efa3ae Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 1 Dec 2025 17:26:21 -0300 Subject: [PATCH 170/225] Move garbageCollectionGracePeriod into the mutable state Not sure why I didn't put it there in 2167277. --- .../Internal/InternalDefaultRealtimeObjects.swift | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index c64e80361..add0fd4cf 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -17,8 +17,6 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo /// The RTO10a interval at which we will perform garbage collection. private let garbageCollectionInterval: TimeInterval - /// The RTO10b grace period for which we will retain tombstoned objects and map entries. - private nonisolated(unsafe) var garbageCollectionGracePeriod: TimeInterval // The task that runs the periodic garbage collection described in RTO10. private nonisolated(unsafe) var garbageCollectionTask: Task! @@ -111,10 +109,10 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo userCallbackQueue: userCallbackQueue, clock: clock, ), + garbageCollectionGracePeriod: garbageCollectionOptions.gracePeriod, ), ) garbageCollectionInterval = garbageCollectionOptions.interval - garbageCollectionGracePeriod = garbageCollectionOptions.gracePeriod garbageCollectionTask = Task { [weak self, garbageCollectionInterval] in do { @@ -354,7 +352,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo internal func performGarbageCollection() { mutableStateMutex.withSync { mutableState in mutableState.objectsPool.nosync_performGarbageCollection( - gracePeriod: garbageCollectionGracePeriod, + gracePeriod: mutableState.garbageCollectionGracePeriod, clock: clock, logger: logger, eventsContinuation: completedGarbageCollectionEventsWithoutBufferingContinuation, @@ -395,6 +393,9 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo internal var onChannelAttachedHasObjects: Bool? internal var objectsEventSubscriptionStorage = SubscriptionStorage() + /// The RTO10b grace period for which we will retain tombstoned objects and map entries. + internal var garbageCollectionGracePeriod: TimeInterval + /// The state that drives the emission of the `syncing` and `synced` events. /// /// This manipulation of this value is based on https://github.com/ably/ably-js/blob/0c5baa9273ca87aec6ca594833d59c4c4d2dddbb/src/plugins/objects/objects.ts. From d647c4603100330b5dddfe155beefc04c282f429 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 1 Dec 2025 17:16:24 -0300 Subject: [PATCH 171/225] Use server-sent GC grace period per spec Skipped in 2167277 due to time constraints. Integration tests copied from JS at b2e5121. Resolves #32. --- .../xcshareddata/swiftpm/Package.resolved | 6 +- Package.resolved | 6 +- Package.swift | 4 +- .../Internal/DefaultInternalPlugin.swift | 19 +++++- .../InternalDefaultRealtimeObjects.swift | 51 ++++++++++++++-- .../PublicDefaultRealtimeObjects.swift | 4 ++ .../Helpers/Ably+Concurrency.swift | 11 ++++ .../ObjectsIntegrationTests.swift | 61 ++++++++++++++++++- .../TestProxyTransport.swift | 2 +- 9 files changed, 148 insertions(+), 16 deletions(-) diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index a84573016..8a911e994 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "fafd5590fad90c81ba4c543a3eb57d220d0404f5b00c0fdebfcc9a899cc2ed31", + "originHash" : "a55a900b5d9d976e54130b801325ef9dd5d55ff1733d4e77fc8a6efe3c8a005a", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "23954b55bea7fa24beaa6e43beb580d501e6b6e1" + "revision" : "25d8ef094290aed4571c878da44b9c293b9f8e85" } }, { @@ -14,7 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "37ad19df2cc6063c74ec88ecefc2ddf491db5ebf" + "revision" : "c034504a5ef426f64e7b27534e86a0c547f3b1e8" } }, { diff --git a/Package.resolved b/Package.resolved index 27ef76b0e..0b4f7ce94 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "9002541995dc04fb561afebcaa1a0e9f7c094847cc52b4dcce9284d7664e81b7", + "originHash" : "e7a81222a8751fd72c61f872a5ee9227192a1382b05fa1af8af69a6613b24fe1", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "23954b55bea7fa24beaa6e43beb580d501e6b6e1" + "revision" : "25d8ef094290aed4571c878da44b9c293b9f8e85" } }, { @@ -14,7 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "37ad19df2cc6063c74ec88ecefc2ddf491db5ebf" + "revision" : "c034504a5ef426f64e7b27534e86a0c547f3b1e8" } }, { diff --git a/Package.swift b/Package.swift index 58d36df2f..f371ed6c2 100644 --- a/Package.swift +++ b/Package.swift @@ -21,13 +21,13 @@ let package = Package( .package( url: "https://github.com/ably/ably-cocoa", // TODO: Unpin before next release - revision: "23954b55bea7fa24beaa6e43beb580d501e6b6e1", + revision: "25d8ef094290aed4571c878da44b9c293b9f8e85", ), .package( url: "https://github.com/ably/ably-cocoa-plugin-support", // Be sure to use `exact` here and not `from`; SPM does not have any special handling of 0.x versions and will resolve 'from: "0.2.0"' to anything less than 1.0.0. // TODO: Unpin before next release - revision: "37ad19df2cc6063c74ec88ecefc2ddf491db5ebf", + revision: "c034504a5ef426f64e7b27534e86a0c547f3b1e8", ), .package( url: "https://github.com/apple/swift-argument-parser", diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 42b7597e0..e9f801db3 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -40,6 +40,16 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. 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( @@ -47,7 +57,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. internalQueue: internalQueue, userCallbackQueue: callbackQueue, clock: DefaultSimpleClock(), - garbageCollectionOptions: options.garbageCollectionOptions ?? .init(), + garbageCollectionOptions: garbageCollectionOptions, ) pluginAPI.nosync_setPluginDataValue(liveObjects, forKey: Self.pluginDataKey, channel: channel) } @@ -133,6 +143,13 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. ) } + internal func nosync_onConnected(withConnectionDetails connectionDetails: (any ConnectionDetailsProtocol)?, channel: any RealtimeChannel) { + let gracePeriod = connectionDetails?.objectsGCGracePeriod?.doubleValue ?? InternalDefaultRealtimeObjects.GarbageCollectionOptions.defaultGracePeriod + + // RTO10b + nosync_realtimeObjects(for: channel).nosync_setGarbageCollectionGracePeriod(gracePeriod) + } + // MARK: - Sending `OBJECT` ProtocolMessage internal static func sendObject( diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index add0fd4cf..344a88ffc 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -27,10 +27,30 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo /// 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 `gcGracePeriod` of a `CONNECTED` `ProtocolMessage` from Realtime. + /// 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: TimeInterval = 24 * 60 * 60 + 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 { @@ -352,7 +372,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo internal func performGarbageCollection() { mutableStateMutex.withSync { mutableState in mutableState.objectsPool.nosync_performGarbageCollection( - gracePeriod: mutableState.garbageCollectionGracePeriod, + gracePeriod: mutableState.garbageCollectionGracePeriod.toTimeInterval, clock: clock, logger: logger, eventsContinuation: completedGarbageCollectionEventsWithoutBufferingContinuation, @@ -368,6 +388,29 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo completedGarbageCollectionEventsWithoutBuffering } + /// 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 + } + } + // MARK: - Testing /// Finishes the following streams, to allow a test to perform assertions about which elements the streams have emitted to this moment: @@ -394,7 +437,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo internal var objectsEventSubscriptionStorage = SubscriptionStorage() /// The RTO10b grace period for which we will retain tombstoned objects and map entries. - internal var garbageCollectionGracePeriod: TimeInterval + internal var garbageCollectionGracePeriod: GarbageCollectionOptions.GracePeriod /// The state that drives the emission of the `syncing` and `synced` events. /// diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index e42da95a3..66cd808c3 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -122,4 +122,8 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void) { coreSDK.testsOnly_overridePublish(with: newImplementation) } + + internal var testsOnly_gcGracePeriod: TimeInterval { + proxied.testsOnly_gcGracePeriod + } } diff --git a/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift b/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift index b554a80d1..a41296974 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift @@ -48,3 +48,14 @@ extension ARTRestProtocol { }.get() } } + +extension ARTConnectionProtocol { + @discardableResult + func onceAsync(_ event: ARTRealtimeConnectionEvent) async -> ARTConnectionStateChange { + await withCheckedContinuation { continuation in + once(event) { stateChange in + continuation.resume(returning: stateChange) + } + } + } +} diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 0cfce1961..fcab1b6e2 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -3730,6 +3730,63 @@ private struct ObjectsIntegrationTests { // 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) // 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) // 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 { @@ -3895,7 +3952,7 @@ private struct ObjectsIntegrationTests { var options = testCase.options let garbageCollectionOptions = InternalDefaultRealtimeObjects.GarbageCollectionOptions( interval: 0.5, - gracePeriod: 0.25, + gracePeriod: .fixed(0.25), ) options.garbageCollectionOptions = garbageCollectionOptions @@ -3913,7 +3970,7 @@ private struct ObjectsIntegrationTests { 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).timeIntervalSince(.init()) + let timeUntilGracePeriodExpires = (tombstonedAt + garbageCollectionOptions.gracePeriod.toTimeInterval).timeIntervalSince(.init()) if timeUntilGracePeriodExpires > 0 { try await Task.sleep(nanoseconds: UInt64(timeUntilGracePeriodExpires * Double(NSEC_PER_SEC))) } diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift index 154285cff..62278a011 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift @@ -404,7 +404,7 @@ class TestProxyTransport: ARTWebSocketTransport, @unchecked Sendable { 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) + 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) super.receive(msg) } } From 769478998e8e6f562f96d72f5bafe73c78ac2644 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 3 Dec 2025 10:49:33 -0300 Subject: [PATCH 172/225] Remove RTO15 TODO The new versions of ably-cocoa and ably-cocoa-plugin-support give us conformance to this spec point (with the exception of message size checking, which remains deferred to #13). Resolves #47. --- .../xcshareddata/swiftpm/Package.resolved | 6 +++--- Package.resolved | 6 +++--- Package.swift | 4 ++-- Sources/AblyLiveObjects/Internal/CoreSDK.swift | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8a911e994..7031d9d86 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "a55a900b5d9d976e54130b801325ef9dd5d55ff1733d4e77fc8a6efe3c8a005a", + "originHash" : "ef00c477e86ff8a8a2c0c6751d4f06cabc60f9f46c566992f1e11a699b8dad63", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "25d8ef094290aed4571c878da44b9c293b9f8e85" + "revision" : "4a394311f110b9a67b372934346605740f0e7a53" } }, { @@ -14,7 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "c034504a5ef426f64e7b27534e86a0c547f3b1e8" + "revision" : "9699dfefd26134a808f116d28428c230907faf27" } }, { diff --git a/Package.resolved b/Package.resolved index 0b4f7ce94..33fb284a6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "e7a81222a8751fd72c61f872a5ee9227192a1382b05fa1af8af69a6613b24fe1", + "originHash" : "ca4d8571e4ac09e465effbe93e88bb953c6e5822995b2c9d37b78740cec9c8d7", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "25d8ef094290aed4571c878da44b9c293b9f8e85" + "revision" : "4a394311f110b9a67b372934346605740f0e7a53" } }, { @@ -14,7 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "c034504a5ef426f64e7b27534e86a0c547f3b1e8" + "revision" : "9699dfefd26134a808f116d28428c230907faf27" } }, { diff --git a/Package.swift b/Package.swift index f371ed6c2..29ba58514 100644 --- a/Package.swift +++ b/Package.swift @@ -21,13 +21,13 @@ let package = Package( .package( url: "https://github.com/ably/ably-cocoa", // TODO: Unpin before next release - revision: "25d8ef094290aed4571c878da44b9c293b9f8e85", + revision: "4a394311f110b9a67b372934346605740f0e7a53", ), .package( url: "https://github.com/ably/ably-cocoa-plugin-support", // Be sure to use `exact` here and not `from`; SPM does not have any special handling of 0.x versions and will resolve 'from: "0.2.0"' to anything less than 1.0.0. // TODO: Unpin before next release - revision: "c034504a5ef426f64e7b27534e86a0c547f3b1e8", + revision: "9699dfefd26134a808f116d28428c230907faf27", ), .package( url: "https://github.com/apple/swift-argument-parser", diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index c9a6d9e76..07493cf05 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -69,7 +69,7 @@ internal final class DefaultCoreSDK: CoreSDK { return } - // TODO: Implement the full spec of RTO15 (https://github.com/ably/ably-liveobjects-swift-plugin/issues/47) + // TODO: Implement message size checking (https://github.com/ably/ably-liveobjects-swift-plugin/issues/13) try await DefaultInternalPlugin.sendObject( objectMessages: objectMessages, channel: channel, From 6a40eb2b7138acc28c97459b3153c4d84ba9d4e9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 3 Dec 2025 14:45:52 -0300 Subject: [PATCH 173/225] Fix nesting of test cases I accidentally nested the CreateCounterTests inside the CreateMapTests. --- .../InternalDefaultRealtimeObjectsTests.swift | 200 +++++++++--------- 1 file changed, 100 insertions(+), 100 deletions(-) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 591f2ea11..e4b361dd0 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -1315,133 +1315,133 @@ struct InternalDefaultRealtimeObjectsTests { // 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 + /// 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 RTO12g - // @spec RTO12h3a - // @spec RTO12h3b - @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) + // @spec RTO12f5 + // @spec RTO12g + // @spec RTO12h3a + // @spec RTO12h3b + @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) - // Track published messages - var publishedMessages: [OutboundObjectMessage] = [] - coreSDK.setPublishHandler { messages in - publishedMessages.append(contentsOf: messages) - } + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } - // Call createCounter - let returnedCounter = try await realtimeObjects.createCounter(count: 10.5, coreSDK: coreSDK) + // Call createCounter + let returnedCounter = try await realtimeObjects.createCounter(count: 10.5, coreSDK: coreSDK) - // Verify ObjectMessage was published (RTO12g) - #expect(publishedMessages.count == 1) - let publishedMessage = publishedMessages[0] + // Verify ObjectMessage was published (RTO12g) + #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?.counter?.count == 10.5) + // 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?.counter?.count == 10.5) - // Verify initial value was merged per RTO12h3a - #expect(try returnedCounter.value(coreSDK: coreSDK) == 10.5) + // Verify initial value was merged per RTO12h3a + #expect(try returnedCounter.value(coreSDK: coreSDK) == 10.5) - // Verify object was added to pool per RTO12h3b - #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.counterValue === returnedCounter) - } + // Verify object was added to pool per RTO12h3b + #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.counterValue === returnedCounter) + } - // @spec RTO12f2a - @Test - func withNoEntriesArgumentCreatesWithZeroValue() async throws { - let internalQueue = TestFactories.createInternalQueue() - let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) - let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) + // @spec RTO12f2a + @Test + func withNoEntriesArgumentCreatesWithZeroValue() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) - // Track published messages - var publishedMessages: [OutboundObjectMessage] = [] - coreSDK.setPublishHandler { messages in - publishedMessages.append(contentsOf: messages) - } + // Track published messages + var publishedMessages: [OutboundObjectMessage] = [] + coreSDK.setPublishHandler { messages in + publishedMessages.append(contentsOf: messages) + } - // Call createCounter with no count - let result = try await realtimeObjects.createCounter(coreSDK: coreSDK) + // 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 ObjectMessage was published + #expect(publishedMessages.count == 1) + let publishedMessage = publishedMessages[0] - // Verify counter operation has zero count per RTO12f2a - let counterOperation = publishedMessage.operation?.counter - // swiftlint:disable:next empty_count - #expect(counterOperation?.count == 0) + // Verify counter operation has zero count per RTO12f2a + let counterOperation = publishedMessage.operation?.counter + // swiftlint:disable:next empty_count + #expect(counterOperation?.count == 0) - // Verify LiveCounter has zero value - #expect(try result.value(coreSDK: coreSDK) == 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) + // @spec RTO12h2 + @Test + func returnsExistingObjectIfAlreadyInPool() async throws { + let internalQueue = TestFactories.createInternalQueue() + let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) + let coreSDK = MockCoreSDK(channelState: .attached, internalQueue: internalQueue) - // Track published messages and the generated objectId - var publishedMessages: [OutboundObjectMessage] = [] - var maybeGeneratedObjectID: String? - var maybeExistingObject: AnyObject? + // Track published messages and the generated objectId + var publishedMessages: [OutboundObjectMessage] = [] + var maybeGeneratedObjectID: String? + var maybeExistingObject: AnyObject? - coreSDK.setPublishHandler { messages in - publishedMessages.append(contentsOf: messages) + 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 + // 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 - } + // 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 } + } - // Call createCounter - the publishHandler will create the object with the generated ID - let result = try await realtimeObjects.createCounter(count: 10.5, coreSDK: coreSDK) + // 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) + // 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) + // 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) + // 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) - } + // Check that the existing object has not been replaced in the pool + #expect(realtimeObjects.testsOnly_objectsPool.entries[generatedObjectID]?.counterValue === existingObject) } } } From 0c2440655ee372214ad4fbbc2b6c6b283d268cc2 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 3 Dec 2025 14:58:45 -0300 Subject: [PATCH 174/225] Add tests for sync events Didn't have time to write tests for these in ad60504. These tests are based on JS's current behaviour, which was not tested; I've implemented the equivalent tests for JS in [1]. Will use these tests as a basis for writing the spec in #80. To get these tests to pass I've had to add two behaviours from JS that I missed in ad60504: 1. Don't always transition to SYNCING on ATTACHED 2. Transition to SYNCING when an OBJECT_SYNC is received when already SYNCED [1] https://github.com/ably/ably-js/pull/2121 --- .../InternalDefaultRealtimeObjects.swift | 8 +- .../InternalDefaultRealtimeObjectsTests.swift | 169 ++++++++++++++++++ 2 files changed, 175 insertions(+), 2 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 344a88ffc..e061f8c23 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -489,8 +489,10 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo 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 - transition(to: .syncing, userCallbackQueue: userCallbackQueue) + if hasObjects || state == .initialized { + // We will subsequently transition to .synced either by the completion of the RTO4a OBJECT_SYNC, or by the RTO4b no-HAS_OBJECTS case below + transition(to: .syncing, 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 { @@ -522,6 +524,8 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) + transition(to: .syncing, userCallbackQueue: userCallbackQueue) + // If populated, this contains a full set of sync data for the channel, and should be applied to the ObjectsPool. let completedSyncObjectsPool: [SyncObjectsPoolEntry]? // If populated, this contains a set of buffered inbound OBJECT messages that should be applied. diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index e4b361dd0..b4643349a 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -1444,4 +1444,173 @@ struct InternalDefaultRealtimeObjectsTests { #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] + } + + @MainActor + @Test( + // TODO: This is based on the JS behaviour; update with spec points once specified in https://github.com/ably/ably-liveobjects-swift-plugin/issues/80 + 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 should not provoke further events", + channelEvents: [.attached(hasObjects: false), .attached(hasObjects: false)], + expectedSyncEvents: [.syncing, .synced], + ), + + .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) + } + } } From bc790148773a954551bcd92af94fef67d3a2c848 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 4 Dec 2025 11:44:18 -0300 Subject: [PATCH 175/225] Use the sync state to drive getRoot's wait-for-sync The `state` variable was added in ad60504 alongside the existing `syncStatus` variable, introducing redundant concepts of "is the sync completed?". Use the `state` as our source of truth, to be consistent with JS and eliminate the redundancy. In #80, I'll update the spec to make this behaviour explicit. Note that this commit looks more complicated than it should; this is because (as commented in the code) adding a new internal SubscriptionStorage is much harder than it should be; will address this in #102. --- .../InternalDefaultRealtimeObjects.swift | 80 ++++++++++++------- 1 file changed, 53 insertions(+), 27 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index e061f8c23..7398384d4 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -86,26 +86,6 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo internal var bufferedObjectOperations: [InboundObjectMessage] } - /// Tracks whether an object sync sequence has happened yet. This allows us to wait for a sync before returning from `getRoot()`, per RTO1c. - private struct SyncStatus { - private(set) var isSyncComplete = false - private let syncCompletionEvents: AsyncStream - private let syncCompletionContinuation: AsyncStream.Continuation - - internal init() { - (syncCompletionEvents, syncCompletionContinuation) = AsyncStream.makeStream() - } - - internal mutating func signalSyncComplete() { - isSyncComplete = true - syncCompletionContinuation.yield() - } - - internal func waitForSyncCompletion() async { - await syncCompletionEvents.first { _ in true } - } - } - internal init( logger: Logger, internalQueue: DispatchQueue, @@ -168,18 +148,23 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo // MARK: - Internal methods that power RealtimeObjects conformance internal func getRoot(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { - let syncStatus = try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in + 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.syncStatus + return mutableState.state } - if !syncStatus.isSyncComplete { + if state != .synced { // RTO1c waitingForSyncEventsContinuation.yield() logger.log("getRoot started waiting for sync sequence to complete", level: .debug) - await syncStatus.waitForSyncCompletion() + 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) } @@ -283,6 +268,24 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } } + /// 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() @@ -432,10 +435,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo internal var objectsPool: ObjectsPool /// 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. internal var syncSequence: SyncSequence? - internal var syncStatus = SyncStatus() 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 @@ -506,7 +511,6 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo // RTO4b3, RTO4b4, RTO4b5, RTO5c3, RTO5c4, RTO5c5 syncSequence = nil - syncStatus.signalSyncComplete() transition(to: .synced, userCallbackQueue: userCallbackQueue) } @@ -610,7 +614,6 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo // RTO5c3, RTO5c4, RTO5c5 syncSequence = nil - syncStatus.signalSyncComplete() transition(to: .synced, userCallbackQueue: userCallbackQueue) } } @@ -723,6 +726,28 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo 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 + 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) + } + private struct ObjectsEventResponse: OnObjectsEventResponse { let subscription: any SubscribeResponse @@ -737,6 +762,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo internal func emitObjectsEvent(_ event: ObjectsEvent, on queue: DispatchQueue) { objectsEventSubscriptionStorage.emit(eventName: event, on: queue) + internalObjectsEventSubscriptionStorage.emit(eventName: event, on: queue) } } } From fba49ef278f6fc0f2fa3a94c13949f88cd112b8d Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 4 Dec 2025 12:42:17 -0300 Subject: [PATCH 176/225] Incorporate sync sequence data into the SYNCING state The `state` variable was added in ad60504 alongside the existing `syncSequence` variable, however this ignored the constraint (unclear to me at the time) that you only have a sync sequence if you're in SYNCING. Represent this constraint using the type system. Part of #80. --- .../InternalDefaultRealtimeObjects.swift | 77 ++++++++++++------- .../Internal/ObjectsSyncState.swift | 19 +++++ 2 files changed, 67 insertions(+), 29 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 7398384d4..8f873f00d 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -62,7 +62,11 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo /// 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 - mutableState.syncSequence != nil + if case let .syncing(syncingData) = mutableState.state, syncingData.syncSequence != nil { + true + } else { + false + } } } @@ -155,7 +159,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo return mutableState.state } - if state != .synced { + if state.toObjectsSyncState != .synced { // RTO1c waitingForSyncEventsContinuation.yield() logger.log("getRoot started waiting for sync sequence to complete", level: .debug) @@ -433,8 +437,6 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo private struct MutableState { internal var objectsPool: ObjectsPool - /// 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. - internal var syncSequence: SyncSequence? internal var onChannelAttachedHasObjects: Bool? internal var objectsEventSubscriptionStorage = SubscriptionStorage() @@ -444,25 +446,36 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo /// The RTO10b grace period for which we will retain tombstoned objects and map entries. internal var garbageCollectionGracePeriod: GarbageCollectionOptions.GracePeriod - /// The state that drives the emission of the `syncing` and `synced` events. + /// The state that drives the emission of the `syncing` and `synced` events and which stores the sync sequence data. /// /// This manipulation of this value is based on https://github.com/ably/ably-js/blob/0c5baa9273ca87aec6ca594833d59c4c4d2dddbb/src/plugins/objects/objects.ts. - /// TODO: Bring in line with spec once it exists (https://github.com/ably/ably-liveobjects-swift-plugin/issues/80) and reconcile it with the existing state that we have + /// TODO: Bring in line with spec once it exists (https://github.com/ably/ably-liveobjects-swift-plugin/issues/80) internal var state = State.initialized - /// The state that drives the emission of the `syncing` and `synced` events. - /// - /// This type is copied from https://github.com/ably/ably-js/blob/0c5baa9273ca87aec6ca594833d59c4c4d2dddbb/src/plugins/objects/objects.ts. - /// TODO: Bring in line with spec once it exists (https://github.com/ably/ably-liveobjects-swift-plugin/issues/80) + /// 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 + case syncing(AssociatedData.Syncing) case synced - var toEvent: ObjectsEvent? { + /// 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 { + /// 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 (this behaviour is copied from JS and will be specified in https://github.com/ably/ably-liveobjects-swift-plugin/issues/80). + var syncSequence: SyncSequence? + + init(syncSequence: SyncSequence?) { + self.syncSequence = syncSequence + } + } + } + + var toObjectsSyncState: ObjectsSyncState { switch self { case .initialized: - nil + .initialized case .syncing: .syncing case .synced: @@ -475,11 +488,11 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo to newState: State, userCallbackQueue: DispatchQueue, ) { - guard newState != state else { - return + guard newState.toObjectsSyncState != state.toObjectsSyncState else { + preconditionFailure("Cannot transition to the current state") } state = newState - guard let event = newState.toEvent else { + guard let event = newState.toObjectsSyncState.toEvent else { return } emitObjectsEvent(event, on: userCallbackQueue) @@ -494,9 +507,9 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo onChannelAttachedHasObjects = hasObjects - if hasObjects || state == .initialized { + if (hasObjects && state.toObjectsSyncState != .syncing) || state.toObjectsSyncState == .initialized { // We will subsequently transition to .synced either by the completion of the RTO4a OBJECT_SYNC, or by the RTO4b no-HAS_OBJECTS case below - transition(to: .syncing, userCallbackQueue: userCallbackQueue) + transition(to: .syncing(.init(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) @@ -510,8 +523,9 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo // 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, RTO4b5, RTO5c3, RTO5c4, RTO5c5 - syncSequence = nil - transition(to: .synced, userCallbackQueue: userCallbackQueue) + if state.toObjectsSyncState != .synced { + transition(to: .synced, userCallbackQueue: userCallbackQueue) + } } /// Implements the `OBJECT_SYNC` handling of RTO5. @@ -528,12 +542,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) - transition(to: .syncing, userCallbackQueue: userCallbackQueue) - // If populated, this contains a full set of sync data for the channel, and should be applied to the ObjectsPool. let completedSyncObjectsPool: [SyncObjectsPoolEntry]? // If populated, this contains a set of buffered inbound OBJECT messages that should be applied. let completedSyncBufferedObjectOperations: [InboundObjectMessage]? + // The SyncSequence, if any, to store in the SYNCING state that results from this OBJECT_SYNC. + let syncSequenceForSyncingState: SyncSequence? if let protocolMessageChannelSerial { let syncCursor: SyncCursor @@ -546,7 +560,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } // Figure out whether to continue any existing sync sequence or start a new one - var updatedSyncSequence: SyncSequence = if let syncSequence { + var updatedSyncSequence: SyncSequence = if case let .syncing(syncingData) = state, let syncSequence = syncingData.syncSequence { if syncCursor.sequenceID == syncSequence.id { // RTO5a3: Continue existing sync sequence syncSequence @@ -568,13 +582,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } }) - syncSequence = updatedSyncSequence - (completedSyncObjectsPool, completedSyncBufferedObjectOperations) = if syncCursor.isEndOfSequence { (updatedSyncSequence.syncObjectsPool, updatedSyncSequence.bufferedObjectOperations) } else { (nil, nil) } + + syncSequenceForSyncingState = updatedSyncSequence } else { // RTO5a5: The sync data is contained entirely within this single OBJECT_SYNC completedSyncObjectsPool = objectMessages.compactMap { objectMessage in @@ -585,6 +599,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } } completedSyncBufferedObjectOperations = nil + syncSequenceForSyncingState = nil + } + + if case let .syncing(syncingData) = state { + syncingData.syncSequence = syncSequenceForSyncingState + } else { + transition(to: .syncing(.init(syncSequence: syncSequenceForSyncingState)), userCallbackQueue: userCallbackQueue) } if let completedSyncObjectsPool { @@ -612,8 +633,6 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } // RTO5c3, RTO5c4, RTO5c5 - syncSequence = nil - transition(to: .synced, userCallbackQueue: userCallbackQueue) } } @@ -631,12 +650,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo logger.log("handleObjectProtocolMessage(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) - if let existingSyncSequence = syncSequence { + if case let .syncing(syncingData) = state, let existingSyncSequence = syncingData.syncSequence { // RTO8a: Buffer the OBJECT message, to be handled once the sync completes logger.log("Buffering OBJECT message due to in-progress sync", level: .debug) var newSyncSequence = existingSyncSequence newSyncSequence.bufferedObjectOperations.append(contentsOf: objectMessages) - syncSequence = newSyncSequence + syncingData.syncSequence = newSyncSequence } else { // RTO8b: Handle the OBJECT message immediately for objectMessage in objectMessages { diff --git a/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift b/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift new file mode 100644 index 000000000..ee719bd97 --- /dev/null +++ b/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. +/// +/// (TODO: This isn't actually in the spec yet, will specify in https://github.com/ably/ably-liveobjects-swift-plugin/issues/80; it's currently copied from https://github.com/ably/ably-js/blob/0c5baa9273ca87aec6ca594833d59c4c4d2dddbb/src/plugins/objects/objects.ts) +internal enum ObjectsSyncState { + case initialized + case syncing + case synced + + internal var toEvent: ObjectsEvent? { + switch self { + case .initialized: + nil + case .syncing: + .syncing + case .synced: + .synced + } + } +} From 1c901a8210b42a4eb32c14f59f9215e3cf6fcdf6 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 5 Dec 2025 11:39:43 -0300 Subject: [PATCH 177/225] Always transition to SYNCING on receipt of ATTACHED This copies the change added in the referenced JS commit. --- .../Internal/InternalDefaultRealtimeObjects.swift | 10 ++++------ .../AblyLiveObjects/Internal/InternalLiveObject.swift | 2 +- .../AblyLiveObjects/Internal/ObjectsSyncState.swift | 2 +- .../InternalDefaultRealtimeObjectsTests.swift | 7 +++++-- 4 files changed, 11 insertions(+), 10 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 8f873f00d..46284f449 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -448,7 +448,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo /// The state that drives the emission of the `syncing` and `synced` events and which stores the sync sequence data. /// - /// This manipulation of this value is based on https://github.com/ably/ably-js/blob/0c5baa9273ca87aec6ca594833d59c4c4d2dddbb/src/plugins/objects/objects.ts. + /// This manipulation of this value is based on https://github.com/ably/ably-js/blob/e280bff11a4a7627362c5185e764b7ebd0490570/src/plugins/objects/objects.ts. /// TODO: Bring in line with spec once it exists (https://github.com/ably/ably-liveobjects-swift-plugin/issues/80) internal var state = State.initialized @@ -507,8 +507,8 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo onChannelAttachedHasObjects = hasObjects - if (hasObjects && state.toObjectsSyncState != .syncing) || state.toObjectsSyncState == .initialized { - // We will subsequently transition to .synced either by the completion of the RTO4a OBJECT_SYNC, or by the RTO4b no-HAS_OBJECTS case below + // We will subsequently transition to .synced either by the completion of the RTO4a OBJECT_SYNC, or by the RTO4b no-HAS_OBJECTS case below + if state.toObjectsSyncState != .syncing { transition(to: .syncing(.init(syncSequence: nil)), userCallbackQueue: userCallbackQueue) } @@ -523,9 +523,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo // 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, RTO4b5, RTO5c3, RTO5c4, RTO5c5 - if state.toObjectsSyncState != .synced { - transition(to: .synced, userCallbackQueue: userCallbackQueue) - } + transition(to: .synced, userCallbackQueue: userCallbackQueue) } /// Implements the `OBJECT_SYNC` handling of RTO5. diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift b/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift index 0a308efb9..11679d152 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift @@ -35,7 +35,7 @@ internal extension InternalLiveObject { resetDataToZeroValued() // Emit the deleted lifecycle event - // Taken from https://github.com/ably/ably-js/blob/0c5baa9273ca87aec6ca594833d59c4c4d2dddbb/src/plugins/objects/liveobject.ts#L168 + // 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) } diff --git a/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift b/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift index ee719bd97..02ae283b0 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift @@ -1,6 +1,6 @@ /// The type that the spec uses to represent the client's state of syncing its local Objects data with the server. /// -/// (TODO: This isn't actually in the spec yet, will specify in https://github.com/ably/ably-liveobjects-swift-plugin/issues/80; it's currently copied from https://github.com/ably/ably-js/blob/0c5baa9273ca87aec6ca594833d59c4c4d2dddbb/src/plugins/objects/objects.ts) +/// (TODO: This isn't actually in the spec yet, will specify in https://github.com/ably/ably-liveobjects-swift-plugin/issues/80; it's currently copied from https://github.com/ably/ably-js/blob/e280bff11a4a7627362c5185e764b7ebd0490570/src/plugins/objects/objects.ts) internal enum ObjectsSyncState { case initialized case syncing diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index b4643349a..228957d3b 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -1475,9 +1475,12 @@ struct InternalDefaultRealtimeObjectsTests { ), .init( - description: "ATTACHED with HAS_OBJECTS false once SYNCED should not provoke further events", + description: "ATTACHED with HAS_OBJECTS false once SYNCED emits SYNCING and then SYNCED", channelEvents: [.attached(hasObjects: false), .attached(hasObjects: false)], - expectedSyncEvents: [.syncing, .synced], + expectedSyncEvents: [ + .syncing, .synced, // The initial SYNCED + .syncing, .synced, // From the subsequent ATTACHED + ], ), .init( From 1aca9aa002ce05920fd2b78b699f15cc4237e465 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 5 Dec 2025 16:47:07 -0300 Subject: [PATCH 178/225] Add spec points for sync events Based on [1] at 6d2b56a. Resolves #80. [1] https://github.com/ably/specification/pull/404 --- .../InternalDefaultRealtimeObjects.swift | 16 +++++++++------- .../Internal/ObjectsSyncState.swift | 5 ++--- .../InternalDefaultRealtimeObjectsTests.swift | 5 ++++- 3 files changed, 15 insertions(+), 11 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 46284f449..86f175776 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -256,6 +256,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo try await createCounter(count: 0, coreSDK: coreSDK) } + // RTO18 @discardableResult internal func on(event: ObjectsEvent, callback: @escaping ObjectsEventCallback) -> any OnObjectsEventResponse { mutableStateMutex.withSync { mutableState in @@ -446,10 +447,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo /// The RTO10b grace period for which we will retain tombstoned objects and map entries. internal var garbageCollectionGracePeriod: GarbageCollectionOptions.GracePeriod - /// The state that drives the emission of the `syncing` and `synced` events and which stores the sync sequence data. - /// - /// This manipulation of this value is based on https://github.com/ably/ably-js/blob/e280bff11a4a7627362c5185e764b7ebd0490570/src/plugins/objects/objects.ts. - /// TODO: Bring in line with spec once it exists (https://github.com/ably/ably-liveobjects-swift-plugin/issues/80) + /// 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. @@ -463,7 +461,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo class Syncing { /// 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 (this behaviour is copied from JS and will be specified in https://github.com/ably/ably-liveobjects-swift-plugin/issues/80). + /// 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(syncSequence: SyncSequence?) { @@ -495,6 +493,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo guard let event = newState.toObjectsSyncState.toEvent else { return } + // RTO17b emitObjectsEvent(event, on: userCallbackQueue) } @@ -509,6 +508,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo // We will subsequently transition to .synced either by the completion of the RTO4a OBJECT_SYNC, or by the RTO4b no-HAS_OBJECTS case below if state.toObjectsSyncState != .syncing { + // RTO4c transition(to: .syncing(.init(syncSequence: nil)), userCallbackQueue: userCallbackQueue) } @@ -522,7 +522,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo // 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, RTO4b5, RTO5c3, RTO5c4, RTO5c5 + // RTO4b3, RTO4b4, RTO4b5, RTO5c3, RTO5c4, RTO5c5, RTO5c8 transition(to: .synced, userCallbackQueue: userCallbackQueue) } @@ -603,6 +603,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo if case let .syncing(syncingData) = state { syncingData.syncSequence = syncSequenceForSyncingState } else { + // RTO5e transition(to: .syncing(.init(syncSequence: syncSequenceForSyncingState)), userCallbackQueue: userCallbackQueue) } @@ -630,7 +631,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } } - // RTO5c3, RTO5c4, RTO5c5 + // RTO5c3, RTO5c4, RTO5c5, RTO5c8 transition(to: .synced, userCallbackQueue: userCallbackQueue) } } @@ -765,6 +766,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo return ObjectsEventResponse(subscription: subscription) } + // RTO18f private struct ObjectsEventResponse: OnObjectsEventResponse { let subscription: any SubscribeResponse diff --git a/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift b/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift index 02ae283b0..0a95d9520 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift @@ -1,11 +1,10 @@ -/// The type that the spec uses to represent the client's state of syncing its local Objects data with the server. -/// -/// (TODO: This isn't actually in the spec yet, will specify in https://github.com/ably/ably-liveobjects-swift-plugin/issues/80; it's currently copied from https://github.com/ably/ably-js/blob/e280bff11a4a7627362c5185e764b7ebd0490570/src/plugins/objects/objects.ts) +/// The type that the spec uses to represent the client's state of syncing its local Objects data with the server, per RTO17a. internal enum ObjectsSyncState { case initialized case syncing case synced + /// The event to emit when transitioning to this state, per RTO17b. internal var toEvent: ObjectsEvent? { switch self { case .initialized: diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 228957d3b..01975cc0a 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -1462,9 +1462,12 @@ struct InternalDefaultRealtimeObjectsTests { var expectedSyncEvents: [ObjectsEvent] } + // @spec RTO17b + // @spec RTO4c + // @spec RTO5e + // @spec RTO5c8 @MainActor @Test( - // TODO: This is based on the JS behaviour; update with spec points once specified in https://github.com/ably/ably-liveobjects-swift-plugin/issues/80 arguments: [ // 1. ATTACHED with HAS_OBJECTS false From 425b9adbf5d4aa6b3121dd69269d965b3a5b9dba Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 3 Dec 2025 10:03:34 -0300 Subject: [PATCH 179/225] Fix method name in comment --- Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h index f7552ec60..234464ea9 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h +++ b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h @@ -51,7 +51,7 @@ NS_SWIFT_SENDABLE /// Encodes an `ObjectMessage` to be sent over the wire. /// /// Parameters: -/// - objectMessage: An `ObjectMessage` that this plugin earlier passed to `APPluginAPI`'s `-sendStateWithObjectMessages:channel:completion:`. +/// - 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`. From 6f7790cfe1ed83b9ed0fc6d94b216258f7e266c3 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 3 Dec 2025 10:14:02 -0300 Subject: [PATCH 180/225] =?UTF-8?q?Document=20the=20sendObject=E2=80=A6=20?= =?UTF-8?q?method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This builds on the internal documentation added in ably-cocoa commit 4788c0e. Relates to [1]. [1] https://github.com/ably/ably-liveobjects-swift-plugin/issues/47 --- .../_AblyPluginSupportPrivate/include/APPluginAPI.h | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h index 1871e6816..499904ccd 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h +++ b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h @@ -62,9 +62,20 @@ NS_SWIFT_SENDABLE /// All `_AblyPluginSupportPrivate` methods whose names begin with `nosync_` must be called on this queue. - (dispatch_queue_t)internalQueueForClient:(id)client; -/// Sends an `OBJECT` `ProtocolMessage` on a channel and indicates the result of waiting for an `ACK`. TODO there is still some deciding to be done about the exact contract of this method. +/// 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 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 error))completion; From 73f567206c2769a0d9f0755b78cdc1d20c3353b1 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 8 Dec 2025 14:50:29 -0300 Subject: [PATCH 181/225] Unpin ably-cocoa and plugin-support --- .../xcshareddata/swiftpm/Package.resolved | 8 +++++--- Package.resolved | 8 +++++--- Package.swift | 7 ++----- 3 files changed, 12 insertions(+), 11 deletions(-) diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7031d9d86..4807fd430 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,13 @@ { - "originHash" : "ef00c477e86ff8a8a2c0c6751d4f06cabc60f9f46c566992f1e11a699b8dad63", + "originHash" : "d2622c7dbe5a5b55d2e9d411424ad63f4ff8431f36d6cdf3f0a79a62cafd7f3c", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "4a394311f110b9a67b372934346605740f0e7a53" + "revision" : "aa7db56bfda49595835d7f9248dacd40c5f3faf0", + "version" : "1.2.55" } }, { @@ -14,7 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "9699dfefd26134a808f116d28428c230907faf27" + "revision" : "dd118432a6e023c3d2c8a051299e8081a06db036", + "version" : "1.0.0" } }, { diff --git a/Package.resolved b/Package.resolved index 33fb284a6..09520cd28 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,13 @@ { - "originHash" : "ca4d8571e4ac09e465effbe93e88bb953c6e5822995b2c9d37b78740cec9c8d7", + "originHash" : "786c7c01be692ceb19bda5333dd720f2afb1cc13314dabce1d4c6101c9c6700b", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa", "state" : { - "revision" : "4a394311f110b9a67b372934346605740f0e7a53" + "revision" : "aa7db56bfda49595835d7f9248dacd40c5f3faf0", + "version" : "1.2.55" } }, { @@ -14,7 +15,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "9699dfefd26134a808f116d28428c230907faf27" + "revision" : "dd118432a6e023c3d2c8a051299e8081a06db036", + "version" : "1.0.0" } }, { diff --git a/Package.swift b/Package.swift index 29ba58514..294f0e82e 100644 --- a/Package.swift +++ b/Package.swift @@ -20,14 +20,11 @@ let package = Package( dependencies: [ .package( url: "https://github.com/ably/ably-cocoa", - // TODO: Unpin before next release - revision: "4a394311f110b9a67b372934346605740f0e7a53", + from: "1.2.55", ), .package( url: "https://github.com/ably/ably-cocoa-plugin-support", - // Be sure to use `exact` here and not `from`; SPM does not have any special handling of 0.x versions and will resolve 'from: "0.2.0"' to anything less than 1.0.0. - // TODO: Unpin before next release - revision: "9699dfefd26134a808f116d28428c230907faf27", + from: "1.0.0", ), .package( url: "https://github.com/apple/swift-argument-parser", From ed05520225a2c34f28fcfdbf89acbd772f0eae9e Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 8 Dec 2025 14:56:09 -0300 Subject: [PATCH 182/225] Release version 0.3.0 --- CHANGELOG.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e12b6ae53..8570c88f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Change Log +## [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 From c4bf274d0176e40d474b2f283209ebaad3d841c9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 15 Jan 2026 11:49:56 +0000 Subject: [PATCH 183/225] Bump CI simulator OS versions The current ones don't seem to be available any more. --- .github/workflows/check.yaml | 20 ++++++++++---------- Sources/BuildTool/BuildTool.swift | 2 +- Sources/BuildTool/Platform.swift | 4 ++-- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index a2a046b56..7d8be5714 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -20,10 +20,10 @@ jobs: with: submodules: true - # This step can be removed once the runners' default version of Xcode is 16.3 or above + # This step can be removed once the runners' default version of Xcode is 16.4 or above - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 16.3 + xcode-version: 16.4 # We use caching for Mint because at the time of writing SwiftLint took about 5 minutes to build in CI, which is unacceptably slow. # https://github.com/actions/cache/blob/40c3b67b2955d93d83b27ed164edd0756bc24049/examples.md#swift---mint @@ -49,10 +49,10 @@ jobs: # with: # submodules: true # - # # This step can be removed once the runners' default version of Xcode is 16.3 or above + # # This step can be removed once the runners' default version of Xcode is 16.4 or above # - uses: maxim-lobanov/setup-xcode@v1 # with: - # xcode-version: 16.3 + # xcode-version: 16.4 # # - name: Spec coverage # run: swift run BuildTool spec-coverage --spec-commit-sha 2f88b1b @@ -68,10 +68,10 @@ jobs: with: submodules: true - # This step can be removed once the runners' default version of Xcode is 16.3 or above + # This step can be removed once the runners' default version of Xcode is 16.4 or above - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 16.3 + xcode-version: 16.4 - id: generation-step run: swift run BuildTool generate-matrices >> $GITHUB_OUTPUT @@ -149,10 +149,10 @@ jobs: with: submodules: true - # This step can be removed once the runners' default version of Xcode is 16.3 or above + # This step can be removed once the runners' default version of Xcode is 16.4 or above - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 16.3 + xcode-version: 16.4 - run: swift run BuildTool generate-code-coverage --result-bundle-path CodeCoverage.xcresult @@ -226,10 +226,10 @@ jobs: with: submodules: true - # This step can be removed once the runners' default version of Xcode is 16.3 or above + # This step can be removed once the runners' default version of Xcode is 16.4 or above - uses: maxim-lobanov/setup-xcode@v1 with: - xcode-version: 16.3 + xcode-version: 16.4 # Dry run upload-action to get base-path url - name: Dry-Run Upload (to get url) diff --git a/Sources/BuildTool/BuildTool.swift b/Sources/BuildTool/BuildTool.swift index 403c501f7..80aa8433d 100644 --- a/Sources/BuildTool/BuildTool.swift +++ b/Sources/BuildTool/BuildTool.swift @@ -148,7 +148,7 @@ struct GenerateMatrices: ParsableCommand { ) mutating func run() throws { - let tooling = ["16.3"].map { xcodeVersion in + let tooling = ["16.4"].map { xcodeVersion in [ "xcodeVersion": xcodeVersion, ] diff --git a/Sources/BuildTool/Platform.swift b/Sources/BuildTool/Platform.swift index e5c59d9a8..f8147ea27 100644 --- a/Sources/BuildTool/Platform.swift +++ b/Sources/BuildTool/Platform.swift @@ -11,9 +11,9 @@ enum Platform: String, CaseIterable { case .macOS: .fixed(platform: "macOS") case .iOS: - .lookup(destinationPredicate: .init(runtime: "iOS-18-4", deviceType: "iPhone-16")) + .lookup(destinationPredicate: .init(runtime: "iOS-26-2", deviceType: "iPhone-16")) case .tvOS: - .lookup(destinationPredicate: .init(runtime: "tvOS-18-4", deviceType: "Apple-TV-4K-3rd-generation-4K")) + .lookup(destinationPredicate: .init(runtime: "tvOS-26-2", deviceType: "Apple-TV-4K-3rd-generation-4K")) } } From 4d4ee4daf0419b42220d0c9bce1471006c8976ed Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 15 Jan 2026 10:07:48 +0000 Subject: [PATCH 184/225] refactor: Simplify handling of object sync Grab the buffered ops from the SYNCING associated data, dedupe creation of syncObjectsPoolEntries, and handle changed sync sequence upfront. --- .../InternalDefaultRealtimeObjects.swift | 79 +++++++++---------- 1 file changed, 38 insertions(+), 41 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 86f175776..4a0367244 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -540,15 +540,8 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo receivedObjectSyncProtocolMessagesContinuation.yield(objectMessages) - // If populated, this contains a full set of sync data for the channel, and should be applied to the ObjectsPool. - let completedSyncObjectsPool: [SyncObjectsPoolEntry]? - // If populated, this contains a set of buffered inbound OBJECT messages that should be applied. - let completedSyncBufferedObjectOperations: [InboundObjectMessage]? - // The SyncSequence, if any, to store in the SYNCING state that results from this OBJECT_SYNC. - let syncSequenceForSyncingState: SyncSequence? - + let syncCursor: SyncCursor? if let protocolMessageChannelSerial { - let syncCursor: SyncCursor do { // RTO5a syncCursor = try SyncCursor(channelSerial: protocolMessageChannelSerial) @@ -556,47 +549,47 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo 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 - var updatedSyncSequence: SyncSequence = if case let .syncing(syncingData) = state, let syncSequence = syncingData.syncSequence { - if syncCursor.sequenceID == syncSequence.id { - // RTO5a3: Continue existing sync sequence - syncSequence - } else { - // RTO5a2a, RTO5a2b: new sequence started, discard previous - .init(id: syncCursor.sequenceID, syncObjectsPool: [], bufferedObjectOperations: []) - } + let isNewSyncSequence = syncCursor == nil || syncingData.syncSequence?.id != syncCursor?.sequenceID + if isNewSyncSequence { + // RTO5a2a, RTO5a2b: new sequence started, discard previous. Else we continue the existing sequence per RTO5a3 + syncingData.syncSequence = nil + } + } + + let syncObjectsPoolEntries = objectMessages.compactMap { objectMessage in + if let object = objectMessage.object { + SyncObjectsPoolEntry(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) } else { - // There's no current sync sequence; start one - .init(id: syncCursor.sequenceID, syncObjectsPool: [], bufferedObjectOperations: []) + nil } + } - // RTO5b - updatedSyncSequence.syncObjectsPool.append(contentsOf: objectMessages.compactMap { objectMessage in - if let object = objectMessage.object { - .init(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) - } else { - nil - } - }) + // If populated, this contains a full set of sync data for the channel, and should be applied to the ObjectsPool. + let completedSyncObjectsPool: [SyncObjectsPoolEntry]? + // The SyncSequence, if any, to store in the SYNCING state that results from this OBJECT_SYNC. + let syncSequenceForSyncingState: SyncSequence? - (completedSyncObjectsPool, completedSyncBufferedObjectOperations) = if syncCursor.isEndOfSequence { - (updatedSyncSequence.syncObjectsPool, updatedSyncSequence.bufferedObjectOperations) + if let syncCursor { + let syncSequenceToContinue: SyncSequence? = if case let .syncing(syncingData) = state { + syncingData.syncSequence } else { - (nil, nil) + nil } - + var updatedSyncSequence = syncSequenceToContinue ?? .init(id: syncCursor.sequenceID, syncObjectsPool: [], bufferedObjectOperations: []) + // RTO5b + updatedSyncSequence.syncObjectsPool.append(contentsOf: syncObjectsPoolEntries) syncSequenceForSyncingState = updatedSyncSequence + + completedSyncObjectsPool = syncCursor.isEndOfSequence ? updatedSyncSequence.syncObjectsPool : nil } else { // RTO5a5: The sync data is contained entirely within this single OBJECT_SYNC - completedSyncObjectsPool = objectMessages.compactMap { objectMessage in - if let object = objectMessage.object { - .init(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) - } else { - nil - } - } - completedSyncBufferedObjectOperations = nil + completedSyncObjectsPool = syncObjectsPoolEntries syncSequenceForSyncingState = nil } @@ -618,9 +611,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo ) // RTO5c6 - if let completedSyncBufferedObjectOperations, !completedSyncBufferedObjectOperations.isEmpty { - logger.log("Applying \(completedSyncBufferedObjectOperations.count) buffered OBJECT ObjectMessages", level: .debug) - for objectMessage in completedSyncBufferedObjectOperations { + guard case let .syncing(syncingData) = state else { + // We put ourselves into SYNCING above + preconditionFailure() + } + if let bufferedObjectOperations = syncingData.syncSequence?.bufferedObjectOperations, !bufferedObjectOperations.isEmpty { + logger.log("Applying \(bufferedObjectOperations.count) buffered OBJECT ObjectMessages", level: .debug) + for objectMessage in bufferedObjectOperations { nosync_applyObjectProtocolMessageObjectMessage( objectMessage, logger: logger, From 6a3493857fa11b2e495f91257319cc7c2e175453 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 15 Jan 2026 11:13:34 +0000 Subject: [PATCH 185/225] Buffer when not SYNCED per updated RTO8a Implement the behaviour described in the RTO8a modification made in spec commit 1956e2f. That is, buffer messages received from the point at which we become ATTACHED, not just whilst we're receiving a multi-OBJECT_SYNC sync sequence. Note that currently, these messages will incorrectly get dropped upon receipt of the sync sequence; this is a bug in all implementations and will be addressed in [1]. [1] https://ably.atlassian.net/browse/AIT-287 --- .../InternalDefaultRealtimeObjects.swift | 26 ++++++++++--------- 1 file changed, 14 insertions(+), 12 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 4a0367244..373a9eae2 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -85,9 +85,6 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo /// The `ObjectMessage`s gathered during this sync sequence. internal var syncObjectsPool: [SyncObjectsPoolEntry] - - /// `OBJECT` ProtocolMessages that were received during this sync sequence, to be applied once the sync sequence is complete, per RTO7a. - internal var bufferedObjectOperations: [InboundObjectMessage] } internal init( @@ -459,12 +456,16 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo /// 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(syncSequence: SyncSequence?) { + init(bufferedObjectOperations: [InboundObjectMessage], syncSequence: SyncSequence?) { + self.bufferedObjectOperations = bufferedObjectOperations self.syncSequence = syncSequence } } @@ -509,7 +510,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo // We will subsequently transition to .synced either by the completion of the RTO4a OBJECT_SYNC, or by the RTO4b no-HAS_OBJECTS case below if state.toObjectsSyncState != .syncing { // RTO4c - transition(to: .syncing(.init(syncSequence: nil)), userCallbackQueue: userCallbackQueue) + 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) @@ -559,6 +560,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo if isNewSyncSequence { // RTO5a2a, RTO5a2b: new sequence started, discard previous. Else we continue the existing sequence per RTO5a3 syncingData.syncSequence = nil + syncingData.bufferedObjectOperations = [] } } @@ -581,7 +583,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } else { nil } - var updatedSyncSequence = syncSequenceToContinue ?? .init(id: syncCursor.sequenceID, syncObjectsPool: [], bufferedObjectOperations: []) + var updatedSyncSequence = syncSequenceToContinue ?? .init(id: syncCursor.sequenceID, syncObjectsPool: []) // RTO5b updatedSyncSequence.syncObjectsPool.append(contentsOf: syncObjectsPoolEntries) syncSequenceForSyncingState = updatedSyncSequence @@ -597,7 +599,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo syncingData.syncSequence = syncSequenceForSyncingState } else { // RTO5e - transition(to: .syncing(.init(syncSequence: syncSequenceForSyncingState)), userCallbackQueue: userCallbackQueue) + transition(to: .syncing(.init(bufferedObjectOperations: [], syncSequence: syncSequenceForSyncingState)), userCallbackQueue: userCallbackQueue) } if let completedSyncObjectsPool { @@ -615,7 +617,8 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo // We put ourselves into SYNCING above preconditionFailure() } - if let bufferedObjectOperations = syncingData.syncSequence?.bufferedObjectOperations, !bufferedObjectOperations.isEmpty { + let bufferedObjectOperations = syncingData.bufferedObjectOperations + if !bufferedObjectOperations.isEmpty { logger.log("Applying \(bufferedObjectOperations.count) buffered OBJECT ObjectMessages", level: .debug) for objectMessage in bufferedObjectOperations { nosync_applyObjectProtocolMessageObjectMessage( @@ -646,12 +649,11 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo logger.log("handleObjectProtocolMessage(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) - if case let .syncing(syncingData) = state, let existingSyncSequence = syncingData.syncSequence { + 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) - var newSyncSequence = existingSyncSequence - newSyncSequence.bufferedObjectOperations.append(contentsOf: objectMessages) - syncingData.syncSequence = newSyncSequence + syncingData.bufferedObjectOperations.append(contentsOf: objectMessages) } else { // RTO8b: Handle the OBJECT message immediately for objectMessage in objectMessages { From 1185b08f5496dfa3ae02326a1cb9d24506cf78a9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 16 Jan 2026 11:08:47 +0000 Subject: [PATCH 186/225] Fix events emitted upon sync This implements the spec changes from [1] at 3f86319. That is, the update events emitted by a sync are now calculated from the before/after diff of the object state, as opposed to just being calculated from the createOp. Written by Claude based on the spec. [1] https://github.com/ably/specification/pull/414 --- .../Internal/InternalDefaultLiveCounter.swift | 14 +- .../Internal/InternalDefaultLiveMap.swift | 14 +- .../Internal/ObjectDiffHelpers.swift | 57 ++++++ .../InternalDefaultLiveCounterTests.swift | 58 ++++++ .../InternalDefaultLiveMapTests.swift | 101 ++++++++++ .../ObjectDiffHelpersTests.swift | 181 ++++++++++++++++++ .../ObjectsPoolTests.swift | 16 +- 7 files changed, 427 insertions(+), 14 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift create mode 100644 Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 15f12a10f..d87130638 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -331,6 +331,9 @@ internal final class InternalDefaultLiveCounter: Sendable { 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 @@ -338,12 +341,13 @@ internal final class InternalDefaultLiveCounter: Sendable { data = state.counter?.count?.doubleValue ?? 0 // RTLC6d: If ObjectState.createOp is present, merge the initial value into the LiveCounter as described in RTLC10 - return if let createOp = state.createOp { - mergeInitialValue(from: createOp) - } else { - // TODO: I assume this is what to do, clarify in https://github.com/ably/specification/pull/346/files#r2201363446 - .noop + // 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 RTLC10. diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index d1ec8922e..788eaff58 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -468,6 +468,9 @@ internal final class InternalDefaultLiveMap: Sendable { return .update(.init(update: dataBeforeTombstoning.mapValues { _ in .removed })) } + // RTLM6g: Store the current data value as previousData for use in RTLM6h + let previousData = data + // RTLM6b: Set the private flag createOperationIsMerged to false liveObjectMutableState.createOperationIsMerged = false @@ -493,8 +496,9 @@ internal final class InternalDefaultLiveMap: Sendable { } ?? [:] // RTLM6d: If ObjectState.createOp is present, merge the initial value into the LiveMap as described in RTLM17 - return if let createOp = state.createOp { - mergeInitialValue( + // Discard the LiveMapUpdate object returned by the merge operation + if let createOp = state.createOp { + _ = mergeInitialValue( from: createOp, objectsPool: &objectsPool, logger: logger, @@ -502,10 +506,10 @@ internal final class InternalDefaultLiveMap: Sendable { userCallbackQueue: userCallbackQueue, clock: clock, ) - } else { - // TODO: I assume this is what to do, clarify in https://github.com/ably/specification/pull/346/files#r2201363446 - .noop } + + // 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 RTLM17. diff --git a/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift b/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift new file mode 100644 index 000000000..dbd930918 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift @@ -0,0 +1,57 @@ +import Foundation + +/// Helper methods for calculating diffs between LiveObject data values. +internal enum ObjectDiffHelpers { + /// Calculates the diff between two LiveCounter data values, per RTLC14. + /// + /// - Parameters: + /// - previousData: The previous `data` value (RTLC14a1). + /// - newData: The new `data` value (RTLC14a2). + /// - Returns: Per RTLC14b. + internal static func calculateCounterDiff( + previousData: Double, + newData: Double, + ) -> LiveObjectUpdate { + // RTLC14b + .update(DefaultLiveCounterUpdate(amount: newData - previousData)) + } + + /// Calculates the diff between two LiveMap data values, per RTLM22. + /// + /// - Parameters: + /// - previousData: The previous `data` value (RTLM22a1). + /// - newData: The new `data` value (RTLM22a2). + /// - Returns: Per RTLM22b. + internal static func calculateMapDiff( + previousData: [String: InternalObjectsMapEntry], + newData: [String: InternalObjectsMapEntry], + ) -> LiveObjectUpdate { + // RTLM22b + let previousNonTombstonedKeys = Set(previousData.filter { !$0.value.tombstone }.keys) + let newNonTombstonedKeys = Set(newData.filter { !$0.value.tombstone }.keys) + + var update: [String: LiveMapUpdateAction] = [:] + + // RTLM22b1 + for key in previousNonTombstonedKeys.subtracting(newNonTombstonedKeys) { + update[key] = .removed + } + + // RTLM22b2 + for key in newNonTombstonedKeys.subtracting(previousNonTombstonedKeys) { + update[key] = .updated + } + + // RTLM22b3 + for key in previousNonTombstonedKeys.intersection(newNonTombstonedKeys) { + let previousEntry = previousData[key]! + let newEntry = newData[key]! + + if previousEntry.data != newEntry.data { + update[key] = .updated + } + } + + return .update(DefaultLiveMapUpdate(update: update)) + } +} diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index 520ad03b2..f749be692 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -149,6 +149,64 @@ struct InternalDefaultLiveCounterTests { #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 RTLC10 specification points diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 3fd05e224..72ee524d2 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -293,6 +293,107 @@ struct InternalDefaultLiveMapTests { #expect(try map.get(key: "keyFromCreateOp", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromCreateOp") #expect(map.testsOnly_createOperationIsMerged) } + + /// 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 diff --git a/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift b/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift new file mode 100644 index 000000000..640d4d831 --- /dev/null +++ b/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/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 866a6cff3..3b0757331 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -119,8 +119,11 @@ struct ObjectsPoolTests { #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() - #expect(subscriberInvocations.map(\.0) == [.init(update: ["createOpKey": .updated])]) + 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 @@ -157,8 +160,9 @@ struct ObjectsPoolTests { #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: 5)]) // From createOp + #expect(subscriberInvocations.map(\.0) == [.init(amount: 15)]) // Diff from 0 to 15 } // @spec RTO5c1b1a @@ -367,16 +371,20 @@ struct ObjectsPoolTests { #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() - #expect(existingMapSubscriberInvocations.map(\.0) == [.init(update: ["createOpKey": .updated])]) + 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: 5)]) + #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) From 58f9d8ff5700d02f34e7118b2c03574deb6b4801 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 24 Feb 2026 14:22:20 -0300 Subject: [PATCH 187/225] Tag integration tests with .integration The UnitTests test plan (added in b38ce55) filters out tests tagged with .integration, but no tests actually had this tag, so the filter had no effect. Define the tag and apply it to AblyLiveObjectsTests, ObjectsIntegrationTests, and ObjectLifetimesTests so that the UnitTests scheme correctly excludes integration tests. Co-Authored-By: Claude Opus 4.6 --- Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift | 1 + Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift | 6 ++++++ .../JS Integration Tests/ObjectsIntegrationTests.swift | 1 + Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift | 1 + 4 files changed, 9 insertions(+) create mode 100644 Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index bb5a419e4..12e4ca39e 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -3,6 +3,7 @@ import Ably @testable import AblyLiveObjects import Testing +@Suite(.tags(.integration)) struct AblyLiveObjectsTests { @Test func objectsProperty() async throws { diff --git a/Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift b/Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift new file mode 100644 index 000000000..11a3a4cd4 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift @@ -0,0 +1,6 @@ +import Testing + +extension Tag { + /// Tests that integrate with ably-cocoa. Usually long-running. + @Tag static var integration: Self +} diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index fcab1b6e2..6f6b01e08 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -338,6 +338,7 @@ class MainActorStorage { // 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 diff --git a/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift index 9c83e4d8b..d4a4cbba1 100644 --- a/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift @@ -2,6 +2,7 @@ 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 { From 7a0fcef70e3315ba201c075f3ba42e3c035a62b4 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 24 Feb 2026 14:53:23 -0300 Subject: [PATCH 188/225] Add --only-unit-tests flag to BuildTool test-library Since `swift test` doesn't support tag-based filtering, add a flag to the test-library command that selects the UnitTests test plan. This allows running only unit tests from the command line via: swift run BuildTool test-library --platform macOS --only-unit-tests Co-Authored-By: Claude Opus 4.6 --- CONTRIBUTING.md | 10 ++++++++-- Sources/BuildTool/BuildTool.swift | 4 +++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8295b9964..b99ee9f7b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,9 +21,15 @@ Either: ### Running only the unit tests -There is a test plan called `UnitTests` which will run only the unit tests. These tests are very quick to execute, so it's a useful option to have for quick feedback when developing. +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. -Here's how to set this test plan as the _active test plan_ (the test plan which ⌘U will run): +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) diff --git a/Sources/BuildTool/BuildTool.swift b/Sources/BuildTool/BuildTool.swift index 80aa8433d..e70e11397 100644 --- a/Sources/BuildTool/BuildTool.swift +++ b/Sources/BuildTool/BuildTool.swift @@ -63,12 +63,14 @@ struct TestLibrary: AsyncParsableCommand { ) @Option var platform: Platform + @Flag(help: "Only run unit tests (excludes integration tests).") + var onlyUnitTests = false mutating func run() async throws { let destinationSpecifier = try await platform.resolve() let scheme = "AblyLiveObjects" - try await XcodeRunner.runXcodebuild(action: "test-without-building", scheme: scheme, destination: destinationSpecifier) + try await XcodeRunner.runXcodebuild(action: "test-without-building", scheme: scheme, destination: destinationSpecifier, testPlan: onlyUnitTests ? "UnitTests" : nil) } } From 1de0384a2cdd162351f942ddf2c69ba13d6c5eec Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 24 Feb 2026 14:56:01 -0300 Subject: [PATCH 189/225] Add CLAUDE.md Direct Claude to use the --only-unit-tests flag (added in 7a0fcef) as its first way of verifying changes, rather than running the full test suite. Co-Authored-By: Claude Opus 4.6 --- CLAUDE.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 CLAUDE.md diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 000000000..f530e24b3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,25 @@ +# 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. From d0eb12cecc5cf8c13bf948be4b3abd259856d624 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 25 Feb 2026 10:11:40 -0300 Subject: [PATCH 190/225] Make test-library build by default Previously test-library always used xcodebuild test-without-building, requiring a separate build-library-for-testing step first. This meant that running test-library standalone (e.g. during local development) would either fail or run stale tests. Now test-library uses xcodebuild test (build + test) by default. A new --without-building flag preserves the old behaviour for CI, where the build and test steps are split for timing visibility. Co-Authored-By: Claude Opus 4.6 --- .github/workflows/check.yaml | 2 +- Sources/BuildTool/BuildTool.swift | 9 ++++++--- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check.yaml b/.github/workflows/check.yaml index 7d8be5714..79105d2b5 100644 --- a/.github/workflows/check.yaml +++ b/.github/workflows/check.yaml @@ -138,7 +138,7 @@ jobs: run: swift run BuildTool build-library-for-testing --platform ${{ matrix.platform }} - name: Run tests - run: swift run BuildTool test-library --platform ${{ matrix.platform }} + run: swift run BuildTool test-library --platform ${{ matrix.platform }} --without-building code-coverage: name: Generate code coverage diff --git a/Sources/BuildTool/BuildTool.swift b/Sources/BuildTool/BuildTool.swift index e70e11397..8e9fbb858 100644 --- a/Sources/BuildTool/BuildTool.swift +++ b/Sources/BuildTool/BuildTool.swift @@ -42,7 +42,7 @@ struct BuildLibrary: AsyncParsableCommand { struct BuildLibraryForTesting: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Build the AblyLiveObjects library for testing", - discussion: "After running this command, you can run the test-library command.", + discussion: "This is for use with test-library --without-building.", ) @Option var platform: Platform @@ -59,18 +59,21 @@ struct BuildLibraryForTesting: AsyncParsableCommand { struct TestLibrary: AsyncParsableCommand { static let configuration = CommandConfiguration( abstract: "Test the AblyLiveObjects library", - discussion: "You need to run the build-library-for-testing command before running this command.", + 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" - try await XcodeRunner.runXcodebuild(action: "test-without-building", scheme: scheme, destination: destinationSpecifier, testPlan: onlyUnitTests ? "UnitTests" : nil) + let action = withoutBuilding ? "test-without-building" : "test" + try await XcodeRunner.runXcodebuild(action: action, scheme: scheme, destination: destinationSpecifier, testPlan: onlyUnitTests ? "UnitTests" : nil) } } From 1d9864d1ed9cb661b6dc8ebf4894a41326bde2e5 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 27 Feb 2026 15:10:08 -0300 Subject: [PATCH 191/225] Fix incorrect comment on `overriddenPublishImplementation` The doc comment introduced in aa552c4 said "If set to true" but the property is an optional closure, not a boolean. Co-Authored-By: Claude Opus 4.6 --- Sources/AblyLiveObjects/Internal/CoreSDK.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index 07493cf05..c7a08ab04 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -29,7 +29,7 @@ internal final class DefaultCoreSDK: CoreSDK { private let pluginAPI: PluginAPIProtocol private let logger: Logger - /// If set to true, ``publish(objectMessages:)`` will behave like a no-op. + /// If set, ``publish(objectMessages:)`` delegates to this implementation. /// /// This enables the `testsOnly_overridePublish(with:)` test hook. /// From c45cbe852137558f008ddf37a6fc7d4cdd6829d2 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 2 Mar 2026 09:55:31 -0300 Subject: [PATCH 192/225] Synchronize access to MockCoreSDK._publishHandler Missed this in dcdb350. --- Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 688327f6a..94853df11 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -16,7 +16,13 @@ final class MockCoreSDK: CoreSDK { } func publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) { - if let handler = _publishHandler { + // 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 handler: (([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void)? + mutex.withLock { + handler = _publishHandler + } + + if let handler { try await handler(objectMessages) } else { protocolRequirementNotImplemented() From 35850d94642def40273e979c184bdf633b45e02e Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 27 Feb 2026 15:11:17 -0300 Subject: [PATCH 193/225] Switch CoreSDK to use callback-based methods Replace CoreSDK's `publish` and `fetchServerTime` methods to use callbacks and expect to be called on the internal queue. The usage of `async` methods was not a good mix with the rest of this plugin's heavy usage of the internal queue as its synchronisation mechanism, and led to a lot of switching between normal code and `async` code which was hard to read. Each caller now enters the internal queue once via withCheckedContinuation + dispatchQueue.async and stays there through the whole operation chain, eliminating repeated queue hops. I've left the overridable publish implementations (used by the tests) as `async` functions, because Claude ran into all sorts of concurrency checking issues when trying to port the JS integration tests to use callbacks for this, and I didn't really feel like trying to fix it (nor do these implementations need access to the plugin's internal state at the moment so there's no compelling reason for them to do their work on the internal queue). Co-Authored-By: Claude Opus 4.6 --- .../AblyLiveObjects/Internal/CoreSDK.swift | 60 +++---- .../Internal/DefaultInternalPlugin.swift | 35 ++-- .../Internal/InternalDefaultLiveCounter.swift | 62 ++++--- .../Internal/InternalDefaultLiveMap.swift | 98 ++++++----- .../InternalDefaultRealtimeObjects.swift | 166 +++++++++++------- .../Mocks/MockCoreSDK.swift | 16 +- 6 files changed, 255 insertions(+), 182 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index c7a08ab04..e8de327e2 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -6,12 +6,12 @@ import Ably /// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to `_AblyPluginSupportPrivate`'s Objective-C API (i.e. boxing). internal protocol CoreSDK: AnyObject, Sendable { /// Implements the internal `#publish` method of RTO15. - func publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) + 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 fetchServerTime() async throws(ARTErrorInfo) -> Date + func nosync_fetchServerTime(callback: @escaping @Sendable (Result) -> Void) - /// Replaces the implementation of ``publish(objectMessages:)``. + /// 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) -> Void) @@ -50,31 +50,36 @@ internal final class DefaultCoreSDK: CoreSDK { // MARK: - CoreSDK conformance - internal func publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) { - logger.log("publish(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) + 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 { - do { - try await overriddenImplementation(objectMessages) - } catch { - guard let artErrorInfo = error as? ARTErrorInfo else { - preconditionFailure("Expected ARTErrorInfo, got \(error)") + let queue = pluginAPI.internalQueue(for: client) + Task { + do { + try await overriddenImplementation(objectMessages) + queue.async { callback(.success(())) } + } catch { + guard let artErrorInfo = error as? ARTErrorInfo else { + preconditionFailure("Expected ARTErrorInfo, got \(error)") + } + queue.async { callback(.failure(artErrorInfo)) } } - throw artErrorInfo } return } // TODO: Implement message size checking (https://github.com/ably/ably-liveobjects-swift-plugin/issues/13) - try await DefaultInternalPlugin.sendObject( + DefaultInternalPlugin.nosync_sendObject( objectMessages: objectMessages, channel: channel, client: client, pluginAPI: pluginAPI, + callback: callback, ) } @@ -84,26 +89,21 @@ internal final class DefaultCoreSDK: CoreSDK { } } - internal func fetchServerTime() async throws(ARTErrorInfo) -> Date { - try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in - let internalQueue = pluginAPI.internalQueue(for: client) - - internalQueue.async { [client, pluginAPI] in - pluginAPI.nosync_fetchServerTime(for: client) { serverTime, error in - // We don't currently rely on this documented behaviour of `noSync_fetchServerTime` but we may do later, so assert it to be sure it's happening. - dispatchPrecondition(condition: .onQueue(internalQueue)) - - if let error { - continuation.resume(returning: .failure(ARTErrorInfo.castPluginPublicErrorInfo(error))) - } else { - guard let serverTime else { - preconditionFailure("nosync_fetchServerTime gave nil serverTime and nil error") - } - continuation.resume(returning: .success(serverTime)) - } + 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)) } - }.get() + } } internal var nosync_channelState: _AblyPluginSupportPrivate.RealtimeChannelState { diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index e9f801db3..7bd19ea1c 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -152,32 +152,27 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. // MARK: - Sending `OBJECT` ProtocolMessage - internal static func sendObject( + internal static func nosync_sendObject( objectMessages: [OutboundObjectMessage], channel: _AblyPluginSupportPrivate.RealtimeChannel, client: _AblyPluginSupportPrivate.RealtimeClient, pluginAPI: PluginAPIProtocol, - ) async throws(ARTErrorInfo) { + 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, + ) { error in + dispatchPrecondition(condition: .onQueue(internalQueue)) - try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in - let internalQueue = pluginAPI.internalQueue(for: client) - - internalQueue.async { - pluginAPI.nosync_sendObject( - withObjectMessages: objectMessageBoxes, - channel: channel, - ) { error in - // We don't currently rely on this documented behaviour of `nosync_sendObject` but we may do later, so assert it to be sure it's happening. - dispatchPrecondition(condition: .onQueue(internalQueue)) - - if let error { - continuation.resume(returning: .failure(ARTErrorInfo.castPluginPublicErrorInfo(error))) - } else { - continuation.resume(returning: .success(())) - } - } + if let error { + callback(.failure(ARTErrorInfo.castPluginPublicErrorInfo(error))) + } else { + callback(.success(())) } - }.get() + } } } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index d87130638..b45fdd95f 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -104,34 +104,42 @@ internal final class InternalDefaultLiveCounter: Sendable { } internal func increment(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { - let objectMessage = try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - // RTLC12c - try coreSDK.nosync_validateChannelState( - notIn: [.detached, .failed, .suspended], - operationDescription: "LiveCounter.increment", - ) - - // RTLC12e1 - if !amount.isFinite { - throw LiveObjectsError.counterIncrementAmountInvalid(amount: amount).toARTErrorInfo() + 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, + counterOp: .init( + // RTLC12e4 + amount: .init(value: amount), + ), + ), + ) + + // RTLC12f + coreSDK.nosync_publish(objectMessages: [objectMessage]) { result in + continuation.resume(returning: result) + } + } + } catch { + continuation.resume(returning: .failure(error)) } - - return OutboundObjectMessage( - operation: .init( - // RTLC12e2 - action: .known(.counterInc), - // RTLC12e3 - objectId: mutableState.liveObjectMutableState.objectID, - counterOp: .init( - // RTLC12e4 - amount: .init(value: amount), - ), - ), - ) - } - - // RTLC12f - try await coreSDK.publish(objectMessages: [objectMessage]) + }.get() } internal func decrement(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 788eaff58..3d6da5eab 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -160,50 +160,66 @@ internal final class InternalDefaultLiveMap: Sendable { } internal func set(key: String, value: InternalLiveMapValue, coreSDK: CoreSDK) async throws(ARTErrorInfo) { - let objectMessage = try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - // RTLM20c - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.set") - - return OutboundObjectMessage( - operation: .init( - // RTLM20e2 - action: .known(.mapSet), - // RTLM20e3 - objectId: mutableState.liveObjectMutableState.objectID, - mapOp: .init( - // RTLM20e4 - key: key, - // RTLM20e5 - data: value.nosync_toObjectData, - ), - ), - ) - } - - try await coreSDK.publish(objectMessages: [objectMessage]) + 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, + mapOp: .init( + // RTLM20e4 + key: key, + // RTLM20e5 + data: value.nosync_toObjectData, + ), + ), + ) + + coreSDK.nosync_publish(objectMessages: [objectMessage]) { result in + continuation.resume(returning: result) + } + } + } catch { + continuation.resume(returning: .failure(error)) + } + }.get() } internal func remove(key: String, coreSDK: CoreSDK) async throws(ARTErrorInfo) { - let objectMessage = try mutableStateMutex.withSync { mutableState throws(ARTErrorInfo) in - // RTLM21c - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "LiveMap.remove") - - return OutboundObjectMessage( - operation: .init( - // RTLM21e2 - action: .known(.mapRemove), - // RTLM21e3 - objectId: mutableState.liveObjectMutableState.objectID, - mapOp: .init( - // RTLM21e4 - key: key, - ), - ), - ) - } - - // RTLM21f - try await coreSDK.publish(objectMessages: [objectMessage]) + 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, + mapOp: .init( + // RTLM21e4 + key: key, + ), + ), + ) + + // RTLM21f + coreSDK.nosync_publish(objectMessages: [objectMessage]) { result in + continuation.resume(returning: result) + } + } + } catch { + continuation.resume(returning: .failure(error)) + } + }.get() } @discardableResult diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 373a9eae2..f3e8a0018 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -176,35 +176,54 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } internal func createMap(entries: [String: InternalLiveMapValue], coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { - try mutableStateMutex.withSync { _ throws(ARTErrorInfo) in - // RTO11d - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createMap") - } - - // RTO11f7 - let timestamp = try await coreSDK.fetchServerTime() - - let creationOperation = mutableStateMutex.withSync { _ in - // RTO11f - ObjectCreationHelpers.nosync_creationOperationForLiveMap( - entries: entries, - timestamp: timestamp, - ) - } - - // RTO11g - try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) + 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, + ) - // RTO11h - return mutableStateMutex.withSync { mutableState in - mutableState.objectsPool.nosync_getOrCreateMap( - creationOperation: creationOperation, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } + // RTO11g + coreSDK.nosync_publish(objectMessages: [creationOperation.objectMessage]) { [self] result in + switch result { + case let .failure(error): + continuation.resume(returning: .failure(error)) + case .success: + // RTO11h + let map = mutableStateMutex.withoutSync { mutableState in + mutableState.objectsPool.nosync_getOrCreateMap( + creationOperation: creationOperation, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + continuation.resume(returning: .success(map)) + } + } + } + } + } catch { + continuation.resume(returning: .failure(error)) + } + }.get() } internal func createMap(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { @@ -213,39 +232,60 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } internal func createCounter(count: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { - // RTO12d - try mutableStateMutex.withSync { _ throws(ARTErrorInfo) in - try coreSDK.nosync_validateChannelState(notIn: [.detached, .failed, .suspended], operationDescription: "RealtimeObjects.createCounter") - } - - // RTO12f1 - if !count.isFinite { - throw LiveObjectsError.counterInitialValueInvalid(value: count).toARTErrorInfo() - } - - // RTO12f - - // RTO12f5 - let timestamp = try await coreSDK.fetchServerTime() - - let creationOperation = ObjectCreationHelpers.creationOperationForLiveCounter( - count: count, - timestamp: timestamp, - ) + 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() + } - // RTO12g - try await coreSDK.publish(objectMessages: [creationOperation.objectMessage]) + // 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, + ) - // RTO12h - return mutableStateMutex.withSync { mutableState in - mutableState.objectsPool.nosync_getOrCreateCounter( - creationOperation: creationOperation, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - } + // RTO12g + coreSDK.nosync_publish(objectMessages: [creationOperation.objectMessage]) { [self] result in + switch result { + case let .failure(error): + continuation.resume(returning: .failure(error)) + case .success: + // RTO12h + let counter = mutableStateMutex.withoutSync { mutableState in + mutableState.objectsPool.nosync_getOrCreateCounter( + creationOperation: creationOperation, + logger: logger, + internalQueue: mutableStateMutex.dispatchQueue, + userCallbackQueue: userCallbackQueue, + clock: clock, + ) + } + continuation.resume(returning: .success(counter)) + } + } + } + } + } catch { + continuation.resume(returning: .failure(error)) + } + }.get() } internal func createCounter(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { @@ -368,7 +408,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo // 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 coreSDK.publish(objectMessages: objectMessages) + try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in + mutableStateMutex.withSync { _ in + coreSDK.nosync_publish(objectMessages: objectMessages) { result in + continuation.resume(returning: result) + } + } + }.get() } // MARK: - Garbage collection of deleted objects and map entries diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 94853df11..9a9b143c5 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -15,7 +15,7 @@ final class MockCoreSDK: CoreSDK { self.serverTime = serverTime } - func publish(objectMessages: [OutboundObjectMessage]) async throws(ARTErrorInfo) { + 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 handler: (([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void)? mutex.withLock { @@ -23,7 +23,15 @@ final class MockCoreSDK: CoreSDK { } if let handler { - try await handler(objectMessages) + let queue = channelStateMutex.dispatchQueue + Task { + do throws(ARTErrorInfo) { + try await handler(objectMessages) + queue.async { callback(.success(())) } + } catch { + queue.async { callback(.failure(error)) } + } + } } else { protocolRequirementNotImplemented() } @@ -44,7 +52,7 @@ final class MockCoreSDK: CoreSDK { } } - func fetchServerTime() async throws(ARTErrorInfo) -> Date { - serverTime + func nosync_fetchServerTime(callback: @escaping @Sendable (Result) -> Void) { + callback(.success(serverTime)) } } From 8bfbbaad56aa413aa53fc399e6a4ad284177ee84 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 20 Feb 2026 15:17:31 -0300 Subject: [PATCH 194/225] Add supporting API needed for apply-on-ACK Add methods for plugin to: - get PublishResult when performing a publish - learn about channel state changes - learn about the siteCode of the server it's connected to Co-Authored-By: Claude Opus 4.6 --- README.md | 57 +++++++++++++++++++ .../include/APConnectionDetails.h | 7 +++ .../include/APLiveObjectsPlugin.h | 14 +++++ .../include/APPluginAPI.h | 10 ++++ .../include/APPublishResult.h | 25 ++++++++ 5 files changed, 113 insertions(+) create mode 100644 Sources/_AblyPluginSupportPrivate/include/APPublishResult.h diff --git a/README.md b/README.md index 5fe437efd..15cea81e3 100644 --- a/README.md +++ b/README.md @@ -10,3 +10,60 @@ 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. + +## 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. diff --git a/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h b/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h index 3fdc91c01..b5d6e8e7f 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h +++ b/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h @@ -10,6 +10,13 @@ NS_SWIFT_SENDABLE /// Wraps an `NSTimeInterval` containing the `objectsGCGracePeriod`, if any, in seconds. @property (nonatomic, readonly, nullable) NSNumber *objectsGCGracePeriod; +@optional + +/// The site code of the server that the client is connected to (CD2j). +/// +/// May be absent if the server does not provide it. +- (nullable NSString *)siteCode; + @end NS_ASSUME_NONNULL_END diff --git a/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h index 234464ea9..ee4d64f95 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h +++ b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h @@ -1,5 +1,6 @@ #import #import "APEncodingFormat.h" +#import "APRealtimeChannelState.h" @protocol APLiveObjectsInternalPluginProtocol; @protocol APObjectMessageProtocol; @@ -91,6 +92,19 @@ NS_SWIFT_SENDABLE - (void)nosync_onConnectedWithConnectionDetails:(nullable id)connectionDetails channel:(id)channel; +@optional + +/// 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`. diff --git a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h index 499904ccd..23c558b49 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h +++ b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h @@ -11,6 +11,7 @@ NS_ASSUME_NONNULL_BEGIN @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) @@ -80,6 +81,15 @@ NS_SWIFT_SENDABLE channel:(id)channel completion:(void (^ _Nullable)(_Nullable id error))completion; +@optional + +/// Same as `-nosync_sendObjectWithObjectMessages:channel:completion:`, but the completion handler additionally receives an `APPublishResultProtocol` containing the serials assigned to the published messages by the server. +- (void)nosync_sendObjectWithObjectMessages:(NSArray> *)objectMessages + channel:(id)channel + completionWithResult:(void (^ _Nullable)(_Nullable id publishResult, _Nullable id error))completion; + +@required + /// Returns a realtime channel's current state. - (APRealtimeChannelState)nosync_stateForChannel:(id)channel; diff --git a/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h b/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h new file mode 100644 index 000000000..9050f5d62 --- /dev/null +++ b/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 From f2468ee39f3e18955b0ec4fe41dc5a9906a72633 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 24 Feb 2026 11:11:08 -0300 Subject: [PATCH 195/225] Introduce process for increasing protocol version MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We need to start using protocol version 6 as it introduces new LiveObjects functionality. The main problem to solve is: how do we make sure that ably-cocoa doesn't force a protocol version on a plugin that can't handle that version? Here we propose a simple solution. It's a bit clunky, but to be honest our whole plugin mechanism is a bit clunky so I think we can live with this additional clunkiness. One other problem of the solution given here — that is, doing major plugin-api releases — is that if, in the future, we have further plugins, then we'll have to do new releases of _all_ the plugins just because we needed to bump plugin-support for one of them. I hope that by the time we need to do further plugins we'll have moved to ably-swift (with plugins in-repo) and we won't have to worry about this! Resolves ably/ably-liveobjects-swift-plugin#107. Co-Authored-By: Claude Opus 4.6 --- README.md | 69 ++++++++++++++++++- .../include/APLiveObjectsPlugin.h | 12 ++++ .../include/APPluginAPI.h | 12 ++++ 3 files changed, 92 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 15cea81e3..91559f098 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ 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. +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 @@ -67,3 +67,70 @@ Since the plugin can exclude older versions of ably-cocoa, the plugin is able to #### 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/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h index ee4d64f95..d57f6b959 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h +++ b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h @@ -31,6 +31,18 @@ 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. diff --git a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h index 23c558b49..c413100e4 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h +++ b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h @@ -18,6 +18,18 @@ 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`. From 1dc20e7bad4726ab3ea5d5e1046c8ec7aeae3c7e Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Fri, 20 Feb 2026 15:18:02 -0300 Subject: [PATCH 196/225] Apply operations on ACK Instead of waiting for the server to echo back an operation before applying it locally, operations are now applied immediately upon receiving the ACK from Realtime. Implements the behaviours from spec commit 56a0bba and ports the corresponding integration tests from ably-js commit 6b1c2de, plus the test fix in ably-js commit f9fbe8e (from [1]). [1] https://github.com/ably/ably-js/pull/2175 Co-Authored-By: Claude Opus 4.6 --- .../xcshareddata/swiftpm/Package.resolved | 10 +- Package.resolved | 10 +- Package.swift | 8 +- .../AblyLiveObjects/Internal/CoreSDK.swift | 14 +- .../Internal/DefaultInternalPlugin.swift | 47 +- .../Internal/InternalDefaultLiveCounter.swift | 36 +- .../Internal/InternalDefaultLiveMap.swift | 45 +- .../InternalDefaultRealtimeObjects.swift | 299 +++++- .../Internal/ObjectsOperationSource.swift | 7 + .../Internal/ObjectsPool.swift | 102 +- .../Internal/PublishResult.swift | 14 + .../InboundObjectMessage+Synthetic.swift | 19 + .../Protocol/ObjectMessage.swift | 2 + .../InternalLiveMapValue+ToPublic.swift | 6 +- .../PublicDefaultLiveCounter.swift | 8 +- .../PublicDefaultLiveMap.swift | 26 +- .../PublicDefaultRealtimeObjects.swift | 10 +- .../PublicObjectsStore.swift | 6 +- Sources/AblyLiveObjects/Utility/Errors.swift | 42 +- .../InternalDefaultLiveCounterTests.swift | 86 +- .../InternalDefaultLiveMapTests.swift | 103 +- .../InternalDefaultRealtimeObjectsTests.swift | 436 +++++++- .../ObjectsIntegrationTests.swift | 949 +++++++++++++++--- .../TestProxyTransport.swift | 2 +- .../Mocks/MockCoreSDK.swift | 50 +- .../Mocks/MockRealtimeObjects.swift | 45 + 26 files changed, 1989 insertions(+), 393 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift create mode 100644 Sources/AblyLiveObjects/Internal/PublishResult.swift create mode 100644 Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift create mode 100644 Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index 4807fd430..7e8c8c351 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,12 @@ { - "originHash" : "d2622c7dbe5a5b55d2e9d411424ad63f4ff8431f36d6cdf3f0a79a62cafd7f3c", + "originHash" : "58cd31c1c01a78a95f7855b224513d79a667a7d83cbeb436a18d5cc025f3ce5d", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", - "location" : "https://github.com/ably/ably-cocoa", + "location" : "https://github.com/ably/ably-cocoa.git", "state" : { - "revision" : "aa7db56bfda49595835d7f9248dacd40c5f3faf0", - "version" : "1.2.55" + "revision" : "fe6f8cf1e680276f4475229979595512fdc2b9e5" } }, { @@ -15,8 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "dd118432a6e023c3d2c8a051299e8081a06db036", - "version" : "1.0.0" + "revision" : "8bfbbaad56aa413aa53fc399e6a4ad284177ee84" } }, { diff --git a/Package.resolved b/Package.resolved index 09520cd28..476fce2a3 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,12 @@ { - "originHash" : "786c7c01be692ceb19bda5333dd720f2afb1cc13314dabce1d4c6101c9c6700b", + "originHash" : "3e074d12cfa495911564b66e11593d652165b7c0b01f7402f1e59aea3039d221", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", - "location" : "https://github.com/ably/ably-cocoa", + "location" : "https://github.com/ably/ably-cocoa.git", "state" : { - "revision" : "aa7db56bfda49595835d7f9248dacd40c5f3faf0", - "version" : "1.2.55" + "revision" : "fe6f8cf1e680276f4475229979595512fdc2b9e5" } }, { @@ -15,8 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "dd118432a6e023c3d2c8a051299e8081a06db036", - "version" : "1.0.0" + "revision" : "8bfbbaad56aa413aa53fc399e6a4ad284177ee84" } }, { diff --git a/Package.swift b/Package.swift index 294f0e82e..f45844cc8 100644 --- a/Package.swift +++ b/Package.swift @@ -18,13 +18,15 @@ let package = Package( ), ], dependencies: [ + // TODO: Unpin before release .package( - url: "https://github.com/ably/ably-cocoa", - from: "1.2.55", + url: "https://github.com/ably/ably-cocoa.git", + revision: "fe6f8cf1e680276f4475229979595512fdc2b9e5", ), + // TODO: Unpin before release .package( url: "https://github.com/ably/ably-cocoa-plugin-support", - from: "1.0.0", + revision: "8bfbbaad56aa413aa53fc399e6a4ad284177ee84", ), .package( url: "https://github.com/apple/swift-argument-parser", diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/Sources/AblyLiveObjects/Internal/CoreSDK.swift index e8de327e2..8c4e9c4e9 100644 --- a/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -6,7 +6,7 @@ import Ably /// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to `_AblyPluginSupportPrivate`'s Objective-C API (i.e. boxing). internal protocol CoreSDK: AnyObject, Sendable { /// Implements the internal `#publish` method of RTO15. - func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) + 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) @@ -14,7 +14,7 @@ internal protocol CoreSDK: AnyObject, Sendable { /// Replaces the implementation of ``nosync_publish(objectMessages:callback:)``. /// /// Used by integration tests, for example to disable `ObjectMessage` publishing so that a test can verify that a behaviour is not a side effect of an `ObjectMessage` sent by the SDK. - func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void) + 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 } @@ -34,7 +34,7 @@ internal final class DefaultCoreSDK: CoreSDK { /// This enables the `testsOnly_overridePublish(with:)` test hook. /// /// - Note: This should be `throws(ARTErrorInfo)` but that causes a compilation error of "Runtime support for typed throws function types is only available in macOS 15.0.0 or newer". - private nonisolated(unsafe) var overriddenPublishImplementation: (([OutboundObjectMessage]) async throws -> Void)? + private nonisolated(unsafe) var overriddenPublishImplementation: (([OutboundObjectMessage]) async throws -> PublishResult)? internal init( channel: _AblyPluginSupportPrivate.RealtimeChannel, @@ -50,7 +50,7 @@ internal final class DefaultCoreSDK: CoreSDK { // MARK: - CoreSDK conformance - internal func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { + internal func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { logger.log("nosync_publish(objectMessages: \(LoggingUtilities.formatObjectMessagesForLogging(objectMessages)))", level: .debug) // Use the overridden implementation if supplied @@ -61,8 +61,8 @@ internal final class DefaultCoreSDK: CoreSDK { let queue = pluginAPI.internalQueue(for: client) Task { do { - try await overriddenImplementation(objectMessages) - queue.async { callback(.success(())) } + let publishResult = try await overriddenImplementation(objectMessages) + queue.async { callback(.success(publishResult)) } } catch { guard let artErrorInfo = error as? ARTErrorInfo else { preconditionFailure("Expected ARTErrorInfo, got \(error)") @@ -83,7 +83,7 @@ internal final class DefaultCoreSDK: CoreSDK { ) } - internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void) { + internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { mutex.withLock { overriddenPublishImplementation = newImplementation } diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 7bd19ea1c..6c69f5547 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -70,10 +70,10 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. /// 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`. - private final class ObjectMessageBox: _AblyPluginSupportPrivate.ObjectMessageProtocol where T: Sendable { + internal final class ObjectMessageBox: _AblyPluginSupportPrivate.ObjectMessageProtocol where T: Sendable { internal let objectMessage: T - init(objectMessage: T) { + internal init(objectMessage: T) { self.objectMessage = objectMessage } } @@ -143,11 +143,32 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. ) } + 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 gracePeriod = connectionDetails?.objectsGCGracePeriod?.doubleValue ?? InternalDefaultRealtimeObjects.GarbageCollectionOptions.defaultGracePeriod + let realtimeObjects = nosync_realtimeObjects(for: channel) + let gracePeriod = connectionDetails?.objectsGCGracePeriod?.doubleValue ?? InternalDefaultRealtimeObjects.GarbageCollectionOptions.defaultGracePeriod // RTO10b - nosync_realtimeObjects(for: channel).nosync_setGarbageCollectionGracePeriod(gracePeriod) + realtimeObjects.nosync_setGarbageCollectionGracePeriod(gracePeriod) + + // Push the siteCode from connectionDetails + let siteCode: String? = { + guard let connectionDetails else { + return nil + } + + // This is a fallback; our ably-cocoa dependency version should ensure that this is never triggered. + guard (connectionDetails as AnyObject).responds(to: #selector(ConnectionDetailsProtocol.siteCode)) else { + preconditionFailure("ably-cocoa's connectionDetails does not implement siteCode. Please update ably-cocoa to a version that supports apply-on-ACK.") + } + + return connectionDetails.siteCode?() + }() + realtimeObjects.nosync_setSiteCode(siteCode) } // MARK: - Sending `OBJECT` ProtocolMessage @@ -157,21 +178,31 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. channel: _AblyPluginSupportPrivate.RealtimeChannel, client: _AblyPluginSupportPrivate.RealtimeClient, pluginAPI: PluginAPIProtocol, - callback: @escaping @Sendable (Result) -> Void, + callback: @escaping @Sendable (Result) -> Void, ) { let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } let internalQueue = pluginAPI.internalQueue(for: client) - pluginAPI.nosync_sendObject( + // This is a fallback; our ably-cocoa dependency version should ensure that this is never triggered. + guard (pluginAPI as AnyObject).responds(to: #selector(PluginAPIProtocol.nosync_sendObject(withObjectMessages:channel:completionWithResult:))) else { + preconditionFailure("ably-cocoa does not implement nosync_sendObjectWithObjectMessages:channel:completionWithResult:. Please update ably-cocoa to a version that supports apply-on-ACK.") + } + + pluginAPI.nosync_sendObject!( withObjectMessages: objectMessageBoxes, channel: channel, - ) { error in + ) { pluginPublishResult, error in dispatchPrecondition(condition: .onQueue(internalQueue)) if let error { callback(.failure(ARTErrorInfo.castPluginPublicErrorInfo(error))) } else { - callback(.success(())) + guard let pluginPublishResult else { + preconditionFailure("Got nil publishResult and nil error") + } + + let publishResult = PublishResult(pluginPublishResult: pluginPublishResult) + callback(.success(publishResult)) } } } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index b45fdd95f..4817521df 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -103,7 +103,7 @@ internal final class InternalDefaultLiveCounter: Sendable { } } - internal func increment(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + 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 @@ -131,8 +131,8 @@ internal final class InternalDefaultLiveCounter: Sendable { ), ) - // RTLC12f - coreSDK.nosync_publish(objectMessages: [objectMessage]) { result in + // RTLC12g + realtimeObjects.nosync_publishAndApply(objectMessages: [objectMessage], coreSDK: coreSDK) { result in continuation.resume(returning: result) } } @@ -142,9 +142,9 @@ internal final class InternalDefaultLiveCounter: Sendable { }.get() } - internal func decrement(amount: Double, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + internal func decrement(amount: Double, coreSDK: CoreSDK, realtimeObjects: any InternalRealtimeObjectsProtocol) async throws(ARTErrorInfo) { // RTLC13b - try await increment(amount: -amount, coreSDK: coreSDK) + try await increment(amount: -amount, coreSDK: coreSDK, realtimeObjects: realtimeObjects) } @discardableResult @@ -245,16 +245,20 @@ internal final class InternalDefaultLiveCounter: Sendable { } /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. + /// + /// - Returns: `true` if the operation was applied, `false` if it was skipped (RTLC7g). 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, @@ -379,8 +383,11 @@ internal final class InternalDefaultLiveCounter: Sendable { } /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLC7. + /// + /// - Returns: `true` if the operation was applied, `false` if skipped (RTLC7g). internal mutating func apply( _ operation: ObjectOperation, + source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, objectMessageSerialTimestamp: Date?, @@ -388,20 +395,22 @@ internal final class InternalDefaultLiveCounter: Sendable { 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 + return false } // RTLC7c - liveObjectMutableState.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial + 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 + return false } switch operation.action { @@ -413,11 +422,15 @@ internal final class InternalDefaultLiveCounter: Sendable { ) // RTLC7d1a liveObjectMutableState.emit(update, on: userCallbackQueue) + // RTLC7d1b + return true case .known(.counterInc): // RTLC7d2 let update = applyCounterIncOperation(operation.counterOp) // RTLC7d2a liveObjectMutableState.emit(update, on: userCallbackQueue) + // RTLC7d2b + return true case .known(.objectDelete): let dataBeforeApplyingOperation = data @@ -431,9 +444,12 @@ internal final class InternalDefaultLiveCounter: Sendable { // 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 } } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 3d6da5eab..f061a5d4e 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -159,7 +159,7 @@ internal final class InternalDefaultLiveMap: Sendable { try entries(coreSDK: coreSDK, delegate: delegate).map(\.value) } - internal func set(key: String, value: InternalLiveMapValue, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + 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 @@ -181,7 +181,8 @@ internal final class InternalDefaultLiveMap: Sendable { ), ) - coreSDK.nosync_publish(objectMessages: [objectMessage]) { result in + // RTLM20g + realtimeObjects.nosync_publishAndApply(objectMessages: [objectMessage], coreSDK: coreSDK) { result in continuation.resume(returning: result) } } @@ -191,7 +192,7 @@ internal final class InternalDefaultLiveMap: Sendable { }.get() } - internal func remove(key: String, coreSDK: CoreSDK) async throws(ARTErrorInfo) { + 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 @@ -211,8 +212,8 @@ internal final class InternalDefaultLiveMap: Sendable { ), ) - // RTLM21f - coreSDK.nosync_publish(objectMessages: [objectMessage]) { result in + // RTLM21g + realtimeObjects.nosync_publishAndApply(objectMessages: [objectMessage], coreSDK: coreSDK) { result in continuation.resume(returning: result) } } @@ -331,16 +332,20 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLM15. + /// + /// - Returns: `true` if the operation was applied, `false` if it was skipped (RTLM15g). 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, @@ -590,8 +595,11 @@ internal final class InternalDefaultLiveMap: Sendable { } /// Attempts to apply an operation from an inbound `ObjectMessage`, per RTLM15. + /// + /// - Returns: `true` if the operation was applied, `false` if skipped (RTLM15g). internal mutating func apply( _ operation: ObjectOperation, + source: ObjectsOperationSource, objectMessageSerial: String?, objectMessageSiteCode: String?, objectMessageSerialTimestamp: Date?, @@ -600,20 +608,22 @@ internal final class InternalDefaultLiveMap: Sendable { 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 + return false } // RTLM15c - liveObjectMutableState.siteTimeserials[applicableOperation.objectMessageSiteCode] = applicableOperation.objectMessageSerial + 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 + return false } switch operation.action { @@ -629,14 +639,16 @@ internal final class InternalDefaultLiveMap: Sendable { ) // RTLM15d1a liveObjectMutableState.emit(update, on: userCallbackQueue) + // RTLM15d1b + return true case .known(.mapSet): guard let mapOp = operation.mapOp else { logger.log("Could not apply MAP_SET since operation.mapOp is missing", level: .warn) - return + return false } guard let data = mapOp.data else { logger.log("Could not apply MAP_SET since operation.data is missing", level: .warn) - return + return false } // RTLM15d2 @@ -652,9 +664,11 @@ internal final class InternalDefaultLiveMap: Sendable { ) // RTLM15d2a liveObjectMutableState.emit(update, on: userCallbackQueue) + // RTLM15d2b + return true case .known(.mapRemove): guard let mapOp = operation.mapOp else { - return + return false } // RTLM15d3 @@ -667,6 +681,8 @@ internal final class InternalDefaultLiveMap: Sendable { ) // RTLM15d3a liveObjectMutableState.emit(update, on: userCallbackQueue) + // RTLM15d3b + return true case .known(.objectDelete): let dataBeforeApplyingOperation = data @@ -680,9 +696,12 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM15d5a liveObjectMutableState.emit(.update(.init(update: dataBeforeApplyingOperation.mapValues { _ in .removed })), on: userCallbackQueue) + // RTLM15d5b + return true default: // RTLM15d4 logger.log("Operation \(operation) has unsupported action for LiveMap; discarding", level: .warn) + return false } } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index f3e8a0018..4f3face3a 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -1,8 +1,22 @@ internal import _AblyPluginSupportPrivate import Ably +/// Protocol that abstracts `InternalDefaultRealtimeObjects`, for testability. +internal protocol InternalRealtimeObjectsProtocol: LiveMapObjectsPoolDelegate { + /// Per RTO20. + /// + /// The callback may be invoked while exclusive access to internal state is held, so it must not + /// call other methods on this object that read or mutate its state. + /// https://github.com/ably/ably-liveobjects-swift-plugin/issues/120 tracks removing this restriction. + func nosync_publishAndApply( + objectMessages: [OutboundObjectMessage], + coreSDK: CoreSDK, + callback: @escaping @Sendable (Result) -> Void, + ) +} + /// This provides the implementation behind ``PublicDefaultRealtimeObjects``, via internal versions of the ``RealtimeObjects`` API. -internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoolDelegate { +internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeObjectsProtocol { private let mutableStateMutex: DispatchQueueMutex private let logger: Logger @@ -199,23 +213,23 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo timestamp: timestamp, ) - // RTO11g - coreSDK.nosync_publish(objectMessages: [creationOperation.objectMessage]) { [self] result in + // 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 - let map = mutableStateMutex.withoutSync { mutableState in - mutableState.objectsPool.nosync_getOrCreateMap( - creationOperation: creationOperation, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) + + // 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(map)) + + continuation.resume(returning: .success(existingMap)) } } } @@ -261,23 +275,23 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo timestamp: timestamp, ) - // RTO12g - coreSDK.nosync_publish(objectMessages: [creationOperation.objectMessage]) { [self] result in + // 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 - let counter = mutableStateMutex.withoutSync { mutableState in - mutableState.objectsPool.nosync_getOrCreateCounter( - creationOperation: creationOperation, - logger: logger, - internalQueue: mutableStateMutex.dispatchQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) + + // 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(counter)) + + continuation.resume(returning: .success(existingCounter)) } } } @@ -352,6 +366,16 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } } + 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 } @@ -411,12 +435,123 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo try await withCheckedContinuation { (continuation: CheckedContinuation, _>) in mutableStateMutex.withSync { _ in coreSDK.nosync_publish(objectMessages: objectMessages) { result in - continuation.resume(returning: result) + 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. @@ -439,6 +574,15 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo 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. @@ -462,6 +606,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } } + 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: @@ -490,6 +640,30 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo /// 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 @@ -569,8 +743,14 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo // 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, RTO4b5, RTO5c3, RTO5c4, RTO5c5, RTO5c8 + // RTO4b3, RTO4b4, RTO4b5, 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. @@ -667,8 +847,10 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo 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, @@ -677,8 +859,16 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } } + // 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, + ) } } @@ -705,6 +895,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo for objectMessage in objectMessages { nosync_applyObjectProtocolMessageObjectMessage( objectMessage, + source: .channel, logger: logger, internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, @@ -715,8 +906,9 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } /// Implements the `OBJECT` application of RTO9. - private mutating func nosync_applyObjectProtocolMessageObjectMessage( + internal mutating func nosync_applyObjectProtocolMessageObjectMessage( _ objectMessage: InboundObjectMessage, + source: ObjectsOperationSource, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, @@ -728,6 +920,13 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo 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] { @@ -752,13 +951,19 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo switch action { case .mapCreate, .mapSet, .mapRemove, .counterCreate, .counterInc, .objectDelete: // RTO9a2a3 - entry.nosync_apply( + 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 @@ -767,6 +972,44 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo } } + /// 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 @@ -792,7 +1035,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, LiveMapObjectsPoo /// 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 + // 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) diff --git a/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift b/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift new file mode 100644 index 000000000..eb8672a77 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift @@ -0,0 +1,7 @@ +/// RTO22: Describes the source of an operation being applied. +internal enum ObjectsOperationSource { + /// RTO22a: An operation that originated locally, being applied upon receipt of the ACK from Realtime. + case local + /// RTO22b: An operation received over a Realtime channel. + case channel +} diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index addfe833e..4a48954d4 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -30,17 +30,21 @@ internal struct ObjectsPool { } /// 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, @@ -49,6 +53,7 @@ internal struct ObjectsPool { case let .counter(counter): counter.nosync_apply( operation, + source: source, objectMessageSerial: objectMessageSerial, objectMessageSiteCode: objectMessageSiteCode, objectMessageSerialTimestamp: objectMessageSerialTimestamp, @@ -375,101 +380,6 @@ internal struct ObjectsPool { logger.log("applySyncObjectsPool completed. Pool now contains \(entries.count) objects", level: .debug) } - /// Gets or creates a counter object in the pool, implementing the "find or create zero-value" behavior of RTO12h1. - /// - /// - Parameters: - /// - creationOperation: The CounterCreationOperation containing the object ID and operation to merge - /// - logger: The logger to use for any created LiveObject - /// - internalQueue: The internal queue 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 counter object - internal mutating func nosync_getOrCreateCounter( - creationOperation: ObjectCreationHelpers.CounterCreationOperation, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) -> InternalDefaultLiveCounter { - // RTO12h2: If an object with the ObjectMessage.operation.objectId exists in the internal ObjectsPool, return it - if let existingEntry = entries[creationOperation.objectID] { - switch existingEntry { - case let .counter(counter): - return counter - case .map: - // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-liveobjects-swift-plugin/issues/36 - preconditionFailure("Expected counter object with ID \(creationOperation.objectID) but found map object") - } - } - - // RTO12h3: Otherwise, if the object does not exist in the internal ObjectsPool: - // RTO12h3a: Create a zero-value LiveCounter, set its objectId to ObjectMessage.operation.objectId, and merge the initial value - let counter = InternalDefaultLiveCounter.createZeroValued( - objectID: creationOperation.objectID, - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - - // Merge the initial value from the creation operation - _ = counter.nosync_mergeInitialValue(from: creationOperation.operation) - - // RTO12h3b: Add the created LiveCounter instance to the internal ObjectsPool - entries[creationOperation.objectID] = .counter(counter) - - // RTO12h3c: Return the created LiveCounter instance - return counter - } - - /// Gets or creates a map object in the pool, implementing the "find or create zero-value" behavior of RTO11h1. - /// - /// - Parameters: - /// - creationOperation: The MapCreationOperation containing the object ID and operation to merge - /// - logger: The logger to use for any created LiveObject - /// - internalQueue: The internal queue 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 map object - internal mutating func nosync_getOrCreateMap( - creationOperation: ObjectCreationHelpers.MapCreationOperation, - logger: Logger, - internalQueue: DispatchQueue, - userCallbackQueue: DispatchQueue, - clock: SimpleClock, - ) -> InternalDefaultLiveMap { - // RTO11h2: If an object with the ObjectMessage.operation.objectId exists in the internal ObjectsPool, return it - if let existingEntry = entries[creationOperation.objectID] { - switch existingEntry { - case let .map(map): - return map - case .counter: - // TODO: Add the ability to statically reason about the type of pool entries in https://github.com/ably/ably-liveobjects-swift-plugin/issues/36 - preconditionFailure("Expected map object with ID \(creationOperation.objectID) but found counter object") - } - } - - // RTO11h3: Otherwise, if the object does not exist in the internal ObjectsPool: - // RTO11h3a: Create a zero-value LiveMap, set its objectId to ObjectMessage.operation.objectId, set its semantics to ObjectMessage.operation.map.semantics, and merge the initial value - let map = InternalDefaultLiveMap.createZeroValued( - objectID: creationOperation.objectID, - semantics: .known(creationOperation.semantics), - logger: logger, - internalQueue: internalQueue, - userCallbackQueue: userCallbackQueue, - clock: clock, - ) - - // Merge the initial value from the creation operation - _ = map.nosync_mergeInitialValue(from: creationOperation.operation, objectsPool: &self) - - // RTO11h3b: Add the created LiveMap instance to the internal ObjectsPool - entries[creationOperation.objectID] = .map(map) - - // RTO11h3c: Return the created LiveMap instance - return map - } - /// 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 diff --git a/Sources/AblyLiveObjects/Internal/PublishResult.swift b/Sources/AblyLiveObjects/Internal/PublishResult.swift new file mode 100644 index 000000000..3c4a9202b --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/PublishResult.swift @@ -0,0 +1,14 @@ +internal import _AblyPluginSupportPrivate + +/// As described by `PBR*` spec points. +/// +/// We use this internally instead of `_AblyCocoaPluginSupportPrivate.PublishResultProtocol` because Swift allows us to directly store `nil` values in an array. +internal struct PublishResult { + internal var serials: [String?] +} + +internal extension PublishResult { + init(pluginPublishResult: _AblyPluginSupportPrivate.PublishResultProtocol) { + serials = pluginPublishResult.serials.map(\.value) + } +} diff --git a/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift b/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift new file mode 100644 index 000000000..48cf75a59 --- /dev/null +++ b/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift @@ -0,0 +1,19 @@ +internal extension InboundObjectMessage { + /// Creates a synthetic inbound message from an outbound message, per RTO20d2 and RTO20d3. + /// + /// Used to apply a locally-published operation upon receipt of the ACK from Realtime. + static func createSynthetic(from outboundMessage: OutboundObjectMessage, serial: String, siteCode: String) -> InboundObjectMessage { + InboundObjectMessage( + id: outboundMessage.id, + clientId: outboundMessage.clientId, + connectionId: outboundMessage.connectionId, + extras: outboundMessage.extras, + timestamp: outboundMessage.timestamp, + operation: outboundMessage.operation, + object: nil, + serial: serial, // RTO20d2a + siteCode: siteCode, // RTO20d2b + serialTimestamp: nil, + ) + } +} diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index f1411894f..6bd2bc538 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -19,6 +19,8 @@ internal struct InboundObjectMessage { } /// An `ObjectMessage` to be sent in the `state` property of an `OBJECT` `ProtocolMessage`. +/// +/// - Important: When adding new fields, also update ``InboundObjectMessage/createSynthetic(from:serial:siteCode:)``. internal struct OutboundObjectMessage: Equatable { internal var id: String? // OM2a internal var clientId: String? // OM2b diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift index d669c0cda..6e0419a75 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift @@ -5,15 +5,15 @@ internal extension InternalLiveMapValue { struct PublicValueCreationArgs { internal var coreSDK: CoreSDK - internal var mapDelegate: LiveMapObjectsPoolDelegate + internal var realtimeObjects: any InternalRealtimeObjectsProtocol internal var logger: Logger internal var toCounterCreationArgs: PublicObjectsStore.CounterCreationArgs { - .init(coreSDK: coreSDK, logger: logger) + .init(coreSDK: coreSDK, realtimeObjects: realtimeObjects, logger: logger) } internal var toMapCreationArgs: PublicObjectsStore.MapCreationArgs { - .init(coreSDK: coreSDK, delegate: mapDelegate, logger: logger) + .init(coreSDK: coreSDK, realtimeObjects: realtimeObjects, logger: logger) } } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift index 90a408da1..53995a79a 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift @@ -10,11 +10,13 @@ internal final class PublicDefaultLiveCounter: LiveCounter { // 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, 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 } @@ -27,11 +29,11 @@ internal final class PublicDefaultLiveCounter: LiveCounter { } internal func increment(amount: Double) async throws(ARTErrorInfo) { - try await proxied.increment(amount: amount, coreSDK: coreSDK) + 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) + try await proxied.decrement(amount: amount, coreSDK: coreSDK, realtimeObjects: realtimeObjects) } internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift index 58f2036d3..d3b003cf6 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift @@ -10,23 +10,23 @@ internal final class PublicDefaultLiveMap: LiveMap { // MARK: - Dependencies that hold a strong reference to `proxied` private let coreSDK: CoreSDK - private let delegate: LiveMapObjectsPoolDelegate + private let realtimeObjects: any InternalRealtimeObjectsProtocol private let logger: Logger - internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, delegate: LiveMapObjectsPoolDelegate, logger: Logger) { + internal init(proxied: InternalDefaultLiveMap, coreSDK: CoreSDK, realtimeObjects: any InternalRealtimeObjectsProtocol, logger: Logger) { self.proxied = proxied self.coreSDK = coreSDK - self.delegate = delegate + 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: delegate)?.toPublic( + try proxied.get(key: key, coreSDK: coreSDK, delegate: realtimeObjects)?.toPublic( creationArgs: .init( coreSDK: coreSDK, - mapDelegate: delegate, + realtimeObjects: realtimeObjects, logger: logger, ), ) @@ -34,19 +34,19 @@ internal final class PublicDefaultLiveMap: LiveMap { internal var size: Int { get throws(ARTErrorInfo) { - try proxied.size(coreSDK: coreSDK, delegate: delegate) + try proxied.size(coreSDK: coreSDK, delegate: realtimeObjects) } } internal var entries: [(key: String, value: LiveMapValue)] { get throws(ARTErrorInfo) { - try proxied.entries(coreSDK: coreSDK, delegate: delegate).map { entry in + try proxied.entries(coreSDK: coreSDK, delegate: realtimeObjects).map { entry in ( entry.key, entry.value.toPublic( creationArgs: .init( coreSDK: coreSDK, - mapDelegate: delegate, + realtimeObjects: realtimeObjects, logger: logger, ), ) @@ -57,17 +57,17 @@ internal final class PublicDefaultLiveMap: LiveMap { internal var keys: [String] { get throws(ARTErrorInfo) { - try proxied.keys(coreSDK: coreSDK, delegate: delegate) + try proxied.keys(coreSDK: coreSDK, delegate: realtimeObjects) } } internal var values: [LiveMapValue] { get throws(ARTErrorInfo) { - try proxied.values(coreSDK: coreSDK, delegate: delegate).map { value in + try proxied.values(coreSDK: coreSDK, delegate: realtimeObjects).map { value in value.toPublic( creationArgs: .init( coreSDK: coreSDK, - mapDelegate: delegate, + realtimeObjects: realtimeObjects, logger: logger, ), ) @@ -78,11 +78,11 @@ internal final class PublicDefaultLiveMap: LiveMap { 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) + 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) + try await proxied.remove(key: key, coreSDK: coreSDK, realtimeObjects: realtimeObjects) } internal func subscribe(listener: @escaping LiveObjectUpdateCallback) throws(ARTErrorInfo) -> any SubscribeResponse { diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift index 66cd808c3..1dd1fb3a1 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift @@ -29,7 +29,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { proxying: internalMap, creationArgs: .init( coreSDK: coreSDK, - delegate: proxied, + realtimeObjects: proxied, logger: logger, ), ) @@ -43,7 +43,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { proxying: internalMap, creationArgs: .init( coreSDK: coreSDK, - delegate: proxied, + realtimeObjects: proxied, logger: logger, ), ) @@ -56,7 +56,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { proxying: internalMap, creationArgs: .init( coreSDK: coreSDK, - delegate: proxied, + realtimeObjects: proxied, logger: logger, ), ) @@ -69,6 +69,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { proxying: internalCounter, creationArgs: .init( coreSDK: coreSDK, + realtimeObjects: proxied, logger: logger, ), ) @@ -81,6 +82,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { proxying: internalCounter, creationArgs: .init( coreSDK: coreSDK, + realtimeObjects: proxied, logger: logger, ), ) @@ -119,7 +121,7 @@ internal final class PublicDefaultRealtimeObjects: RealtimeObjects { /// 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) -> Void) { + internal func testsOnly_overridePublish(with newImplementation: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { coreSDK.testsOnly_overridePublish(with: newImplementation) } diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift index d8d915b07..865210856 100644 --- a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift +++ b/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift @@ -33,6 +33,7 @@ internal final class PublicObjectsStore: Sendable { internal struct CounterCreationArgs { internal var coreSDK: CoreSDK + internal var realtimeObjects: any InternalRealtimeObjectsProtocol internal var logger: Logger } @@ -45,7 +46,7 @@ internal final class PublicObjectsStore: Sendable { internal struct MapCreationArgs { internal var coreSDK: CoreSDK - internal var delegate: LiveMapObjectsPoolDelegate + internal var realtimeObjects: any InternalRealtimeObjectsProtocol internal var logger: Logger } @@ -132,6 +133,7 @@ internal final class PublicObjectsStore: Sendable { .init( proxied: proxied, coreSDK: creationArgs.coreSDK, + realtimeObjects: creationArgs.realtimeObjects, logger: creationArgs.logger, ) } @@ -149,7 +151,7 @@ internal final class PublicObjectsStore: Sendable { .init( proxied: proxied, coreSDK: creationArgs.coreSDK, - delegate: creationArgs.delegate, + realtimeObjects: creationArgs.realtimeObjects, logger: creationArgs.logger, ) } diff --git a/Sources/AblyLiveObjects/Utility/Errors.swift b/Sources/AblyLiveObjects/Utility/Errors.swift index 564f382fb..e434d6094 100644 --- a/Sources/AblyLiveObjects/Utility/Errors.swift +++ b/Sources/AblyLiveObjects/Utility/Errors.swift @@ -9,6 +9,10 @@ internal enum LiveObjectsError { 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. @@ -19,6 +23,11 @@ internal enum LiveObjectsError { case .counterInitialValueInvalid, .counterIncrementAmountInvalid: // RTO12f1, RTLC12e1 .invalidParameterValue + case .publishAndApplyFailedChannelStateChanged: + // RTO20e1 + .unableToApplyObjectsOperationSyncDidNotComplete + case .newlyCreatedObjectNotInPool: + .internalError case .other: .badRequest } @@ -30,8 +39,11 @@ internal enum LiveObjectsError { case .objectsOperationFailedInvalidChannelState, .counterInitialValueInvalid, .counterIncrementAmountInvalid, + .publishAndApplyFailedChannelStateChanged, .other: 400 + case .newlyCreatedObjectNotInPool: + 500 } } @@ -44,17 +56,43 @@ internal enum LiveObjectsError { "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 { - ARTErrorInfo.create( + 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: [liveObjectsErrorUserInfoKey: self], + additionalUserInfo: userInfo, ) } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index f749be692..eb622aef0 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -408,15 +408,17 @@ struct InternalDefaultLiveCounterTests { 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) - internalQueue.ably_syncNoDeadlock { + 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) @@ -425,9 +427,10 @@ struct InternalDefaultLiveCounterTests { #expect(counter.testsOnly_siteTimeserials == ["site1": "ts2"]) } - // @specOneOf(1/2) RTLC7c - We test this spec point for each possible operation + // @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 { @@ -443,15 +446,17 @@ struct InternalDefaultLiveCounterTests { var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply COUNTER_CREATE operation - internalQueue.ably_syncNoDeadlock { + 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) @@ -464,9 +469,10 @@ struct InternalDefaultLiveCounterTests { #expect(subscriberInvocations.map(\.0) == [.init(amount: 15)]) } - // @specOneOf(2/2) RTLC7c - We test this spec point for each possible operation + // @specOneOf(2/3) RTLC7c - We test this spec point for each possible operation // @spec RTLC7d2 - Tests COUNTER_INC operation application // @spec RTLC7d2a + // @spec RTLC7d2b @available(iOS 17.0.0, tvOS 17.0.0, *) @Test func appliesCounterIncOperation() async throws { @@ -491,15 +497,17 @@ struct InternalDefaultLiveCounterTests { var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply COUNTER_INC operation - internalQueue.ably_syncNoDeadlock { + 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 @@ -511,6 +519,39 @@ struct InternalDefaultLiveCounterTests { #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), + counterOp: TestFactories.counterOp(amount: 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 @@ -525,15 +566,17 @@ struct InternalDefaultLiveCounterTests { // Try to apply a MAP_CREATE to the counter (not supported) var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) - internalQueue.ably_syncNoDeadlock { + 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() @@ -550,9 +593,10 @@ struct InternalDefaultLiveCounterTests { 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) + try await counter.increment(amount: 10, coreSDK: coreSDK, realtimeObjects: realtimeObjects) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false @@ -573,9 +617,10 @@ struct InternalDefaultLiveCounterTests { 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) + try await counter.increment(amount: amount, coreSDK: coreSDK, realtimeObjects: realtimeObjects) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false @@ -588,20 +633,22 @@ struct InternalDefaultLiveCounterTests { // @spec RTLC12e2 // @spec RTLC12e3 // @spec RTLC12e4 - // @spec RTLC12f + // @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] = [] - coreSDK.setPublishHandler { messages in + realtimeObjects.setPublishAndApplyHandler { messages in publishedMessages.append(contentsOf: messages) + return .success(()) } - try await counter.increment(amount: 10.5, coreSDK: coreSDK) + try await counter.increment(amount: 10.5, coreSDK: coreSDK, realtimeObjects: realtimeObjects) let expectedMessage = OutboundObjectMessage( operation: ObjectOperation( @@ -613,7 +660,7 @@ struct InternalDefaultLiveCounterTests { counterOp: WireObjectsCounterOp(amount: NSNumber(value: 10.5)), ), ) - // RTLC12f + // RTLC12g #expect(publishedMessages.count == 1) #expect(publishedMessages[0] == expectedMessage) } @@ -624,13 +671,14 @@ struct InternalDefaultLiveCounterTests { 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() - coreSDK.setPublishHandler { _ throws(ARTErrorInfo) in - throw LiveObjectsError.other(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"])).toARTErrorInfo() + 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) + try await counter.increment(amount: 10, coreSDK: coreSDK, realtimeObjects: realtimeObjects) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false @@ -649,15 +697,17 @@ struct InternalDefaultLiveCounterTests { 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] = [] - coreSDK.setPublishHandler { messages in + realtimeObjects.setPublishAndApplyHandler { messages in publishedMessages.append(contentsOf: messages) + return .success(()) } - try await counter.decrement(amount: 10.5, coreSDK: coreSDK) + try await counter.decrement(amount: 10.5, coreSDK: coreSDK, realtimeObjects: realtimeObjects) - // RTLC12f + // RTLC12g #expect(publishedMessages.count == 1) #expect(publishedMessages[0].operation?.counterOp?.amount == -10.5 /* i.e. assert the amount gets negated */ ) } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 72ee524d2..10b29db37 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -1221,15 +1221,17 @@ struct InternalDefaultLiveMapTests { ) // 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) - internalQueue.ably_syncNoDeadlock { + 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) @@ -1238,9 +1240,10 @@ struct InternalDefaultLiveMapTests { #expect(map.testsOnly_siteTimeserials == ["site1": "ts2"]) } - // @specOneOf(1/3) RTLM15c - We test this spec point for each possible operation + // @specOneOf(1/4) 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 { @@ -1259,15 +1262,17 @@ struct InternalDefaultLiveMapTests { var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) // Apply MAP_CREATE operation - internalQueue.ably_syncNoDeadlock { + 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") @@ -1280,9 +1285,10 @@ struct InternalDefaultLiveMapTests { #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .updated])]) } - // @specOneOf(2/3) RTLM15c - We test this spec point for each possible operation + // @specOneOf(2/4) RTLM15c - We test this spec point for each possible operation // @spec RTLM15d2 - Tests MAP_SET operation application // @spec RTLM15d2a + // @spec RTLM15d2b @available(iOS 17.0.0, tvOS 17.0.0, *) @Test func appliesMapSetOperation() async throws { @@ -1316,15 +1322,17 @@ struct InternalDefaultLiveMapTests { ) // Apply MAP_SET operation - internalQueue.ably_syncNoDeadlock { + 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") @@ -1336,9 +1344,10 @@ struct InternalDefaultLiveMapTests { #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .updated])]) } - // @specOneOf(3/3) RTLM15c - We test this spec point for each possible operation + // @specOneOf(3/4) RTLM15c - We test this spec point for each possible operation // @spec RTLM15d3 - Tests MAP_REMOVE operation application // @spec RTLM15d3a + // @spec RTLM15d3b @available(iOS 17.0.0, tvOS 17.0.0, *) @Test func appliesMapRemoveOperation() async throws { @@ -1372,15 +1381,17 @@ struct InternalDefaultLiveMapTests { ) // Apply MAP_REMOVE operation - internalQueue.ably_syncNoDeadlock { + 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) @@ -1392,6 +1403,40 @@ struct InternalDefaultLiveMapTests { #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .removed])]) } + // @specOneOf(4/4) 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), + mapOp: ObjectsMapOp(key: "key1", data: 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 @@ -1406,15 +1451,17 @@ struct InternalDefaultLiveMapTests { // Try to apply a COUNTER_CREATE to the map (not supported) var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) - internalQueue.ably_syncNoDeadlock { + 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() @@ -1431,9 +1478,10 @@ struct InternalDefaultLiveMapTests { 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) + 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 @@ -1454,7 +1502,7 @@ struct InternalDefaultLiveMapTests { // @spec RTLM20e5d // @spec RTLM20e5e // @spec RTLM20e5f - // @spec RTLM20f + // @spec RTLM20g @Test(arguments: [ // RTLM20e5a (value: { @Sendable internalQueue in .liveMap(.createZeroValued(objectID: "map:test@123", logger: TestLogger(), internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock())) }, expectedData: .init(objectId: "map:test@123")), @@ -1476,13 +1524,15 @@ struct InternalDefaultLiveMapTests { 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? - coreSDK.setPublishHandler { messages in + realtimeObjects.setPublishAndApplyHandler { messages in publishedMessage = messages.first + return .success(()) } - try await map.set(key: "testKey", value: value(internalQueue), coreSDK: coreSDK) + try await map.set(key: "testKey", value: value(internalQueue), coreSDK: coreSDK, realtimeObjects: realtimeObjects) let expectedMessage = OutboundObjectMessage( operation: ObjectOperation( @@ -1498,7 +1548,7 @@ struct InternalDefaultLiveMapTests { ), ), ) - // RTLM20f + // RTLM20g let message = try #require(publishedMessage) #expect(message == expectedMessage) } @@ -1509,13 +1559,14 @@ struct InternalDefaultLiveMapTests { 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() - coreSDK.setPublishHandler { _ throws(ARTErrorInfo) in - throw LiveObjectsError.other(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"])).toARTErrorInfo() + 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) + 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 @@ -1534,9 +1585,10 @@ struct InternalDefaultLiveMapTests { 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) + try await map.remove(key: "test", coreSDK: coreSDK, realtimeObjects: realtimeObjects) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false @@ -1551,20 +1603,22 @@ struct InternalDefaultLiveMapTests { // @spec RTLM21e2 // @spec RTLM21e3 // @spec RTLM21e4 - // @spec RTLM21f + // @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] = [] - coreSDK.setPublishHandler { messages in + realtimeObjects.setPublishAndApplyHandler { messages in publishedMessages.append(contentsOf: messages) + return .success(()) } - try await map.remove(key: "testKey", coreSDK: coreSDK) + try await map.remove(key: "testKey", coreSDK: coreSDK, realtimeObjects: realtimeObjects) let expectedMessage = OutboundObjectMessage( operation: ObjectOperation( @@ -1579,7 +1633,7 @@ struct InternalDefaultLiveMapTests { ), ), ) - // RTLM21f + // RTLM21g #expect(publishedMessages.count == 1) #expect(publishedMessages[0] == expectedMessage) } @@ -1590,13 +1644,14 @@ struct InternalDefaultLiveMapTests { 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() - coreSDK.setPublishHandler { _ throws(ARTErrorInfo) in - throw LiveObjectsError.other(NSError(domain: "test", code: 0, userInfo: [NSLocalizedDescriptionKey: "Publish failed"])).toARTErrorInfo() + 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) + try await map.remove(key: "testKey", coreSDK: coreSDK, realtimeObjects: realtimeObjects) } throws: { error in guard let errorInfo = error as? ARTErrorInfo else { return false diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index 01975cc0a..aa398210e 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -182,11 +182,26 @@ struct InternalDefaultRealtimeObjectsTests { // MARK: - RTO5c: Post-Sync Behavior Tests - // A smoke test that the RTO5c post-sync behaviours get performed. They are tested in more detail in the ObjectsPool.applySyncObjectsPool 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")] @@ -202,6 +217,9 @@ struct InternalDefaultRealtimeObjectsTests { 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 @@ -425,6 +443,16 @@ struct InternalDefaultRealtimeObjectsTests { 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( @@ -462,8 +490,9 @@ struct InternalDefaultRealtimeObjectsTests { #expect(newRoot as AnyObject === originalPool.root as AnyObject) // Should be same instance #expect(newRoot.testsOnly_data.isEmpty) // Should be zero-valued (empty) - // RTO4b3, RTO4b4, RTO4b5: SyncObjectsPool must be cleared, sync sequence cleared, BufferedObjectOperations cleared + // RTO4b3, RTO4b4, RTO4b5: 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 @@ -800,6 +829,107 @@ struct InternalDefaultRealtimeObjectsTests { // @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, + amount: 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, + amount: 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 @@ -1167,6 +1297,8 @@ struct InternalDefaultRealtimeObjectsTests { 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) @@ -1203,19 +1335,24 @@ struct InternalDefaultRealtimeObjectsTests { } // @spec RTO11f7 - // @spec RTO11g - // @spec RTO11h3a - // @spec RTO11h3b + // @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 @@ -1226,7 +1363,7 @@ struct InternalDefaultRealtimeObjectsTests { coreSDK: coreSDK, ) - // Verify ObjectMessage was published (RTO11g) + // Verify ObjectMessage was published (RTO11i) #expect(publishedMessages.count == 1) let publishedMessage = publishedMessages[0] @@ -1239,13 +1376,13 @@ struct InternalDefaultRealtimeObjectsTests { "stringKey": .init(data: .init(string: "stringValue")), ]) - // Verify initial value was merged per RTO11h3a + // Sense check that create op was applied locally by publishAndApply ( #expect(returnedMap.testsOnly_data == ["stringKey": InternalObjectsMapEntry(data: ObjectData(string: "stringValue"))]) - - // Verify object was added to pool per RTO11h3b #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 RTO11f4b @Test func withNoEntriesArgumentCreatesEmptyMap() async throws { @@ -1253,10 +1390,17 @@ struct InternalDefaultRealtimeObjectsTests { 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 @@ -1281,6 +1425,12 @@ struct InternalDefaultRealtimeObjectsTests { 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? @@ -1297,6 +1447,7 @@ struct InternalDefaultRealtimeObjectsTests { // 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 @@ -1337,25 +1488,30 @@ struct InternalDefaultRealtimeObjectsTests { } // @spec RTO12f5 - // @spec RTO12g - // @spec RTO12h3a - // @spec RTO12h3b + // @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 (RTO12g) + // Verify ObjectMessage was published (RTO12i) #expect(publishedMessages.count == 1) let publishedMessage = publishedMessages[0] @@ -1366,13 +1522,13 @@ struct InternalDefaultRealtimeObjectsTests { #expect(objectID.contains("1754042434000")) // check contains the server timestamp in milliseconds per RTO12f5 #expect(publishedMessage.operation?.counter?.count == 10.5) - // Verify initial value was merged per RTO12h3a + // Sense check that create op was applied locally by publishAndApply #expect(try returnedCounter.value(coreSDK: coreSDK) == 10.5) - - // Verify object was added to pool per RTO12h3b #expect(realtimeObjects.testsOnly_objectsPool.entries[objectID]?.counterValue === returnedCounter) } + // @specUntested RTO12h3d - This spec point covers a logic error so let's not try to test + // @spec RTO12f2a @Test func withNoEntriesArgumentCreatesWithZeroValue() async throws { @@ -1380,10 +1536,17 @@ struct InternalDefaultRealtimeObjectsTests { 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 @@ -1409,6 +1572,12 @@ struct InternalDefaultRealtimeObjectsTests { 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? @@ -1425,6 +1594,7 @@ struct InternalDefaultRealtimeObjectsTests { // 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 @@ -1619,4 +1789,238 @@ struct InternalDefaultRealtimeObjectsTests { #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/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 6f6b01e08..7614111ba 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -125,6 +125,248 @@ func waitForObjectSync(_ realtime: ARTRealtime) async throws { } } +// MARK: - ARTProtocolMessage test-only extension + +extension ARTProtocolMessage { + /// Extract `InboundObjectMessage`s from the protocol message's state array. + var testsOnly_inboundObjectMessages: [InboundObjectMessage] { + // Claude's explanation, which I don't really have the time to verify or try and do better than — it's only test code so good enough: + // + // > The `state` property on `ARTProtocolMessage` is behind `#ifdef ABLY_SUPPORTS_PLUGINS`, + // > which is defined as a C flag on the ably-cocoa SPM target. However, this define is not + // > visible when the Clang module is built for the `Ably.Private` submodule, so the property + // > is not importable into Swift. We use KVC to access it instead. + guard let stateArray = value(forKey: "state") as? [AnyObject] else { + return [] + } + return stateArray.compactMap { item -> InboundObjectMessage? in + guard let box = item as? DefaultInternalPlugin.ObjectMessageBox + else { return nil } + return box.objectMessage + } + } +} + +// MARK: - Echo/ACK interceptors for apply-on-ACK tests + +/// Intercepts OBJECT messages (action 19) arriving from Realtime, holding them so tests can +/// control the timing of echo delivery, to test ACK-before-echo scenarios. Call when CONNECTING +/// or CONNECTED to intercept messages on the active transport. +/// +/// An echo is a subset of OBJECT messages — specifically, OBJECT messages that originated from +/// this client and are being echoed back. The name "echo" reflects how this interceptor is +/// intended to be used in tests, but it doesn't actually filter for echoes — it intercepts all +/// OBJECT messages. +/// +/// - ``waitForEcho()`` / ``waitForEchoCount(_:)``: resolve immediately if enough OBJECT messages +/// have already been intercepted, otherwise wait for one. Only one call can be pending at a +/// time; subsequent calls will trap. +/// - ``releaseAll()``: replays all held OBJECT messages through the channel's internal message +/// handler. +/// - ``releaseFirst()``: replays only the first held OBJECT message. +/// - ``restore()``: removes the interceptor, restoring normal message handling. +private final class EchoInterceptor: @unchecked Sendable { + private let transport: TestProxyTransport + private let channel: ARTRealtimeChannel + private let lock = NSLock() + private var _heldEchoes: [ARTProtocolMessage] = [] + private var echoContinuation: CheckedContinuation? + + var heldEchoes: [ARTProtocolMessage] { + lock.withLock { _heldEchoes } + } + + init(client: ARTRealtime, channel: ARTRealtimeChannel) { + // swiftlint:disable:next force_cast + transport = client.internal.transport as! TestProxyTransport + self.channel = channel + + transport.setBeforeIncomingMessageModifier { [weak self] message in + guard let self else { + return message + } + + if message.action == .object { + lock.withLock { + _heldEchoes.append(message) + echoContinuation?.resume() + echoContinuation = nil + } + return nil // suppress the OBJECT message + } + return message + } + } + + /// Waits until at least one new OBJECT message has been intercepted since the last + /// ``releaseAll()`` / ``releaseFirst()`` (or since construction). + func waitForEcho() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.withLock { + if !_heldEchoes.isEmpty { + continuation.resume() + } else { + precondition(echoContinuation == nil, "Only one waitForEcho/waitForEchoCount call can be pending at a time") + echoContinuation = continuation + } + } + } + } + + /// Waits until at least `count` OBJECT messages have been intercepted. + func waitForEchoCount(_ count: Int) async { + while true { + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.withLock { + if _heldEchoes.count >= count { + continuation.resume() + } else { + precondition(echoContinuation == nil, "Only one waitForEcho/waitForEchoCount call can be pending at a time") + echoContinuation = continuation + } + } + } + let current = lock.withLock { _heldEchoes.count } + if current >= count { break } + } + } + + /// Replays all held OBJECT messages through the channel's internal message handler. + func releaseAll() async { + let echoes = lock.withLock { + let e = _heldEchoes + _heldEchoes.removeAll() + return e + } + for echo in echoes { + nonisolated(unsafe) let echo = echo + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.internal.queue.async { + self.channel.internal.onChannelMessage(echo) + continuation.resume() + } + } + } + } + + /// Replays only the first held OBJECT message. + func releaseFirst() async { + let echo: ARTProtocolMessage? = lock.withLock { + _heldEchoes.isEmpty ? nil : _heldEchoes.removeFirst() + } + guard let echo else { + return + } + + nonisolated(unsafe) let unsafeEcho = echo + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.internal.queue.async { + self.channel.internal.onChannelMessage(unsafeEcho) + continuation.resume() + } + } + } + + /// Removes the interceptor, restoring normal message handling. + func restore() { + transport.setBeforeIncomingMessageModifier(nil) + } +} + +/// Intercepts ACK messages (action 1) arriving from Realtime, holding them so tests can control +/// the timing of ACK delivery, to test echo-before-ACK scenarios. Call when CONNECTING or +/// CONNECTED to intercept messages on the active transport. +/// +/// - ``waitForAck()``: resolves immediately if an ACK has already been intercepted, otherwise +/// waits for one. Only one call can be pending at a time; subsequent calls will trap. +/// - ``releaseAll()``: replays all held ACKs through the client's internal ACK handler. +/// - ``restore()``: removes the interceptor, restoring normal message handling. +private final class AckInterceptor: @unchecked Sendable { + private let transport: TestProxyTransport + private let client: ARTRealtime + private let lock = NSLock() + private var _heldAcks: [ARTProtocolMessage] = [] + private var ackContinuation: CheckedContinuation? + + var heldAcks: [ARTProtocolMessage] { + lock.withLock { _heldAcks } + } + + init(client: ARTRealtime) { + self.client = client + // swiftlint:disable:next force_cast + transport = client.internal.transport as! TestProxyTransport + + transport.setBeforeIncomingMessageModifier { [weak self] message in + guard let self else { + return message + } + + if message.action == .ack { + lock.withLock { + _heldAcks.append(message) + ackContinuation?.resume() + ackContinuation = nil + } + return nil // suppress the ACK + } + return message + } + } + + /// Waits until at least one ACK has been intercepted. + func waitForAck() async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + lock.withLock { + if !_heldAcks.isEmpty { + continuation.resume() + } else { + precondition(ackContinuation == nil, "Only one waitForAck call can be pending at a time") + ackContinuation = continuation + } + } + } + } + + /// Replays all held ACK messages through the client's internal ACK handler. + func releaseAll() async { + let acks = lock.withLock { + let a = _heldAcks + _heldAcks.removeAll() + return a + } + for ack in acks { + nonisolated(unsafe) let ack = ack + await withCheckedContinuation { (continuation: CheckedContinuation) in + client.internal.queue.async { + self.client.internal.onAck(ack) + continuation.resume() + } + } + } + } + + /// Removes the interceptor, restoring normal message handling. + func restore() { + transport.setBeforeIncomingMessageModifier(nil) + } +} + +/// Injects an ATTACHED protocol message into the channel, optionally with the HAS_OBJECTS flag. +/// This triggers a sync sequence when `hasObjects` is true. +private func injectAttachedMessage(channel: ARTRealtimeChannel, hasObjects: Bool) async { + await withCheckedContinuation { (continuation: CheckedContinuation) in + channel.internal.queue.async { + let pm = ARTProtocolMessage() + pm.action = .attached + pm.channel = channel.name + pm.flags = hasObjects ? Int64(1 << 7) : 0 // ARTProtocolMessageFlagHasObjects + channel.internal.onChannelMessage(pm) + continuation.resume() + } + } +} + // MARK: - Constants private let objectsFixturesChannel = "objects_fixtures" @@ -420,51 +662,21 @@ private struct ObjectsIntegrationTests { let root = ctx.root let objects = ctx.objects - // Create the promise first, before the operations that will trigger it - 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 {} - } - // 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 and await the promise + // 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, objectsCreatedPromise) + _ = try await (setMapPromise, setCounterPromise) - // Create the promise first, before the operations that will trigger it - let operationsAppliedPromiseUpdates1 = try map.updates() - let operationsAppliedPromiseUpdates2 = try map.updates() - let operationsAppliedPromiseUpdates3 = try counter.updates() - async let operationsAppliedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(operationsAppliedPromiseUpdates1, "anotherKey") - } - group.addTask { - await waitForMapKeyUpdate(operationsAppliedPromiseUpdates2, "shouldDelete") - } - group.addTask { - await waitForCounterUpdate(operationsAppliedPromiseUpdates3) - } - while try await group.next() != nil {} - } - - // Perform the operations and await the promise + // 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, operationsAppliedPromise) + _ = 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) @@ -497,26 +709,13 @@ private struct ObjectsIntegrationTests { let channel = ctx.channel let client = ctx.client - // Create the promise first, before the operations that will trigger it - 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 {} - } - let map = try await objects.createMap() let counter = try await objects.createCounter() - // Set the values and await the promise + // 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, objectsCreatedPromise) + _ = try await (setMapPromise, setCounterPromise) try await channel.detachAsync() @@ -1223,12 +1422,8 @@ private struct ObjectsIntegrationTests { for (i, increment) in increments.enumerated() { expectedCounterValue += Double(increment) - let counterUpdatedPromiseUpdates = try counter.updates() - async let counterUpdatedPromise: Void = waitForCounterUpdate(counterUpdatedPromiseUpdates) - // Use the public API to increment - this will send COUNTER_INC internally try await counter.increment(amount: Double(increment)) - _ = await counterUpdatedPromise #expect(try counter.value == expectedCounterValue, "Check counter at \"\(counterKey)\" key in root has correct value after \(i + 1) COUNTER_INC ops") } @@ -1323,12 +1518,8 @@ private struct ObjectsIntegrationTests { #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") - let keyRemovedPromiseUpdates = try map.updates() - async let keyRemovedPromise: Void = waitForMapKeyUpdate(keyRemovedPromiseUpdates, "shouldDelete") - // Send MAP_REMOVE op using the public API try await map.remove(key: "shouldDelete") - _ = await keyRemovedPromise // 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") @@ -2404,11 +2595,7 @@ private struct ObjectsIntegrationTests { for (i, increment) in increments.enumerated() { expectedCounterValue += increment - let counterUpdatedPromiseUpdates = try counter.updates() - async let counterUpdatedPromise: Void = waitForCounterUpdate(counterUpdatedPromiseUpdates) - try await counter.increment(amount: increment) - _ = await counterUpdatedPromise #expect(try counter.value == expectedCounterValue, "Check counter has correct value after \(i + 1) LiveCounter.increment calls") } @@ -2488,11 +2675,7 @@ private struct ObjectsIntegrationTests { for (i, decrement) in decrements.enumerated() { expectedCounterValue -= decrement - let counterUpdatedPromiseUpdates = try counter.updates() - async let counterUpdatedPromise: Void = waitForCounterUpdate(counterUpdatedPromiseUpdates) - try await counter.decrement(amount: decrement) - _ = await counterUpdatedPromise #expect(try counter.value == expectedCounterValue, "Check counter has correct value after \(i + 1) LiveCounter.decrement calls") } @@ -2542,16 +2725,6 @@ private struct ObjectsIntegrationTests { action: { ctx in let root = ctx.root - 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 {} - } - _ = try await withThrowingTaskGroup(of: Void.self) { group in for keyData in primitiveKeyData { group.addTask { @@ -2560,7 +2733,6 @@ private struct ObjectsIntegrationTests { } while try await group.next() != nil {} } - _ = try await keysUpdatedPromise // Check everything is applied correctly for keyData in primitiveKeyData { @@ -2623,21 +2795,9 @@ private struct ObjectsIntegrationTests { let counter = try #require(root.get(key: "counter")?.liveCounterValue) let map = try #require(root.get(key: "map")?.liveMapValue) - let keysUpdatedPromiseUpdates1 = try root.updates() - let keysUpdatedPromiseUpdates2 = try root.updates() - async let keysUpdatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(keysUpdatedPromiseUpdates1, "counter2") - } - group.addTask { - await waitForMapKeyUpdate(keysUpdatedPromiseUpdates2, "map2") - } - while try await group.next() != nil {} - } - 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, keysUpdatedPromise) + _ = try await (setCounter2Promise, setMap2Promise) let counter2 = try #require(root.get(key: "counter2")?.liveCounterValue) let map2 = try #require(root.get(key: "map2")?.liveMapValue) @@ -2705,21 +2865,9 @@ private struct ObjectsIntegrationTests { let map = try #require(root.get(key: "map")?.liveMapValue) - let keysUpdatedPromiseUpdates1 = try map.updates() - let keysUpdatedPromiseUpdates2 = try map.updates() - async let keysUpdatedPromise: Void = withThrowingTaskGroup(of: Void.self) { group in - group.addTask { - await waitForMapKeyUpdate(keysUpdatedPromiseUpdates1, "foo") - } - group.addTask { - await waitForMapKeyUpdate(keysUpdatedPromiseUpdates2, "bar") - } - while try await group.next() != nil {} - } - async let removeFooPromise: Void = map.remove(key: "foo") async let removeBarPromise: Void = map.remove(key: "bar") - _ = try await (removeFooPromise, removeBarPromise, keysUpdatedPromise) + _ = 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") @@ -2821,9 +2969,11 @@ private struct ObjectsIntegrationTests { action: { ctx in let objects = ctx.objects - // prevent publishing of ops to realtime so we guarantee that the initial value doesn't come from a CREATE op + // 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: { _ in }) + 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") @@ -2858,12 +3008,15 @@ private struct ObjectsIntegrationTests { } catch { throw LiveObjectsError.other(error).toARTErrorInfo() } + return PublishResult(serials: objectMessages.map { _ in "fake-serial" }) }) let counter = try await objects.createCounter(count: 1) - // Counter should be created with forged initial value instead of the actual one - #expect(try counter.value == 10, "Check counter value has the expected initial value from a CREATE operation") + // 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") }, ), @@ -2878,11 +3031,12 @@ private struct ObjectsIntegrationTests { // Prevent publishing of ops to realtime so we can guarantee order of operations let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - internallyTypedObjects.testsOnly_overridePublish(with: { _ in - // Do nothing - prevent publishing + 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, should have an initial value set + // 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 @@ -3068,9 +3222,11 @@ private struct ObjectsIntegrationTests { let objects = ctx.objects let internallyTypedObjects = try #require(objects as? PublicDefaultRealtimeObjects) - internallyTypedObjects.testsOnly_overridePublish(with: { _ in }) + 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 doesn't come from a CREATE op + // 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") }, @@ -3114,13 +3270,16 @@ private struct ObjectsIntegrationTests { } catch { throw LiveObjectsError.other(error).toARTErrorInfo() } + return PublishResult(serials: objectMessages.map { _ in "fake-serial" }) }) let map = try await objects.createMap(entries: ["foo": "bar"]) - // Map should be created with forged initial value instead of the actual one + // 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 a CREATE operation after object creation") + #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") }, ), @@ -3133,13 +3292,13 @@ private struct ObjectsIntegrationTests { let objectsHelper = ctx.objectsHelper let channel = ctx.channel - // Prevent publishing of ops to realtime so we can guarantee order of operations + // 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: { _ in - // Do nothing - prevent publishing + internallyTypedObjects.testsOnly_overridePublish(with: { objectMessages in + PublishResult(serials: objectMessages.map { _ in "fake-serial" }) }) - // Create map locally, should have an initial value set + // 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 @@ -3751,7 +3910,7 @@ private struct ObjectsIntegrationTests { 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) // all arbitrary except objectsGCGracePeriod + 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) } @@ -3779,7 +3938,7 @@ private struct ObjectsIntegrationTests { 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) // all arbitrary except objectsGCGracePeriod + 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) } @@ -3993,6 +4152,558 @@ private struct ObjectsIntegrationTests { ) } } + + // 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, amount: 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, hasObjects: true) + + // 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, hasObjects: true) + + // 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, hasObjects: true) + + // 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/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift index 62278a011..d7d29e00f 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift @@ -404,7 +404,7 @@ class TestProxyTransport: ARTWebSocketTransport, @unchecked Sendable { 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) + 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) } } diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift index 9a9b143c5..669c4b017 100644 --- a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift +++ b/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift @@ -3,9 +3,10 @@ import Ably @testable import AblyLiveObjects final class MockCoreSDK: CoreSDK { - /// Synchronizes access to `_publishHandler`. + /// Synchronizes access to `_publishHandler` and `_publishCallbackHandler`. private let mutex = NSLock() - private nonisolated(unsafe) var _publishHandler: (([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void)? + 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 @@ -15,19 +16,23 @@ final class MockCoreSDK: CoreSDK { self.serverTime = serverTime } - func nosync_publish(objectMessages: [OutboundObjectMessage], callback: @escaping @Sendable (Result) -> Void) { + 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 handler: (([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void)? + var asyncHandler: (([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult)? + var callbackHandler: (([OutboundObjectMessage], @escaping @Sendable (Result) -> Void) -> Void)? mutex.withLock { - handler = _publishHandler + asyncHandler = _publishHandler + callbackHandler = _publishCallbackHandler } - if let handler { + if let callbackHandler { + callbackHandler(objectMessages, callback) + } else if let asyncHandler { let queue = channelStateMutex.dispatchQueue Task { do throws(ARTErrorInfo) { - try await handler(objectMessages) - queue.async { callback(.success(())) } + let publishResult = try await asyncHandler(objectMessages) + queue.async { callback(.success(publishResult)) } } catch { queue.async { callback(.failure(error)) } } @@ -37,7 +42,7 @@ final class MockCoreSDK: CoreSDK { } } - func testsOnly_overridePublish(with _: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void) { + func testsOnly_overridePublish(with _: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> PublishResult) { protocolRequirementNotImplemented() } @@ -45,13 +50,36 @@ final class MockCoreSDK: CoreSDK { channelStateMutex.withoutSync { $0 } } - /// Sets a custom publish handler for testing - func setPublishHandler(_ handler: @escaping ([OutboundObjectMessage]) async throws(ARTErrorInfo) -> Void) { + /// 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/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift b/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift new file mode 100644 index 000000000..e6904b1f4 --- /dev/null +++ b/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(())) + } + } +} From 2dcf9bb6658dd6da4b9de63ff048a2af31f40b73 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 24 Feb 2026 10:57:02 -0300 Subject: [PATCH 197/225] Bump protocol version to 6 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements the ObjectOperation field changes specified in spec commit 47a9d51. Integration test updates ported from JS commit fdc33cb (excluding the public API changes, since we don't yet expose any sort of ObjectOperation, nor does the spec). Note that this is the first time that we've had to bump the protocol version in a way that affects LiveObjects, and here we're following the process documented in the pinned plugin-support commit. Also note that this spec change formalises the then-unspecified change that we made in ec37cca. The downside of getting Claude to do this sort of enormous mechanical change is that for a human to review its changes with 100% confidence — in particular, all the spec point reference updates — would require almost as much work as just doing it by hand. So I haven't reviewed it with 100% confidence. I've: - glanced over the whole diff a few times - spot-checked that the most interesting behaviours are implemented and tested (specifically, the creation of the initial value JSON, the retention of the derivedFrom op and its usage when applying the create op) - prompted Claude to review its own work from scratch a few times Beyond that, I'm relying on the fact that the barely-changed integration tests continue to pass. Co-Authored-By: Claude Opus 4.6 --- .../xcshareddata/swiftpm/Package.resolved | 6 +- Package.resolved | 6 +- Package.swift | 4 +- .../Internal/DefaultInternalPlugin.swift | 6 + .../Internal/InternalDefaultLiveCounter.swift | 44 +- .../Internal/InternalDefaultLiveMap.swift | 74 ++-- .../InternalDefaultRealtimeObjects.swift | 4 +- .../Internal/InternalLiveMapValue.swift | 8 +- .../Internal/ObjectCreationHelpers.swift | 64 +-- .../Protocol/ObjectMessage.swift | 298 +++++++------ .../Protocol/WireObjectMessage.swift | 398 +++++++++++++----- .../AblyLiveObjectsTests.swift | 12 +- .../Helpers/TestFactories.swift | 48 ++- .../InternalDefaultLiveCounterTests.swift | 99 +++-- .../InternalDefaultLiveMapTests.swift | 156 ++++--- .../InternalDefaultRealtimeObjectsTests.swift | 39 +- .../JS Integration Tests/ObjectsHelper.swift | 72 ++-- .../ObjectsIntegrationTests.swift | 10 +- .../ObjectCreationHelpersTests.swift | 186 ++++---- .../WireObjectMessageTests.swift | 276 ++++++++---- 20 files changed, 1112 insertions(+), 698 deletions(-) diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7e8c8c351..307b9437c 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "58cd31c1c01a78a95f7855b224513d79a667a7d83cbeb436a18d5cc025f3ce5d", + "originHash" : "2243b041f8a3c47356becd9f9ef3c8b40f5ee96bc4a684123fbddd591a62d2fc", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa.git", "state" : { - "revision" : "fe6f8cf1e680276f4475229979595512fdc2b9e5" + "revision" : "460c1d11333dd58bbc924a3d153d1364b39bbdf0" } }, { @@ -14,7 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "8bfbbaad56aa413aa53fc399e6a4ad284177ee84" + "revision" : "f2468ee39f3e18955b0ec4fe41dc5a9906a72633" } }, { diff --git a/Package.resolved b/Package.resolved index 476fce2a3..bf91bc3ce 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "3e074d12cfa495911564b66e11593d652165b7c0b01f7402f1e59aea3039d221", + "originHash" : "2f8e8b6ffcc3449cba40f64ba2682ed6e8f3052873bb4689ed9a4926889da6e8", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa.git", "state" : { - "revision" : "fe6f8cf1e680276f4475229979595512fdc2b9e5" + "revision" : "460c1d11333dd58bbc924a3d153d1364b39bbdf0" } }, { @@ -14,7 +14,7 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "8bfbbaad56aa413aa53fc399e6a4ad284177ee84" + "revision" : "f2468ee39f3e18955b0ec4fe41dc5a9906a72633" } }, { diff --git a/Package.swift b/Package.swift index f45844cc8..12d93139b 100644 --- a/Package.swift +++ b/Package.swift @@ -21,12 +21,12 @@ let package = Package( // TODO: Unpin before release .package( url: "https://github.com/ably/ably-cocoa.git", - revision: "fe6f8cf1e680276f4475229979595512fdc2b9e5", + revision: "460c1d11333dd58bbc924a3d153d1364b39bbdf0", ), // TODO: Unpin before release .package( url: "https://github.com/ably/ably-cocoa-plugin-support", - revision: "8bfbbaad56aa413aa53fc399e6a4ad284177ee84", + revision: "f2468ee39f3e18955b0ec4fe41dc5a9906a72633", ), .package( url: "https://github.com/apple/swift-argument-parser", diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 6c69f5547..7aa07bf72 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -9,7 +9,13 @@ import ObjectiveC.NSObject 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 } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift index 4817521df..a94b77516 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -124,9 +124,9 @@ internal final class InternalDefaultLiveCounter: Sendable { action: .known(.counterInc), // RTLC12e3 objectId: mutableState.liveObjectMutableState.objectID, - counterOp: .init( - // RTLC12e4 - amount: .init(value: amount), + counterInc: .init( + // RTLC12e5 + number: .init(value: amount), ), ), ) @@ -223,7 +223,7 @@ internal final class InternalDefaultLiveCounter: Sendable { } } - /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC10. + /// 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) @@ -238,7 +238,7 @@ internal final class InternalDefaultLiveCounter: Sendable { } /// Test-only method to apply a COUNTER_INC operation, per RTLC9. - internal func testsOnly_applyCounterIncOperation(_ operation: WireObjectsCounterOp?) -> LiveObjectUpdate { + internal func testsOnly_applyCounterIncOperation(_ operation: WireCounterInc?) -> LiveObjectUpdate { mutableStateMutex.withSync { mutableState in mutableState.applyCounterIncOperation(operation) } @@ -352,7 +352,7 @@ internal final class InternalDefaultLiveCounter: Sendable { // 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 RTLC10 + // 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) @@ -362,21 +362,25 @@ internal final class InternalDefaultLiveCounter: Sendable { return ObjectDiffHelpers.calculateCounterDiff(previousData: previousData, newData: data) } - /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC10. + /// Merges the initial value from an ObjectOperation into this LiveCounter, per RTLC16. internal mutating func mergeInitialValue(from operation: ObjectOperation) -> LiveObjectUpdate { let update: LiveObjectUpdate - // RTLC10a: Add ObjectOperation.counter.count to data, if it exists - if let operationCount = operation.counter?.count?.doubleValue { + // 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 - // RTLC10c + // RTLC16c update = .update(DefaultLiveCounterUpdate(amount: operationCount)) } else { - // RTLC10d + // RTLC16d update = .noop } - // RTLC10b: Set the private flag createOperationIsMerged to true + // RTLC16b: Set the private flag createOperationIsMerged to true liveObjectMutableState.createOperationIsMerged = true return update @@ -425,11 +429,11 @@ internal final class InternalDefaultLiveCounter: Sendable { // RTLC7d1b return true case .known(.counterInc): - // RTLC7d2 - let update = applyCounterIncOperation(operation.counterOp) - // RTLC7d2a + // RTLC7d5 + let update = applyCounterIncOperation(operation.counterInc) + // RTLC7d5a liveObjectMutableState.emit(update, on: userCallbackQueue) - // RTLC7d2b + // RTLC7d5b return true case .known(.objectDelete): let dataBeforeApplyingOperation = data @@ -469,14 +473,14 @@ internal final class InternalDefaultLiveCounter: Sendable { } /// Applies a `COUNTER_INC` operation, per RTLC9. - internal mutating func applyCounterIncOperation(_ operation: WireObjectsCounterOp?) -> LiveObjectUpdate { + internal mutating func applyCounterIncOperation(_ operation: WireCounterInc?) -> LiveObjectUpdate { guard let operation else { - // RTL9e + // RTLC9h return .noop } - // RTLC9b, RTLC9d - let amount = operation.amount.doubleValue + // RTLC9f, RTLC9g + let amount = operation.number.doubleValue data += amount return .update(DefaultLiveCounterUpdate(amount: amount)) } diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index f061a5d4e..3ac68b325 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -172,11 +172,11 @@ internal final class InternalDefaultLiveMap: Sendable { action: .known(.mapSet), // RTLM20e3 objectId: mutableState.liveObjectMutableState.objectID, - mapOp: .init( - // RTLM20e4 + mapSet: .init( + // RTLM20e6 key: key, - // RTLM20e5 - data: value.nosync_toObjectData, + // RTLM20e7 + value: value.nosync_toObjectData, ), ), ) @@ -205,8 +205,8 @@ internal final class InternalDefaultLiveMap: Sendable { action: .known(.mapRemove), // RTLM21e3 objectId: mutableState.liveObjectMutableState.objectID, - mapOp: .init( - // RTLM21e4 + mapRemove: .init( + // RTLM21e5 key: key, ), ), @@ -303,7 +303,7 @@ internal final class InternalDefaultLiveMap: Sendable { } } - /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM17. + /// 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( @@ -516,7 +516,7 @@ internal final class InternalDefaultLiveMap: Sendable { return .init(objectsMapEntry: entry, tombstonedAt: tombstonedAt) } ?? [:] - // RTLM6d: If ObjectState.createOp is present, merge the initial value into the LiveMap as described in RTLM17 + // 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( @@ -533,7 +533,7 @@ internal final class InternalDefaultLiveMap: Sendable { return ObjectDiffHelpers.calculateMapDiff(previousData: previousData, newData: data) } - /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM17. + /// Merges the initial value from an ObjectOperation into this LiveMap, per RTLM23. internal mutating func mergeInitialValue( from operation: ObjectOperation, objectsPool: inout ObjectsPool, @@ -542,12 +542,16 @@ internal final class InternalDefaultLiveMap: Sendable { userCallbackQueue: DispatchQueue, clock: SimpleClock, ) -> LiveObjectUpdate { - // RTLM17a: For each key–ObjectsMapEntry pair in ObjectOperation.map.entries - let perKeyUpdates: [LiveObjectUpdate] = if let entries = operation.map?.entries { + // 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 { - // RTLM17a2: If ObjectsMapEntry.tombstone is true, apply the MAP_REMOVE operation - // as described in RTLM8, passing in the current key as ObjectsMapOp, ObjectsMapEntry.timeserial as the operation's serial, and ObjectsMapEntry.serialTimestamp as the operation's serial timestamp + // 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, @@ -556,8 +560,8 @@ internal final class InternalDefaultLiveMap: Sendable { clock: clock, ) } else { - // RTLM17a1: If ObjectsMapEntry.tombstone is false, apply the MAP_SET operation - // as described in RTLM7, passing in ObjectsMapEntry.data and the current key as ObjectsMapOp, and ObjectsMapEntry.timeserial as the operation's serial + // 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, @@ -574,10 +578,10 @@ internal final class InternalDefaultLiveMap: Sendable { [] } - // RTLM17b: Set the private flag createOperationIsMerged to true + // RTLM23b: Set the private flag createOperationIsMerged to true liveObjectMutableState.createOperationIsMerged = true - // RTLM17c: Merge the updates, skipping no-ops + // 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 { @@ -642,46 +646,46 @@ internal final class InternalDefaultLiveMap: Sendable { // RTLM15d1b return true case .known(.mapSet): - guard let mapOp = operation.mapOp else { - logger.log("Could not apply MAP_SET since operation.mapOp is missing", level: .warn) + guard let mapSet = operation.mapSet else { + logger.log("Could not apply MAP_SET since operation.mapSet is missing", level: .warn) return false } - guard let data = mapOp.data else { - logger.log("Could not apply MAP_SET since operation.data is missing", level: .warn) + guard let value = mapSet.value else { + logger.log("Could not apply MAP_SET since operation.mapSet.value is missing", level: .warn) return false } - // RTLM15d2 + // RTLM15d6 let update = applyMapSetOperation( - key: mapOp.key, + key: mapSet.key, operationTimeserial: applicableOperation.objectMessageSerial, - operationData: data, + operationData: value, objectsPool: &objectsPool, logger: logger, internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) - // RTLM15d2a + // RTLM15d6a liveObjectMutableState.emit(update, on: userCallbackQueue) - // RTLM15d2b + // RTLM15d6b return true case .known(.mapRemove): - guard let mapOp = operation.mapOp else { + guard let mapRemove = operation.mapRemove else { return false } - // RTLM15d3 + // RTLM15d7 let update = applyMapRemoveOperation( - key: mapOp.key, + key: mapRemove.key, operationTimeserial: applicableOperation.objectMessageSerial, operationSerialTimestamp: objectMessageSerialTimestamp, logger: logger, clock: clock, ) - // RTLM15d3a + // RTLM15d7a liveObjectMutableState.emit(update, on: userCallbackQueue) - // RTLM15d3b + // RTLM15d7b return true case .known(.objectDelete): let dataBeforeApplyingOperation = data @@ -723,7 +727,7 @@ internal final class InternalDefaultLiveMap: Sendable { return .noop } // RTLM7a2: Otherwise, apply the operation - // RTLM7a2a: Set ObjectsMapEntry.data to the ObjectData from 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 @@ -733,14 +737,14 @@ internal final class InternalDefaultLiveMap: Sendable { data[key] = updatedEntry } else { // RTLM7b: If an entry does not exist in the private data for the specified key - // RTLM7b1: Create a new entry in data for the specified key with the provided ObjectData and the operation's serial + // 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) } - // RTLM7c: If the operation has a non-empty ObjectData.objectId attribute + // RTLM7g: If MapSet.value.objectId is non-empty if let objectId = operationData?.objectId, !objectId.isEmpty { - // RTLM7c1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 + // RTLM7g1: Create a zero-value LiveObject in the internal ObjectsPool per RTO6 _ = objectsPool.createZeroValueObject( forObjectID: objectId, logger: logger, diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 4f3face3a..508865fed 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -241,7 +241,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } internal func createMap(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveMap { - // RTO11f4b + // RTO11f14b try await createMap(entries: [:], coreSDK: coreSDK) } @@ -303,7 +303,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } internal func createCounter(coreSDK: CoreSDK) async throws(ARTErrorInfo) -> InternalDefaultLiveCounter { - // RTO12f2a + // RTO12f12a try await createCounter(count: 0, coreSDK: coreSDK) } diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift index 7bf39f144..d8264ed89 100644 --- a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -47,9 +47,9 @@ internal enum InternalLiveMapValue: Sendable, Equatable { // 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 RTO11f4 and RTLM20e4. + /// 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 { - // RTO11f4c1: Create an ObjectsMapEntry for the current value + // RTO11f14c1: Create an ObjectsMapEntry for the current value switch self { case let .bool(value): .init(boolean: value) @@ -64,10 +64,10 @@ internal enum InternalLiveMapValue: Sendable, Equatable { case let .jsonObject(value): .init(json: .object(value)) case let .liveMap(liveMap): - // RTO11f4c1a: If the value is of type LiveMap, set ObjectsMapEntry.data.objectId to the objectId of that object + // 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): - // RTO11f4c1a: If the value is of type LiveCounter, set ObjectsMapEntry.data.objectId to the objectId of that object + // RTO11f14c1a: If the value is of type LiveCounter, set ObjectsMapEntry.data.objectId to the objectId of that object .init(objectId: liveCounter.nosync_objectID) } } diff --git a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift index e823ab030..66b17485d 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift @@ -13,11 +13,6 @@ internal enum ObjectCreationHelpers { /// 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 operation that should be merged into any created LiveCounter. - /// - /// 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 operation: ObjectOperation - /// The ObjectMessage that must be sent in order for Realtime to create the object. internal var objectMessage: OutboundObjectMessage } @@ -29,11 +24,6 @@ internal enum ObjectCreationHelpers { /// 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 operation that should be merged into any 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 operation: ObjectOperation - /// The ObjectMessage that must be sent in order for Realtime to create the object. internal var objectMessage: OutboundObjectMessage @@ -52,13 +42,11 @@ internal enum ObjectCreationHelpers { count: Double, timestamp: Date, ) -> CounterCreationOperation { - // RTO12f2: Create initial value for the new LiveCounter - let initialValue = PartialObjectOperation( - counter: WireObjectsCounter(count: NSNumber(value: count)), - ) + // RTO12f12: Create initial value for the new LiveCounter + let counterCreate = WireCounterCreate(count: NSNumber(value: count)) - // RTO12f3: Create an initial value JSON string as described in RTO13 - let initialValueJSONString = createInitialValueJSONString(from: initialValue) + // 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() @@ -78,9 +66,11 @@ internal enum ObjectCreationHelpers { let operation = ObjectOperation( action: .known(.counterCreate), objectId: objectId, - counter: WireObjectsCounter(count: NSNumber(value: count)), - nonce: nonce, - initialValue: initialValueJSONString, + counterCreateWithObjectId: .init( + initialValue: initialValueJSONString, + nonce: nonce, + derivedFrom: counterCreate, // RTO12f16 + ), ) // Create the OutboundObjectMessage @@ -90,7 +80,6 @@ internal enum ObjectCreationHelpers { return CounterCreationOperation( objectID: objectId, - operation: operation, objectMessage: objectMessage, ) } @@ -104,20 +93,19 @@ internal enum ObjectCreationHelpers { entries: [String: InternalLiveMapValue], timestamp: Date, ) -> MapCreationOperation { - // RTO11f4: Create initial value for the new LiveMap + // RTO11f14: Create initial value for the new LiveMap let mapEntries = entries.mapValues { liveMapValue -> ObjectsMapEntry in ObjectsMapEntry(data: liveMapValue.nosync_toObjectData) } - let initialValue = PartialObjectOperation( - map: ObjectsMap( - semantics: .known(.lww), - entries: mapEntries, - ), + let semantics = ObjectsMapSemantics.lww + let mapCreate = MapCreate( + semantics: .known(semantics), + entries: mapEntries, ) - // RTO11f5: Create an initial value JSON string as described in RTO13 - let initialValueJSONString = createInitialValueJSONString(from: initialValue) + // 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() @@ -134,16 +122,14 @@ internal enum ObjectCreationHelpers { ) // RTO11f9-13: Set ObjectMessage.operation fields - let semantics = ObjectsMapSemantics.lww let operation = ObjectOperation( action: .known(.mapCreate), objectId: objectId, - map: ObjectsMap( - semantics: .known(semantics), - entries: mapEntries, + mapCreateWithObjectId: .init( + initialValue: initialValueJSONString, + nonce: nonce, + derivedFrom: mapCreate, // RTO11f18 ), - nonce: nonce, - initialValue: initialValueJSONString, ) // Create the OutboundObjectMessage @@ -153,7 +139,6 @@ internal enum ObjectCreationHelpers { return MapCreationOperation( objectID: objectId, - operation: operation, objectMessage: objectMessage, semantics: semantics, ) @@ -161,11 +146,9 @@ internal enum ObjectCreationHelpers { // MARK: - Private Helper Methods - /// Creates an initial value JSON string from a PartialObjectOperation, per RTO13. - private static func createInitialValueJSONString(from initialValue: PartialObjectOperation) -> String { - // RTO13b: Encode the initial value using OM4 encoding - let partialWireObjectOperation = initialValue.toWire(format: .json) - let jsonObject = partialWireObjectOperation.toWireObject.mapValues { wireValue in + /// 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 { @@ -174,7 +157,6 @@ internal enum ObjectCreationHelpers { } } - // RTO13c return JSONObjectOrArray.object(jsonObject).toJSONString } diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 6bd2bc538..d721aa9e4 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -34,27 +34,17 @@ internal struct OutboundObjectMessage: Equatable { internal var serialTimestamp: Date? // OM2j } -/// A partial version of `ObjectOperation` that excludes the `action` and `objectId` property. Used for encoding initial values which don't include the `action` and where the `objectId` is not yet known. -/// -/// `ObjectOperation` delegates its encoding and decoding to `PartialObjectOperation`. -internal struct PartialObjectOperation { - internal var mapOp: ObjectsMapOp? // OOP3c - internal var counterOp: WireObjectsCounterOp? // OOP3d - internal var map: ObjectsMap? // OOP3e - internal var counter: WireObjectsCounter? // OOP3f - internal var nonce: String? // OOP3g - internal var initialValue: String? // OOP3h -} - internal struct ObjectOperation: Equatable { internal var action: WireEnum // OOP3a internal var objectId: String // OOP3b - internal var mapOp: ObjectsMapOp? // OOP3c - internal var counterOp: WireObjectsCounterOp? // OOP3d - internal var map: ObjectsMap? // OOP3e - internal var counter: WireObjectsCounter? // OOP3f - internal var nonce: String? // OOP3g - internal var initialValue: String? // OOP3h + internal 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 struct ObjectData: Equatable { @@ -66,9 +56,34 @@ internal struct ObjectData: Equatable { internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) } -internal struct ObjectsMapOp: Equatable { - internal var key: String // OMO2a - internal var data: ObjectData? // OMO2b +internal struct MapSet: Equatable { + internal var key: String // MST2a + internal var value: ObjectData? // MST2b +} + +internal struct MapCreate: Equatable { + internal var semantics: WireEnum // MCR2a + internal var entries: [String: ObjectsMapEntry]? // MCR2b +} + +internal struct MapCreateWithObjectId: Equatable { + internal var initialValue: String // MCRO2a + internal var nonce: String // MCRO2b + + /// The source `MapCreate` from which this `MapCreateWithObjectId` was derived. + /// For local use only (apply-on-ACK per RTLM23); must not be sent over the wire. + /// - SeeAlso: RTO11f18 + internal var derivedFrom: MapCreate? +} + +internal struct CounterCreateWithObjectId: Equatable { + internal var initialValue: String // CCRO2a + internal var nonce: String // CCRO2b + + /// The source `WireCounterCreate` from which this `CounterCreateWithObjectId` was derived. + /// For local use only (apply-on-ACK per RTLC16); must not be sent over the wire. + /// - SeeAlso: RTO12f16 + internal var derivedFrom: WireCounterCreate? } internal struct ObjectsMapEntry: Equatable { @@ -150,30 +165,22 @@ internal extension ObjectOperation { wireObjectOperation: WireObjectOperation, format: _AblyPluginSupportPrivate.EncodingFormat ) throws(ARTErrorInfo) { - // Decode the action and objectId first they're not part of PartialObjectOperation action = wireObjectOperation.action objectId = wireObjectOperation.objectId - // Delegate to PartialObjectOperation for decoding - let partialOperation = try PartialObjectOperation( - partialWireObjectOperation: PartialWireObjectOperation( - mapOp: wireObjectOperation.mapOp, - counterOp: wireObjectOperation.counterOp, - map: wireObjectOperation.map, - counter: wireObjectOperation.counter, - nonce: wireObjectOperation.nonce, - initialValue: wireObjectOperation.initialValue, - ), - format: format, - ) - - // Copy the decoded values - mapOp = partialOperation.mapOp - counterOp = partialOperation.counterOp - map = partialOperation.map - counter = partialOperation.counter - nonce = partialOperation.nonce - initialValue = partialOperation.initialValue + 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 + // 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. @@ -181,66 +188,17 @@ internal extension ObjectOperation { /// - Parameters: /// - format: The format to use when applying the encoding rules of OD4. func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireObjectOperation { - let partialWireOperation = PartialObjectOperation( - mapOp: mapOp, - counterOp: counterOp, - map: map, - counter: counter, - nonce: nonce, - initialValue: initialValue, - ).toWire(format: format) - - // Create WireObjectOperation from PartialWireObjectOperation and add action and objectId - return WireObjectOperation( + .init( action: action, objectId: objectId, - mapOp: partialWireOperation.mapOp, - counterOp: partialWireOperation.counterOp, - map: partialWireOperation.map, - counter: partialWireOperation.counter, - nonce: partialWireOperation.nonce, - initialValue: partialWireOperation.initialValue, - ) - } -} - -internal extension PartialObjectOperation { - /// Initializes a `PartialObjectOperation` from a `PartialWireObjectOperation`, applying the data decoding rules of OD5. - /// - /// - Parameters: - /// - format: The format to use when applying the decoding rules of OD5. - /// - Throws: `ARTErrorInfo` if JSON or Base64 decoding fails. - init( - partialWireObjectOperation: PartialWireObjectOperation, - format: _AblyPluginSupportPrivate.EncodingFormat - ) throws(ARTErrorInfo) { - mapOp = try partialWireObjectOperation.mapOp.map { wireObjectsMapOp throws(ARTErrorInfo) in - try .init(wireObjectsMapOp: wireObjectsMapOp, format: format) - } - counterOp = partialWireObjectOperation.counterOp - map = try partialWireObjectOperation.map.map { wireMap throws(ARTErrorInfo) in - try .init(wireObjectsMap: wireMap, format: format) - } - counter = partialWireObjectOperation.counter - - // Do not access on inbound data, per OOP3g - nonce = nil - // Do not access on inbound data, per OOP3h - initialValue = nil - } - - /// Converts this `PartialObjectOperation` to a `PartialWireObjectOperation`, applying the data encoding rules of OD4. - /// - /// - Parameters: - /// - format: The format to use when applying the encoding rules of OD4. - func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> PartialWireObjectOperation { - .init( - mapOp: mapOp?.toWire(format: format), - counterOp: counterOp, - map: map?.toWire(format: format), - counter: counter, - nonce: nonce, - initialValue: initialValue, + mapCreate: mapCreate?.toWire(format: format), + mapSet: mapSet?.toWire(format: format), + mapRemove: mapRemove, + counterCreate: counterCreate, + counterInc: counterInc, + objectDelete: objectDelete, + mapCreateWithObjectId: mapCreateWithObjectId?.toWire(), + counterCreateWithObjectId: counterCreateWithObjectId?.toWire(), ) } } @@ -349,34 +307,66 @@ internal extension ObjectData { } } -internal extension ObjectsMapOp { - /// Initializes a `ObjectsMapOp` from a `WireObjectsMapOp`, 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. +internal extension MapSet { init( - wireObjectsMapOp: WireObjectsMapOp, + wireMapSet: WireMapSet, format: _AblyPluginSupportPrivate.EncodingFormat ) throws(ARTErrorInfo) { - key = wireObjectsMapOp.key - data = try wireObjectsMapOp.data.map { wireObjectData throws(ARTErrorInfo) in + key = wireMapSet.key + value = try wireMapSet.value.map { wireObjectData throws(ARTErrorInfo) in try .init(wireObjectData: wireObjectData, format: format) } } - /// Converts this `ObjectsMapOp` to a `WireObjectsMapOp`, 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) -> WireObjectsMapOp { + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireMapSet { .init( key: key, - data: data?.toWire(format: format), + value: value?.toWire(format: format), ) } } +internal extension MapCreate { + init( + wireMapCreate: WireMapCreate, + format: _AblyPluginSupportPrivate.EncodingFormat + ) throws(ARTErrorInfo) { + semantics = wireMapCreate.semantics + entries = try wireMapCreate.entries?.ablyLiveObjects_mapValuesWithTypedThrow { wireMapEntry throws(ARTErrorInfo) in + try .init(wireObjectsMapEntry: wireMapEntry, format: format) + } + } + + func toWire(format: _AblyPluginSupportPrivate.EncodingFormat) -> WireMapCreate { + .init( + semantics: semantics, + entries: entries?.mapValues { $0.toWire(format: format) }, + ) + } +} + +internal extension MapCreateWithObjectId { + init(wireMapCreateWithObjectId: WireMapCreateWithObjectId) { + nonce = wireMapCreateWithObjectId.nonce + initialValue = wireMapCreateWithObjectId.initialValue + } + + func toWire() -> WireMapCreateWithObjectId { + .init(initialValue: initialValue, nonce: nonce) + } +} + +internal extension CounterCreateWithObjectId { + init(wireCounterCreateWithObjectId: WireCounterCreateWithObjectId) { + nonce = wireCounterCreateWithObjectId.nonce + initialValue = wireCounterCreateWithObjectId.initialValue + } + + func toWire() -> WireCounterCreateWithObjectId { + .init(initialValue: initialValue, nonce: nonce) + } +} + internal extension ObjectsMapEntry { /// Initializes an `ObjectsMapEntry` from a `WireObjectsMapEntry`, applying the data decoding rules of OD5. /// @@ -522,12 +512,14 @@ extension ObjectOperation: CustomDebugStringConvertible { parts.append("action: \(action)") parts.append("objectId: \(objectId)") - if let mapOp { parts.append("mapOp: \(mapOp)") } - if let counterOp { parts.append("counterOp: \(counterOp)") } - if let map { parts.append("map: \(map)") } - if let counter { parts.append("counter: \(counter)") } - if let nonce { parts.append("nonce: \(nonce)") } - if let initialValue { parts.append("initialValue: \(initialValue)") } + 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)") } return "{ " + parts.joined(separator: ", ") + " }" } @@ -548,17 +540,6 @@ extension ObjectState: CustomDebugStringConvertible { } } -extension ObjectsMapOp: CustomDebugStringConvertible { - internal var debugDescription: String { - var parts: [String] = [] - - parts.append("key: \(key)") - if let data { parts.append("data: \(data)") } - - return "{ " + parts.joined(separator: ", ") + " }" - } -} - extension ObjectsMap: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -604,3 +585,56 @@ extension ObjectData: CustomDebugStringConvertible { return "{ " + parts.joined(separator: ", ") + " }" } } + +extension MapSet: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("key: \(key)") + if let value { parts.append("value: \(value)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension MapCreate: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("semantics: \(semantics)") + if let entries { + let formattedEntries = entries + .map { key, entry in + "\(key): \(entry)" + } + .joined(separator: ", ") + parts.append("entries: { \(formattedEntries) }") + } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension MapCreateWithObjectId: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("initialValue: \(initialValue)") + parts.append("nonce: \(nonce)") + if let derivedFrom { parts.append("derivedFrom: \(derivedFrom)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension CounterCreateWithObjectId: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("initialValue: \(initialValue)") + parts.append("nonce: \(nonce)") + if let derivedFrom { parts.append("derivedFrom: \(derivedFrom)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 20342724a..eb879cfb8 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -158,113 +158,78 @@ internal enum ObjectsMapSemantics: Int { case lww = 0 } -/// A partial version of `WireObjectOperation` that excludes the `action` and `objectId` property. Used for encoding initial values which don't include the `action` and where the `objectId` is not yet known. -/// -/// `WireObjectOperation` delegates its encoding and decoding to `PartialWireObjectOperation`. -internal struct PartialWireObjectOperation { - internal var mapOp: WireObjectsMapOp? // OOP3c - internal var counterOp: WireObjectsCounterOp? // OOP3d - internal var map: WireObjectsMap? // OOP3e - internal var counter: WireObjectsCounter? // OOP3f - internal var nonce: String? // OOP3g - internal var initialValue: String? // OOP3h -} - -extension PartialWireObjectOperation: WireObjectCodable { - internal enum WireKey: String { - case mapOp - case counterOp - case map - case counter - case nonce - case initialValue - } - - internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { - mapOp = try wireObject.optionalDecodableValueForKey(WireKey.mapOp.rawValue) - counterOp = try wireObject.optionalDecodableValueForKey(WireKey.counterOp.rawValue) - map = try wireObject.optionalDecodableValueForKey(WireKey.map.rawValue) - counter = try wireObject.optionalDecodableValueForKey(WireKey.counter.rawValue) - - // Do not access on inbound data, per OOP3g - nonce = nil - // Do not access on inbound data, per OOP3h - initialValue = nil - } - - internal var toWireObject: [String: WireValue] { - var result: [String: WireValue] = [:] - - if let mapOp { - result[WireKey.mapOp.rawValue] = .object(mapOp.toWireObject) - } - if let counterOp { - result[WireKey.counterOp.rawValue] = .object(counterOp.toWireObject) - } - if let map { - result[WireKey.map.rawValue] = .object(map.toWireObject) - } - if let counter { - result[WireKey.counter.rawValue] = .object(counter.toWireObject) - } - if let nonce { - result[WireKey.nonce.rawValue] = .string(nonce) - } - if let initialValue { - result[WireKey.initialValue.rawValue] = .string(initialValue) - } - - return result - } -} - internal struct WireObjectOperation { internal var action: WireEnum // OOP3a internal var objectId: String // OOP3b - internal var mapOp: WireObjectsMapOp? // OOP3c - internal var counterOp: WireObjectsCounterOp? // OOP3d - internal var map: WireObjectsMap? // OOP3e - internal var counter: WireObjectsCounter? // OOP3f - internal var nonce: String? // OOP3g - internal var initialValue: String? // OOP3h + 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 } 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 } internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { - // Decode the action and objectId first since they're not part of PartialWireObjectOperation action = try wireObject.wireEnumValueForKey(WireKey.action.rawValue) objectId = try wireObject.stringValueForKey(WireKey.objectId.rawValue) - // Delegate to PartialWireObjectOperation for decoding - let partialOperation = try PartialWireObjectOperation(wireObject: wireObject) - - // Copy the decoded values - mapOp = partialOperation.mapOp - counterOp = partialOperation.counterOp - map = partialOperation.map - counter = partialOperation.counter - nonce = partialOperation.nonce - initialValue = partialOperation.initialValue + 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) + // Outbound-only — do not access on inbound data + mapCreateWithObjectId = nil + counterCreateWithObjectId = nil } internal var toWireObject: [String: WireValue] { - var result = PartialWireObjectOperation( - mapOp: mapOp, - counterOp: counterOp, - map: map, - counter: counter, - nonce: nonce, - initialValue: initialValue, - ).toWireObject - - // Add the objectId field - result[WireKey.action.rawValue] = .number(action.rawValue as NSNumber) - result[WireKey.objectId.rawValue] = .string(objectId) + 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) + } return result } @@ -324,20 +289,76 @@ extension WireObjectState: WireObjectCodable { } } -internal struct WireObjectsMapOp { - internal var key: String // OMO2a - internal var data: WireObjectData? // OMO2b +internal struct WireObjectsMap { + internal var semantics: WireEnum // OMP3a + internal var entries: [String: WireObjectsMapEntry]? // OMP3b +} + +extension WireObjectsMap: WireObjectCodable { + internal enum WireKey: String { + case semantics + case entries + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + semantics = try wireObject.wireEnumValueForKey(WireKey.semantics.rawValue) + entries = try wireObject.optionalObjectValueForKey(WireKey.entries.rawValue)?.ablyLiveObjects_mapValuesWithTypedThrow { value throws(ARTErrorInfo) in + guard case let .object(object) = value else { + throw WireValueDecodingError.wrongTypeForKey(WireKey.entries.rawValue, actualValue: value).toARTErrorInfo() + } + return try WireObjectsMapEntry(wireObject: object) + } + } + + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [ + WireKey.semantics.rawValue: .number(semantics.rawValue as NSNumber), + ] + + if let entries { + result[WireKey.entries.rawValue] = .object(entries.mapValues { .object($0.toWireObject) }) + } + + return result + } +} + +internal struct WireObjectsCounter: Equatable { + internal var count: NSNumber? // OCN2a +} + +extension WireObjectsCounter: WireObjectCodable { + internal enum WireKey: String { + case count + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + count = try wireObject.optionalNumberValueForKey(WireKey.count.rawValue) + } + + internal var toWireObject: [String: WireValue] { + var result: [String: WireValue] = [:] + if let count { + result[WireKey.count.rawValue] = .number(count) + } + return result + } +} + +internal struct WireMapSet { + internal var key: String // MST2a + internal var value: WireObjectData? // MST2b } -extension WireObjectsMapOp: WireObjectCodable { +extension WireMapSet: WireObjectCodable { internal enum WireKey: String { case key - case data + case value } internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { key = try wireObject.stringValueForKey(WireKey.key.rawValue) - data = try wireObject.optionalDecodableValueForKey(WireKey.data.rawValue) + value = try wireObject.optionalDecodableValueForKey(WireKey.value.rawValue) } internal var toWireObject: [String: WireValue] { @@ -345,40 +366,40 @@ extension WireObjectsMapOp: WireObjectCodable { WireKey.key.rawValue: .string(key), ] - if let data { - result[WireKey.data.rawValue] = .object(data.toWireObject) + if let value { + result[WireKey.value.rawValue] = .object(value.toWireObject) } return result } } -internal struct WireObjectsCounterOp: Equatable { - internal var amount: NSNumber // OCO2a +internal struct WireMapRemove: Equatable { + internal var key: String // MRM2a } -extension WireObjectsCounterOp: WireObjectCodable { +extension WireMapRemove: WireObjectCodable { internal enum WireKey: String { - case amount + case key } internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { - amount = try wireObject.numberValueForKey(WireKey.amount.rawValue) + key = try wireObject.stringValueForKey(WireKey.key.rawValue) } internal var toWireObject: [String: WireValue] { [ - WireKey.amount.rawValue: .number(amount), + WireKey.key.rawValue: .string(key), ] } } -internal struct WireObjectsMap { - internal var semantics: WireEnum // OMP3a - internal var entries: [String: WireObjectsMapEntry]? // OMP3b +internal struct WireMapCreate { + internal var semantics: WireEnum // MCR2a + internal var entries: [String: WireObjectsMapEntry]? // MCR2b } -extension WireObjectsMap: WireObjectCodable { +extension WireMapCreate: WireObjectCodable { internal enum WireKey: String { case semantics case entries @@ -407,11 +428,11 @@ extension WireObjectsMap: WireObjectCodable { } } -internal struct WireObjectsCounter: Equatable { - internal var count: NSNumber? // OCN2a +internal struct WireCounterCreate: Equatable { + internal var count: NSNumber? // CCR2a } -extension WireObjectsCounter: WireObjectCodable { +extension WireCounterCreate: WireObjectCodable { internal enum WireKey: String { case count } @@ -429,6 +450,88 @@ extension WireObjectsCounter: WireObjectCodable { } } +internal struct WireCounterInc: Equatable { + internal var number: NSNumber // CIN2a +} + +extension WireCounterInc: WireObjectCodable { + internal enum WireKey: String { + case number + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + number = try wireObject.numberValueForKey(WireKey.number.rawValue) + } + + internal var toWireObject: [String: WireValue] { + [ + WireKey.number.rawValue: .number(number), + ] + } +} + +internal struct WireObjectDelete: Equatable { + // Empty struct +} + +extension WireObjectDelete: WireObjectCodable { + internal init(wireObject _: [String: WireValue]) throws(ARTErrorInfo) { + // No fields to decode + } + + internal var toWireObject: [String: WireValue] { + [:] + } +} + +internal struct WireMapCreateWithObjectId: Equatable { + internal var initialValue: String // MCRO2a + internal var nonce: String // MCRO2b +} + +extension WireMapCreateWithObjectId: WireObjectCodable { + internal enum WireKey: String { + case nonce + case initialValue + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + nonce = try wireObject.stringValueForKey(WireKey.nonce.rawValue) + initialValue = try wireObject.stringValueForKey(WireKey.initialValue.rawValue) + } + + internal var toWireObject: [String: WireValue] { + [ + WireKey.nonce.rawValue: .string(nonce), + WireKey.initialValue.rawValue: .string(initialValue), + ] + } +} + +internal struct WireCounterCreateWithObjectId: Equatable { + internal var initialValue: String // CCRO2a + internal var nonce: String // CCRO2b +} + +extension WireCounterCreateWithObjectId: WireObjectCodable { + internal enum WireKey: String { + case nonce + case initialValue + } + + internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { + nonce = try wireObject.stringValueForKey(WireKey.nonce.rawValue) + initialValue = try wireObject.stringValueForKey(WireKey.initialValue.rawValue) + } + + internal var toWireObject: [String: WireValue] { + [ + WireKey.nonce.rawValue: .string(nonce), + WireKey.initialValue.rawValue: .string(initialValue), + ] + } +} + internal struct WireObjectsMapEntry { internal var tombstone: Bool? // OME2a internal var timeserial: String? // OME2b @@ -570,12 +673,6 @@ extension WireObjectsCounter: CustomDebugStringConvertible { } } -extension WireObjectsCounterOp: CustomDebugStringConvertible { - internal var debugDescription: String { - "{ amount: \(amount) }" - } -} - extension WireObjectsMapEntry: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -603,3 +700,72 @@ extension WireObjectData: CustomDebugStringConvertible { return "{ " + parts.joined(separator: ", ") + " }" } } + +extension WireMapSet: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("key: \(key)") + if let value { parts.append("value: \(value)") } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension WireMapRemove: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ key: \(key) }" + } +} + +extension WireMapCreate: CustomDebugStringConvertible { + internal var debugDescription: String { + var parts: [String] = [] + + parts.append("semantics: \(semantics)") + if let entries { + let formattedEntries = entries + .map { key, entry in + "\(key): \(entry)" + } + .joined(separator: ", ") + parts.append("entries: { \(formattedEntries) }") + } + + return "{ " + parts.joined(separator: ", ") + " }" + } +} + +extension WireCounterCreate: CustomDebugStringConvertible { + internal var debugDescription: String { + if let count { + "{ count: \(count) }" + } else { + "{ count: nil }" + } + } +} + +extension WireCounterInc: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ number: \(number) }" + } +} + +extension WireObjectDelete: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ }" + } +} + +extension WireMapCreateWithObjectId: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ initialValue: \(initialValue), nonce: \(nonce) }" + } +} + +extension WireCounterCreateWithObjectId: CustomDebugStringConvertible { + internal var debugDescription: String { + "{ initialValue: \(initialValue), nonce: \(nonce) }" + } +} diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift index 12e4ca39e..fbb9ef899 100644 --- a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift @@ -88,15 +88,21 @@ struct AblyLiveObjectsTests { // 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. - // (This objectId comes from copying that which was given in an expected value in an error message from Realtime) - let realtimeCreatedMapObjectID = "map:iC4Nq8EbTSEmw-_tDJdVV8HfiBvJGpZmO_WbGbh0_-4@\(currentAblyTimestamp)" + 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, - nonce: "1", + mapCreate: .init(semantics: .known(.lww), entries: nil), + mapCreateWithObjectId: .init(initialValue: initialValueJSON, nonce: "1"), ), ), ]) diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index 6199bf272..3dd68da5c 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -336,22 +336,26 @@ struct TestFactories { static func objectOperation( action: WireEnum = .known(.mapCreate), objectId: String = "test:object@123", - mapOp: ObjectsMapOp? = nil, - counterOp: WireObjectsCounterOp? = nil, - map: ObjectsMap? = nil, - counter: WireObjectsCounter? = nil, - nonce: String? = nil, - initialValue: String? = nil, + mapCreate: MapCreate? = nil, + mapSet: MapSet? = nil, + mapRemove: WireMapRemove? = nil, + counterCreate: WireCounterCreate? = nil, + counterInc: WireCounterInc? = nil, + objectDelete: WireObjectDelete? = nil, + mapCreateWithObjectId: MapCreateWithObjectId? = nil, + counterCreateWithObjectId: CounterCreateWithObjectId? = nil, ) -> ObjectOperation { ObjectOperation( action: action, objectId: objectId, - mapOp: mapOp, - counterOp: counterOp, - map: map, - counter: counter, - nonce: nonce, - initialValue: initialValue, + mapCreate: mapCreate, + mapSet: mapSet, + mapRemove: mapRemove, + counterCreate: counterCreate, + counterInc: counterInc, + objectDelete: objectDelete, + mapCreateWithObjectId: mapCreateWithObjectId, + counterCreateWithObjectId: counterCreateWithObjectId, ) } @@ -363,7 +367,7 @@ struct TestFactories { objectOperation( action: .known(.mapCreate), objectId: objectId, - map: ObjectsMap( + mapCreate: MapCreate( semantics: .known(.lww), entries: entries, ), @@ -378,13 +382,13 @@ struct TestFactories { objectOperation( action: .known(.counterCreate), objectId: objectId, - counter: WireObjectsCounter(count: count.map { NSNumber(value: $0) }), + counterCreate: WireCounterCreate(count: count.map { NSNumber(value: $0) }), ) } - /// Creates a WireObjectsCounterOp - static func counterOp(amount: Int = 10) -> WireObjectsCounterOp { - WireObjectsCounterOp(amount: NSNumber(value: amount)) + /// Creates a WireCounterInc + static func counterInc(number: Int = 10) -> WireCounterInc { + WireCounterInc(number: NSNumber(value: number)) } // MARK: - ObjectsMapEntry Factory @@ -565,9 +569,9 @@ struct TestFactories { operation: objectOperation( action: .known(.mapSet), objectId: objectId, - mapOp: ObjectsMapOp( + mapSet: MapSet( key: key, - data: ObjectData(string: value), + value: ObjectData(string: value), ), ), serial: serial, @@ -586,7 +590,7 @@ struct TestFactories { operation: objectOperation( action: .known(.mapRemove), objectId: objectId, - mapOp: ObjectsMapOp(key: key), + mapRemove: WireMapRemove(key: key), ), serial: serial, siteCode: siteCode, @@ -630,7 +634,7 @@ struct TestFactories { /// Creates an InboundObjectMessage with a COUNTER_INC operation static func counterIncOperationMessage( objectId: String = "counter:test@123", - amount: Int = 10, + number: Int = 10, serial: String = "ts1", siteCode: String = "site1", ) -> InboundObjectMessage { @@ -638,7 +642,7 @@ struct TestFactories { operation: objectOperation( action: .known(.counterInc), objectId: objectId, - counterOp: counterOp(amount: amount), + counterInc: counterInc(number: number), ), serial: serial, siteCode: siteCode, diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift index eb622aef0..a81046281 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift @@ -62,7 +62,7 @@ struct InternalDefaultLiveCounterTests { /// Tests for the case where createOp is not present struct WithoutCreateOpTests { - // @spec RTLC6b - Tests the case without createOp, as RTLC10b takes precedence when createOp exists + // @spec RTLC6b - Tests the case without createOp, as RTLC16b takes precedence when createOp exists @Test func setsCreateOperationIsMergedToFalse() { // Given: A counter whose createOperationIsMerged is true @@ -129,9 +129,9 @@ struct InternalDefaultLiveCounterTests { } } - /// Tests for RTLC10 (merge initial value from createOp) + /// Tests for RTLC16 (merge initial value from createOp) struct WithCreateOpTests { - // @spec RTLC10 - Tests that replaceData merges initial value when createOp is present + // @spec RTLC16 - Tests that replaceData merges initial value when createOp is present @Test func mergesInitialValueWhenCreateOpPresent() throws { let logger = TestLogger() @@ -145,7 +145,7 @@ struct InternalDefaultLiveCounterTests { 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 (RTLC10a) + #expect(try counter.value(coreSDK: coreSDK) == 15) // First sets to 5 (RTLC6c) then adds 10 (RTLC16a) #expect(counter.testsOnly_createOperationIsMerged) } } @@ -209,10 +209,10 @@ struct InternalDefaultLiveCounterTests { } } - /// Tests for the `mergeInitialValue` method, covering RTLC10 specification points + /// Tests for the `mergeInitialValue` method, covering RTLC16 specification points struct MergeInitialValueTests { - // @specOneOf(1/2) RTLC10a - with count - // @spec RTLC10c + // @specOneOf(1/3) RTLC16a - with count via counterCreate + // @specOneOf(1/2) RTLC16c @Test func addsCounterCountToData() throws { let logger = TestLogger() @@ -238,8 +238,43 @@ struct InternalDefaultLiveCounterTests { #expect(try #require(update.update).amount == 10) } - // @specOneOf(2/2) RTLC10a - no count - // @spec RTLC10d + // @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() @@ -256,7 +291,7 @@ struct InternalDefaultLiveCounterTests { // Apply merge operation with no count let operation = TestFactories.objectOperation( action: .known(.counterCreate), - counter: nil, // Test value - must be nil + counterCreate: nil, // Test value - must be nil ) let update = internalQueue.ably_syncNoDeadlock { counter.nosync_mergeInitialValue(from: operation) @@ -268,7 +303,7 @@ struct InternalDefaultLiveCounterTests { #expect(update.isNoop) } - // @spec RTLC10b + // @spec RTLC16b @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() @@ -332,7 +367,7 @@ struct InternalDefaultLiveCounterTests { let operation = TestFactories.counterCreateOperation(count: 10) let update = counter.testsOnly_applyCounterCreateOperation(operation) - // Verify the operation was applied - initial value merged. (The full logic of RTLC10 is tested elsewhere; we just check for some of its side effects here.) + // 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) @@ -343,24 +378,24 @@ struct InternalDefaultLiveCounterTests { /// Tests for `COUNTER_INC` operations, covering RTLC9 specification points struct CounterIncOperationTests { - // @spec RTLC9b - // @spec RTLC9d - // @spec RTLC9e + // @spec RTLC9f + // @spec RTLC9g + // @spec RTLC9h @Test( arguments: [ ( - operation: TestFactories.counterOp(amount: 10), + operation: TestFactories.counterInc(number: 10), expectedValue: 15.0, // 5 + 10 - expectedUpdate: .update(.init(amount: 10)) // RTLC9d + expectedUpdate: .update(.init(amount: 10)) // RTLC9g ), ( - operation: nil as WireObjectsCounterOp?, + operation: nil as WireCounterInc?, expectedValue: 5.0, // unchanged - expectedUpdate: .noop // RTLC9e + expectedUpdate: .noop // RTLC9h ), - ] as [(operation: WireObjectsCounterOp?, expectedValue: Double, expectedUpdate: LiveObjectUpdate)], + ] as [(operation: WireCounterInc?, expectedValue: Double, expectedUpdate: LiveObjectUpdate)], ) - func addsAmountToData(operation: WireObjectsCounterOp?, expectedValue: Double, expectedUpdate: LiveObjectUpdate) throws { + 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()) @@ -403,7 +438,7 @@ struct InternalDefaultLiveCounterTests { let operation = TestFactories.objectOperation( action: .known(.counterInc), - counterOp: TestFactories.counterOp(amount: 10), + counterInc: TestFactories.counterInc(number: 10), ) var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -470,9 +505,9 @@ struct InternalDefaultLiveCounterTests { } // @specOneOf(2/3) RTLC7c - We test this spec point for each possible operation - // @spec RTLC7d2 - Tests COUNTER_INC operation application - // @spec RTLC7d2a - // @spec RTLC7d2b + // @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 { @@ -492,7 +527,7 @@ struct InternalDefaultLiveCounterTests { let operation = TestFactories.objectOperation( action: .known(.counterInc), - counterOp: TestFactories.counterOp(amount: 10), + counterInc: TestFactories.counterInc(number: 10), ) var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -514,7 +549,7 @@ struct InternalDefaultLiveCounterTests { // Verify RTLC7c side-effect: site timeserial was updated #expect(counter.testsOnly_siteTimeserials == ["site1": "ts1"]) - // Verify update was emitted per RTLC7d2a + // Verify update was emitted per RTLC7d5a let subscriberInvocations = await subscriber.getInvocations() #expect(subscriberInvocations.map(\.0) == [.init(amount: 10)]) } @@ -529,7 +564,7 @@ struct InternalDefaultLiveCounterTests { let operation = TestFactories.objectOperation( action: .known(.counterInc), - counterOp: TestFactories.counterOp(amount: 10), + counterInc: TestFactories.counterInc(number: 10), ) var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -632,7 +667,7 @@ struct InternalDefaultLiveCounterTests { // @spec RTLC12e2 // @spec RTLC12e3 - // @spec RTLC12e4 + // @spec RTLC12e5 // @spec RTLC12g @Test func publishesCorrectObjectMessage() async throws { @@ -656,8 +691,8 @@ struct InternalDefaultLiveCounterTests { action: .known(.counterInc), // RTLC12e3 objectId: "counter:test@123", - // RTLC12e4 - counterOp: WireObjectsCounterOp(amount: NSNumber(value: 10.5)), + // RTLC12e5 + counterInc: WireCounterInc(number: NSNumber(value: 10.5)), ), ) // RTLC12g @@ -709,7 +744,7 @@ struct InternalDefaultLiveCounterTests { // RTLC12g #expect(publishedMessages.count == 1) - #expect(publishedMessages[0].operation?.counterOp?.amount == -10.5 /* i.e. assert the amount gets negated */ ) + #expect(publishedMessages[0].operation?.counterInc?.number == -10.5 /* i.e. assert the amount gets negated */ ) } } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 10b29db37..98af0f569 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -287,7 +287,7 @@ struct InternalDefaultLiveMapTests { 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; RTLM17 is tested in more detail elsewhere + // 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") @@ -621,21 +621,21 @@ struct InternalDefaultLiveMapTests { // Verify the operation was discarded - existing data unchanged #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate)?.stringValue == "existing") - // Verify that RTLM7c1 didn't happen (i.e. that we didn't create a zero-value object in the pool for object ID "new") + // 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) RTLM7c1 + // @specOneOf(1/2) RTLM7g1 // @specOneOf(1/2) RTLM7f @Test(arguments: [ - // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectsPool per RTLM7c) + // 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 RTLM7c) + // 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 RTLM7c and RTLM7c1) + // 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 { @@ -660,7 +660,7 @@ struct InternalDefaultLiveMapTests { objectsPool: &pool, ) - // Update the delegate's pool to include any objects created by the MAP_SET operation (so that when we verify RTLM7b1 using map.get it can return a referenced object) + // 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] } @@ -673,7 +673,7 @@ struct InternalDefaultLiveMapTests { #expect(result?.liveMapValue != nil) } - // RTLM7a2a: Set ObjectsMapEntry.data to the ObjectData from the operation + // 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) @@ -683,7 +683,7 @@ struct InternalDefaultLiveMapTests { // RTLM7a2c: Set ObjectsMapEntry.tombstone to false #expect(map.testsOnly_data["key1"]?.tombstone == false) - // RTLM7c/RTLM7c1: Check if zero-value object was created in pool + // RTLM7g/RTLM7g1: Check if zero-value object was created in pool if let expectedCreatedObjectID { let createdObject = pool.entries[expectedCreatedObjectID] #expect(createdObject != nil) @@ -701,16 +701,16 @@ struct InternalDefaultLiveMapTests { // MARK: - RTLM7b Tests (No Existing Entry) struct NoExistingEntryTests { - // @spec RTLM7b1 + // @spec RTLM7b4 // @spec RTLM7b2 - // @specOneOf(2/2) RTLM7c1 + // @specOneOf(2/2) RTLM7g1 // @specOneOf(2/2) RTLM7f @Test(arguments: [ - // Case 1: ObjectData refers to a number value (shouldn't modify the ObjectsPool per RTLM7c) + // 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 RTLM7c) + // 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 RTLM7c and RTLM7c1) + // 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 { @@ -728,13 +728,13 @@ struct InternalDefaultLiveMapTests { objectsPool: &pool, ) - // Update the delegate's pool to include any objects created by the MAP_SET operation (so that when we verify RTLM7b1 using map.get it can return a referenced object) + // 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 - // RTLM7b1 + // RTLM7b4 let result = try map.get(key: "newKey", coreSDK: coreSDK, delegate: delegate) if let numberValue = operationData.number { #expect(result?.numberValue == numberValue.doubleValue) @@ -746,7 +746,7 @@ struct InternalDefaultLiveMapTests { // RTLM7b2 #expect(entry.tombstone == false) - // RTLM7c/RTLM7c1: Check if zero-value object was created in pool + // 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) @@ -760,9 +760,9 @@ struct InternalDefaultLiveMapTests { } } - // MARK: - RTLM7c1 Standalone Test (RTO6a Integration) + // 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 RTLM7c1 is applied. + // 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() @@ -998,9 +998,9 @@ struct InternalDefaultLiveMapTests { } } - /// Tests for the `mergeInitialValue` method, covering RTLM17 specification points + /// Tests for the `mergeInitialValue` method, covering RTLM23 specification points struct MergeInitialValueTests { - // @spec RTLM17a1 + // @specOneOf(1/2) RTLM23a1 - via mapCreate @Test func appliesMapSetOperationsFromOperation() throws { let logger = TestLogger() @@ -1022,11 +1022,44 @@ struct InternalDefaultLiveMapTests { } // Note that we just check for some basic expected side effects of applying MAP_SET; RTLM7 is tested in more detail elsewhere - // Check that it contains the data from the operation (per RTLM17a1) + // Check that it contains the data from the operation (per RTLM23a1) #expect(try map.get(key: "keyFromCreateOp", coreSDK: coreSDK, delegate: delegate)?.stringValue == "valueFromCreateOp") } - // @spec RTLM17a2 + // @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() @@ -1064,7 +1097,7 @@ struct InternalDefaultLiveMapTests { #expect(try map.get(key: "key1", coreSDK: coreSDK, delegate: delegate) == nil) } - // @spec RTLM17c + // @spec RTLM23c @Test func returnedUpdateMergesOperationUpdates() throws { let logger = TestLogger() @@ -1082,7 +1115,7 @@ struct InternalDefaultLiveMapTests { ) var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) - // Apply merge operation with MAP_CREATE and MAP_REMOVE entries (copied from RTLM17a1 and RTLM17a2 test cases) + // 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: [ @@ -1093,7 +1126,7 @@ struct InternalDefaultLiveMapTests { ), "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 RTLM17c) + 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, @@ -1103,11 +1136,11 @@ struct InternalDefaultLiveMapTests { map.nosync_mergeInitialValue(from: operation, objectsPool: &pool) } - // Verify merged return value per RTLM17c + // Verify merged return value per RTLM23c #expect(try #require(update.update).update == ["keyThatWillBeRemoved": .removed, "keyFromCreateOp": .updated]) } - // @spec RTLM17b + // @spec RTLM23b @Test func setsCreateOperationIsMergedToTrue() { let logger = TestLogger() @@ -1180,7 +1213,7 @@ struct InternalDefaultLiveMapTests { 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 RTLM17 is tested elsewhere; we just check for some of its side effects here.) + // 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) @@ -1217,7 +1250,7 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.objectOperation( action: .known(.mapSet), - mapOp: ObjectsMapOp(key: "key1", data: ObjectData(string: "new")), + 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) @@ -1286,9 +1319,9 @@ struct InternalDefaultLiveMapTests { } // @specOneOf(2/4) RTLM15c - We test this spec point for each possible operation - // @spec RTLM15d2 - Tests MAP_SET operation application - // @spec RTLM15d2a - // @spec RTLM15d2b + // @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 { @@ -1318,7 +1351,7 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.objectOperation( action: .known(.mapSet), - mapOp: ObjectsMapOp(key: "key1", data: ObjectData(string: "new")), + mapSet: MapSet(key: "key1", value: ObjectData(string: "new")), ) // Apply MAP_SET operation @@ -1339,15 +1372,15 @@ struct InternalDefaultLiveMapTests { // Verify RTLM15c side-effect: site timeserial was updated #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) - // Verify update was emitted per RTLM15d2a + // Verify update was emitted per RTLM15d6a let subscriberInvocations = await subscriber.getInvocations() #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .updated])]) } // @specOneOf(3/4) RTLM15c - We test this spec point for each possible operation - // @spec RTLM15d3 - Tests MAP_REMOVE operation application - // @spec RTLM15d3a - // @spec RTLM15d3b + // @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 { @@ -1377,7 +1410,7 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.objectOperation( action: .known(.mapRemove), - mapOp: ObjectsMapOp(key: "key1", data: ObjectData()), + mapRemove: WireMapRemove(key: "key1"), ) // Apply MAP_REMOVE operation @@ -1398,7 +1431,7 @@ struct InternalDefaultLiveMapTests { // Verify RTLM15c side-effect: site timeserial was updated #expect(map.testsOnly_siteTimeserials == ["site1": "ts1"]) - // Verify update was emitted per RTLM15d3a + // Verify update was emitted per RTLM15d7a let subscriberInvocations = await subscriber.getInvocations() #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .removed])]) } @@ -1414,7 +1447,7 @@ struct InternalDefaultLiveMapTests { let operation = TestFactories.objectOperation( action: .known(.mapSet), - mapOp: ObjectsMapOp(key: "key1", data: ObjectData(string: "new")), + mapSet: MapSet(key: "key1", value: ObjectData(string: "new")), ) var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) @@ -1495,28 +1528,28 @@ struct InternalDefaultLiveMapTests { // @specUntested RTLM20e1 - Not needed with Swift's type system // @spec RTLM20e2 // @spec RTLM20e3 - // @spec RTLM20e4 - // @spec RTLM20e5a - // @spec RTLM20e5b - // @spec RTLM20e5c - // @spec RTLM20e5d - // @spec RTLM20e5e - // @spec RTLM20e5f + // @spec RTLM20e6 + // @spec RTLM20e7a + // @spec RTLM20e7b + // @spec RTLM20e7c + // @spec RTLM20e7d + // @spec RTLM20e7e + // @spec RTLM20e7f // @spec RTLM20g @Test(arguments: [ - // RTLM20e5a + // 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")), - // RTLM20e5b + // RTLM20e7b (value: { @Sendable _ in .jsonArray(["test"]) }, expectedData: .init(json: .array(["test"]))), (value: { @Sendable _ in .jsonObject(["foo": "bar"]) }, expectedData: .init(json: .object(["foo": "bar"]))), - // RTLM20e5c + // RTLM20e7c (value: { @Sendable _ in .string("test") }, expectedData: .init(string: "test")), - // RTLM20e5d + // RTLM20e7d (value: { @Sendable _ in .number(42.5) }, expectedData: .init(number: NSNumber(value: 42.5))), - // RTLM20e5e + // RTLM20e7e (value: { @Sendable _ in .bool(true) }, expectedData: .init(boolean: true)), - // RTLM20e5f + // 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 { @@ -1540,11 +1573,11 @@ struct InternalDefaultLiveMapTests { action: .known(.mapSet), // RTLM20e3 objectId: "map:test@123", - mapOp: ObjectsMapOp( - // RTLM20e4 + mapSet: MapSet( + // RTLM20e6 key: "testKey", - // RTLM20e5 - data: expectedData, + // RTLM20e7 + value: expectedData, ), ), ) @@ -1602,7 +1635,7 @@ struct InternalDefaultLiveMapTests { // @specUntested RTLM21e1 - Not needed with Swift's type system // @spec RTLM21e2 // @spec RTLM21e3 - // @spec RTLM21e4 + // @spec RTLM21e5 // @spec RTLM21g @Test func publishesCorrectObjectMessage() async throws { @@ -1626,10 +1659,9 @@ struct InternalDefaultLiveMapTests { action: .known(.mapRemove), // RTLM21e3 objectId: "map:test@123", - mapOp: ObjectsMapOp( - // RTLM21e4 + mapRemove: WireMapRemove( + // RTLM21e5 key: "testKey", - data: nil, ), ), ) diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index aa398210e..e2c32f5dc 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -855,7 +855,7 @@ struct InternalDefaultRealtimeObjectsTests { // Send an echoed OBJECT message with the same serial let echoMessage = TestFactories.counterIncOperationMessage( objectId: counter.testsOnly_objectID, - amount: 10, + number: 10, serial: serial, ) internalQueue.ably_syncNoDeadlock { @@ -907,7 +907,7 @@ struct InternalDefaultRealtimeObjectsTests { let incrementSerial = "serial_inc" let echoMessage = TestFactories.counterIncOperationMessage( objectId: objectId, - amount: 5, + number: 5, serial: incrementSerial, siteCode: siteCode, ) @@ -1196,7 +1196,7 @@ struct InternalDefaultRealtimeObjectsTests { // Create a COUNTER_INC operation message let operationMessage = TestFactories.counterIncOperationMessage( objectId: objectId, - amount: 10, + number: 10, serial: "ts2", // Higher than existing "ts1" siteCode: "site1", ) @@ -1262,7 +1262,7 @@ struct InternalDefaultRealtimeObjectsTests { // Inject second OBJECT ProtocolMessage during sync (RTO8a) let secondObjectMessage = TestFactories.counterIncOperationMessage( objectId: "counter:1@456", - amount: 10, + number: 10, serial: "ts4", // Higher than sync data "ts2" siteCode: "site1", ) @@ -1372,9 +1372,10 @@ struct InternalDefaultRealtimeObjectsTests { 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?.map?.entries == [ - "stringKey": .init(data: .init(string: "stringValue")), - ]) + #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"))]) @@ -1383,7 +1384,7 @@ struct InternalDefaultRealtimeObjectsTests { // @specUntested RTO11h3d - This spec point covers a logic error so let's not try to test - // @spec RTO11f4b + // @spec RTO11f14b @Test func withNoEntriesArgumentCreatesEmptyMap() async throws { let internalQueue = TestFactories.createInternalQueue() @@ -1410,9 +1411,10 @@ struct InternalDefaultRealtimeObjectsTests { #expect(publishedMessages.count == 1) let publishedMessage = publishedMessages[0] - // Verify map operation has empty entries per RTO11f4b - let mapOperation = publishedMessage.operation?.map - #expect(mapOperation?.entries?.isEmpty == true) + // 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) @@ -1520,7 +1522,10 @@ struct InternalDefaultRealtimeObjectsTests { 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?.counter?.count == 10.5) + #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) @@ -1529,7 +1534,7 @@ struct InternalDefaultRealtimeObjectsTests { // @specUntested RTO12h3d - This spec point covers a logic error so let's not try to test - // @spec RTO12f2a + // @specOneOf(2/2) RTO12f12a @Test func withNoEntriesArgumentCreatesWithZeroValue() async throws { let internalQueue = TestFactories.createInternalQueue() @@ -1556,10 +1561,10 @@ struct InternalDefaultRealtimeObjectsTests { #expect(publishedMessages.count == 1) let publishedMessage = publishedMessages[0] - // Verify counter operation has zero count per RTO12f2a - let counterOperation = publishedMessage.operation?.counter - // swiftlint:disable:next empty_count - #expect(counterOperation?.count == 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) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift index 92b79a0b3..d3b329702 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift @@ -131,22 +131,21 @@ final class ObjectsHelper: Sendable { /// 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)), - "nonce": .string(nonce()), - "map": .object(["semantics": .number(NSNumber(value: 0))]), + "mapCreate": .object(mapCreate), ] if let objectId { operation["objectId"] = .string(objectId) } - if let entries { - var mapValue = operation["map"]!.objectValue! - mapValue["entries"] = .object(entries) - operation["map"] = .object(mapValue) - } - return ["operation": .object(operation)] } @@ -156,9 +155,9 @@ final class ObjectsHelper: Sendable { "operation": .object([ "action": .number(NSNumber(value: Actions.mapSet.rawValue)), "objectId": .string(objectId), - "mapOp": .object([ + "mapSet": .object([ "key": .string(key), - "data": data, + "value": data, ]), ]), ] @@ -170,7 +169,7 @@ final class ObjectsHelper: Sendable { "operation": .object([ "action": .number(NSNumber(value: Actions.mapRemove.rawValue)), "objectId": .string(objectId), - "mapOp": .object([ + "mapRemove": .object([ "key": .string(key), ]), ]), @@ -179,30 +178,31 @@ final class ObjectsHelper: Sendable { /// 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)), - "nonce": .string(nonce()), + "counterCreate": .object(counterCreate), ] if let objectId { operation["objectId"] = .string(objectId) } - if let count { - operation["counter"] = .object(["count": .number(NSNumber(value: count))]) - } - return ["operation": .object(operation)] } /// Creates a counter increment operation - func counterIncOp(objectId: String, amount: Int) -> [String: WireValue] { + func counterIncOp(objectId: String, number: Int) -> [String: WireValue] { [ "operation": .object([ "action": .number(NSNumber(value: Actions.counterInc.rawValue)), "objectId": .string(objectId), - "counterOp": .object([ - "amount": .number(NSNumber(value: amount)), + "counterInc": .object([ + "number": .number(NSNumber(value: number)), ]), ]), ] @@ -214,6 +214,7 @@ final class ObjectsHelper: Sendable { "operation": .object([ "action": .number(NSNumber(value: Actions.objectDelete.rawValue)), "objectId": .string(objectId), + "objectDelete": .object([:]), ]), ] } @@ -405,14 +406,22 @@ final class ObjectsHelper: Sendable { /// Creates a map create REST operation func mapCreateRestOp(objectId: String? = nil, nonce: String? = nil, data: [String: JSONValue]? = nil) -> [String: JSONValue] { - var opBody: [String: JSONValue] = [ - "operation": .string(Actions.mapCreate.stringValue), + var mapCreate: [String: JSONValue] = [ + "semantics": .number(0), ] if let data { - opBody["data"] = .object(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 ?? "") @@ -424,9 +433,8 @@ final class ObjectsHelper: Sendable { /// Creates a map set REST operation func mapSetRestOp(objectId: String, key: String, value: [String: JSONValue]) -> [String: JSONValue] { [ - "operation": .string(Actions.mapSet.stringValue), "objectId": .string(objectId), - "data": .object([ + "mapSet": .object([ "key": .string(key), "value": .object(value), ]), @@ -436,9 +444,8 @@ final class ObjectsHelper: Sendable { /// Creates a map remove REST operation func mapRemoveRestOp(objectId: String, key: String) -> [String: JSONValue] { [ - "operation": .string(Actions.mapRemove.stringValue), "objectId": .string(objectId), - "data": .object([ + "mapRemove": .object([ "key": .string(key), ]), ] @@ -446,14 +453,16 @@ final class ObjectsHelper: Sendable { /// Creates a counter create REST operation func counterCreateRestOp(objectId: String? = nil, nonce: String? = nil, number: Double? = nil) -> [String: JSONValue] { - var opBody: [String: JSONValue] = [ - "operation": .string(Actions.counterCreate.stringValue), - ] + var counterCreate: [String: JSONValue] = [:] if let number { - opBody["data"] = .object(["number": .number(number)]) + counterCreate["count"] = .number(number) } + var opBody: [String: JSONValue] = [ + "counterCreate": .object(counterCreate), + ] + if let objectId { opBody["objectId"] = .string(objectId) opBody["nonce"] = .string(nonce ?? "") @@ -465,9 +474,8 @@ final class ObjectsHelper: Sendable { /// Creates a counter increment REST operation func counterIncRestOp(objectId: String, number: Double) -> [String: JSONValue] { [ - "operation": .string(Actions.counterInc.stringValue), "objectId": .string(objectId), - "data": .object(["number": .number(number)]), + "counterInc": .object(["number": .number(number)]), ] } diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 7614111ba..9a2cd3b2f 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -1693,7 +1693,7 @@ private struct ObjectsIntegrationTests { channel: channel, serial: lexicoTimeserial(seriesId: "aaa", timestamp: 5, counter: 0), siteCode: "aaa", - state: [objectsHelper.counterIncOp(objectId: counterResult1.objectId, amount: 1)], + state: [objectsHelper.counterIncOp(objectId: counterResult1.objectId, number: 1)], ) // Objects should still be deleted @@ -1830,7 +1830,7 @@ private struct ObjectsIntegrationTests { channel: channel, serial: testCase.serial, siteCode: testCase.siteCode, - state: [objectsHelper.counterIncOp(objectId: counterId, amount: testCase.amount)], + state: [objectsHelper.counterIncOp(objectId: counterId, number: testCase.amount)], ) } @@ -1957,7 +1957,7 @@ private struct ObjectsIntegrationTests { channel: channel, serial: lexicoTimeserial(seriesId: "bbb", timestamp: 1, counter: 0), siteCode: "bbb", - state: [objectsHelper.counterIncOp(objectId: counterId, amount: 1)], + state: [objectsHelper.counterIncOp(objectId: counterId, number: 1)], ) await objectsHelper.processObjectOperationMessageOnChannel( channel: channel, @@ -2440,7 +2440,7 @@ private struct ObjectsIntegrationTests { channel: channel, serial: operation.serial, siteCode: operation.siteCode, - state: [objectsHelper.counterIncOp(objectId: counterId, amount: Int(operation.amount))], + state: [objectsHelper.counterIncOp(objectId: counterId, number: Int(operation.amount))], ) } @@ -4416,7 +4416,7 @@ private struct ObjectsIntegrationTests { channel: channel, serial: injectedSerial, siteCode: counterCreateSiteCode, - state: [objectsHelper.counterIncOp(objectId: counterId, amount: 100)], + state: [objectsHelper.counterIncOp(objectId: counterId, number: 100)], ) // Step 9: Assert counter.value == 115 diff --git a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift index 9f88051dd..79a434b82 100644 --- a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift @@ -4,20 +4,18 @@ import Testing struct ObjectCreationHelpersTests { struct CreationOperationTests { - // @spec RTO11f4c - // @spec RTO11f4c1a - // @spec RTO11f4c1a - // @spec RTO11f4c1b - // @spec RTO11f4c1c - // @spec RTO11f4c1d - // @spec RTO11f4c1e - // @spec RTO11f4c1f - // @spec RTO11f4c2 + // @spec RTO11f14c + // @spec RTO11f14c1a + // @spec RTO11f14c1b + // @spec RTO11f14c1c + // @spec RTO11f14c1d + // @spec RTO11f14c1e + // @spec RTO11f14c1f + // @spec RTO11f14c2 // @spec RTO11f9 - // @spec RTO11f11 - // @spec RTO11f12 - // @spec RTO11f13 - // @specOneOf(1/2) RTO13 + // @spec RTO11f16 + // @spec RTO11f17 + // @spec RTO11f15 @available(iOS 16.0.0, tvOS 16.0.0, *) // because of using Regex @Test func map() throws { @@ -33,20 +31,20 @@ struct ObjectCreationHelpersTests { let creationOperation = internalQueue.ably_syncNoDeadlock { ObjectCreationHelpers.nosync_creationOperationForLiveMap( entries: [ - // RTO11f4c1a + // RTO11f14c1a "mapRef": .liveMap(referencedMap), - // RTO11f4c1a + // RTO11f14c1a "counterRef": .liveCounter(referencedCounter), - // RTO11f4c1b + // RTO11f14c1b "jsonArrayKey": .jsonArray([.string("arrayItem1"), .string("arrayItem2")]), "jsonObjectKey": .jsonObject(["nestedKey": .string("nestedValue")]), - // RTO11f4c1c + // RTO11f14c1c "stringKey": .string("stringValue"), - // RTO11f4c1d + // RTO11f14c1d "numberKey": .number(42.5), - // RTO11f4c1e + // RTO11f14c1e "booleanKey": .bool(true), - // RTO11f4c1f + // RTO11f14c1f "dataKey": .data(Data([0x01, 0x02, 0x03])), ], timestamp: timestamp, @@ -55,71 +53,70 @@ struct ObjectCreationHelpersTests { // Then - // Check that the denormalized properties match those of the ObjectMessage - #expect(creationOperation.objectMessage.operation == creationOperation.operation) - #expect(creationOperation.objectMessage.operation?.map?.semantics == .known(creationOperation.semantics)) + // 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 initialValue property per RTO11f12, using the RTO11f4 partial ObjectOperation and correctly encoded per RTO13 - let initialValueString = try #require(creationOperation.operation.initialValue) + // 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 == [ - "map": [ - // RTO11f4a - "semantics": .number(Double(ObjectsMapSemantics.lww.rawValue)), - "entries": [ - // RTO11f4c1a - "mapRef": [ - "data": [ - "objectId": "referencedMapID", - ], + // RTO11f14a + "semantics": .number(Double(ObjectsMapSemantics.lww.rawValue)), + "entries": [ + // RTO11f14c1a + "mapRef": [ + "data": [ + "objectId": "referencedMapID", ], - "counterRef": [ - "data": [ - "objectId": "referencedCounterID", - ], + ], + "counterRef": [ + "data": [ + "objectId": "referencedCounterID", ], - // RTO11f4c1b - "jsonArrayKey": [ - "data": [ - "json": #"["arrayItem1","arrayItem2"]"#, - ], + ], + // RTO11f14c1b + "jsonArrayKey": [ + "data": [ + "json": #"["arrayItem1","arrayItem2"]"#, ], - "jsonObjectKey": [ - "data": [ - "json": #"{"nestedKey":"nestedValue"}"#, - ], + ], + "jsonObjectKey": [ + "data": [ + "json": #"{"nestedKey":"nestedValue"}"#, ], - // RTO11f4c1c - "stringKey": [ - "data": [ - "string": "stringValue", - ], + ], + // RTO11f14c1c + "stringKey": [ + "data": [ + "string": "stringValue", ], - // RTO11f4c1d - "numberKey": [ - "data": [ - "number": 42.5, - ], + ], + // RTO11f14c1d + "numberKey": [ + "data": [ + "number": 42.5, ], - // RTO11f4c1e - "booleanKey": [ - "data": [ - "boolean": true, - ], + ], + // RTO11f14c1e + "booleanKey": [ + "data": [ + "boolean": true, ], - // RTO11f4c1f - "dataKey": [ - "data": [ - "bytes": .string(Data([0x01, 0x02, 0x03]).base64EncodedString()), - ], + ], + // RTO11f14c1f + "dataKey": [ + "data": [ + "bytes": .string(Data([0x01, 0x02, 0x03]).base64EncodedString()), ], ], ], ]) - // Check that the partial ObjectOperation properties are set on the ObjectMessage, per RTO11f13 + // Check that derivedFrom has the mapCreate properties + let derivedMapCreate = try #require(mapCreateWithObjectId.derivedFrom) - #expect(creationOperation.objectMessage.operation?.map?.semantics == .known(.lww)) + #expect(derivedMapCreate.semantics == .known(.lww)) let expectedEntries: [String: ObjectsMapEntry] = [ "mapRef": .init(data: .init(objectId: "referencedMapID")), @@ -131,26 +128,27 @@ struct ObjectCreationHelpersTests { "booleanKey": .init(data: .init(boolean: true)), "dataKey": .init(data: .init(bytes: Data([0x01, 0x02, 0x03]))), ] - #expect(creationOperation.objectMessage.operation?.map?.entries == expectedEntries) + #expect(derivedMapCreate.entries == expectedEntries) - // Check the other ObjectMessage properties + // Check the other ObjectMessage.operation properties + let operation = try #require(creationOperation.objectMessage.operation) // RTO11f9 - #expect(creationOperation.operation.action == .known(.mapCreate)) + #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: creationOperation.operation.objectId) != nil) + #expect(try /map:.*@1754042434000/.firstMatch(in: operation.objectId) != nil) - // Check that nonce has been populated per RTO11f11 (we make no assertions about its format or randomness) - #expect(creationOperation.operation.nonce != nil) + // Check that nonce has been populated per RTO11f16, and has 16+ characters per RTO11f6 + #expect(mapCreateWithObjectId.nonce.count >= 16) } - // @spec RTO12f2a - // @spec RTO12f10 + // @specOneOf(1/2) RTO12f12a + // @spec RTO12f15 // @spec RTO12f6 // @spec RTO12f8 - // @spec RTO12f9 - // @specOneOf(2/2) RTO13 + // @spec RTO12f14 + // @spec RTO12f13 @Test @available(iOS 16.0.0, tvOS 16.0.0, *) // because of using Regex func counter() throws { @@ -165,33 +163,31 @@ struct ObjectCreationHelpersTests { // Then - // Check that the denormalized properties match those of the ObjectMessage - #expect(creationOperation.objectMessage.operation == creationOperation.operation) + // 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 initialValue property per RTO12f10, using the RTO12f2 partial ObjectOperation and correctly encoded per RTO13 - let initialValueString = try #require(creationOperation.operation.initialValue) + // 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 == [ - "counter": [ - // RTO12f2a - "count": 10.5, - ], + // RTO12f12a + "count": 10.5, ]) - // Check that the partial ObjectOperation properties are set on the ObjectMessage, per RTO12f10 - - #expect(creationOperation.objectMessage.operation?.counter?.count == 10.5) + // Check that derivedFrom has the counterCreate properties + #expect(counterCreateWithObjectId.derivedFrom?.count == 10.5) - // Check the other ObjectMessage properties + // Check the other ObjectMessage.operation properties + let operation = try #require(creationOperation.objectMessage.operation) // RTO12f7 - #expect(creationOperation.operation.action == .known(.counterCreate)) + #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: creationOperation.operation.objectId) != nil) + #expect(try /counter:.*@1754042434000/.firstMatch(in: operation.objectId) != nil) - // Check that nonce has been populated per RTO12f9 (we make no assertions about its format or randomness) - #expect(creationOperation.operation.nonce != nil) + // Check that nonce has been populated per RTO12f14, and has 16+ characters per RTO12f4 + #expect(counterCreateWithObjectId.nonce.count >= 16) } } diff --git a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift index d2228142a..b1c483387 100644 --- a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -112,12 +112,6 @@ enum WireObjectMessageTests { operation: WireObjectOperation( action: .known(.mapCreate), objectId: "obj1", - mapOp: nil, - counterOp: nil, - map: nil, - counter: nil, - nonce: nil, - initialValue: nil, ), object: nil, serial: "s1", @@ -159,35 +153,33 @@ enum WireObjectMessageTests { } struct WireObjectOperationTests { - // @spec OOP3g - // @spec OOP3h - // @spec OOP3i @Test func decodesAllFields() throws { let wire: [String: WireValue] = [ "action": 0, // mapCreate "objectId": "obj1", - "mapOp": ["key": "key1", "data": ["string": "value1"]], - "counterOp": ["amount": 42], - "map": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], - "counter": ["count": 42], - "nonce": "nonce1", + "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": [:], ] let op = try WireObjectOperation(wireObject: wire) #expect(op.action == .known(.mapCreate)) #expect(op.objectId == "obj1") - #expect(op.mapOp?.key == "key1") - #expect(op.mapOp?.data?.string == "value1") - #expect(op.counterOp?.amount == 42) - #expect(op.map?.semantics == .known(.lww)) - #expect(op.map?.entries?["key1"]?.data?.string == "value1") - #expect(op.map?.entries?["key1"]?.tombstone == false) - #expect(op.counter?.count == 42) - - // Per OOP3g we should not try and extract this - #expect(op.nonce == nil) - // Per OOP3h we should not try and extract this - #expect(op.initialValue == nil) + #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) + // Outbound-only — do not access on inbound data + #expect(op.mapCreateWithObjectId == nil) + #expect(op.counterCreateWithObjectId == nil) } @Test @@ -199,12 +191,14 @@ enum WireObjectMessageTests { let op = try WireObjectOperation(wireObject: wire) #expect(op.action == .known(.mapCreate)) #expect(op.objectId == "obj1") - #expect(op.mapOp == nil) - #expect(op.counterOp == nil) - #expect(op.map == nil) - #expect(op.counter == nil) - #expect(op.nonce == nil) - #expect(op.initialValue == nil) + #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.mapCreateWithObjectId == nil) + #expect(op.counterCreateWithObjectId == nil) } @Test @@ -222,25 +216,22 @@ enum WireObjectMessageTests { let op = WireObjectOperation( action: .known(.mapCreate), objectId: "obj1", - mapOp: WireObjectsMapOp(key: "key1", data: WireObjectData(string: "value1")), - counterOp: WireObjectsCounterOp(amount: 42), - map: WireObjectsMap( + mapCreate: WireMapCreate( semantics: .known(.lww), entries: ["key1": WireObjectsMapEntry(tombstone: false, timeserial: nil, data: WireObjectData(string: "value1"))], ), - counter: WireObjectsCounter(count: 42), - nonce: "nonce1", - initialValue: nil, + 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", - "mapOp": ["key": "key1", "data": ["string": "value1"]], - "counterOp": ["amount": 42], - "map": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], - "counter": ["count": 42], - "nonce": "nonce1", + "mapCreate": ["semantics": 0, "entries": ["key1": ["data": ["string": "value1"], "tombstone": false]]], + "mapSet": ["key": "key1", "value": ["string": "value1"]], + "counterCreate": ["count": 42], + "counterInc": ["number": 10], ]) } @@ -249,12 +240,6 @@ enum WireObjectMessageTests { let op = WireObjectOperation( action: .known(.mapCreate), objectId: "obj1", - mapOp: nil, - counterOp: nil, - map: nil, - counter: nil, - nonce: nil, - initialValue: nil, ) let wire = op.toWireObject #expect(wire == [ @@ -312,12 +297,6 @@ enum WireObjectMessageTests { createOp: WireObjectOperation( action: .known(.mapCreate), objectId: "obj1", - mapOp: nil, - counterOp: nil, - map: nil, - counter: nil, - nonce: nil, - initialValue: nil, ), map: WireObjectsMap( semantics: .known(.lww), @@ -414,44 +393,44 @@ enum WireObjectMessageTests { } } - struct WireMapOpTests { + struct WireMapSetTests { @Test func decodesAllFields() throws { let json: [String: WireValue] = [ "key": "key1", - "data": ["string": "value1"], + "value": ["string": "value1"], ] - let op = try WireObjectsMapOp(wireObject: json) + let op = try WireMapSet(wireObject: json) #expect(op.key == "key1") - #expect(op.data?.string == "value1") + #expect(op.value?.string == "value1") } @Test func decodesWithOptionalFieldsAbsent() throws { let json: [String: WireValue] = ["key": "key1"] - let op = try WireObjectsMapOp(wireObject: json) + let op = try WireMapSet(wireObject: json) #expect(op.key == "key1") - #expect(op.data == nil) + #expect(op.value == nil) } @Test func encodesAllFields() { - let op = WireObjectsMapOp( + let op = WireMapSet( key: "key1", - data: WireObjectData(string: "value1"), + value: WireObjectData(string: "value1"), ) let wire = op.toWireObject #expect(wire == [ "key": "key1", - "data": ["string": "value1"], + "value": ["string": "value1"], ]) } @Test func encodesWithOptionalFieldsNil() { - let op = WireObjectsMapOp( + let op = WireMapSet( key: "key1", - data: nil, + value: nil, ) let wire = op.toWireObject #expect(wire == [ @@ -460,19 +439,172 @@ enum WireObjectMessageTests { } } - struct WireCounterOpTests { + 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] = ["amount": 42] - let op = try WireObjectsCounterOp(wireObject: json) - #expect(op.amount == 42) + 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 = WireObjectsCounterOp(amount: 42) + let op = WireCounterCreateWithObjectId(initialValue: "someJSON", nonce: "abc123") let wire = op.toWireObject - #expect(wire == ["amount": 42]) + #expect(wire == [ + "nonce": "abc123", + "initialValue": "someJSON", + ]) } } From 709f8a6336fa56690d5118365ccdfa5617810712 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 5 Mar 2026 10:58:26 -0300 Subject: [PATCH 198/225] Add test for clearing local state on ATTACHED without HAS_OBJECTS Port of ably-js commit b329fd2. Verifies that when an ATTACHED message is received without the HAS_OBJECTS flag, the SDK clears all local LiveObjects state (root map should have no keys). Co-Authored-By: Claude Opus 4.6 --- .../ObjectsIntegrationTests.swift | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 7614111ba..461a3ed1e 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -605,6 +605,22 @@ private struct ObjectsIntegrationTests { 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, hasObjects: false) + + // 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, From 9c20eb8b0574022c13f3a6c42343a6140f30c108 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 5 Mar 2026 09:58:42 -0300 Subject: [PATCH 199/225] Add Equatable conformance to InboundObjectMessage Will be useful for some upcoming tests. Co-Authored-By: Claude Opus 4.6 --- Sources/AblyLiveObjects/Protocol/ObjectMessage.swift | 2 +- Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index d721aa9e4..4782a063c 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -5,7 +5,7 @@ import Foundation // This file contains the ObjectMessage types that we use within the codebase. We convert them to and from the corresponding wire types (e.g. `InboundWireObjectMessage`) for sending and receiving over the wire. /// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. -internal struct InboundObjectMessage { +internal struct InboundObjectMessage: Equatable { internal var id: String? // OM2a internal var clientId: String? // OM2b internal var connectionId: String? // OM2c diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index eb879cfb8..035f72fda 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -631,7 +631,7 @@ extension WireObjectData: WireObjectCodable { /// A type that can be either a string or binary data. /// /// Used to represent the values that `WireObjectData.bytes` might hold, after being encoded per OD4 or before being decoded per OD5. -internal enum StringOrData: WireCodable { +internal enum StringOrData: Equatable, WireCodable { case string(String) case data(Data) From 98178fc706f082175f3c2ce85b9a825a8588a810 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 24 Feb 2026 09:51:30 -0300 Subject: [PATCH 200/225] Extract SyncObjectsPool struct Give the sync objects pool its own type instead of using a bare [SyncObjectsPoolEntry] array. This makes the concept explicit and is preparation for implementing partial object sync, which will expand the capabilities of this type. I wouldn't pay too much attention to the tests; they'll be replaced soon. Co-Authored-By: Claude Opus 4.6 --- .../InternalDefaultRealtimeObjects.swift | 10 +-- .../Internal/ObjectsPool.swift | 2 +- .../Internal/SyncObjectsPool.swift | 42 +++++++++++ .../Internal/SyncObjectsPoolEntry.swift | 15 ---- .../ObjectsPoolTests.swift | 16 ++-- .../SyncObjectsPoolTests.swift | 74 +++++++++++++++++++ 6 files changed, 130 insertions(+), 29 deletions(-) create mode 100644 Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift delete mode 100644 Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift create mode 100644 Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 508865fed..31d8c23ba 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -98,7 +98,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO internal var id: String /// The `ObjectMessage`s gathered during this sync sequence. - internal var syncObjectsPool: [SyncObjectsPoolEntry] + internal var syncObjectsPool: SyncObjectsPool } internal init( @@ -792,14 +792,14 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO let syncObjectsPoolEntries = objectMessages.compactMap { objectMessage in if let object = objectMessage.object { - SyncObjectsPoolEntry(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) + SyncObjectsPool.Entry(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) } else { nil } } // If populated, this contains a full set of sync data for the channel, and should be applied to the ObjectsPool. - let completedSyncObjectsPool: [SyncObjectsPoolEntry]? + let completedSyncObjectsPool: SyncObjectsPool? // The SyncSequence, if any, to store in the SYNCING state that results from this OBJECT_SYNC. let syncSequenceForSyncingState: SyncSequence? @@ -809,7 +809,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } else { nil } - var updatedSyncSequence = syncSequenceToContinue ?? .init(id: syncCursor.sequenceID, syncObjectsPool: []) + var updatedSyncSequence = syncSequenceToContinue ?? .init(id: syncCursor.sequenceID, syncObjectsPool: .init()) // RTO5b updatedSyncSequence.syncObjectsPool.append(contentsOf: syncObjectsPoolEntries) syncSequenceForSyncingState = updatedSyncSequence @@ -817,7 +817,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO completedSyncObjectsPool = syncCursor.isEndOfSequence ? updatedSyncSequence.syncObjectsPool : nil } else { // RTO5a5: The sync data is contained entirely within this single OBJECT_SYNC - completedSyncObjectsPool = syncObjectsPoolEntries + completedSyncObjectsPool = SyncObjectsPool(entries: syncObjectsPoolEntries) syncSequenceForSyncingState = nil } diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 4a48954d4..43dead7be 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -278,7 +278,7 @@ internal struct ObjectsPool { /// Applies the objects gathered during an `OBJECT_SYNC` to this `ObjectsPool`, per RTO5c1 and RTO5c2. internal mutating func nosync_applySyncObjectsPool( - _ syncObjectsPool: [SyncObjectsPoolEntry], + _ syncObjectsPool: SyncObjectsPool, logger: Logger, internalQueue: DispatchQueue, userCallbackQueue: DispatchQueue, diff --git a/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift b/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift new file mode 100644 index 000000000..a285636d1 --- /dev/null +++ b/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift @@ -0,0 +1,42 @@ +import Foundation + +/// The RTO5b collection of objects gathered during an `OBJECT_SYNC` sequence, ready to be applied to the `ObjectsPool`. +internal struct SyncObjectsPool: Collection { + /// The contents of the spec's `SyncObjectsPool` that is built during an `OBJECT_SYNC` sync sequence. + internal struct Entry { + internal var state: ObjectState + /// The `serialTimestamp` of the `ObjectMessage` that generated this entry. + internal var objectMessageSerialTimestamp: Date? + + // We replace the default memberwise initializer because we don't want a default argument for objectMessageSerialTimestamp (want to make sure we don't forget to set it whenever we create an entry). + // swiftlint:disable:next unneeded_synthesized_initializer + internal init(state: ObjectState, objectMessageSerialTimestamp: Date?) { + self.state = state + self.objectMessageSerialTimestamp = objectMessageSerialTimestamp + } + } + + private var entries: [Entry] + + /// Creates an empty pool. + internal init() { + entries = [] + } + + /// Creates a pool from the given entries. + internal init(entries: [Entry]) { + self.entries = entries + } + + /// Accumulates entries from a sync message per RTO5b. + internal mutating func append(contentsOf newEntries: [Entry]) { + entries.append(contentsOf: newEntries) + } + + // MARK: - Collection conformance + + internal var startIndex: Int { entries.startIndex } + internal var endIndex: Int { entries.endIndex } + internal func index(after i: Int) -> Int { entries.index(after: i) } + internal subscript(position: Int) -> Entry { entries[position] } +} diff --git a/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift b/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift deleted file mode 100644 index 3a7021377..000000000 --- a/Sources/AblyLiveObjects/Internal/SyncObjectsPoolEntry.swift +++ /dev/null @@ -1,15 +0,0 @@ -import Foundation - -/// The contents of the spec's `SyncObjectsPool` that is built during an `OBJECT_SYNC` sync sequence. -internal struct SyncObjectsPoolEntry { - internal var state: ObjectState - /// The `serialTimestamp` of the `ObjectMessage` that generated this entry. - internal var objectMessageSerialTimestamp: Date? - - // We replace the default memberwise initializer because we don't want a default argument for objectMessageSerialTimestamp (want to make sure we don't forget to set it whenever we create an entry). - // swiftlint:disable:next unneeded_synthesized_initializer - internal init(state: ObjectState, objectMessageSerialTimestamp: Date?) { - self.state = state - self.objectMessageSerialTimestamp = objectMessageSerialTimestamp - } -} diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 3b0757331..4de939907 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -107,7 +107,7 @@ struct ObjectsPoolTests { ) internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.nosync_applySyncObjectsPool(.init(entries: [.init(state: objectState, objectMessageSerialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) } // Verify the existing map was updated by checking side effects of InternalDefaultLiveMap.replaceData(using:) @@ -148,7 +148,7 @@ struct ObjectsPoolTests { ) internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.nosync_applySyncObjectsPool(.init(entries: [.init(state: objectState, objectMessageSerialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) } // Verify the existing counter was updated by checking side effects of InternalDefaultLiveCounter.replaceData(using:) @@ -180,7 +180,7 @@ struct ObjectsPoolTests { ) internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.nosync_applySyncObjectsPool(.init(entries: [.init(state: objectState, objectMessageSerialTimestamp: 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:) @@ -211,7 +211,7 @@ struct ObjectsPoolTests { ) internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.nosync_applySyncObjectsPool(.init(entries: [.init(state: objectState, objectMessageSerialTimestamp: 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:) @@ -241,7 +241,7 @@ struct ObjectsPoolTests { let invalidObjectState = TestFactories.objectState(objectId: "invalid") internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool([invalidObjectState, validObjectState].map { .init(state: $0, objectMessageSerialTimestamp: nil) }, logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.nosync_applySyncObjectsPool(.init(entries: [invalidObjectState, validObjectState].map { .init(state: $0, objectMessageSerialTimestamp: nil) }), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) } // Check that there's no entry for the key that we don't know how to handle, and that it didn't interfere with the insertion of the we one that we do know how to handle @@ -269,7 +269,7 @@ struct ObjectsPoolTests { let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool([.init(state: objectState, objectMessageSerialTimestamp: nil)], logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.nosync_applySyncObjectsPool(.init(entries: [.init(state: objectState, objectMessageSerialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) } // Verify only synced object and root remain @@ -290,7 +290,7 @@ struct ObjectsPoolTests { // Sync with empty list (no objects) internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool([], logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.nosync_applySyncObjectsPool(.init(), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) } // Verify root is preserved but other objects are removed @@ -356,7 +356,7 @@ struct ObjectsPoolTests { ] internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool(syncObjects.map { .init(state: $0, objectMessageSerialTimestamp: nil) }, logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.nosync_applySyncObjectsPool(.init(entries: syncObjects.map { .init(state: $0, objectMessageSerialTimestamp: nil) }), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) } // Verify final state diff --git a/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift new file mode 100644 index 000000000..d2516b773 --- /dev/null +++ b/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift @@ -0,0 +1,74 @@ +@testable import AblyLiveObjects +import Foundation +import Testing + +struct SyncObjectsPoolTests { + @Test + func initCreatesEmptyPool() { + let pool = SyncObjectsPool() + + #expect(pool.isEmpty) + } + + @Test + func initWithEntriesCreatesPoolWithThoseEntries() { + let entries: [SyncObjectsPool.Entry] = [ + .init(state: TestFactories.mapObjectState(objectId: "map:a@1"), objectMessageSerialTimestamp: nil), + .init(state: TestFactories.counterObjectState(objectId: "counter:b@2"), objectMessageSerialTimestamp: nil), + ] + + let pool = SyncObjectsPool(entries: entries) + + #expect(pool.count == 2) + } + + @Test + func appendAccumulatesEntries() { + var pool = SyncObjectsPool() + + pool.append(contentsOf: [ + .init(state: TestFactories.mapObjectState(objectId: "map:a@1"), objectMessageSerialTimestamp: nil), + ]) + #expect(pool.count == 1) + + pool.append(contentsOf: [ + .init(state: TestFactories.counterObjectState(objectId: "counter:b@2"), objectMessageSerialTimestamp: nil), + .init(state: TestFactories.mapObjectState(objectId: "map:c@3"), objectMessageSerialTimestamp: nil), + ]) + #expect(pool.count == 3) + } + + @Test + func iterationYieldsAllEntries() { + let objectIds = ["map:a@1", "counter:b@2", "map:c@3"] + let entries: [SyncObjectsPool.Entry] = objectIds.map { objectId in + .init(state: TestFactories.mapObjectState(objectId: objectId), objectMessageSerialTimestamp: nil) + } + let pool = SyncObjectsPool(entries: entries) + + let iteratedObjectIds = pool.map(\.state.objectId) + + #expect(iteratedObjectIds == objectIds) + } + + @Test + func entryPreservesObjectMessageSerialTimestamp() { + let timestamp = Date(timeIntervalSince1970: 1_000_000) + let entry = SyncObjectsPool.Entry( + state: TestFactories.mapObjectState(), + objectMessageSerialTimestamp: timestamp, + ) + + #expect(entry.objectMessageSerialTimestamp == timestamp) + } + + @Test + func entryAllowsNilObjectMessageSerialTimestamp() { + let entry = SyncObjectsPool.Entry( + state: TestFactories.mapObjectState(), + objectMessageSerialTimestamp: nil, + ) + + #expect(entry.objectMessageSerialTimestamp == nil) + } +} From 8631a186f84422b6ab7a326bed13928705d8d3bd Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 26 Feb 2026 10:51:17 -0300 Subject: [PATCH 201/225] Port OBJECT_SYNC sequence integration tests from ably-js Port the two integration tests added in ably-js commit b20d3ed: - OBJECT_SYNC sequence builds object tree across multiple sync messages - OBJECT_SYNC does not break when receiving an unknown object type Co-Authored-By: Claude Opus 4.6 --- .../ObjectsIntegrationTests.swift | 131 ++++++++++++++++++ 1 file changed, 131 insertions(+) diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 9a2cd3b2f..87ef63552 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -733,6 +733,137 @@ private struct ObjectsIntegrationTests { #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: "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, From 12faf8ba9ccb38b57a4eee7fc4473800d244db40 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 26 Feb 2026 10:37:41 -0300 Subject: [PATCH 202/225] Implement partial object sync (RTO5f) Implements the changes from spec commits 1f22417, 9f4d7de, and 963ec30, which allow the server to split a large object across multiple OBJECT_SYNC protocol messages. The new integration test is ported from ably-js commit d0bc431. Co-Authored-By: Claude Opus 4.6 --- .../InternalDefaultRealtimeObjects.swift | 16 +- .../Internal/ObjectsPool.swift | 47 +++-- .../Internal/SyncObjectsPool.swift | 98 ++++++--- .../Helpers/TestFactories.swift | 2 + .../InternalDefaultRealtimeObjectsTests.swift | 2 +- .../ObjectsIntegrationTests.swift | 101 +++++++++ .../ObjectsPoolTests.swift | 54 +++-- .../SyncObjectsPoolTests.swift | 199 +++++++++++++++--- 8 files changed, 393 insertions(+), 126 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 31d8c23ba..c8cbb6086 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -790,14 +790,6 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } } - let syncObjectsPoolEntries = objectMessages.compactMap { objectMessage in - if let object = objectMessage.object { - SyncObjectsPool.Entry(state: object, objectMessageSerialTimestamp: objectMessage.serialTimestamp) - } else { - 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. @@ -810,14 +802,16 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO nil } var updatedSyncSequence = syncSequenceToContinue ?? .init(id: syncCursor.sequenceID, syncObjectsPool: .init()) - // RTO5b - updatedSyncSequence.syncObjectsPool.append(contentsOf: syncObjectsPoolEntries) + // 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 - completedSyncObjectsPool = SyncObjectsPool(entries: syncObjectsPoolEntries) + var pool = SyncObjectsPool() + pool.accumulate(objectMessages, logger: logger) + completedSyncObjectsPool = pool syncSequenceForSyncingState = nil } diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 43dead7be..6d46c0b4c 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -293,17 +293,21 @@ internal struct ObjectsPool { var updatesToExistingObjects: [ObjectsPool.Entry.DeferredUpdate] = [] // RTO5c1: For each ObjectState member in the SyncObjectsPool list - for syncObjectsPoolEntry in syncObjectsPool { - receivedObjectIds.insert(syncObjectsPoolEntry.state.objectId) + 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[syncObjectsPoolEntry.state.objectId] { - logger.log("Updating existing object with ID: \(syncObjectsPoolEntry.state.objectId)", level: .debug) + 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: syncObjectsPoolEntry.state, - objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, + using: state, + objectMessageSerialTimestamp: objectMessage.serialTimestamp, objectsPool: &self, userCallbackQueue: userCallbackQueue, ) @@ -311,32 +315,32 @@ internal struct ObjectsPool { updatesToExistingObjects.append(deferredUpdate) } else { // RTO5c1b: If an object with ObjectState.objectId does not exist in the internal ObjectsPool - logger.log("Creating new object with ID: \(syncObjectsPoolEntry.state.objectId)", level: .debug) + 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? + let newEntry: Entry - if syncObjectsPoolEntry.state.counter != nil { + 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: syncObjectsPoolEntry.state.objectId, + objectID: state.objectId, logger: logger, internalQueue: internalQueue, userCallbackQueue: userCallbackQueue, clock: clock, ) _ = counter.nosync_replaceData( - using: syncObjectsPoolEntry.state, - objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, + using: state, + objectMessageSerialTimestamp: objectMessage.serialTimestamp, ) newEntry = .counter(counter) - } else if let objectsMap = syncObjectsPoolEntry.state.map { + } 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: syncObjectsPoolEntry.state.objectId, + objectID: state.objectId, semantics: objectsMap.semantics, logger: logger, internalQueue: internalQueue, @@ -344,21 +348,18 @@ internal struct ObjectsPool { clock: clock, ) _ = map.nosync_replaceData( - using: syncObjectsPoolEntry.state, - objectMessageSerialTimestamp: syncObjectsPoolEntry.objectMessageSerialTimestamp, + using: state, + objectMessageSerialTimestamp: objectMessage.serialTimestamp, objectsPool: &self, ) newEntry = .map(map) } else { - // RTO5c1b1c: Otherwise, log a warning that an unsupported object state message has been received, and discard the current ObjectState without taking any action - logger.log("Unsupported object state message received for objectId: \(syncObjectsPoolEntry.state.objectId)", level: .warn) - newEntry = nil + // SyncObjectsPool guarantees every yielded message has `.map` or `.counter`. + preconditionFailure("SyncObjectsPool entry for objectId \(state.objectId) has neither counter nor map") } - if let newEntry { - // Note that we will never replace the root object here, and thus never break the RTO3b invariant that the root object is always a map. This is because the pool always contains a root object and thus we always go through the RTO5c1a branch of the `if` above. - entries[syncObjectsPoolEntry.state.objectId] = newEntry - } + // Note that we will never replace the root object here, and thus never break the RTO3b invariant that the root object is always a map. This is because the pool always contains a root object and thus we always go through the RTO5c1a branch of the `if` above. + entries[state.objectId] = newEntry } } diff --git a/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift b/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift index a285636d1..53f43e763 100644 --- a/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift @@ -1,42 +1,86 @@ import Foundation -/// The RTO5b collection of objects gathered during an `OBJECT_SYNC` sequence, ready to be applied to the `ObjectsPool`. +/// The RTO5f collection of objects gathered during an `OBJECT_SYNC` sequence, ready to be applied to the `ObjectsPool`. +/// +/// Every stored message is guaranteed to have a non-nil `.object` with either `.map` or `.counter` populated. internal struct SyncObjectsPool: Collection { - /// The contents of the spec's `SyncObjectsPool` that is built during an `OBJECT_SYNC` sync sequence. - internal struct Entry { - internal var state: ObjectState - /// The `serialTimestamp` of the `ObjectMessage` that generated this entry. - internal var objectMessageSerialTimestamp: Date? - - // We replace the default memberwise initializer because we don't want a default argument for objectMessageSerialTimestamp (want to make sure we don't forget to set it whenever we create an entry). - // swiftlint:disable:next unneeded_synthesized_initializer - internal init(state: ObjectState, objectMessageSerialTimestamp: Date?) { - self.state = state - self.objectMessageSerialTimestamp = objectMessageSerialTimestamp - } - } - - private var entries: [Entry] + /// 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() { - entries = [] + objectMessages = [:] } - /// Creates a pool from the given entries. - internal init(entries: [Entry]) { - self.entries = entries + /// 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 entries from a sync message per RTO5b. - internal mutating func append(contentsOf newEntries: [Entry]) { - entries.append(contentsOf: newEntries) + /// 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 var startIndex: Int { entries.startIndex } - internal var endIndex: Int { entries.endIndex } - internal func index(after i: Int) -> Int { entries.index(after: i) } - internal subscript(position: Int) -> Entry { entries[position] } + 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/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index 3dd68da5c..479357cf1 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -268,6 +268,7 @@ struct TestFactories { object: ObjectState? = nil, serial: String? = nil, siteCode: String? = nil, + serialTimestamp: Date? = nil, ) -> InboundObjectMessage { InboundObjectMessage( id: id, @@ -279,6 +280,7 @@ struct TestFactories { object: object, serial: serial, siteCode: siteCode, + serialTimestamp: serialTimestamp, ) } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index e2c32f5dc..e0ac81e13 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -60,7 +60,7 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO5a1 // @spec RTO5a3 // @spec RTO5a4 - // @spec RTO5b + // @spec RTO5f // @spec RTO5c3 // @spec RTO5c4 // @spec RTO5c5 diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 87ef63552..8f7afc5e9 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -815,6 +815,107 @@ private struct ObjectsIntegrationTests { #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, diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 4de939907..6bb3cbafe 100644 --- a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -2,6 +2,25 @@ import _AblyPluginSupportPrivate @testable import AblyLiveObjects import Testing +private extension SyncObjectsPool { + /// Test-only convenience to create a `SyncObjectsPool` from an array of `(state, serialTimestamp)` pairs, + /// wrapping each in an `InboundObjectMessage` and calling `accumulate`. + static func testsOnly_fromStates( + _ states: [(state: ObjectState, serialTimestamp: Date?)], + logger: AblyLiveObjects.Logger = TestLogger(), + ) -> SyncObjectsPool { + var pool = SyncObjectsPool() + let messages = states.map { pair in + TestFactories.inboundObjectMessage( + object: pair.state, + serialTimestamp: pair.serialTimestamp, + ) + } + pool.accumulate(messages, logger: logger) + return pool + } +} + struct ObjectsPoolTests { /// Tests for the `createZeroValueObject` method, covering RTO6 specification points struct CreateZeroValueObjectTests { @@ -107,7 +126,7 @@ struct ObjectsPoolTests { ) internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool(.init(entries: [.init(state: objectState, objectMessageSerialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + 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:) @@ -148,7 +167,7 @@ struct ObjectsPoolTests { ) internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool(.init(entries: [.init(state: objectState, objectMessageSerialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + 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:) @@ -180,7 +199,7 @@ struct ObjectsPoolTests { ) internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool(.init(entries: [.init(state: objectState, objectMessageSerialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + 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:) @@ -211,7 +230,7 @@ struct ObjectsPoolTests { ) internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool(.init(entries: [.init(state: objectState, objectMessageSerialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + 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:) @@ -225,29 +244,6 @@ struct ObjectsPoolTests { #expect(newMap.testsOnly_semantics == .known(.lww)) } - // @spec RTO5c1b1c - @Test - func ignoresNonMapOrCounterObject() throws { - let logger = TestLogger() - let internalQueue = TestFactories.createInternalQueue() - var pool = ObjectsPool(logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) - - let validObjectState = TestFactories.counterObjectState( - objectId: "counter:hash@456", - siteTimeserials: ["site2": "ts2"], - count: 100, - ) - - let invalidObjectState = TestFactories.objectState(objectId: "invalid") - - internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool(.init(entries: [invalidObjectState, validObjectState].map { .init(state: $0, objectMessageSerialTimestamp: nil) }), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) - } - - // Check that there's no entry for the key that we don't know how to handle, and that it didn't interfere with the insertion of the we one that we do know how to handle - #expect(Set(pool.entries.keys) == ["root", "counter:hash@456"]) - } - // MARK: - RTO5c2 Tests // @spec RTO5c2 @@ -269,7 +265,7 @@ struct ObjectsPoolTests { let objectState = TestFactories.mapObjectState(objectId: "map:hash@1") internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool(.init(entries: [.init(state: objectState, objectMessageSerialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.nosync_applySyncObjectsPool(.testsOnly_fromStates([(state: objectState, serialTimestamp: nil)]), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) } // Verify only synced object and root remain @@ -356,7 +352,7 @@ struct ObjectsPoolTests { ] internalQueue.ably_syncNoDeadlock { - pool.nosync_applySyncObjectsPool(.init(entries: syncObjects.map { .init(state: $0, objectMessageSerialTimestamp: nil) }), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) + pool.nosync_applySyncObjectsPool(.testsOnly_fromStates(syncObjects.map { (state: $0, serialTimestamp: nil) }), logger: logger, internalQueue: internalQueue, userCallbackQueue: .main, clock: MockSimpleClock()) } // Verify final state diff --git a/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift b/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift index d2516b773..5aa56b3be 100644 --- a/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift +++ b/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift @@ -10,65 +10,194 @@ struct SyncObjectsPoolTests { #expect(pool.isEmpty) } + // MARK: - accumulate: skip / reject + + // @specOneOf (1/2) RTO5f3 @Test - func initWithEntriesCreatesPoolWithThoseEntries() { - let entries: [SyncObjectsPool.Entry] = [ - .init(state: TestFactories.mapObjectState(objectId: "map:a@1"), objectMessageSerialTimestamp: nil), - .init(state: TestFactories.counterObjectState(objectId: "counter:b@2"), objectMessageSerialTimestamp: nil), - ] + func accumulateSkipsMessageWithNoObjectState() { + var pool = SyncObjectsPool() + let message = TestFactories.inboundObjectMessage(object: nil) - let pool = SyncObjectsPool(entries: entries) + pool.accumulate([message], logger: TestLogger()) - #expect(pool.count == 2) + #expect(pool.isEmpty) } + // @specOneOf(2/2) RTO5f3 @Test - func appendAccumulatesEntries() { + 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()) - pool.append(contentsOf: [ - .init(state: TestFactories.mapObjectState(objectId: "map:a@1"), objectMessageSerialTimestamp: nil), - ]) #expect(pool.count == 1) + #expect(Array(pool) == [message]) + } - pool.append(contentsOf: [ - .init(state: TestFactories.counterObjectState(objectId: "counter:b@2"), objectMessageSerialTimestamp: nil), - .init(state: TestFactories.mapObjectState(objectId: "map:c@3"), objectMessageSerialTimestamp: nil), - ]) - #expect(pool.count == 3) + // @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 iterationYieldsAllEntries() { - let objectIds = ["map:a@1", "counter:b@2", "map:c@3"] - let entries: [SyncObjectsPool.Entry] = objectIds.map { objectId in - .init(state: TestFactories.mapObjectState(objectId: objectId), objectMessageSerialTimestamp: nil) - } - let pool = SyncObjectsPool(entries: entries) + 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) + } - let iteratedObjectIds = pool.map(\.state.objectId) + // @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(iteratedObjectIds == objectIds) + #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 entryPreservesObjectMessageSerialTimestamp() { - let timestamp = Date(timeIntervalSince1970: 1_000_000) - let entry = SyncObjectsPool.Entry( - state: TestFactories.mapObjectState(), - objectMessageSerialTimestamp: timestamp, + 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) - #expect(entry.objectMessageSerialTimestamp == timestamp) + // 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 entryAllowsNilObjectMessageSerialTimestamp() { - let entry = SyncObjectsPool.Entry( - state: TestFactories.mapObjectState(), - objectMessageSerialTimestamp: nil, + 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()) - #expect(entry.objectMessageSerialTimestamp == nil) + let yielded = Array(pool).sorted { $0.object!.objectId < $1.object!.objectId } + let expected = messages.sorted { $0.object!.objectId < $1.object!.objectId } + #expect(yielded == expected) } } From f15cfacedaf8dc1d70c1197471b46d8f08988994 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 5 Mar 2026 13:22:06 -0300 Subject: [PATCH 203/225] Implement updated rules for clearing ops buffered during sync Implements spec commit 997584f. Integration tests ported from ably-js commit 9b2224d. Co-Authored-By: Claude Opus 4.6 --- .../InternalDefaultRealtimeObjects.swift | 24 +- .../InternalDefaultRealtimeObjectsTests.swift | 32 +-- .../ObjectsIntegrationTests.swift | 209 +++++++++++++++--- 3 files changed, 212 insertions(+), 53 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index 4f3face3a..4bd961cdf 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -84,6 +84,17 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO } } + /// 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 @@ -728,8 +739,12 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO 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 - if state.toObjectsSyncState != .syncing { - // RTO4c + switch state { + case let .syncing(syncingData): + // RTO4d + syncingData.bufferedObjectOperations = [] + case .initialized, .synced: + // RTO4c, RTO4d transition(to: .syncing(.init(bufferedObjectOperations: [], syncSequence: nil)), userCallbackQueue: userCallbackQueue) } @@ -743,7 +758,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO // 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, RTO4b5, RTO5c3, RTO5c4, RTO5c5, RTO5c9, RTO5c8 + // RTO4b3, RTO4b4, RTO5c3, RTO5c4, RTO5c5, RTO5c9, RTO5c8 appliedOnAckSerials.removeAll() transition(to: .synced, userCallbackQueue: userCallbackQueue) @@ -784,9 +799,8 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO // 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, RTO5a2b: new sequence started, discard previous. Else we continue the existing sequence per RTO5a3 + // RTO5a2a: new sequence started, discard previous. Else we continue the existing sequence per RTO5a3 syncingData.syncSequence = nil - syncingData.bufferedObjectOperations = [] } } diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index aa398210e..9a9849501 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -126,7 +126,6 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO5a2 // @spec RTO5a2a - // @spec RTO5a2b @Test func newSequenceIdDiscardsInFlightSync() async throws { let internalQueue = TestFactories.createInternalQueue() @@ -145,13 +144,6 @@ struct InternalDefaultRealtimeObjectsTests { #expect(realtimeObjects.testsOnly_hasSyncSequence) - // Inject an OBJECT; it will get buffered per RTO8a and subsequently discarded per RTO5a2b - internalQueue.ably_syncNoDeadlock { - realtimeObjects.nosync_handleObjectProtocolMessage(objectMessages: [ - TestFactories.mapCreateOperationMessage(objectId: "map:3@789"), - ]) - } - // Start new sequence with different ID (RTO5a2) let secondMessages = [TestFactories.simpleMapMessage(objectId: "map:2@456")] internalQueue.ably_syncNoDeadlock { @@ -175,7 +167,6 @@ struct InternalDefaultRealtimeObjectsTests { // 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:3@789"] == nil) // Check we discarded the OBJECT that was buffered during discarded first sequence (RTO5a2b) #expect(pool.entries["map:2@456"] != nil) // From completed second sequence #expect(!realtimeObjects.testsOnly_hasSyncSequence) } @@ -366,9 +357,10 @@ struct InternalDefaultRealtimeObjectsTests { 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 any internal state + // @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 doesNotModifyStateWhenHasObjectsIsTrue() { + func handlesHasObjectTrue() { let internalQueue = TestFactories.createInternalQueue() let realtimeObjects = InternalDefaultRealtimeObjectsTests.createDefaultRealtimeObjects(internalQueue: internalQueue) @@ -389,12 +381,21 @@ struct InternalDefaultRealtimeObjectsTests { #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: Nothing should be modified + // Then: #expect(realtimeObjects.testsOnly_onChannelAttachedHasObjects == true) // Verify ObjectsPool is unchanged @@ -405,6 +406,9 @@ struct InternalDefaultRealtimeObjectsTests { // 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 @@ -414,7 +418,7 @@ struct InternalDefaultRealtimeObjectsTests { // @spec RTO4b2a // @spec RTO4b3 // @spec RTO4b4 - // @spec RTO4b5 + // @specOneOf(2/2) RTO4d @available(iOS 17.0.0, tvOS 17.0.0, *) @Test func handlesHasObjectsFalse() async throws { @@ -490,7 +494,7 @@ struct InternalDefaultRealtimeObjectsTests { #expect(newRoot as AnyObject === originalPool.root as AnyObject) // Should be same instance #expect(newRoot.testsOnly_data.isEmpty) // Should be zero-valued (empty) - // RTO4b3, RTO4b4, RTO4b5: SyncObjectsPool must be cleared, sync sequence cleared, BufferedObjectOperations cleared, appliedOnAckSerials cleared + // RTO4b3, RTO4b4, RTO4d: SyncObjectsPool must be cleared, sync sequence cleared, BufferedObjectOperations cleared, appliedOnAckSerials cleared #expect(!realtimeObjects.testsOnly_hasSyncSequence) #expect(realtimeObjects.testsOnly_appliedOnAckSerials.isEmpty) } diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index 461a3ed1e..a9d342ce9 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -352,15 +352,14 @@ private final class AckInterceptor: @unchecked Sendable { } } -/// Injects an ATTACHED protocol message into the channel, optionally with the HAS_OBJECTS flag. -/// This triggers a sync sequence when `hasObjects` is true. -private func injectAttachedMessage(channel: ARTRealtimeChannel, hasObjects: Bool) async { +/// Injects an ATTACHED protocol message into the channel with the given flags. +private func injectAttachedMessage(channel: ARTRealtimeChannel, flags: ARTProtocolMessageFlag = []) async { await withCheckedContinuation { (continuation: CheckedContinuation) in channel.internal.queue.async { let pm = ARTProtocolMessage() pm.action = .attached pm.channel = channel.name - pm.flags = hasObjects ? Int64(1 << 7) : 0 // ARTProtocolMessageFlagHasObjects + pm.flags = Int64(flags.rawValue) channel.internal.onChannelMessage(pm) continuation.resume() } @@ -615,7 +614,7 @@ private struct ObjectsIntegrationTests { #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, hasObjects: false) + 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") @@ -2272,12 +2271,11 @@ private struct ObjectsIntegrationTests { .init( disabled: false, allTransportsAndProtocols: false, - description: "buffered object operation messages are discarded when new OBJECT_SYNC sequence starts", + description: "buffered object operation messages are discarded on ATTACHED", 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( @@ -2285,51 +2283,194 @@ private struct ObjectsIntegrationTests { syncSerial: "serial:cursor", ) - // Inject operations, expect them to be discarded when sync with new sequence id starts - // 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) } + // 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")]))], + ) - if let bytesValue = wireData["bytes"], client.internal.options.useBinaryProtocol { - let bytesString = try #require(bytesValue.stringValue) - wireData["bytes"] = try .data(#require(.init(base64Encoded: bytesString))) - } + // Any ATTACHED message must clear buffered operations and start a new sync sequence + await injectAttachedMessage(channel: channel, flags: .hasObjects) - 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))], - ) - } + // 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")]))], + ) - // Start new sync with new sequence id + // End sync await objectsHelper.processObjectStateMessageOnChannel( channel: channel, - syncSerial: "otherserial:cursor", + 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")]))], ) - // Inject another operation that should be applied when latest sync ends + // 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 doesn't have data from operations received during first sync - for keyData in primitiveKeyData { - #expect(try root.get(key: keyData.key) == nil, "Check \"\(keyData.key)\" key doesn't exist on root when OBJECT_SYNC has ended") - } + // 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 - #expect(try #require(root.get(key: "foo")?.stringValue) == "bar", "Check root has data from operations received during second OBJECT_SYNC sequence") + 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( @@ -2491,7 +2632,7 @@ private struct ObjectsIntegrationTests { .init( disabled: false, allTransportsAndProtocols: false, - description: "subsequent object operation messages are applied immediately after OBJECT_SYNC ended and buffers are applied", + 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 @@ -4466,7 +4607,7 @@ private struct ObjectsIntegrationTests { let counterId = internalCounter.proxied.testsOnly_objectID // Inject ATTACHED with HAS_OBJECTS to trigger SYNCING state - await injectAttachedMessage(channel: channel, hasObjects: true) + await injectAttachedMessage(channel: channel, flags: .hasObjects) // Set up ACK interceptor so we can control when the ACK is delivered let ackInterceptor = AckInterceptor(client: client) @@ -4540,7 +4681,7 @@ private struct ObjectsIntegrationTests { let counterId = internalCounter.proxied.testsOnly_objectID // Inject ATTACHED+HAS_OBJECTS to trigger sync - await injectAttachedMessage(channel: channel, hasObjects: true) + 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) @@ -4605,7 +4746,7 @@ private struct ObjectsIntegrationTests { try await root.set(key: "counter", value: .liveCounter(counter)) // Inject ATTACHED+HAS_OBJECTS to trigger SYNCING state - await injectAttachedMessage(channel: channel, hasObjects: true) + await injectAttachedMessage(channel: channel, flags: .hasObjects) // Set up ACK interceptor and start increment let ackInterceptor = AckInterceptor(client: client) From 650f8769c43e5e56cbca0cd39eeb943cd717a9ef Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 11 Mar 2026 09:57:14 -0300 Subject: [PATCH 204/225] Split ObjectsPool "apply sync" method in two Requested by Evgenii in code review, for readability. Co-Authored-By: Claude Opus 4.6 --- .../Internal/ObjectsPool.swift | 113 +++++++++++------- 1 file changed, 68 insertions(+), 45 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift index 6d46c0b4c..5d36f674d 100644 --- a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -315,51 +315,15 @@ internal struct ObjectsPool { updatesToExistingObjects.append(deferredUpdate) } else { // RTO5c1b: If an object with ObjectState.objectId does not exist in the internal ObjectsPool - logger.log("Creating new object with ID: \(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 { - // SyncObjectsPool guarantees every yielded message has `.map` or `.counter`. - preconditionFailure("SyncObjectsPool entry for objectId \(state.objectId) has neither counter nor map") - } - - // Note that we will never replace the root object here, and thus never break the RTO3b invariant that the root object is always a map. This is because the pool always contains a root object and thus we always go through the RTO5c1a branch of the `if` above. - entries[state.objectId] = newEntry + // (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, + ) } } @@ -381,6 +345,65 @@ internal struct ObjectsPool { 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 From 3a348aa41968cc46ab0a3d0e9ec003697f976f05 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 12 Mar 2026 23:28:32 -0300 Subject: [PATCH 205/225] Port Cursor rules to CLAUDE.md and remove the originals MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit We're no longer using Cursor, so the Cursor rules can go. They did contain useful project-specific guidance (spec document format, Swift style preferences, testing conventions) that Claude may not have been picking up on, so this ports across the things that Claude judged to be non-obvious — i.e. things it might not intuit from the code alone — into CLAUDE.md files. Testing guidelines go in a separate CLAUDE.md under the test directory so they only load when working with test files. The Cursor rules hardcoded a path for where to find the specification repo. Instead, the CLAUDE.md just says "ask if you haven't been told where to find it", on the basis that the broader working environment (e.g. sdk-workspace [1]) should provide that context. Let's see if this works in practice. What to include was based on Claude's own judgement of what it would and wouldn't already know, and I pretty blindly accepted it. This is a first pass; we can continue to iterate on the content. [1] https://github.com/ably/sdk-workspace Co-Authored-By: Claude Opus 4.6 --- .cursor/rules/specification.mdc | 14 ------------ .cursor/rules/swift.mdc | 28 ----------------------- .cursor/rules/testing.mdc | 20 ---------------- CLAUDE.md | 34 ++++++++++++++++++++++++++++ Package.swift | 3 +++ Tests/AblyLiveObjectsTests/CLAUDE.md | 15 ++++++++++++ 6 files changed, 52 insertions(+), 62 deletions(-) delete mode 100644 .cursor/rules/specification.mdc delete mode 100644 .cursor/rules/swift.mdc delete mode 100644 .cursor/rules/testing.mdc create mode 100644 Tests/AblyLiveObjectsTests/CLAUDE.md diff --git a/.cursor/rules/specification.mdc b/.cursor/rules/specification.mdc deleted file mode 100644 index c0c086e0d..000000000 --- a/.cursor/rules/specification.mdc +++ /dev/null @@ -1,14 +0,0 @@ ---- -description: Defines the meaning of the "Specification Document" (a.k.a. "specification" or "spec") -globs: -alwaysApply: false ---- -- When we refer to the _Specification Document_, we mean the contents of the file `textile/features.textile` in the repository https://github.com/ably/specification. -- If you need to consult the Specification Document, then check it out as a Git repository locally. You should check it out to the path `cursor-support/specification` in this repository. If this directory already exists, then you can assume that the repository is already checked out and is up to date; do not try checking it out again. -- The Specification Document is sometimes referred to as simply "the specification" or "the spec". -- The Specification Document specifies much of the behaviour of this codebase. -- If you are given a task that requires knowledge of the Specification Document, you must consult the Specification Document before proceeding. You must never make a guess about the contents of the Specification Document. -- If you are given a task that requires you to interpret the Specification Document, but the Specification Document is unclear, be sure to mention this. -- The Specification Document is structured as a list of specification points, each with an identifier. An example identifier is "OD1". In the Specification Document, the start of specification point OD1 would be represented by the string @(OD1)@. These specification points are sometimes referred to as "specification items". -- Some specification points have subpoints. For example REC2 has (amongst others) the subpoint RSC2a, which has subpoints REC2a1 and REC2a2. -- The LiveObjects functionality is referred to the in the Specification simply as "Objects". diff --git a/.cursor/rules/swift.mdc b/.cursor/rules/swift.mdc deleted file mode 100644 index b4a23b5bc..000000000 --- a/.cursor/rules/swift.mdc +++ /dev/null @@ -1,28 +0,0 @@ ---- -description: Guidelines for writing Swift -globs: -alwaysApply: false ---- -When writing Swift: - -- Be sure to satisfy SwiftLint's `explicit_acl` rule ("All declarations should specify Access Control Level keywords explicitly). - - When writing an `extension` of a type, favour placing the access level on the declaration of the extension rather than each of its individual members. - - This does not apply when writing test code. -- When writing initializer expressions, when the type that is being initialized can be inferred, favour using the implicit `.init(…)` form instead of explicitly writing the type name. -- When writing enum value expressions, when the type that is being initialized can be inferred, favour using the implicit `.caseName` form instead of explicitly writing the type name. -- When writing JSONValue or WireValue types, favour using the literal syntax enabled by their conformance to the `ExpressibleBy*Literal` protocols where possible. -- When writing a JSON string, favour using Swift raw string literals instead of escaping double quotes. -- When you need to import the following modules inside the AblyLiveObjects library code (that is, in non-test code), do so in the following way: - - Ably: use `import Ably` - - `_AblyPluginSupportPrivate`: use `internal import _AblyPluginSupportPrivate` -- When writing an array literal that starts with an initializer expression, start the initializer expression on the line after the opening square bracket of the array literal. That is, instead of writing: - ```swift - objectMessages: [InboundObjectMessage( - id: nil, - ``` - write: - ```swift - objectMessages: [ - InboundObjectMessage( - id: nil, - ``` diff --git a/.cursor/rules/testing.mdc b/.cursor/rules/testing.mdc deleted file mode 100644 index f21ef642f..000000000 --- a/.cursor/rules/testing.mdc +++ /dev/null @@ -1,20 +0,0 @@ ---- -description: Guidelines for writing tests -globs: -alwaysApply: false ---- -When writing tests: - -- Use the Swift Testing framework (`import Testing`), not XCTest. -- Do not use `fatalError` in response to a test expectation failure. Favour the usage of Swift Testing's `#require` macro. -- Only add labels to test cases or suites when the label is different to the name of the suite `struct` or test method. -- When writing tests, follow the guidelines given under "Attributing tests to a spec point" in the file `CONTRIBUTING.md` in order to tag the unit tests with the relevant specification points. Make sure to follow the exact format of the comments as described in that file. Pay particular attention to the difference between the meaning of `@spec` and `@specPartial` and be sure not to write `@spec` multiple times for the same specification point. -- When writing tests, make sure to add comments that explain when some piece of test data is not important for the scenario being tested. -- When writing tests, run the tests to check they pass. -- When you need to import the following modules in the tests, do so in the following way: - - Ably: use `import Ably` - - AblyLiveObjects: use `@testable import AblyLiveObjects` - - `_AblyPluginSupportPrivate`: use `import _AblyPluginSupportPrivate`; _do not_ do `internal import` -- When you need to pass a logger to internal components in the tests, pass `TestLogger()`. -- When you need to unwrap an optional value in the tests, favour using `#require` instead of `guard let`. -- When creating `testsOnly_` property declarations, do not write generic comments of the form "Test-only access to the private createOperationIsMerged property"; the meaning of these properties is already well understood. diff --git a/CLAUDE.md b/CLAUDE.md index f530e24b3..b1b9c7324 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -23,3 +23,37 @@ 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/Package.swift b/Package.swift index f45844cc8..bc27d3d35 100644 --- a/Package.swift +++ b/Package.swift @@ -72,6 +72,9 @@ let package = Package( package: "ably-cocoa-plugin-support", ), ], + exclude: [ + "CLAUDE.md", + ], resources: [ .copy("ably-common"), ], diff --git a/Tests/AblyLiveObjectsTests/CLAUDE.md b/Tests/AblyLiveObjectsTests/CLAUDE.md new file mode 100644 index 000000000..ab3e56b56 --- /dev/null +++ b/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. From 3fa17377c020495fa9e705307b80969d986eb8df Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Wed, 11 Mar 2026 13:11:01 -0300 Subject: [PATCH 206/225] Unify reset-to-zero-value code Make everything go via a single method that implements the RTLM4 definition. --- Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index 3ac68b325..b405dd8de 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -873,7 +873,7 @@ internal final class InternalDefaultLiveMap: Sendable { internal mutating func resetData(userCallbackQueue: DispatchQueue) { // RTO4b2 let previousData = data - data = [:] + resetDataToZeroValued() // RTO4b2a let mapUpdate = DefaultLiveMapUpdate(update: previousData.mapValues { _ in .removed }) From 99fe9ba6394d80b75b5ebcad229aaa13fd701768 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Tue, 10 Mar 2026 09:50:42 -0300 Subject: [PATCH 207/225] Implement the MAP_CLEAR operation Per spec commit a9f839b. Integration tests ported from JS commit d2ddac6. Co-Authored-By: Claude Opus 4.6 --- .../Internal/InternalDefaultLiveMap.swift | 74 +++ .../InternalDefaultRealtimeObjects.swift | 2 +- .../Protocol/ObjectMessage.swift | 8 + .../Protocol/WireObjectMessage.swift | 27 + .../Helpers/TestFactories.swift | 21 + .../InternalDefaultLiveMapTests.swift | 331 +++++++++- .../InternalDefaultRealtimeObjectsTests.swift | 66 +- .../JS Integration Tests/ObjectsHelper.swift | 48 +- .../ObjectsIntegrationTests.swift | 586 ++++++++++++++++++ .../WireObjectMessageTests.swift | 3 + 10 files changed, 1152 insertions(+), 14 deletions(-) diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift index b405dd8de..e13ab40de 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -35,6 +35,12 @@ internal final class InternalDefaultLiveMap: Sendable { } } + internal var testsOnly_clearTimeserial: String? { + mutableStateMutex.withSync { mutableState in + mutableState.clearTimeserial + } + } + private let logger: Logger private let userCallbackQueue: DispatchQueue private let clock: SimpleClock @@ -396,6 +402,15 @@ internal final class InternalDefaultLiveMap: Sendable { } } + /// 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 @@ -452,6 +467,9 @@ internal final class InternalDefaultLiveMap: Sendable { /// 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: @@ -492,6 +510,9 @@ internal final class InternalDefaultLiveMap: Sendable { // 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 @@ -702,6 +723,15 @@ internal final class InternalDefaultLiveMap: Sendable { 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) @@ -720,6 +750,11 @@ internal final class InternalDefaultLiveMap: Sendable { 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 @@ -762,6 +797,11 @@ internal final class InternalDefaultLiveMap: Sendable { 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 { @@ -869,6 +909,39 @@ internal final class InternalDefaultLiveMap: Sendable { ) } + /// 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 @@ -884,6 +957,7 @@ internal final class InternalDefaultLiveMap: Sendable { mutating func resetDataToZeroValued() { // RTLM4 data = [:] + clearTimeserial = nil } /// Releases entries that were tombstoned more than `gracePeriod` ago, per RTLM19. diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift index d2aaf5656..d50ca339e 100644 --- a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -957,7 +957,7 @@ internal final class InternalDefaultRealtimeObjects: Sendable, InternalRealtimeO switch operation.action { case let .known(action): switch action { - case .mapCreate, .mapSet, .mapRemove, .counterCreate, .counterInc, .objectDelete: + case .mapCreate, .mapSet, .mapRemove, .counterCreate, .counterInc, .objectDelete, .mapClear: // RTO9a2a3 let applied = entry.nosync_apply( operation, diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift index 4782a063c..9ca106f58 100644 --- a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift @@ -45,6 +45,7 @@ internal struct ObjectOperation: Equatable { internal var objectDelete: WireObjectDelete? // OOP3o internal var mapCreateWithObjectId: MapCreateWithObjectId? // OOP3p internal var counterCreateWithObjectId: CounterCreateWithObjectId? // OOP3q + internal var mapClear: WireMapClear? // OOP3r } internal struct ObjectData: Equatable { @@ -96,6 +97,7 @@ internal struct ObjectsMapEntry: Equatable { internal struct ObjectsMap: Equatable { internal var semantics: WireEnum // OMP3a internal var entries: [String: ObjectsMapEntry]? // OMP3b + internal var clearTimeserial: String? // OMP3c } internal struct ObjectState: Equatable { @@ -178,6 +180,7 @@ internal extension ObjectOperation { counterCreate = wireObjectOperation.counterCreate counterInc = wireObjectOperation.counterInc objectDelete = wireObjectOperation.objectDelete + mapClear = wireObjectOperation.mapClear // Outbound-only — do not access on inbound data mapCreateWithObjectId = nil counterCreateWithObjectId = nil @@ -199,6 +202,7 @@ internal extension ObjectOperation { objectDelete: objectDelete, mapCreateWithObjectId: mapCreateWithObjectId?.toWire(), counterCreateWithObjectId: counterCreateWithObjectId?.toWire(), + mapClear: mapClear, ) } } @@ -414,6 +418,7 @@ internal extension ObjectsMap { 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. @@ -424,6 +429,7 @@ internal extension ObjectsMap { .init( semantics: semantics, entries: entries?.mapValues { $0.toWire(format: format) }, + clearTimeserial: clearTimeserial, ) } } @@ -520,6 +526,7 @@ extension ObjectOperation: CustomDebugStringConvertible { 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: ", ") + " }" } @@ -553,6 +560,7 @@ extension ObjectsMap: CustomDebugStringConvertible { .joined(separator: ", ") parts.append("entries: { \(formattedEntries) }") } + if let clearTimeserial { parts.append("clearTimeserial: \(clearTimeserial)") } return "{ " + parts.joined(separator: ", ") + " }" } diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift index 035f72fda..8647e733f 100644 --- a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -151,6 +151,7 @@ internal enum ObjectOperationAction: Int { case counterCreate = 3 case counterInc = 4 case objectDelete = 5 + case mapClear = 6 } // OMP2 @@ -169,6 +170,7 @@ internal struct WireObjectOperation { internal var objectDelete: WireObjectDelete? // OOP3o internal var mapCreateWithObjectId: WireMapCreateWithObjectId? // OOP3p internal var counterCreateWithObjectId: WireCounterCreateWithObjectId? // OOP3q + internal var mapClear: WireMapClear? // OOP3r } extension WireObjectOperation: WireObjectCodable { @@ -183,6 +185,7 @@ extension WireObjectOperation: WireObjectCodable { case objectDelete case mapCreateWithObjectId case counterCreateWithObjectId + case mapClear } internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { @@ -195,6 +198,7 @@ extension WireObjectOperation: WireObjectCodable { 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 @@ -230,6 +234,9 @@ extension WireObjectOperation: WireObjectCodable { if let counterCreateWithObjectId { result[WireKey.counterCreateWithObjectId.rawValue] = .object(counterCreateWithObjectId.toWireObject) } + if let mapClear { + result[WireKey.mapClear.rawValue] = .object(mapClear.toWireObject) + } return result } @@ -292,12 +299,14 @@ extension WireObjectState: WireObjectCodable { internal struct WireObjectsMap { internal var semantics: WireEnum // OMP3a internal var entries: [String: WireObjectsMapEntry]? // OMP3b + internal var clearTimeserial: String? // OMP3c } extension WireObjectsMap: WireObjectCodable { internal enum WireKey: String { case semantics case entries + case clearTimeserial } internal init(wireObject: [String: WireValue]) throws(ARTErrorInfo) { @@ -308,6 +317,7 @@ extension WireObjectsMap: WireObjectCodable { } return try WireObjectsMapEntry(wireObject: object) } + clearTimeserial = try wireObject.optionalStringValueForKey(WireKey.clearTimeserial.rawValue) } internal var toWireObject: [String: WireValue] { @@ -318,6 +328,9 @@ extension WireObjectsMap: WireObjectCodable { if let entries { result[WireKey.entries.rawValue] = .object(entries.mapValues { .object($0.toWireObject) }) } + if let clearTimeserial { + result[WireKey.clearTimeserial.rawValue] = .string(clearTimeserial) + } return result } @@ -484,6 +497,20 @@ extension WireObjectDelete: WireObjectCodable { } } +internal struct WireMapClear: Equatable { + // Empty struct +} + +extension WireMapClear: WireObjectCodable { + internal init(wireObject _: [String: WireValue]) throws(ARTErrorInfo) { + // No fields to decode + } + + internal var toWireObject: [String: WireValue] { + [:] + } +} + internal struct WireMapCreateWithObjectId: Equatable { internal var initialValue: String // MCRO2a internal var nonce: String // MCRO2b diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift index 479357cf1..55b63a008 100644 --- a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift +++ b/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift @@ -346,6 +346,7 @@ struct TestFactories { objectDelete: WireObjectDelete? = nil, mapCreateWithObjectId: MapCreateWithObjectId? = nil, counterCreateWithObjectId: CounterCreateWithObjectId? = nil, + mapClear: WireMapClear? = nil, ) -> ObjectOperation { ObjectOperation( action: action, @@ -358,6 +359,7 @@ struct TestFactories { objectDelete: objectDelete, mapCreateWithObjectId: mapCreateWithObjectId, counterCreateWithObjectId: counterCreateWithObjectId, + mapClear: mapClear, ) } @@ -533,10 +535,12 @@ struct TestFactories { static func objectsMap( semantics: WireEnum = .known(.lww), entries: [String: ObjectsMapEntry]? = nil, + clearTimeserial: String? = nil, ) -> ObjectsMap { ObjectsMap( semantics: semantics, entries: entries, + clearTimeserial: clearTimeserial, ) } @@ -599,6 +603,23 @@ struct TestFactories { ) } + /// 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", diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift index 98af0f569..6d5a89203 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift @@ -294,6 +294,49 @@ struct InternalDefaultLiveMapTests { #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 @@ -591,6 +634,62 @@ struct InternalDefaultLiveMapTests { /// 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 { @@ -811,6 +910,65 @@ struct InternalDefaultLiveMapTests { /// 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 { @@ -1223,6 +1381,111 @@ struct InternalDefaultLiveMapTests { } } + /// 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 @@ -1273,7 +1536,7 @@ struct InternalDefaultLiveMapTests { #expect(map.testsOnly_siteTimeserials == ["site1": "ts2"]) } - // @specOneOf(1/4) RTLM15c - We test this spec point for each possible operation + // @specOneOf(1/5) RTLM15c - We test this spec point for each possible operation // @spec RTLM15d1 - Tests MAP_CREATE operation application // @spec RTLM15d1a // @spec RTLM15d1b @@ -1318,7 +1581,7 @@ struct InternalDefaultLiveMapTests { #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .updated])]) } - // @specOneOf(2/4) RTLM15c - We test this spec point for each possible operation + // @specOneOf(2/5) RTLM15c - We test this spec point for each possible operation // @spec RTLM15d6 - Tests MAP_SET operation application // @spec RTLM15d6a // @spec RTLM15d6b @@ -1377,7 +1640,7 @@ struct InternalDefaultLiveMapTests { #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .updated])]) } - // @specOneOf(3/4) RTLM15c - We test this spec point for each possible operation + // @specOneOf(3/5) RTLM15c - We test this spec point for each possible operation // @spec RTLM15d7 - Tests MAP_REMOVE operation application // @spec RTLM15d7a // @spec RTLM15d7b @@ -1436,7 +1699,67 @@ struct InternalDefaultLiveMapTests { #expect(subscriberInvocations.map(\.0) == [.init(update: ["key1": .removed])]) } - // @specOneOf(4/4) RTLM15c - Tests that siteTimeserials is NOT updated when source is LOCAL + // @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() diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift index ddc235bf4..f37595450 100644 --- a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift +++ b/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift @@ -443,6 +443,11 @@ struct InternalDefaultRealtimeObjectsTests { 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) @@ -493,6 +498,7 @@ struct InternalDefaultRealtimeObjectsTests { 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) @@ -968,7 +974,7 @@ struct InternalDefaultRealtimeObjectsTests { // TODO: Understand what to do with OBJECT_DELETE (https://github.com/ably/specification/pull/343#discussion_r2193126548) - // @specOneOf(1/5) RTO9a2a3 - Tests MAP_CREATE operation application + // @specOneOf(1/6) RTO9a2a3 - Tests MAP_CREATE operation application @Test func appliesMapCreateOperation() throws { let internalQueue = TestFactories.createInternalQueue() @@ -1020,7 +1026,7 @@ struct InternalDefaultRealtimeObjectsTests { // MARK: - RTO9a2a3 Tests for MAP_SET - // @specOneOf(2/5) RTO9a2a3 - Tests MAP_SET operation application + // @specOneOf(2/6) RTO9a2a3 - Tests MAP_SET operation application @Test func appliesMapSetOperation() throws { let internalQueue = TestFactories.createInternalQueue() @@ -1071,7 +1077,7 @@ struct InternalDefaultRealtimeObjectsTests { // MARK: - RTO9a2a3 Tests for MAP_REMOVE - // @specOneOf(3/5) RTO9a2a3 - Tests MAP_REMOVE operation application + // @specOneOf(3/6) RTO9a2a3 - Tests MAP_REMOVE operation application @Test func appliesMapRemoveOperation() throws { let internalQueue = TestFactories.createInternalQueue() @@ -1121,7 +1127,7 @@ struct InternalDefaultRealtimeObjectsTests { // MARK: - RTO9a2a3 Tests for COUNTER_CREATE - // @specOneOf(4/5) RTO9a2a3 - Tests COUNTER_CREATE operation application + // @specOneOf(4/6) RTO9a2a3 - Tests COUNTER_CREATE operation application @Test func appliesCounterCreateOperation() throws { let internalQueue = TestFactories.createInternalQueue() @@ -1170,7 +1176,7 @@ struct InternalDefaultRealtimeObjectsTests { // MARK: - RTO9a2a3 Tests for COUNTER_INC - // @specOneOf(5/5) RTO9a2a3 - Tests COUNTER_INC operation application + // @specOneOf(5/6) RTO9a2a3 - Tests COUNTER_INC operation application @Test func appliesCounterIncOperation() throws { let internalQueue = TestFactories.createInternalQueue() @@ -1216,6 +1222,56 @@ struct InternalDefaultRealtimeObjectsTests { #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. diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift index d3b329702..34cd3b18c 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift @@ -17,6 +17,7 @@ final class ObjectsHelper: Sendable { case counterCreate = 3 case counterInc = 4 case objectDelete = 5 + case mapClear = 6 var stringValue: String { switch self { @@ -32,6 +33,8 @@ final class ObjectsHelper: Sendable { "COUNTER_INC" case .objectDelete: "OBJECT_DELETE" + case .mapClear: + "MAP_CLEAR" } } } @@ -219,6 +222,36 @@ final class ObjectsHelper: Sendable { ] } + /// 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, @@ -226,15 +259,22 @@ final class ObjectsHelper: Sendable { 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([ - "semantics": .number(NSNumber(value: 0)), - "entries": .object(materialisedEntries ?? [:]), - ]), + "map": .object(mapDict), ] if let initialEntries { diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift index d4a795667..c19bc8fa5 100644 --- a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift +++ b/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift @@ -96,6 +96,19 @@ 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 @@ -1239,6 +1252,160 @@ private struct ObjectsIntegrationTests { _ = 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] = [ @@ -2391,6 +2558,392 @@ private struct ObjectsIntegrationTests { _ = 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] = [ @@ -3793,6 +4346,7 @@ private struct ObjectsIntegrationTests { @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 @@ -3996,6 +4550,37 @@ private struct ObjectsIntegrationTests { 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, @@ -4264,6 +4849,7 @@ private struct ObjectsIntegrationTests { try await testCase.scenario.action( .init( + objects: objects, root: root, objectsHelper: objectsHelper, channelName: testCase.channelName, diff --git a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift index b1c483387..a3cfcea65 100644 --- a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift +++ b/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift @@ -164,6 +164,7 @@ enum WireObjectMessageTests { "counterCreate": ["count": 42], "counterInc": ["number": 10], "objectDelete": [:], + "mapClear": [:], ] let op = try WireObjectOperation(wireObject: wire) #expect(op.action == .known(.mapCreate)) @@ -177,6 +178,7 @@ enum WireObjectMessageTests { #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) @@ -197,6 +199,7 @@ enum WireObjectMessageTests { #expect(op.counterCreate == nil) #expect(op.counterInc == nil) #expect(op.objectDelete == nil) + #expect(op.mapClear == nil) #expect(op.mapCreateWithObjectId == nil) #expect(op.counterCreateWithObjectId == nil) } From 686fb43d2dae032e7f89cb8dfe94a90d8a043de5 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 16 Mar 2026 09:56:28 -0300 Subject: [PATCH 208/225] Bump plugin-support to 1.1.0 --- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- Package.resolved | 4 ++-- Package.swift | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7e8c8c351..d99aa66be 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "58cd31c1c01a78a95f7855b224513d79a667a7d83cbeb436a18d5cc025f3ce5d", + "originHash" : "8500c5ec9623d041bb76dea7109087ce23e671352893fc297feb6c772c5f1d0a", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa.git", "state" : { - "revision" : "fe6f8cf1e680276f4475229979595512fdc2b9e5" + "revision" : "3cfa896eafecb08a2eec89d9f57cc0ba0709f0cf" } }, { diff --git a/Package.resolved b/Package.resolved index 476fce2a3..6bf1d9cdd 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "3e074d12cfa495911564b66e11593d652165b7c0b01f7402f1e59aea3039d221", + "originHash" : "92fbd8c2c514430506bbcc062a3fe2b92154eb7e3b2722d592f6ab6e032e828d", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa.git", "state" : { - "revision" : "fe6f8cf1e680276f4475229979595512fdc2b9e5" + "revision" : "3cfa896eafecb08a2eec89d9f57cc0ba0709f0cf" } }, { diff --git a/Package.swift b/Package.swift index bc27d3d35..b4d67cfb2 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,7 @@ let package = Package( // TODO: Unpin before release .package( url: "https://github.com/ably/ably-cocoa.git", - revision: "fe6f8cf1e680276f4475229979595512fdc2b9e5", + revision: "3cfa896eafecb08a2eec89d9f57cc0ba0709f0cf", ), // TODO: Unpin before release .package( From a69ec0d126d54f5baf7d56ccd1a3ad087da69381 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 16 Mar 2026 10:29:23 -0300 Subject: [PATCH 209/225] Remove backwards-compatibility scaffolding from apply-on-ACK APIs Since we're doing a v2 release of plugin-support, the @optional workarounds introduced in 8bfbbaa are no longer needed. Make all methods required, convert the siteCode getter method back to a property, and fold the completionWithResult: variant back into the original completion: method. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../include/APConnectionDetails.h | 4 +--- .../include/APLiveObjectsPlugin.h | 2 -- .../_AblyPluginSupportPrivate/include/APPluginAPI.h | 13 ++----------- 3 files changed, 3 insertions(+), 16 deletions(-) diff --git a/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h b/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h index b5d6e8e7f..e45056ec9 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h +++ b/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h @@ -10,12 +10,10 @@ NS_SWIFT_SENDABLE /// Wraps an `NSTimeInterval` containing the `objectsGCGracePeriod`, if any, in seconds. @property (nonatomic, readonly, nullable) NSNumber *objectsGCGracePeriod; -@optional - /// The site code of the server that the client is connected to (CD2j). /// /// May be absent if the server does not provide it. -- (nullable NSString *)siteCode; +@property (nonatomic, readonly, nullable) NSString *siteCode; @end diff --git a/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h index d57f6b959..d0db6082d 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h +++ b/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h @@ -104,8 +104,6 @@ NS_SWIFT_SENDABLE - (void)nosync_onConnectedWithConnectionDetails:(nullable id)connectionDetails channel:(id)channel; -@optional - /// Called when the channel undergoes a state transition. /// /// Parameters: diff --git a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h index c413100e4..9e64bcf46 100644 --- a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h +++ b/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h @@ -82,7 +82,7 @@ NS_SWIFT_SENDABLE /// - 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. +/// 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:`). /// @@ -91,16 +91,7 @@ NS_SWIFT_SENDABLE /// - 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 error))completion; - -@optional - -/// Same as `-nosync_sendObjectWithObjectMessages:channel:completion:`, but the completion handler additionally receives an `APPublishResultProtocol` containing the serials assigned to the published messages by the server. -- (void)nosync_sendObjectWithObjectMessages:(NSArray> *)objectMessages - channel:(id)channel - completionWithResult:(void (^ _Nullable)(_Nullable id publishResult, _Nullable id error))completion; - -@required + completion:(void (^ _Nullable)(_Nullable id publishResult, _Nullable id error))completion; /// Returns a realtime channel's current state. - (APRealtimeChannelState)nosync_stateForChannel:(id)channel; From bddb0f6087b157b3d37392ab6ad3a00c1dbe8b94 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 16 Mar 2026 10:54:12 -0300 Subject: [PATCH 210/225] Bump plugin-support to v2 Since plugin-support v2 makes all previously @optional methods required, the backwards-compatibility scaffolding introduced alongside them is no longer needed. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../xcshareddata/swiftpm/Package.resolved | 7 +++--- Package.resolved | 7 +++--- Package.swift | 5 ++--- .../Internal/DefaultInternalPlugin.swift | 22 ++----------------- 4 files changed, 12 insertions(+), 29 deletions(-) diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index 7a6a10d72..550ef478c 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "5aea056244a889067e86d6f98f6f12626421c174d7cfb60fb5df63301164fa7d", + "originHash" : "95afbce8759332da020c1fee21274fc36d3a4d553a84ba74cc9c6b6a362b92af", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa.git", "state" : { - "revision" : "68e9b6fc786f0c2c425d095b3cf3366844fd0507" + "revision" : "5e4cca89749ec051a57c5758412c080f049fcf42" } }, { @@ -14,7 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "36c529db38154d01537e782f7ed4aa7535fb3237" + "revision" : "a290b8942086ffb6e21e4805d9319143669d9414", + "version" : "2.0.0" } }, { diff --git a/Package.resolved b/Package.resolved index 00d334698..80f72e8d6 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,12 @@ { - "originHash" : "4d96d1482cdd660c6afc130908b76428f5bb02e40f03d67d9b8d6297ff13473f", + "originHash" : "a99712beebe11b3d7800b25f4f287f116646da3fd909c3fe89a79fb9ec598335", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa.git", "state" : { - "revision" : "68e9b6fc786f0c2c425d095b3cf3366844fd0507" + "revision" : "5e4cca89749ec051a57c5758412c080f049fcf42" } }, { @@ -14,7 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa-plugin-support", "state" : { - "revision" : "36c529db38154d01537e782f7ed4aa7535fb3237" + "revision" : "a290b8942086ffb6e21e4805d9319143669d9414", + "version" : "2.0.0" } }, { diff --git a/Package.swift b/Package.swift index 6d0c1ec86..886488b58 100644 --- a/Package.swift +++ b/Package.swift @@ -21,12 +21,11 @@ let package = Package( // TODO: Unpin before release .package( url: "https://github.com/ably/ably-cocoa.git", - revision: "68e9b6fc786f0c2c425d095b3cf3366844fd0507", + revision: "5e4cca89749ec051a57c5758412c080f049fcf42", ), - // TODO: Unpin before release .package( url: "https://github.com/ably/ably-cocoa-plugin-support", - revision: "36c529db38154d01537e782f7ed4aa7535fb3237", + from: "2.0.0", ), .package( url: "https://github.com/apple/swift-argument-parser", diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift index 7aa07bf72..b15c7ab74 100644 --- a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -161,20 +161,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. // RTO10b realtimeObjects.nosync_setGarbageCollectionGracePeriod(gracePeriod) - // Push the siteCode from connectionDetails - let siteCode: String? = { - guard let connectionDetails else { - return nil - } - - // This is a fallback; our ably-cocoa dependency version should ensure that this is never triggered. - guard (connectionDetails as AnyObject).responds(to: #selector(ConnectionDetailsProtocol.siteCode)) else { - preconditionFailure("ably-cocoa's connectionDetails does not implement siteCode. Please update ably-cocoa to a version that supports apply-on-ACK.") - } - - return connectionDetails.siteCode?() - }() - realtimeObjects.nosync_setSiteCode(siteCode) + realtimeObjects.nosync_setSiteCode(connectionDetails?.siteCode) } // MARK: - Sending `OBJECT` ProtocolMessage @@ -189,12 +176,7 @@ internal final class DefaultInternalPlugin: NSObject, _AblyPluginSupportPrivate. let objectMessageBoxes: [ObjectMessageBox] = objectMessages.map { .init(objectMessage: $0) } let internalQueue = pluginAPI.internalQueue(for: client) - // This is a fallback; our ably-cocoa dependency version should ensure that this is never triggered. - guard (pluginAPI as AnyObject).responds(to: #selector(PluginAPIProtocol.nosync_sendObject(withObjectMessages:channel:completionWithResult:))) else { - preconditionFailure("ably-cocoa does not implement nosync_sendObjectWithObjectMessages:channel:completionWithResult:. Please update ably-cocoa to a version that supports apply-on-ACK.") - } - - pluginAPI.nosync_sendObject!( + pluginAPI.nosync_sendObject( withObjectMessages: objectMessageBoxes, channel: channel, ) { pluginPublishResult, error in From 8aaaab354d941ac75e9271689ddde8cad5897b25 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Mon, 16 Mar 2026 12:07:25 -0300 Subject: [PATCH 211/225] Release version 0.4.0 --- .../xcshareddata/swiftpm/Package.resolved | 5 ++-- CHANGELOG.md | 26 +++++++++++++++++++ Package.resolved | 5 ++-- Package.swift | 3 +-- 4 files changed, 33 insertions(+), 6 deletions(-) diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved index 550ef478c..a45cc6840 100644 --- a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,13 @@ { - "originHash" : "95afbce8759332da020c1fee21274fc36d3a4d553a84ba74cc9c6b6a362b92af", + "originHash" : "882ea3086372221060f5fc4b93d0254adddc2a879699f35a91adee80d3ba15ce", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa.git", "state" : { - "revision" : "5e4cca89749ec051a57c5758412c080f049fcf42" + "revision" : "83bb5366b09408723446d8ba8a146938eeba829e", + "version" : "1.2.59" } }, { diff --git a/CHANGELOG.md b/CHANGELOG.md index 8570c88f4..c51b74820 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,31 @@ # 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 diff --git a/Package.resolved b/Package.resolved index 80f72e8d6..95a949028 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,12 +1,13 @@ { - "originHash" : "a99712beebe11b3d7800b25f4f287f116646da3fd909c3fe89a79fb9ec598335", + "originHash" : "263b1ea0fd786ff1456ebaef26e6b6baa0f018c0154cf2929eaa91abb0b54061", "pins" : [ { "identity" : "ably-cocoa", "kind" : "remoteSourceControl", "location" : "https://github.com/ably/ably-cocoa.git", "state" : { - "revision" : "5e4cca89749ec051a57c5758412c080f049fcf42" + "revision" : "83bb5366b09408723446d8ba8a146938eeba829e", + "version" : "1.2.59" } }, { diff --git a/Package.swift b/Package.swift index 886488b58..982af18db 100644 --- a/Package.swift +++ b/Package.swift @@ -18,10 +18,9 @@ let package = Package( ), ], dependencies: [ - // TODO: Unpin before release .package( url: "https://github.com/ably/ably-cocoa.git", - revision: "5e4cca89749ec051a57c5758412c080f049fcf42", + from: "1.2.59", ), .package( url: "https://github.com/ably/ably-cocoa-plugin-support", From fd04eabe2f39bd2824e76f1ae2a55805ee4916d9 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 15:38:33 -0300 Subject: [PATCH 212/225] Move repo contents into merged-repos/ably-cocoa-plugin-support/ Prepares this repo to be merged into ably-cocoa under a sub-directory of that name. Done as a single move commit so that the subsequent merge into ably-cocoa is conflict-free at every path (no top-level collisions with ably-cocoa's .gitignore, COPYRIGHT, Package.swift, README.md, or LICENSE), while every pre-move commit on this branch retains its original SHA. Cross-references to those SHAs from elsewhere (PR descriptions, commit messages) continue to resolve. Original file history is reachable via `git log --follow` on the new paths, or by walking from the plugin-support-pre-merge tag in ably-cocoa. Co-Authored-By: Claude Opus 4.7 (1M context) --- .gitignore => merged-repos/ably-cocoa-plugin-support/.gitignore | 0 COPYRIGHT => merged-repos/ably-cocoa-plugin-support/COPYRIGHT | 0 LICENSE => merged-repos/ably-cocoa-plugin-support/LICENSE | 0 .../ably-cocoa-plugin-support/Package.swift | 0 README.md => merged-repos/ably-cocoa-plugin-support/README.md | 0 .../Sources}/_AblyPluginSupportPrivate/APDependencyStore.m | 0 .../_AblyPluginSupportPrivate/include/APConnectionDetails.h | 0 .../_AblyPluginSupportPrivate/include/APDecodingContext.h | 0 .../_AblyPluginSupportPrivate/include/APDependencyStore.h | 0 .../Sources}/_AblyPluginSupportPrivate/include/APEncodingFormat.h | 0 .../_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h | 0 .../Sources}/_AblyPluginSupportPrivate/include/APLogLevel.h | 0 .../Sources}/_AblyPluginSupportPrivate/include/APLogger.h | 0 .../Sources}/_AblyPluginSupportPrivate/include/APPluginAPI.h | 0 .../_AblyPluginSupportPrivate/include/APPublicClientOptions.h | 0 .../_AblyPluginSupportPrivate/include/APPublicErrorInfo.h | 0 .../_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h | 0 .../include/APPublicRealtimeChannelUnderlyingObjects.h | 0 .../Sources}/_AblyPluginSupportPrivate/include/APPublishResult.h | 0 .../_AblyPluginSupportPrivate/include/APRealtimeChannel.h | 0 .../_AblyPluginSupportPrivate/include/APRealtimeChannelState.h | 0 .../Sources}/_AblyPluginSupportPrivate/include/APRealtimeClient.h | 0 22 files changed, 0 insertions(+), 0 deletions(-) rename .gitignore => merged-repos/ably-cocoa-plugin-support/.gitignore (100%) rename COPYRIGHT => merged-repos/ably-cocoa-plugin-support/COPYRIGHT (100%) rename LICENSE => merged-repos/ably-cocoa-plugin-support/LICENSE (100%) rename Package.swift => merged-repos/ably-cocoa-plugin-support/Package.swift (100%) rename README.md => merged-repos/ably-cocoa-plugin-support/README.md (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/APDependencyStore.m (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APConnectionDetails.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APDecodingContext.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APDependencyStore.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APEncodingFormat.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APLogLevel.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APLogger.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APPluginAPI.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APPublicClientOptions.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APPublishResult.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APRealtimeChannel.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h (100%) rename {Sources => merged-repos/ably-cocoa-plugin-support/Sources}/_AblyPluginSupportPrivate/include/APRealtimeClient.h (100%) diff --git a/.gitignore b/merged-repos/ably-cocoa-plugin-support/.gitignore similarity index 100% rename from .gitignore rename to merged-repos/ably-cocoa-plugin-support/.gitignore diff --git a/COPYRIGHT b/merged-repos/ably-cocoa-plugin-support/COPYRIGHT similarity index 100% rename from COPYRIGHT rename to merged-repos/ably-cocoa-plugin-support/COPYRIGHT diff --git a/LICENSE b/merged-repos/ably-cocoa-plugin-support/LICENSE similarity index 100% rename from LICENSE rename to merged-repos/ably-cocoa-plugin-support/LICENSE diff --git a/Package.swift b/merged-repos/ably-cocoa-plugin-support/Package.swift similarity index 100% rename from Package.swift rename to merged-repos/ably-cocoa-plugin-support/Package.swift diff --git a/README.md b/merged-repos/ably-cocoa-plugin-support/README.md similarity index 100% rename from README.md rename to merged-repos/ably-cocoa-plugin-support/README.md diff --git a/Sources/_AblyPluginSupportPrivate/APDependencyStore.m b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/APDependencyStore.m similarity index 100% rename from Sources/_AblyPluginSupportPrivate/APDependencyStore.m rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/APDependencyStore.m diff --git a/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APConnectionDetails.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APDecodingContext.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APDependencyStore.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APEncodingFormat.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APLiveObjectsPlugin.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APLogLevel.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APLogLevel.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APLogger.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APLogger.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APLogger.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APLogger.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPluginAPI.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicClientOptions.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicErrorInfo.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannel.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublicRealtimeChannelUnderlyingObjects.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APPublishResult.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APPublishResult.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannel.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APRealtimeChannelState.h diff --git a/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h b/merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h similarity index 100% rename from Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h rename to merged-repos/ably-cocoa-plugin-support/Sources/_AblyPluginSupportPrivate/include/APRealtimeClient.h From 758242cd5ada63184d7c0cff1f8ddf78107f9185 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 15:39:27 -0300 Subject: [PATCH 213/225] Move repo contents into merged-repos/ably-liveobjects-swift-plugin/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prepares this repo to be merged into ably-cocoa under a sub-directory of that name. Done as a single move commit so that the subsequent merge into ably-cocoa is conflict-free at every path, while every pre-move commit on this branch retains its original SHA. Cross-references to those SHAs from elsewhere (PR descriptions, commit messages) continue to resolve. Note: the ably-common submodule's registration moves with the rest of the repo to merged-repos/.../.gitmodules. Git only consults a .gitmodules file at the repo root, so submodule operations against this branch will fail until the submodule is re-registered at the new path in ably-cocoa's root .gitmodules — to be done as a follow-up commit after the merge. Original file history is reachable via `git log --follow` on the new paths, or by walking from the liveobjects-plugin-pre-merge tag in ably-cocoa. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../.github}/workflows/check.yaml | 0 .../ably-liveobjects-swift-plugin/.gitignore | 0 .../ably-liveobjects-swift-plugin/.gitmodules | 0 .../ably-liveobjects-swift-plugin/.prettierignore | 0 .../ably-liveobjects-swift-plugin/.prettierrc | 0 .../ably-liveobjects-swift-plugin/.swift-version | 0 .../ably-liveobjects-swift-plugin/.swiftformat | 0 .../ably-liveobjects-swift-plugin/.swiftlint.yml | 0 .../xcshareddata/WorkspaceSettings.xcsettings | 0 .../xcschemes/AblyLiveObjects-Package.xcscheme | 0 .../xcshareddata/xcschemes/AblyLiveObjects.xcscheme | 0 .../xcode/xcshareddata/xcschemes/BuildTool.xcscheme | 0 .../contents.xcworkspacedata | 0 .../xcshareddata/IDEWorkspaceChecks.plist | 0 .../xcshareddata/swiftpm/Package.resolved | 0 .../ably-liveobjects-swift-plugin/CHANGELOG.md | 0 .../ably-liveobjects-swift-plugin/CLAUDE.md | 0 .../ably-liveobjects-swift-plugin/CONTRIBUTING.md | 0 .../ably-liveobjects-swift-plugin/COPYRIGHT | 0 .../project.pbxproj | 0 .../project.xcworkspace/contents.xcworkspacedata | 0 .../xcshareddata/IDEWorkspaceChecks.plist | 0 .../xcshareddata/swiftpm/Package.resolved | 0 .../AblyLiveObjectsExample.entitlements | 0 .../AblyLiveObjectsExampleApp.swift | 0 .../AccentColor.colorset/Contents.json | 0 .../AppIcon.appiconset/Contents.json | 0 .../Assets.xcassets/Contents.json | 0 .../AblyLiveObjectsExample/ContentView.swift | 0 .../Helpers/ARTRealtimeChannel+Async.swift | 0 .../Preview Assets.xcassets/Contents.json | 0 .../AblyLiveObjectsExample/Secrets.example.swift | 0 .../ViewModels/LiveCounterViewModel.swift | 0 .../ViewModels/TaskBoardViewModel.swift | 0 .../Views/LiveCounterView.swift | 0 .../Views/TaskBoardView.swift | 0 .../ably-liveobjects-swift-plugin/LICENSE | 0 .../ably-liveobjects-swift-plugin/MAINTAINERS.md | 0 .../ably-liveobjects-swift-plugin/Mintfile | 0 .../ably-liveobjects-swift-plugin/Package.resolved | 0 .../ably-liveobjects-swift-plugin/Package.swift | 0 .../ably-liveobjects-swift-plugin/README.md | 0 .../Sources}/AblyLiveObjects/.swiftformat | 0 .../Sources}/AblyLiveObjects/.swiftlint.yml | 0 .../Internal/ARTClientOptions+Objects.swift | 0 .../Sources}/AblyLiveObjects/Internal/CoreSDK.swift | 0 .../Internal/DefaultInternalPlugin.swift | 0 .../Internal/DefaultLiveCounterUpdate.swift | 0 .../Internal/DefaultLiveMapUpdate.swift | 0 .../Internal/InternalDefaultLiveCounter.swift | 0 .../Internal/InternalDefaultLiveMap.swift | 0 .../Internal/InternalDefaultRealtimeObjects.swift | 0 .../Internal/InternalLiveMapValue.swift | 0 .../Internal/InternalLiveObject.swift | 0 .../Internal/InternalObjectsMapEntry.swift | 0 .../Internal/LiveObjectMutableState.swift | 0 .../AblyLiveObjects/Internal/LiveObjectUpdate.swift | 0 .../Internal/ObjectCreationHelpers.swift | 0 .../Internal/ObjectDiffHelpers.swift | 0 .../Internal/ObjectsOperationSource.swift | 0 .../AblyLiveObjects/Internal/ObjectsPool.swift | 0 .../AblyLiveObjects/Internal/ObjectsSyncState.swift | 0 .../AblyLiveObjects/Internal/PublishResult.swift | 0 .../AblyLiveObjects/Internal/SimpleClock.swift | 0 .../Internal/SubscriptionStorage.swift | 0 .../AblyLiveObjects/Internal/SyncObjectsPool.swift | 0 .../Protocol/InboundObjectMessage+Synthetic.swift | 0 .../AblyLiveObjects/Protocol/ObjectMessage.swift | 0 .../AblyLiveObjects/Protocol/SyncCursor.swift | 0 .../AblyLiveObjects/Protocol/WireEnum.swift | 0 .../Protocol/WireObjectMessage.swift | 0 .../Public/ARTRealtimeChannel+Objects.swift | 0 .../Sources}/AblyLiveObjects/Public/Plugin.swift | 0 .../InternalLiveMapValue+ToPublic.swift | 0 .../PublicDefaultLiveCounter.swift | 0 .../Public Proxy Objects/PublicDefaultLiveMap.swift | 0 .../PublicDefaultRealtimeObjects.swift | 0 .../Public Proxy Objects/PublicObjectsStore.swift | 0 .../AblyLiveObjects/Public/PublicTypes.swift | 0 .../AblyLiveObjects/Utility/Assertions.swift | 0 .../AblyLiveObjects/Utility/Data+Extensions.swift | 0 .../Utility/Dictionary+Extensions.swift | 0 .../Utility/DispatchQueue+Extensions.swift | 0 .../Utility/DispatchQueueMutex.swift | 0 .../Sources}/AblyLiveObjects/Utility/Errors.swift | 0 .../AblyLiveObjects/Utility/ExtendedJSONValue.swift | 0 .../AblyLiveObjects/Utility/JSONValue.swift | 0 .../Sources}/AblyLiveObjects/Utility/Logger.swift | 0 .../AblyLiveObjects/Utility/LoggingUtilities.swift | 0 .../Utility/MarkerProtocolHelpers.swift | 0 .../AblyLiveObjects/Utility/NSLock+Extensions.swift | 0 .../Sources}/AblyLiveObjects/Utility/WeakRef.swift | 0 .../AblyLiveObjects/Utility/WireCodable.swift | 0 .../AblyLiveObjects/Utility/WireValue.swift | 0 .../Sources}/BuildTool/BuildTool.swift | 0 .../Sources}/BuildTool/Configuration.swift | 0 .../Sources}/BuildTool/DestinationFetcher.swift | 0 .../Sources}/BuildTool/DestinationPredicate.swift | 0 .../Sources}/BuildTool/DestinationSpecifier.swift | 0 .../Sources}/BuildTool/DestinationStrategy.swift | 0 .../Sources}/BuildTool/Error.swift | 0 .../Sources}/BuildTool/Platform.swift | 0 .../Sources}/BuildTool/ProcessRunner.swift | 0 .../Sources}/BuildTool/String+Decoding.swift | 0 .../Sources}/BuildTool/XcodeRunner.swift | 0 .../TestPlans}/AllTests.xctestplan | 0 .../TestPlans}/UnitTests.xctestplan | 0 .../Tests}/AblyLiveObjectsTests/.swiftformat | 0 .../Tests}/AblyLiveObjectsTests/.swiftlint.yml | 0 .../AblyLiveObjectsTests/AblyLiveObjectsTests.swift | 0 .../Tests}/AblyLiveObjectsTests/CLAUDE.md | 0 .../Helpers/Ably+Concurrency.swift | 0 .../AblyLiveObjectsTests/Helpers/Assertions.swift | 0 .../AblyLiveObjectsTests/Helpers/ClientHelper.swift | 0 .../AblyLiveObjectsTests/Helpers/Sandbox.swift | 0 .../AblyLiveObjectsTests/Helpers/Subscriber.swift | 0 .../Helpers/Tag+Integration.swift | 0 .../Helpers/TestFactories.swift | 0 .../AblyLiveObjectsTests/Helpers/TestLogger.swift | 0 .../InternalDefaultLiveCounterTests.swift | 0 .../InternalDefaultLiveMapTests.swift | 0 .../InternalDefaultRealtimeObjectsTests.swift | 0 .../JS Integration Tests/ObjectsHelper.swift | 0 .../ObjectsIntegrationTests.swift | 0 .../JS Integration Tests/TestProxyTransport.swift | 0 .../AblyLiveObjectsTests/JSONValueTests.swift | 0 .../LiveObjectMutableStateTests.swift | 0 .../AblyLiveObjectsTests/Mocks/MockCoreSDK.swift | 0 .../Mocks/MockLiveMapObjectsPoolDelegate.swift | 0 .../Mocks/MockRealtimeObjects.swift | 0 .../Mocks/MockSimpleClock.swift | 0 .../ObjectCreationHelpersTests.swift | 0 .../ObjectDiffHelpersTests.swift | 0 .../AblyLiveObjectsTests/ObjectLifetimesTests.swift | 0 .../AblyLiveObjectsTests/ObjectMessageTests.swift | 0 .../AblyLiveObjectsTests/ObjectsPoolTests.swift | 0 .../AblyLiveObjectsTests/SyncCursorTests.swift | 0 .../AblyLiveObjectsTests/SyncObjectsPoolTests.swift | 0 .../WireObjectMessageTests.swift | 0 .../AblyLiveObjectsTests/WireValueTests.swift | 0 .../Tests}/AblyLiveObjectsTests/ably-common | 0 .../images}/SwiftSDK-LiveObjects-github.png | Bin .../images}/unit-tests-test-plan-screenshot.png | Bin .../ably-liveobjects-swift-plugin/package-lock.json | 0 .../ably-liveobjects-swift-plugin/package.json | 0 145 files changed, 0 insertions(+), 0 deletions(-) rename {.github => merged-repos/ably-liveobjects-swift-plugin/.github}/workflows/check.yaml (100%) rename .gitignore => merged-repos/ably-liveobjects-swift-plugin/.gitignore (100%) rename .gitmodules => merged-repos/ably-liveobjects-swift-plugin/.gitmodules (100%) rename .prettierignore => merged-repos/ably-liveobjects-swift-plugin/.prettierignore (100%) rename .prettierrc => merged-repos/ably-liveobjects-swift-plugin/.prettierrc (100%) rename .swift-version => merged-repos/ably-liveobjects-swift-plugin/.swift-version (100%) rename .swiftformat => merged-repos/ably-liveobjects-swift-plugin/.swiftformat (100%) rename .swiftlint.yml => merged-repos/ably-liveobjects-swift-plugin/.swiftlint.yml (100%) rename {.swiftpm => merged-repos/ably-liveobjects-swift-plugin/.swiftpm}/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings (100%) rename {.swiftpm => merged-repos/ably-liveobjects-swift-plugin/.swiftpm}/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme (100%) rename {.swiftpm => merged-repos/ably-liveobjects-swift-plugin/.swiftpm}/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme (100%) rename {.swiftpm => merged-repos/ably-liveobjects-swift-plugin/.swiftpm}/xcode/xcshareddata/xcschemes/BuildTool.xcscheme (100%) rename {AblyLiveObjects.xcworkspace => merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace}/contents.xcworkspacedata (100%) rename {AblyLiveObjects.xcworkspace => merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace}/xcshareddata/IDEWorkspaceChecks.plist (100%) rename {AblyLiveObjects.xcworkspace => merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace}/xcshareddata/swiftpm/Package.resolved (100%) rename CHANGELOG.md => merged-repos/ably-liveobjects-swift-plugin/CHANGELOG.md (100%) rename CLAUDE.md => merged-repos/ably-liveobjects-swift-plugin/CLAUDE.md (100%) rename CONTRIBUTING.md => merged-repos/ably-liveobjects-swift-plugin/CONTRIBUTING.md (100%) rename COPYRIGHT => merged-repos/ably-liveobjects-swift-plugin/COPYRIGHT (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample.xcodeproj/project.pbxproj (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample/Assets.xcassets/Contents.json (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample/ContentView.swift (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample/Secrets.example.swift (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample/Views/LiveCounterView.swift (100%) rename {Example => merged-repos/ably-liveobjects-swift-plugin/Example}/AblyLiveObjectsExample/Views/TaskBoardView.swift (100%) rename LICENSE => merged-repos/ably-liveobjects-swift-plugin/LICENSE (100%) rename MAINTAINERS.md => merged-repos/ably-liveobjects-swift-plugin/MAINTAINERS.md (100%) rename Mintfile => merged-repos/ably-liveobjects-swift-plugin/Mintfile (100%) rename Package.resolved => merged-repos/ably-liveobjects-swift-plugin/Package.resolved (100%) rename Package.swift => merged-repos/ably-liveobjects-swift-plugin/Package.swift (100%) rename README.md => merged-repos/ably-liveobjects-swift-plugin/README.md (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/.swiftformat (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/.swiftlint.yml (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/CoreSDK.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/DefaultInternalPlugin.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/InternalLiveMapValue.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/InternalLiveObject.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/LiveObjectMutableState.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/LiveObjectUpdate.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/ObjectCreationHelpers.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/ObjectDiffHelpers.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/ObjectsOperationSource.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/ObjectsPool.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/ObjectsSyncState.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/PublishResult.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/SimpleClock.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/SubscriptionStorage.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Internal/SyncObjectsPool.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Protocol/ObjectMessage.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Protocol/SyncCursor.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Protocol/WireEnum.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Protocol/WireObjectMessage.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Public/Plugin.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Public/PublicTypes.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/Assertions.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/Data+Extensions.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/Dictionary+Extensions.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/DispatchQueueMutex.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/Errors.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/ExtendedJSONValue.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/JSONValue.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/Logger.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/LoggingUtilities.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/NSLock+Extensions.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/WeakRef.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/WireCodable.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/AblyLiveObjects/Utility/WireValue.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/BuildTool/BuildTool.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/BuildTool/Configuration.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/BuildTool/DestinationFetcher.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/BuildTool/DestinationPredicate.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/BuildTool/DestinationSpecifier.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/BuildTool/DestinationStrategy.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/BuildTool/Error.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/BuildTool/Platform.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/BuildTool/ProcessRunner.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/BuildTool/String+Decoding.swift (100%) rename {Sources => merged-repos/ably-liveobjects-swift-plugin/Sources}/BuildTool/XcodeRunner.swift (100%) rename {TestPlans => merged-repos/ably-liveobjects-swift-plugin/TestPlans}/AllTests.xctestplan (100%) rename {TestPlans => merged-repos/ably-liveobjects-swift-plugin/TestPlans}/UnitTests.xctestplan (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/.swiftformat (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/.swiftlint.yml (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/AblyLiveObjectsTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/CLAUDE.md (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/Helpers/Assertions.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/Helpers/ClientHelper.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/Helpers/Sandbox.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/Helpers/Subscriber.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/Helpers/Tag+Integration.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/Helpers/TestFactories.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/Helpers/TestLogger.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/JSONValueTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/ObjectLifetimesTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/ObjectMessageTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/ObjectsPoolTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/SyncCursorTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/SyncObjectsPoolTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/WireObjectMessageTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/WireValueTests.swift (100%) rename {Tests => merged-repos/ably-liveobjects-swift-plugin/Tests}/AblyLiveObjectsTests/ably-common (100%) rename {images => merged-repos/ably-liveobjects-swift-plugin/images}/SwiftSDK-LiveObjects-github.png (100%) rename {images => merged-repos/ably-liveobjects-swift-plugin/images}/unit-tests-test-plan-screenshot.png (100%) rename package-lock.json => merged-repos/ably-liveobjects-swift-plugin/package-lock.json (100%) rename package.json => merged-repos/ably-liveobjects-swift-plugin/package.json (100%) diff --git a/.github/workflows/check.yaml b/merged-repos/ably-liveobjects-swift-plugin/.github/workflows/check.yaml similarity index 100% rename from .github/workflows/check.yaml rename to merged-repos/ably-liveobjects-swift-plugin/.github/workflows/check.yaml diff --git a/.gitignore b/merged-repos/ably-liveobjects-swift-plugin/.gitignore similarity index 100% rename from .gitignore rename to merged-repos/ably-liveobjects-swift-plugin/.gitignore diff --git a/.gitmodules b/merged-repos/ably-liveobjects-swift-plugin/.gitmodules similarity index 100% rename from .gitmodules rename to merged-repos/ably-liveobjects-swift-plugin/.gitmodules diff --git a/.prettierignore b/merged-repos/ably-liveobjects-swift-plugin/.prettierignore similarity index 100% rename from .prettierignore rename to merged-repos/ably-liveobjects-swift-plugin/.prettierignore diff --git a/.prettierrc b/merged-repos/ably-liveobjects-swift-plugin/.prettierrc similarity index 100% rename from .prettierrc rename to merged-repos/ably-liveobjects-swift-plugin/.prettierrc diff --git a/.swift-version b/merged-repos/ably-liveobjects-swift-plugin/.swift-version similarity index 100% rename from .swift-version rename to merged-repos/ably-liveobjects-swift-plugin/.swift-version diff --git a/.swiftformat b/merged-repos/ably-liveobjects-swift-plugin/.swiftformat similarity index 100% rename from .swiftformat rename to merged-repos/ably-liveobjects-swift-plugin/.swiftformat diff --git a/.swiftlint.yml b/merged-repos/ably-liveobjects-swift-plugin/.swiftlint.yml similarity index 100% rename from .swiftlint.yml rename to merged-repos/ably-liveobjects-swift-plugin/.swiftlint.yml diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings b/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings similarity index 100% rename from .swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings rename to merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/package.xcworkspace/xcshareddata/WorkspaceSettings.xcsettings diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme b/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme similarity index 100% rename from .swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme rename to merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects-Package.xcscheme diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme b/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme similarity index 100% rename from .swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme rename to merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme b/merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme similarity index 100% rename from .swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme rename to merged-repos/ably-liveobjects-swift-plugin/.swiftpm/xcode/xcshareddata/xcschemes/BuildTool.xcscheme diff --git a/AblyLiveObjects.xcworkspace/contents.xcworkspacedata b/merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/contents.xcworkspacedata similarity index 100% rename from AblyLiveObjects.xcworkspace/contents.xcworkspacedata rename to merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/contents.xcworkspacedata diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved b/merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved similarity index 100% rename from AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved rename to merged-repos/ably-liveobjects-swift-plugin/AblyLiveObjects.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/CHANGELOG.md b/merged-repos/ably-liveobjects-swift-plugin/CHANGELOG.md similarity index 100% rename from CHANGELOG.md rename to merged-repos/ably-liveobjects-swift-plugin/CHANGELOG.md diff --git a/CLAUDE.md b/merged-repos/ably-liveobjects-swift-plugin/CLAUDE.md similarity index 100% rename from CLAUDE.md rename to merged-repos/ably-liveobjects-swift-plugin/CLAUDE.md diff --git a/CONTRIBUTING.md b/merged-repos/ably-liveobjects-swift-plugin/CONTRIBUTING.md similarity index 100% rename from CONTRIBUTING.md rename to merged-repos/ably-liveobjects-swift-plugin/CONTRIBUTING.md diff --git a/COPYRIGHT b/merged-repos/ably-liveobjects-swift-plugin/COPYRIGHT similarity index 100% rename from COPYRIGHT rename to merged-repos/ably-liveobjects-swift-plugin/COPYRIGHT diff --git a/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj similarity index 100% rename from Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.pbxproj diff --git a/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata similarity index 100% rename from Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/contents.xcworkspacedata diff --git a/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist similarity index 100% rename from Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist diff --git a/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 similarity index 100% rename from Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Example/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements similarity index 100% rename from Example/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/AblyLiveObjectsExample.entitlements diff --git a/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift similarity index 100% rename from Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/AblyLiveObjectsExampleApp.swift diff --git a/Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json similarity index 100% rename from Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/AccentColor.colorset/Contents.json diff --git a/Example/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json similarity index 100% rename from Example/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/AppIcon.appiconset/Contents.json diff --git a/Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json similarity index 100% rename from Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Assets.xcassets/Contents.json diff --git a/Example/AblyLiveObjectsExample/ContentView.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/ContentView.swift similarity index 100% rename from Example/AblyLiveObjectsExample/ContentView.swift rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/ContentView.swift diff --git a/Example/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift similarity index 100% rename from Example/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Helpers/ARTRealtimeChannel+Async.swift diff --git a/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 similarity index 100% rename from Example/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Preview Content/Preview Assets.xcassets/Contents.json diff --git a/Example/AblyLiveObjectsExample/Secrets.example.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Secrets.example.swift similarity index 100% rename from Example/AblyLiveObjectsExample/Secrets.example.swift rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Secrets.example.swift diff --git a/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift similarity index 100% rename from Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/ViewModels/LiveCounterViewModel.swift diff --git a/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift similarity index 100% rename from Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/ViewModels/TaskBoardViewModel.swift diff --git a/Example/AblyLiveObjectsExample/Views/LiveCounterView.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Views/LiveCounterView.swift similarity index 100% rename from Example/AblyLiveObjectsExample/Views/LiveCounterView.swift rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Views/LiveCounterView.swift diff --git a/Example/AblyLiveObjectsExample/Views/TaskBoardView.swift b/merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Views/TaskBoardView.swift similarity index 100% rename from Example/AblyLiveObjectsExample/Views/TaskBoardView.swift rename to merged-repos/ably-liveobjects-swift-plugin/Example/AblyLiveObjectsExample/Views/TaskBoardView.swift diff --git a/LICENSE b/merged-repos/ably-liveobjects-swift-plugin/LICENSE similarity index 100% rename from LICENSE rename to merged-repos/ably-liveobjects-swift-plugin/LICENSE diff --git a/MAINTAINERS.md b/merged-repos/ably-liveobjects-swift-plugin/MAINTAINERS.md similarity index 100% rename from MAINTAINERS.md rename to merged-repos/ably-liveobjects-swift-plugin/MAINTAINERS.md diff --git a/Mintfile b/merged-repos/ably-liveobjects-swift-plugin/Mintfile similarity index 100% rename from Mintfile rename to merged-repos/ably-liveobjects-swift-plugin/Mintfile diff --git a/Package.resolved b/merged-repos/ably-liveobjects-swift-plugin/Package.resolved similarity index 100% rename from Package.resolved rename to merged-repos/ably-liveobjects-swift-plugin/Package.resolved diff --git a/Package.swift b/merged-repos/ably-liveobjects-swift-plugin/Package.swift similarity index 100% rename from Package.swift rename to merged-repos/ably-liveobjects-swift-plugin/Package.swift diff --git a/README.md b/merged-repos/ably-liveobjects-swift-plugin/README.md similarity index 100% rename from README.md rename to merged-repos/ably-liveobjects-swift-plugin/README.md diff --git a/Sources/AblyLiveObjects/.swiftformat b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/.swiftformat similarity index 100% rename from Sources/AblyLiveObjects/.swiftformat rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/.swiftformat diff --git a/Sources/AblyLiveObjects/.swiftlint.yml b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/.swiftlint.yml similarity index 100% rename from Sources/AblyLiveObjects/.swiftlint.yml rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/.swiftlint.yml diff --git a/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ARTClientOptions+Objects.swift diff --git a/Sources/AblyLiveObjects/Internal/CoreSDK.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/CoreSDK.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/CoreSDK.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/CoreSDK.swift diff --git a/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift diff --git a/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/InternalLiveObject.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift diff --git a/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift diff --git a/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift diff --git a/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift diff --git a/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift diff --git a/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift diff --git a/Sources/AblyLiveObjects/Internal/ObjectsPool.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsPool.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/ObjectsPool.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsPool.swift diff --git a/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift diff --git a/Sources/AblyLiveObjects/Internal/PublishResult.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/PublishResult.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/PublishResult.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/PublishResult.swift diff --git a/Sources/AblyLiveObjects/Internal/SimpleClock.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SimpleClock.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/SimpleClock.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SimpleClock.swift diff --git a/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift diff --git a/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift similarity index 100% rename from Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SyncObjectsPool.swift diff --git a/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift similarity index 100% rename from Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/InboundObjectMessage+Synthetic.swift diff --git a/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift similarity index 100% rename from Sources/AblyLiveObjects/Protocol/ObjectMessage.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/ObjectMessage.swift diff --git a/Sources/AblyLiveObjects/Protocol/SyncCursor.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/SyncCursor.swift similarity index 100% rename from Sources/AblyLiveObjects/Protocol/SyncCursor.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/SyncCursor.swift diff --git a/Sources/AblyLiveObjects/Protocol/WireEnum.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireEnum.swift similarity index 100% rename from Sources/AblyLiveObjects/Protocol/WireEnum.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireEnum.swift diff --git a/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift similarity index 100% rename from Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift diff --git a/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift similarity index 100% rename from Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/ARTRealtimeChannel+Objects.swift diff --git a/Sources/AblyLiveObjects/Public/Plugin.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Plugin.swift similarity index 100% rename from Sources/AblyLiveObjects/Public/Plugin.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Plugin.swift diff --git a/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 similarity index 100% rename from Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/InternalLiveMapValue+ToPublic.swift diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift similarity index 100% rename from Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveCounter.swift diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift similarity index 100% rename from Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultLiveMap.swift diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift similarity index 100% rename from Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicDefaultRealtimeObjects.swift diff --git a/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift similarity index 100% rename from Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Public Proxy Objects/PublicObjectsStore.swift diff --git a/Sources/AblyLiveObjects/Public/PublicTypes.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/PublicTypes.swift similarity index 100% rename from Sources/AblyLiveObjects/Public/PublicTypes.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/PublicTypes.swift diff --git a/Sources/AblyLiveObjects/Utility/Assertions.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Assertions.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/Assertions.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Assertions.swift diff --git a/Sources/AblyLiveObjects/Utility/Data+Extensions.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Data+Extensions.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/Data+Extensions.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Data+Extensions.swift diff --git a/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Dictionary+Extensions.swift diff --git a/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/DispatchQueue+Extensions.swift diff --git a/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift diff --git a/Sources/AblyLiveObjects/Utility/Errors.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Errors.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/Errors.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Errors.swift diff --git a/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift diff --git a/Sources/AblyLiveObjects/Utility/JSONValue.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/JSONValue.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/JSONValue.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/JSONValue.swift diff --git a/Sources/AblyLiveObjects/Utility/Logger.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Logger.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/Logger.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Logger.swift diff --git a/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/LoggingUtilities.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift diff --git a/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift diff --git a/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/NSLock+Extensions.swift diff --git a/Sources/AblyLiveObjects/Utility/WeakRef.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WeakRef.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/WeakRef.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WeakRef.swift diff --git a/Sources/AblyLiveObjects/Utility/WireCodable.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireCodable.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/WireCodable.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireCodable.swift diff --git a/Sources/AblyLiveObjects/Utility/WireValue.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireValue.swift similarity index 100% rename from Sources/AblyLiveObjects/Utility/WireValue.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireValue.swift diff --git a/Sources/BuildTool/BuildTool.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/BuildTool.swift similarity index 100% rename from Sources/BuildTool/BuildTool.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/BuildTool.swift diff --git a/Sources/BuildTool/Configuration.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/Configuration.swift similarity index 100% rename from Sources/BuildTool/Configuration.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/Configuration.swift diff --git a/Sources/BuildTool/DestinationFetcher.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationFetcher.swift similarity index 100% rename from Sources/BuildTool/DestinationFetcher.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationFetcher.swift diff --git a/Sources/BuildTool/DestinationPredicate.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationPredicate.swift similarity index 100% rename from Sources/BuildTool/DestinationPredicate.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationPredicate.swift diff --git a/Sources/BuildTool/DestinationSpecifier.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationSpecifier.swift similarity index 100% rename from Sources/BuildTool/DestinationSpecifier.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationSpecifier.swift diff --git a/Sources/BuildTool/DestinationStrategy.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationStrategy.swift similarity index 100% rename from Sources/BuildTool/DestinationStrategy.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/DestinationStrategy.swift diff --git a/Sources/BuildTool/Error.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/Error.swift similarity index 100% rename from Sources/BuildTool/Error.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/Error.swift diff --git a/Sources/BuildTool/Platform.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/Platform.swift similarity index 100% rename from Sources/BuildTool/Platform.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/Platform.swift diff --git a/Sources/BuildTool/ProcessRunner.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/ProcessRunner.swift similarity index 100% rename from Sources/BuildTool/ProcessRunner.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/ProcessRunner.swift diff --git a/Sources/BuildTool/String+Decoding.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/String+Decoding.swift similarity index 100% rename from Sources/BuildTool/String+Decoding.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/String+Decoding.swift diff --git a/Sources/BuildTool/XcodeRunner.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/XcodeRunner.swift similarity index 100% rename from Sources/BuildTool/XcodeRunner.swift rename to merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/XcodeRunner.swift diff --git a/TestPlans/AllTests.xctestplan b/merged-repos/ably-liveobjects-swift-plugin/TestPlans/AllTests.xctestplan similarity index 100% rename from TestPlans/AllTests.xctestplan rename to merged-repos/ably-liveobjects-swift-plugin/TestPlans/AllTests.xctestplan diff --git a/TestPlans/UnitTests.xctestplan b/merged-repos/ably-liveobjects-swift-plugin/TestPlans/UnitTests.xctestplan similarity index 100% rename from TestPlans/UnitTests.xctestplan rename to merged-repos/ably-liveobjects-swift-plugin/TestPlans/UnitTests.xctestplan diff --git a/Tests/AblyLiveObjectsTests/.swiftformat b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/.swiftformat similarity index 100% rename from Tests/AblyLiveObjectsTests/.swiftformat rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/.swiftformat diff --git a/Tests/AblyLiveObjectsTests/.swiftlint.yml b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/.swiftlint.yml similarity index 100% rename from Tests/AblyLiveObjectsTests/.swiftlint.yml rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/.swiftlint.yml diff --git a/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/AblyLiveObjectsTests.swift diff --git a/Tests/AblyLiveObjectsTests/CLAUDE.md b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/CLAUDE.md similarity index 100% rename from Tests/AblyLiveObjectsTests/CLAUDE.md rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/CLAUDE.md diff --git a/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Ably+Concurrency.swift diff --git a/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/Helpers/Assertions.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Assertions.swift diff --git a/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/ClientHelper.swift diff --git a/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Sandbox.swift diff --git a/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Subscriber.swift diff --git a/Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/Tag+Integration.swift diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/TestFactories.swift diff --git a/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/InternalDefaultLiveCounterTests.swift diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/InternalDefaultLiveMapTests.swift diff --git a/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/InternalDefaultRealtimeObjectsTests.swift diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsHelper.swift diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JS Integration Tests/ObjectsIntegrationTests.swift diff --git a/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JS Integration Tests/TestProxyTransport.swift diff --git a/Tests/AblyLiveObjectsTests/JSONValueTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JSONValueTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/JSONValueTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/JSONValueTests.swift diff --git a/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/LiveObjectMutableStateTests.swift diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockCoreSDK.swift diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockLiveMapObjectsPoolDelegate.swift diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockRealtimeObjects.swift diff --git a/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Mocks/MockSimpleClock.swift diff --git a/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectCreationHelpersTests.swift diff --git a/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectDiffHelpersTests.swift diff --git a/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectLifetimesTests.swift diff --git a/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/ObjectMessageTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectMessageTests.swift diff --git a/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift diff --git a/Tests/AblyLiveObjectsTests/SyncCursorTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/SyncCursorTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/SyncCursorTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/SyncCursorTests.swift diff --git a/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/SyncObjectsPoolTests.swift diff --git a/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/WireObjectMessageTests.swift diff --git a/Tests/AblyLiveObjectsTests/WireValueTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/WireValueTests.swift similarity index 100% rename from Tests/AblyLiveObjectsTests/WireValueTests.swift rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/WireValueTests.swift diff --git a/Tests/AblyLiveObjectsTests/ably-common b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ably-common similarity index 100% rename from Tests/AblyLiveObjectsTests/ably-common rename to merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ably-common diff --git a/images/SwiftSDK-LiveObjects-github.png b/merged-repos/ably-liveobjects-swift-plugin/images/SwiftSDK-LiveObjects-github.png similarity index 100% rename from images/SwiftSDK-LiveObjects-github.png rename to merged-repos/ably-liveobjects-swift-plugin/images/SwiftSDK-LiveObjects-github.png diff --git a/images/unit-tests-test-plan-screenshot.png b/merged-repos/ably-liveobjects-swift-plugin/images/unit-tests-test-plan-screenshot.png similarity index 100% rename from images/unit-tests-test-plan-screenshot.png rename to merged-repos/ably-liveobjects-swift-plugin/images/unit-tests-test-plan-screenshot.png diff --git a/package-lock.json b/merged-repos/ably-liveobjects-swift-plugin/package-lock.json similarity index 100% rename from package-lock.json rename to merged-repos/ably-liveobjects-swift-plugin/package-lock.json diff --git a/package.json b/merged-repos/ably-liveobjects-swift-plugin/package.json similarity index 100% rename from package.json rename to merged-repos/ably-liveobjects-swift-plugin/package.json From 45af6341aaa3fb3fafa4e2c2b7bfc2f224ecd65c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 15:51:24 -0300 Subject: [PATCH 214/225] Annotate AblyLiveObjects declarations with @available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every top-level type, extension, and free function in AblyLiveObjects gains an @available(macOS 10.15, iOS 13, tvOS 13, *) attribute. In the standalone ably-liveobjects-swift-plugin package the floor was macOS 11 / iOS 14 / tvOS 14, so these annotations were unnecessary. Once the target is folded into ably-cocoa's package, which declares macOS 10.11 / iOS 9 / tvOS 10, the Swift compiler needs an explicit @available on any declaration that uses a post-floor API (Swift Concurrency runtime, CryptoKit, Scanner's modern API, etc.). The chosen floor (10.15 / 13) matches what the APIs in question actually require — it is the lowest floor at which AblyLiveObjects can compile, and is one major version lower than the standalone plugin's previous floor. Generated mechanically by prepending @available to every line matching `(access-modifier)? (final )? (class|struct|enum|protocol |actor|extension|typealias|func)` at file scope. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Internal/ARTClientOptions+Objects.swift | 1 + .../AblyLiveObjects/Internal/CoreSDK.swift | 3 ++ .../Internal/DefaultInternalPlugin.swift | 1 + .../Internal/DefaultLiveCounterUpdate.swift | 1 + .../Internal/DefaultLiveMapUpdate.swift | 1 + .../Internal/InternalDefaultLiveCounter.swift | 1 + .../Internal/InternalDefaultLiveMap.swift | 2 + .../InternalDefaultRealtimeObjects.swift | 2 + .../Internal/InternalLiveMapValue.swift | 1 + .../Internal/InternalLiveObject.swift | 2 + .../Internal/InternalObjectsMapEntry.swift | 2 + .../Internal/LiveObjectMutableState.swift | 1 + .../Internal/LiveObjectUpdate.swift | 2 + .../Internal/ObjectCreationHelpers.swift | 1 + .../Internal/ObjectDiffHelpers.swift | 1 + .../Internal/ObjectsOperationSource.swift | 1 + .../Internal/ObjectsPool.swift | 1 + .../Internal/ObjectsSyncState.swift | 1 + .../Internal/PublishResult.swift | 2 + .../Internal/SimpleClock.swift | 2 + .../Internal/SubscriptionStorage.swift | 2 + .../Internal/SyncObjectsPool.swift | 1 + .../InboundObjectMessage+Synthetic.swift | 1 + .../Protocol/ObjectMessage.swift | 33 +++++++++++++ .../AblyLiveObjects/Protocol/SyncCursor.swift | 1 + .../AblyLiveObjects/Protocol/WireEnum.swift | 3 ++ .../Protocol/WireObjectMessage.swift | 49 +++++++++++++++++++ .../Public/ARTRealtimeChannel+Objects.swift | 1 + .../AblyLiveObjects/Public/Plugin.swift | 1 + .../InternalLiveMapValue+ToPublic.swift | 1 + .../PublicDefaultLiveCounter.swift | 1 + .../PublicDefaultLiveMap.swift | 1 + .../PublicDefaultRealtimeObjects.swift | 1 + .../PublicObjectsStore.swift | 1 + .../AblyLiveObjects/Public/PublicTypes.swift | 23 +++++++++ .../AblyLiveObjects/Utility/Assertions.swift | 1 + .../Utility/Data+Extensions.swift | 2 + .../Utility/Dictionary+Extensions.swift | 1 + .../Utility/DispatchQueue+Extensions.swift | 1 + .../Utility/DispatchQueueMutex.swift | 1 + .../AblyLiveObjects/Utility/Errors.swift | 11 +++++ .../Utility/ExtendedJSONValue.swift | 2 + .../AblyLiveObjects/Utility/JSONValue.swift | 14 ++++++ .../AblyLiveObjects/Utility/Logger.swift | 4 ++ .../Utility/LoggingUtilities.swift | 1 + .../Utility/MarkerProtocolHelpers.swift | 4 ++ .../Utility/NSLock+Extensions.swift | 1 + .../AblyLiveObjects/Utility/WeakRef.swift | 2 + .../AblyLiveObjects/Utility/WireCodable.swift | 14 ++++++ .../AblyLiveObjects/Utility/WireValue.swift | 10 ++++ 50 files changed, 218 insertions(+) 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 index 685543452..90c10d6b9 100644 --- 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 @@ -1,6 +1,7 @@ internal import _AblyPluginSupportPrivate import Ably +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension ARTClientOptions { private class Box { internal let boxed: T 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 index 8c4e9c4e9..0c1dd025f 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/CoreSDK.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/CoreSDK.swift @@ -4,6 +4,7 @@ import Ably /// The API that the internal components of the SDK (that is, `DefaultLiveObjects` and down) use to interact with our core SDK (i.e. ably-cocoa). /// /// This provides us with a mockable interface to ably-cocoa, and it also allows internal components and their tests not to need to worry about some of the boring details of how we bridge Swift types to `_AblyPluginSupportPrivate`'s Objective-C API (i.e. boxing). +@available(macOS 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) @@ -20,6 +21,7 @@ internal protocol CoreSDK: AnyObject, Sendable { 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() @@ -114,6 +116,7 @@ internal final class DefaultCoreSDK: CoreSDK { // 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. /// 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 index b15c7ab74..76c796ac4 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultInternalPlugin.swift @@ -6,6 +6,7 @@ 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 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 index b45a130d8..87a27c68d 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveCounterUpdate.swift @@ -1,3 +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 index 3c1453950..78e9fc6a7 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/DefaultLiveMapUpdate.swift @@ -1,3 +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 index a94b77516..5e0bcc897 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveCounter.swift @@ -3,6 +3,7 @@ import Ably import Foundation /// This provides the implementation behind ``PublicDefaultLiveCounter``, via internal versions of the ``LiveCounter`` API. +@available(macOS 10.15, iOS 13, tvOS 13, *) internal final class InternalDefaultLiveCounter: Sendable { private let mutableStateMutex: DispatchQueueMutex 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 index e13ab40de..1b261a989 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultLiveMap.swift @@ -2,12 +2,14 @@ internal import _AblyPluginSupportPrivate import Ably /// Protocol for accessing objects from the ObjectsPool. This is used by a LiveMap when it needs to return an object given an object ID. +@available(macOS 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 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 index d50ca339e..a3e8e0975 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalDefaultRealtimeObjects.swift @@ -2,6 +2,7 @@ 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. /// @@ -16,6 +17,7 @@ internal protocol InternalRealtimeObjectsProtocol: LiveMapObjectsPoolDelegate { } /// 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 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 index d8264ed89..6305ef7a9 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveMapValue.swift @@ -1,6 +1,7 @@ import Foundation /// Same as the public ``LiveMapValue`` type but with associated values of internal type. +@available(macOS 10.15, iOS 13, tvOS 13, *) internal enum InternalLiveMapValue: Sendable, Equatable { case string(String) case number(Double) 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 index 11679d152..f6f56b2e9 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalLiveObject.swift @@ -3,6 +3,7 @@ internal import _AblyPluginSupportPrivate /// Provides RTLO spec point functionality common to all LiveObjects. /// /// This exists in addition to ``LiveObjectMutableState`` to enable polymorphism. +@available(macOS 10.15, iOS 13, tvOS 13, *) internal protocol InternalLiveObject { associatedtype Update: Sendable @@ -12,6 +13,7 @@ internal protocol InternalLiveObject { 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( 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 index 4922ddfc3..1ce8d49d4 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/InternalObjectsMapEntry.swift @@ -1,6 +1,7 @@ import Foundation /// The entries stored in a `LiveMap`'s data. Same as an `ObjectsMapEntry` but with an additional `tombstonedAt` property, per RTLM3a. +@available(macOS 10.15, iOS 13, tvOS 13, *) internal struct InternalObjectsMapEntry: Equatable { internal var tombstonedAt: Date? // RTLM3a internal var tombstone: Bool { @@ -12,6 +13,7 @@ internal struct InternalObjectsMapEntry: Equatable { internal var data: ObjectData? // OME2c } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension InternalObjectsMapEntry { init(objectsMapEntry: ObjectsMapEntry, tombstonedAt: Date?) { self.tombstonedAt = tombstonedAt 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 index 8978b9ea2..38b78add3 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectMutableState.swift @@ -4,6 +4,7 @@ import Ably /// This is the equivalent of the `LiveObject` abstract class described in RTLO. /// /// ``InternalDefaultLiveCounter`` and ``InternalDefaultLiveMap`` include it by composition. +@available(macOS 10.15, iOS 13, tvOS 13, *) internal struct LiveObjectMutableState { // RTLO3a internal var objectID: String 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 index e31a06393..a5077807b 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/LiveObjectUpdate.swift @@ -1,3 +1,4 @@ +@available(macOS 10.15, iOS 13, tvOS 13, *) internal enum LiveObjectUpdate: Sendable { case noop // RTLO4b4 case update(Update) // RTLO4b4a @@ -23,4 +24,5 @@ internal enum LiveObjectUpdate: Sendable { } } +@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 index 66b17485d..6c074cc83 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectCreationHelpers.swift @@ -5,6 +5,7 @@ import Foundation /// Helpers for creating a new LiveObject. /// /// These generate an object ID and the `ObjectMessage` needed to create the LiveObject. +@available(macOS 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 { 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 index dbd930918..2755f773d 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectDiffHelpers.swift @@ -1,6 +1,7 @@ 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. /// 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 index eb8672a77..ba7326312 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsOperationSource.swift @@ -1,4 +1,5 @@ /// 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 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 index 5d36f674d..dbecc8683 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsPool.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsPool.swift @@ -3,6 +3,7 @@ internal import _AblyPluginSupportPrivate /// Maintains the list of objects present on a channel, as described by RTO3. /// /// Note that this is a value type. +@available(macOS 10.15, iOS 13, tvOS 13, *) internal struct ObjectsPool { /// The possible `ObjectsPool` entries, as described by RTO3a. internal enum Entry { 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 index 0a95d9520..35ed21d41 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/ObjectsSyncState.swift @@ -1,4 +1,5 @@ /// The type that the spec uses to represent the client's state of syncing its local Objects data with the server, per RTO17a. +@available(macOS 10.15, iOS 13, tvOS 13, *) internal enum ObjectsSyncState { case initialized case syncing 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 index 3c4a9202b..f7dcb1473 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/PublishResult.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/PublishResult.swift @@ -3,10 +3,12 @@ internal import _AblyPluginSupportPrivate /// As described by `PBR*` spec points. /// /// We use this internally instead of `_AblyCocoaPluginSupportPrivate.PublishResultProtocol` because Swift allows us to directly store `nil` values in an array. +@available(macOS 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 index bc6c0faa0..caa74d37a 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SimpleClock.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SimpleClock.swift @@ -4,12 +4,14 @@ import Foundation /// /// This protocol allows for dependency injection of time-related functionality, /// making it easier to test time-dependent code. +@available(macOS 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() {} 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 index e03ac2a21..2a764494b 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Internal/SubscriptionStorage.swift @@ -1,6 +1,7 @@ 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. @@ -83,6 +84,7 @@ internal struct SubscriptionStorage // OOP3a internal var objectId: String // OOP3b @@ -48,6 +51,7 @@ internal struct ObjectOperation: Equatable { internal var mapClear: WireMapClear? // OOP3r } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal struct ObjectData: Equatable { internal var objectId: String? // OD2a internal var boolean: Bool? // OD2c @@ -57,16 +61,19 @@ internal struct ObjectData: Equatable { internal var json: JSONObjectOrArray? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) } +@available(macOS 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 @@ -77,6 +84,7 @@ internal struct MapCreateWithObjectId: Equatable { 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 @@ -87,6 +95,7 @@ internal struct CounterCreateWithObjectId: Equatable { 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 @@ -94,12 +103,14 @@ internal struct ObjectsMapEntry: Equatable { 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 @@ -109,6 +120,7 @@ internal struct ObjectState: Equatable { 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. /// @@ -136,6 +148,7 @@ internal extension InboundObjectMessage { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension OutboundObjectMessage { /// Converts this `OutboundObjectMessage` to an `OutboundWireObjectMessage`, applying the data encoding rules of OD4. /// @@ -157,6 +170,7 @@ internal extension OutboundObjectMessage { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension ObjectOperation { /// Initializes an `ObjectOperation` from a `WireObjectOperation`, applying the data decoding rules of OD5. /// @@ -207,6 +221,7 @@ internal extension ObjectOperation { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension ObjectData { /// Initializes an `ObjectData` from a `WireObjectData`, applying the data decoding rules of OD5. /// @@ -311,6 +326,7 @@ internal extension ObjectData { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension MapSet { init( wireMapSet: WireMapSet, @@ -330,6 +346,7 @@ internal extension MapSet { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension MapCreate { init( wireMapCreate: WireMapCreate, @@ -349,6 +366,7 @@ internal extension MapCreate { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension MapCreateWithObjectId { init(wireMapCreateWithObjectId: WireMapCreateWithObjectId) { nonce = wireMapCreateWithObjectId.nonce @@ -360,6 +378,7 @@ internal extension MapCreateWithObjectId { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension CounterCreateWithObjectId { init(wireCounterCreateWithObjectId: WireCounterCreateWithObjectId) { nonce = wireCounterCreateWithObjectId.nonce @@ -371,6 +390,7 @@ internal extension CounterCreateWithObjectId { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension ObjectsMapEntry { /// Initializes an `ObjectsMapEntry` from a `WireObjectsMapEntry`, applying the data decoding rules of OD5. /// @@ -404,6 +424,7 @@ internal extension ObjectsMapEntry { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension ObjectsMap { /// Initializes an `ObjectsMap` from a `WireObjectsMap`, applying the data decoding rules of OD5. /// @@ -434,6 +455,7 @@ internal extension ObjectsMap { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension ObjectState { /// Initializes an `ObjectState` from a `WireObjectState`, applying the data decoding rules of OD5. /// @@ -474,6 +496,7 @@ internal extension ObjectState { // MARK: - CustomDebugStringConvertible +@available(macOS 10.15, iOS 13, tvOS 13, *) extension InboundObjectMessage: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -493,6 +516,7 @@ extension InboundObjectMessage: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension OutboundObjectMessage: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -512,6 +536,7 @@ extension OutboundObjectMessage: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension ObjectOperation: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -532,6 +557,7 @@ extension ObjectOperation: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension ObjectState: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -547,6 +573,7 @@ extension ObjectState: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension ObjectsMap: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -566,6 +593,7 @@ extension ObjectsMap: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension ObjectsMapEntry: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -579,6 +607,7 @@ extension ObjectsMapEntry: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension ObjectData: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -594,6 +623,7 @@ extension ObjectData: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension MapSet: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -605,6 +635,7 @@ extension MapSet: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension MapCreate: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -623,6 +654,7 @@ extension MapCreate: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension MapCreateWithObjectId: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -635,6 +667,7 @@ extension MapCreateWithObjectId: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension CounterCreateWithObjectId: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] 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 index 311e8f98e..9a1cd601f 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/SyncCursor.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/SyncCursor.swift @@ -2,6 +2,7 @@ import Ably import Foundation /// The `OBJECT_SYNC` sync cursor, as extracted from a `channelSerial` per RTO5a1 and RTO5a4. +@available(macOS 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). 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 index 9df4f47e9..653aea01c 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireEnum.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireEnum.swift @@ -1,4 +1,5 @@ /// An enum extracted from a wire representation that either belongs to one of a set of known values or is a new, unknown value. +@available(macOS 10.15, iOS 13, tvOS 13, *) internal enum WireEnum where Known: RawRepresentable { case known(Known) case unknown(Known.RawValue) @@ -21,5 +22,7 @@ internal enum WireEnum where Known: RawRepresentable { } } +@available(macOS 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 index 8647e733f..afd924691 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Protocol/WireObjectMessage.swift @@ -5,6 +5,7 @@ import Foundation // This file contains the ObjectMessage types that we send and receive over the wire. We convert them to and from the corresponding non-wire types (e.g. `InboundObjectMessage`) for use within the codebase. /// An `ObjectMessage` received in the `state` property of an `OBJECT` or `OBJECT_SYNC` `ProtocolMessage`. +@available(macOS 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 @@ -20,6 +21,7 @@ internal struct InboundWireObjectMessage { } /// 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 @@ -34,6 +36,7 @@ internal struct OutboundWireObjectMessage { } /// 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 @@ -47,6 +50,7 @@ internal enum WireObjectMessageWireKey: String { 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 { @@ -104,6 +108,7 @@ internal extension InboundWireObjectMessage { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension OutboundWireObjectMessage: WireObjectEncodable { internal var toWireObject: [String: WireValue] { var result: [String: WireValue] = [:] @@ -144,6 +149,7 @@ extension OutboundWireObjectMessage: WireObjectEncodable { } // OOP2 +@available(macOS 10.15, iOS 13, tvOS 13, *) internal enum ObjectOperationAction: Int { case mapCreate = 0 case mapSet = 1 @@ -155,10 +161,12 @@ internal enum ObjectOperationAction: Int { } // 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 @@ -173,6 +181,7 @@ internal struct WireObjectOperation { internal var mapClear: WireMapClear? // OOP3r } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension WireObjectOperation: WireObjectCodable { internal enum WireKey: String { case action @@ -242,6 +251,7 @@ extension WireObjectOperation: WireObjectCodable { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal struct WireObjectState { internal var objectId: String // OST2a internal var siteTimeserials: [String: String] // OST2b @@ -251,6 +261,7 @@ internal struct WireObjectState { internal var counter: WireObjectsCounter? // OST2f } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension WireObjectState: WireObjectCodable { internal enum WireKey: String { case objectId @@ -296,12 +307,14 @@ extension WireObjectState: WireObjectCodable { } } +@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 @@ -336,10 +349,12 @@ extension WireObjectsMap: WireObjectCodable { } } +@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 @@ -358,11 +373,13 @@ extension WireObjectsCounter: WireObjectCodable { } } +@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 @@ -387,10 +404,12 @@ extension WireMapSet: WireObjectCodable { } } +@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 @@ -407,11 +426,13 @@ extension WireMapRemove: WireObjectCodable { } } +@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 @@ -441,10 +462,12 @@ extension WireMapCreate: WireObjectCodable { } } +@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 @@ -463,10 +486,12 @@ extension WireCounterCreate: WireObjectCodable { } } +@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 @@ -483,10 +508,12 @@ extension WireCounterInc: WireObjectCodable { } } +@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 @@ -497,10 +524,12 @@ extension WireObjectDelete: WireObjectCodable { } } +@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 @@ -511,11 +540,13 @@ extension WireMapClear: WireObjectCodable { } } +@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 @@ -535,11 +566,13 @@ extension WireMapCreateWithObjectId: WireObjectCodable { } } +@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 @@ -559,6 +592,7 @@ extension WireCounterCreateWithObjectId: WireObjectCodable { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal struct WireObjectsMapEntry { internal var tombstone: Bool? // OME2a internal var timeserial: String? // OME2b @@ -566,6 +600,7 @@ internal struct WireObjectsMapEntry { internal var serialTimestamp: Date? // OME2d } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension WireObjectsMapEntry: WireObjectCodable { internal enum WireKey: String { case tombstone @@ -601,6 +636,7 @@ extension WireObjectsMapEntry: WireObjectCodable { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal struct WireObjectData { internal var objectId: String? // OD2a internal var boolean: Bool? // OD2c @@ -610,6 +646,7 @@ internal struct WireObjectData { internal var json: String? // TODO: Needs specification (see https://github.com/ably/ably-liveobjects-swift-plugin/issues/46) } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension WireObjectData: WireObjectCodable { internal enum WireKey: String { case objectId @@ -658,6 +695,7 @@ extension WireObjectData: WireObjectCodable { /// A type that can be either a string or binary data. /// /// Used to represent the values that `WireObjectData.bytes` might hold, after being encoded per OD4 or before being decoded per OD5. +@available(macOS 10.15, iOS 13, tvOS 13, *) internal enum StringOrData: Equatable, WireCodable { case string(String) case data(Data) @@ -690,6 +728,7 @@ internal enum StringOrData: Equatable, WireCodable { // MARK: - CustomDebugStringConvertible +@available(macOS 10.15, iOS 13, tvOS 13, *) extension WireObjectsCounter: CustomDebugStringConvertible { internal var debugDescription: String { if let count { @@ -700,6 +739,7 @@ extension WireObjectsCounter: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension WireObjectsMapEntry: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -713,6 +753,7 @@ extension WireObjectsMapEntry: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension WireObjectData: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -728,6 +769,7 @@ extension WireObjectData: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension WireMapSet: CustomDebugStringConvertible { internal var debugDescription: String { var parts: [String] = [] @@ -739,12 +781,14 @@ extension WireMapSet: CustomDebugStringConvertible { } } +@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] = [] @@ -763,6 +807,7 @@ extension WireMapCreate: CustomDebugStringConvertible { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension WireCounterCreate: CustomDebugStringConvertible { internal var debugDescription: String { if let count { @@ -773,24 +818,28 @@ extension WireCounterCreate: CustomDebugStringConvertible { } } +@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 index 0beb04b02..8bfc72511 100644 --- 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 @@ -1,6 +1,7 @@ internal import _AblyPluginSupportPrivate import Ably +@available(macOS 10.15, iOS 13, tvOS 13, *) public extension ARTRealtimeChannel { /// A ``RealtimeObjects`` object. var objects: RealtimeObjects { 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 index a39b7a74f..57018e77a 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Plugin.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/Plugin.swift @@ -36,6 +36,7 @@ import ObjectiveC.NSObject /// // …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() 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 index 6e0419a75..a5070c42e 100644 --- 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 @@ -1,5 +1,6 @@ internal import _AblyPluginSupportPrivate +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension InternalLiveMapValue { // MARK: - Mapping to public types 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 index 53995a79a..4e309ddb9 100644 --- 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 @@ -4,6 +4,7 @@ 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 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 index d3b003cf6..95feab057 100644 --- 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 @@ -4,6 +4,7 @@ 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 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 index 1dd1fb3a1..3ab533332 100644 --- 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 @@ -4,6 +4,7 @@ import Ably /// The class that provides the public API for interacting with LiveObjects, via the ``ARTRealtimeChannel/objects`` property. /// /// This is largely a wrapper around ``InternalDefaultRealtimeObjects``. +@available(macOS 10.15, iOS 13, tvOS 13, *) internal final class PublicDefaultRealtimeObjects: RealtimeObjects { private let proxied: InternalDefaultRealtimeObjects internal var testsOnly_proxied: InternalDefaultRealtimeObjects { 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 index 865210856..29785acf7 100644 --- 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 @@ -12,6 +12,7 @@ import Foundation /// This differs from the approach that we take in ably-cocoa, in which we create a new public object each time we need to return one. Given that the LiveObjects SDK revolves around the concept of various live-updating objects, it seemed like it might be quite a confusing user experience if the pointer identity of, say, a `LiveMap` changed each time it was fetched. /// /// - Note: We can only make a best-effort attempt to maintain the pointer identity of the public objects. Since the SDK cannot maintain a strong reference to the public objects (given that the whole reason that these objects exist is for us to know whether the user holds a strong reference to them), if the user releases all of their strong references to a public object then the next time they fetch the public object they will receive a new object. +@available(macOS 10.15, iOS 13, tvOS 13, *) internal final class PublicObjectsStore: Sendable { // Used to synchronize access to mutable state private let mutex = NSLock() 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 index 086ee9863..e3da8a028 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/PublicTypes.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Public/PublicTypes.swift @@ -5,18 +5,22 @@ import Ably /// - Parameters: /// - update: The update object describing the changes made to the object. /// - subscription: A ``SubscribeResponse`` object that allows the provided listener to deregister itself from future updates. +@available(macOS 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 @@ -25,12 +29,14 @@ public enum ObjectsEvent: Sendable { } /// 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 @@ -87,6 +93,7 @@ public protocol RealtimeObjects: Sendable { /// ], /// ]) /// ``` +@available(macOS 10.15, iOS 13, tvOS 13, *) public enum LiveMapValue: Sendable, Equatable { case string(String) case number(Double) @@ -191,36 +198,42 @@ public enum LiveMapValue: Sendable, Equatable { // MARK: - ExpressibleBy*Literal conformances +@available(macOS 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) @@ -228,6 +241,7 @@ extension LiveMapValue: ExpressibleByBooleanLiteral { } /// Object returned from an `on` call, allowing the listener provided in that call to be deregistered. +@available(macOS 10.15, iOS 13, tvOS 13, *) public protocol OnObjectsEventResponse: Sendable { /// Deregisters the listener passed to the `on` call. func off() @@ -238,6 +252,7 @@ public protocol OnObjectsEventResponse: Sendable { /// meaning that if two clients update the same key in the map, the update with the most recent timestamp wins. /// /// Keys must be strings. Values can be another ``LiveObject``, or a primitive type, such as a string, number, boolean, JSON-serializable object or array, or binary data. +@available(macOS 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. /// @@ -281,6 +296,7 @@ public protocol LiveMap: LiveObject where Update == LiveMapUpdate { } /// Describes whether an entry in ``LiveMapUpdate/update`` represents an update or a removal. +@available(macOS 10.15, iOS 13, tvOS 13, *) public enum LiveMapUpdateAction: Sendable { /// The value of a key in the map was updated. case updated @@ -289,6 +305,7 @@ public enum LiveMapUpdateAction: Sendable { } /// Represents an update to a ``LiveMap`` object, describing the keys that were updated or removed. +@available(macOS 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. @@ -297,6 +314,7 @@ public protocol LiveMapUpdate: Sendable { } /// The `LiveCounter` class represents a counter that can be incremented or decremented and is synchronized across clients in realtime. +@available(macOS 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) } @@ -317,12 +335,14 @@ public protocol LiveCounter: LiveObject where Update == LiveCounterUpdate { } /// 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 @@ -351,12 +371,14 @@ public protocol LiveObject: AnyObject, Sendable { } /// Object returned from a `subscribe` call, allowing the listener provided in that call to be deregistered. +@available(macOS 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() @@ -365,6 +387,7 @@ public protocol OnLiveObjectLifecycleEventResponse: Sendable { // 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`. /// 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 index bc1045e27..8638b711a 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Assertions.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Assertions.swift @@ -1,4 +1,5 @@ /// Stops execution because we tried to use a feature that is not yet implemented. +@available(macOS 10.15, iOS 13, tvOS 13, *) internal func notYetImplemented(_ message: @autoclosure () -> String = String(), file _: StaticString = #file, line _: UInt = #line) -> Never { fatalError({ let returnedMessage = message() 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 index 286e0bb67..71aac0826 100644 --- 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 @@ -2,10 +2,12 @@ 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 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 index 9635b9ebd..2a95000d8 100644 --- 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 @@ -1,3 +1,4 @@ +@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 { 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 index fc188589d..5195b6299 100644 --- 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 @@ -1,5 +1,6 @@ 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) { 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 index 5be97eb46..d7df6aeb5 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/DispatchQueueMutex.swift @@ -5,6 +5,7 @@ import Foundation /// In order to access or mutate the mutex's value, it is expected that you know whether or not you are already executing on the mutex's queue. If you are, then use ``withoutSync(_:)``, which simply performs a runtime check that the current queue is correct. If not, then use ``withSync(_:)``, which synchronously dispatches to the queue. ``withSync(_:)`` must not be called from the queue, as doing so would cause a deadlock (there is a runtime check which terminates execution in this case). /// /// This class is styled on Swift's built-in `Mutex` type. +@available(macOS 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 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 index e434d6094..6fecf93fc 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Errors.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Errors.swift @@ -4,6 +4,7 @@ import Ably /** Describes the errors that can be thrown by the LiveObjects SDK. Use ``toARTErrorInfo()`` to convert to an `ARTErrorInfo` that you can throw. */ +@available(macOS 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) @@ -104,10 +105,12 @@ internal enum LiveObjectsError { /// We deliberately do not conform `ARTErrorInfo` (or its parent types `NSError` or `Error`) to this protocol, so that we do not accidentally end up flattening an `ARTErrorInfo` into the `.other` `LiveObjectsError` case; if we have an `ARTErrorInfo` then it should just be thrown directly. /// /// If you need to convert a non-specific `NSError` or `Error` to a `LiveObjects` error, then do so explicitly using `LiveObjectsError.other`. +@available(macOS 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 { @@ -117,42 +120,49 @@ internal extension ConvertibleToLiveObjectsError { // 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) @@ -164,6 +174,7 @@ extension JSONObjectOrArray.ConversionError: ConvertibleToLiveObjectsError { /// 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`. /// 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 index 1f1d133d9..feecdcc1d 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/ExtendedJSONValue.swift @@ -13,6 +13,7 @@ internal indirect enum ExtendedJSONValue { // MARK: - Bridging with Foundation +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension ExtendedJSONValue { /// Creates an `ExtendedJSONValue` from an object. /// @@ -66,6 +67,7 @@ internal extension ExtendedJSONValue { // 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 { 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 index 02a71c907..c930937fd 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/JSONValue.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/JSONValue.swift @@ -90,36 +90,42 @@ public indirect enum JSONValue: Sendable, Equatable { } } +@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) @@ -128,6 +134,7 @@ extension JSONValue: ExpressibleByBooleanLiteral { // 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`. /// @@ -158,6 +165,7 @@ internal extension JSONValue { // 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]) @@ -196,18 +204,21 @@ internal enum JSONObjectOrArray: Equatable { } } +@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] { @@ -215,6 +226,7 @@ internal extension [String: JSONValue] { } } +@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] { @@ -224,6 +236,7 @@ internal extension [JSONValue] { // MARK: - Conversion to/from ExtendedJSONValue +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension JSONValue { init(extendedJSONValue: ExtendedJSONValue) { switch extendedJSONValue { @@ -262,6 +275,7 @@ internal extension JSONValue { // 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) 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 index 90cb7d49a..e865ada55 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Logger.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/Logger.swift @@ -1,6 +1,7 @@ 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 @@ -8,10 +9,12 @@ internal struct CodeLocation: Equatable { 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) { @@ -20,6 +23,7 @@ internal extension AblyLiveObjects.Logger { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal final class DefaultLogger: Logger { private let pluginLogger: _AblyPluginSupportPrivate.Logger private let pluginAPI: _AblyPluginSupportPrivate.PluginAPIProtocol 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 index c1939abdc..9f6932419 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/LoggingUtilities.swift @@ -1,5 +1,6 @@ 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 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 index 28bf619c3..cea2d1d50 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/MarkerProtocolHelpers.swift @@ -2,6 +2,7 @@ internal import _AblyPluginSupportPrivate import Ably /// Upcasts an instance of an `_AblyPluginSupportPrivate` marker protocol to the concrete type that this marker protocol represents. +@available(macOS 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))") @@ -10,6 +11,7 @@ internal func castPluginPublicMarkerProtocolValue(_ pluginMarkerProtocolValue return actualPublicValue } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension ARTRealtimeChannel { /// Downcasts this `ARTRealtimeChannel` to its `_AblyPluginSupportPrivate` equivalent type `PublicRealtimeChannel`. /// @@ -21,6 +23,7 @@ internal extension ARTRealtimeChannel { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension ARTClientOptions { /// Downcasts this `ARTClientOptions` to its `_AblyPluginSupportPrivate` marker protocol type `PublicClientOptions`. /// @@ -37,6 +40,7 @@ internal extension ARTClientOptions { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension ARTErrorInfo { /// Downcasts this `ARTErrorInfo` to its `_AblyPluginSupportPrivate` marker protocol type `PublicErrorInfo`. /// 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 index 0a9540aa1..8dda1130f 100644 --- 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 @@ -1,5 +1,6 @@ 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 { 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 index c7175a7ee..1cde45afc 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WeakRef.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WeakRef.swift @@ -1,8 +1,10 @@ /// A struct that holds a weak reference to an object. /// /// This allows us to store a weak reference inside a Sendable object. The pattern comes from the [`weak let` proposal](https://github.com/swiftlang/swift-evolution/blob/main/proposals/0481-weak-let.md). (We can get rid of this type and use `weak let` once Swift 6.2 is out.) +@available(macOS 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 index b68485d18..c8eb349e1 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireCodable.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireCodable.swift @@ -1,31 +1,38 @@ 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) @@ -34,6 +41,7 @@ internal enum WireValueDecodingError: Error { } // 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 { @@ -44,11 +52,13 @@ internal extension WireObjectDecodable { } } +@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. /// @@ -269,6 +279,7 @@ internal extension [String: WireValue] { // 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). /// @@ -298,6 +309,7 @@ internal extension [String: WireValue] { // 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. /// @@ -335,6 +347,7 @@ internal extension [String: WireValue] { // 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. /// @@ -359,6 +372,7 @@ internal extension [String: WireValue] { // 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. /// 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 index c8b75f28b..65a77d65e 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireValue.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/AblyLiveObjects/Utility/WireValue.swift @@ -79,36 +79,42 @@ internal indirect enum WireValue: Sendable, Equatable { } } +@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) @@ -117,6 +123,7 @@ extension WireValue: ExpressibleByBooleanLiteral { // 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. /// @@ -158,6 +165,7 @@ internal extension WireValue { } } +@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. /// @@ -169,6 +177,7 @@ internal extension [String: WireValue] { // MARK: - Conversion to/from ExtendedJSONValue +@available(macOS 10.15, iOS 13, tvOS 13, *) internal extension WireValue { enum ExtraValue { case data(Data) @@ -218,6 +227,7 @@ internal extension WireValue { // 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) { From 448ab0ed40da6592f581faee89008e61d1156fdc Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 15:51:38 -0300 Subject: [PATCH 215/225] Consolidate Package.swift to absorb the two plugin packages Bumps swift-tools-version from 5.3 to 6.1 (matches the version the former ably-liveobjects-swift-plugin Package.swift required) and restructures the manifest to fold both ex-external-packages in as internal targets: - Drops the package-level dependency on ably-cocoa-plugin-support. - Declares _AblyPluginSupportPrivate as an internal target whose path points into merged-repos/ably-cocoa-plugin-support/Sources/. The Ably target and the AblyTests test target now reference it by name rather than as a cross-package product. - Declares AblyLiveObjects as an internal Swift target whose path points into merged-repos/ably-liveobjects-swift-plugin/Sources/, with dependencies on the in-package Ably and _AblyPluginSupportPrivate targets. Exposes it as a separate .library product so consumers opt in to LiveObjects independently of Ably. What is NOT done in this commit, on purpose: - The former plugin's tests, BuildTool, Example app, and CI workflow remain in-tree at merged-repos/ but are not declared as targets. - The former plugin's transitive dependencies (swift-async-algorithms, swift-argument-parser, Table, swift-docc-plugin) are not declared here, since the AblyLiveObjects library target doesn't use them. - The package-wide `platforms:` floor stays at macOS 10.11 / iOS 9 / tvOS 10. AblyLiveObjects's higher API requirements are gated by @available in the previous commit, not by raising the floor. Verified: `swift build` and `swift build --build-tests` both succeed (warnings from pre-existing Swift 6 strict-concurrency checks remain). Co-Authored-By: Claude Opus 4.7 (1M context) --- Package.resolved | 106 +++++++++++++++++++++-------------------------- Package.swift | 36 +++++++++++++--- 2 files changed, 79 insertions(+), 63 deletions(-) diff --git a/Package.resolved b/Package.resolved index aba9e456a..3af004008 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,61 +1,51 @@ { - "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" - } + "originHash" : "0e61e788b1a8c3fd233101831744f8bbc4d32f5646066a466086c763cec9c2f1", + "pins" : [ + { + "identity" : "cwlcatchexception", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattgallagher/CwlCatchException.git", + "state" : { + "revision" : "07b2ba21d361c223e25e3c1e924288742923f08c", + "version" : "2.2.1" } - ] - }, - "version": 1 + }, + { + "identity" : "cwlpreconditiontesting", + "kind" : "remoteSourceControl", + "location" : "https://github.com/mattgallagher/CwlPreconditionTesting.git", + "state" : { + "revision" : "0139c665ebb45e6a9fbdb68aabfd7c39f3fe0071", + "version" : "2.2.2" + } + }, + { + "identity" : "delta-codec-cocoa", + "kind" : "remoteSourceControl", + "location" : "https://github.com/ably/delta-codec-cocoa", + "state" : { + "revision" : "d53eec08f9443c6160d941327a6f9d8bbb93cea2", + "version" : "1.3.5" + } + }, + { + "identity" : "msgpack-objective-c", + "kind" : "remoteSourceControl", + "location" : "https://github.com/rvi/msgpack-objective-C", + "state" : { + "revision" : "3e36b48e04ecd756cb927bd5f5b9bf6d45e475f9", + "version" : "0.4.0" + } + }, + { + "identity" : "nimble", + "kind" : "remoteSourceControl", + "location" : "https://github.com/quick/nimble", + "state" : { + "revision" : "eb5e3d717224fa0d1f6aff3fc2c5e8e81fa1f728", + "version" : "11.2.2" + } + } + ], + "version" : 3 } diff --git a/Package.swift b/Package.swift index 39774c930..426d489b7 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,30 @@ 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") ], 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 +62,23 @@ 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: "AblyTests", dependencies: [ @@ -59,7 +86,7 @@ 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: [ @@ -94,4 +121,3 @@ let package = Package( ) ] ) - From 6c3f0ec4205f671cf12bbc36d958de470baff43c Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 16:15:10 -0300 Subject: [PATCH 216/225] Reuse ably-cocoa's ably-common for AblyLiveObjectsTests Replaces the gitlink at merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ably-common with a symlink to ably-cocoa's existing submodule at Test/AblyTests/ably-common. The plugin's pinned ably-common SHA (60fd9cf, Nov 2024) was an ancestor of ably-cocoa's pinned SHA (783496f, Sep 2025), so the ably-cocoa pin is strictly more recent. The plugin's tests will see the same test fixtures they had before, plus whatever was added in the ten months between the two pins. With this change there is a single ably-common submodule registered in the repo (still at the same root .gitmodules entry that ably-cocoa already had), instead of needing a second registration at the plugin test location. SPM's resources(.copy) follows the symlink at build time. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Tests/AblyLiveObjectsTests/ably-common | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) mode change 160000 => 120000 merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ably-common diff --git a/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ably-common b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ably-common deleted file mode 160000 index 60fd9cf10..000000000 --- a/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ably-common +++ /dev/null @@ -1 +0,0 @@ -Subproject commit 60fd9cf106abb1d6292fdd87d63ea33c552c8f33 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 From 7b2c40a0e7faca8d4632eae8e95f54011b1966f1 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 16:22:42 -0300 Subject: [PATCH 217/225] Annotate AblyLiveObjectsTests declarations with @available MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same mechanical sweep as 45af634 applied to the test sources, with one adjustment: @available is intentionally omitted from declarations that are decorated with Swift Testing's @Suite or @Test attributes. The macro expansion conflicts with an adjacent explicit @available ("Attribute 'Suite' cannot be applied to this structure because it has been marked '@available …'"), so we rely on the macros' own availability handling for those declarations. In practice this is just the @Suite-decorated ObjectsIntegrationTests struct; the @Test-decorated free functions weren't matched by the sweep regex anyway because the @Test attribute sits on its own line above them. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Helpers/Ably+Concurrency.swift | 6 ++++++ .../Helpers/Tag+Integration.swift | 2 ++ .../AblyLiveObjectsTests/Helpers/TestLogger.swift | 2 ++ .../ObjectsIntegrationTests.swift | 15 +++++++++++++++ .../AblyLiveObjectsTests/ObjectsPoolTests.swift | 2 ++ 5 files changed, 27 insertions(+) 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 index a41296974..4cde079dc 100644 --- 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 @@ -2,6 +2,8 @@ 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 @@ -28,6 +30,8 @@ extension ARTRealtimeChannelProtocol { } } +@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 @@ -49,6 +53,8 @@ extension ARTRestProtocol { } } +@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 { 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 index 11a3a4cd4..b30500214 100644 --- 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 @@ -1,5 +1,7 @@ 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/TestLogger.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift index d38c3869b..05867de49 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/Helpers/TestLogger.swift @@ -18,6 +18,8 @@ final class TestLogger: NSObject, AblyLiveObjects.Logger { } } +@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 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 index c19bc8fa5..099ef6ae8 100644 --- 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 @@ -9,10 +9,12 @@ import Testing // 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() } @@ -25,6 +27,7 @@ private func channelOptionsWithObjects() -> ARTRealtimeChannelOptions { // |____________| |_| |________| |_| // | | | | // 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) @@ -140,6 +143,7 @@ func waitForObjectSync(_ realtime: ARTRealtime) async throws { // 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] { @@ -178,6 +182,7 @@ extension ARTProtocolMessage { /// 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 @@ -294,6 +299,7 @@ private final class EchoInterceptor: @unchecked Sendable { /// 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 @@ -366,6 +372,7 @@ private final class AckInterceptor: @unchecked Sendable { } /// 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 { @@ -469,6 +476,7 @@ private let countersFixtures: [(name: String, count: Double?)] = [ // 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 @@ -493,12 +501,14 @@ private struct TestCase: Identifiable, CustomStringConvertible { } /// 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 @@ -506,6 +516,7 @@ private struct TestScenario { 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") @@ -528,11 +539,13 @@ private func forScenarios(_ scenarios: [TestScenario]) -> [Tes .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) @@ -544,6 +557,7 @@ private extension Scenarios { /// 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? @@ -571,6 +585,7 @@ private actor ObjectsFixturesTrait: SuiteTrait, TestScoping { } } +@available(macOS 10.15, iOS 13, tvOS 13, *) extension Trait where Self == ObjectsFixturesTrait { static var objectsFixtures: Self { Self() } } diff --git a/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift index 6bb3cbafe..eeba2093c 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/ObjectsPoolTests.swift @@ -2,6 +2,8 @@ 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`. From ae2a257851d47614589ca9b95beac1b27caae311 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 16:22:44 -0300 Subject: [PATCH 218/225] Declare AblyLiveObjectsTests test target Adds a .testTarget for the plugin's existing test suite at merged-repos/ably-liveobjects-swift-plugin/Tests/AblyLiveObjectsTests/, with dependencies on AblyLiveObjects, Ably, and _AblyPluginSupportPrivate. Excludes the dotfile configs and CLAUDE.md; copies the ably-common directory (now a symlink to ably-cocoa's existing submodule) as a test resource. Sets swiftLanguageMode(.v5) on the pre-existing AblyTests and AblyTesting targets. Both contain code written under Swift 5 semantics: under Swift 6 language mode (the default once swift-tools-version is bumped to 6.1) the existing strict- concurrency warnings get promoted to errors. Pinning these two targets to .v5 preserves their previous behaviour while the new AblyLiveObjects/AblyLiveObjectsTests targets continue to build in Swift 6 mode, where they were written. Verified: `swift build --build-tests` clean from a fresh .build, and a smoke run of `swift test --filter InternalDefaultLiveCounterTests` passes (26 tests in 12 suites). Co-Authored-By: Claude Opus 4.7 (1M context) --- Package.swift | 26 ++++++++++++++++++++++++-- 1 file changed, 24 insertions(+), 2 deletions(-) diff --git a/Package.swift b/Package.swift index 426d489b7..bdb106dc6 100644 --- a/Package.swift +++ b/Package.swift @@ -79,6 +79,23 @@ let package = Package( ".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"), + ] + ), .testTarget( name: "AblyTests", dependencies: [ @@ -91,7 +108,11 @@ let package = Package( 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( @@ -109,7 +130,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( From f3a96e72c95476037ba33aee03d9351be14990a7 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 16:25:29 -0300 Subject: [PATCH 219/225] Declare BuildTool executable target MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds an .executableTarget for the developer/CI tool from the former plugin package, pointing at merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/. Adds its three runtime dependencies at package level: - swift-argument-parser (for CLI option parsing) - swift-async-algorithms (used in process orchestration) - Table (terminal-table formatting) These are scoped to BuildTool only — they are not declared as target dependencies of the Ably or AblyLiveObjects library products, so they do not enter library consumers' build graphs. They are still added to Package.resolved, which means consumers fetch them at resolution time even though nothing they use compiles against them. `swift run BuildTool --help` works and lists all the previous subcommands. Known gap: BuildTool's `test-library`, `build-library`, `build-example-app`, and `build-documentation` subcommands all invoke xcodebuild with `-scheme AblyLiveObjects` and (for tests) `-testPlan UnitTests`. Those scheme/test-plan names exist only in the standalone plugin's Xcode workspace, not in ably-cocoa's Ably.xcodeproj. Wiring them up needs either an Xcode scheme addition (out of scope here) or a BuildTool refactor that calls `swift test` directly. Until then, the substitute for `swift run BuildTool test-library --platform macOS --only-unit-tests` is `swift test --filter AblyLiveObjectsTests --skip ObjectsIntegrationTests`, which passes 277 tests across 87 suites in this branch. Co-Authored-By: Claude Opus 4.7 (1M context) --- Package.resolved | 38 +++++++++++++++++++++++++++++++++++++- Package.swift | 21 +++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/Package.resolved b/Package.resolved index 3af004008..af47b1b0e 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "0e61e788b1a8c3fd233101831744f8bbc4d32f5646066a466086c763cec9c2f1", + "originHash" : "8ee64927a44ab8598f114b63073c2b250d19877d9e570a204045d5bec921bf79", "pins" : [ { "identity" : "cwlcatchexception", @@ -45,6 +45,42 @@ "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 bdb106dc6..d6d449ec6 100644 --- a/Package.swift +++ b/Package.swift @@ -23,6 +23,12 @@ let package = Package( .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"), + // The next three are used only by the BuildTool 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 @@ -96,6 +102,21 @@ let package = Package( .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: "BuildTool", + 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: [ From 7ab9a1eb40cdfa96ca6854b4842891707f2d6e0f Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 16:27:43 -0300 Subject: [PATCH 220/225] Add SPM-based CI workflow for AblyLiveObjects; drop dead inner one MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Translates the SPM-only subset of the former plugin repo's check.yaml into a new workflow at .github/workflows/check-liveobjects.yaml at the actual root, where GitHub Actions can see it. Two jobs: - build-and-test-spm: builds the AblyLiveObjects target and runs the unit tests via `swift test --filter AblyLiveObjectsTests --skip ObjectsIntegrationTests`. - build-release-configuration-spm: builds AblyLiveObjects with --configuration release. Both invocations were verified locally in this branch. Also removes the original check.yaml from merged-repos/ably-liveobjects-swift-plugin/.github/workflows/, which lived under merged-repos/ and could never run (GitHub Actions only reads root-level .github/workflows/). Its full original contents remain reachable via the liveobjects-plugin-pre-merge tag. What is NOT yet ported, with the reason in each case: - lint: requires `swift run BuildTool lint`, which shells out to `mint run swiftformat`, `mint run swiftlint`, and `npm run prettier:check` from CWD. Running it from this repo's root would walk into ably-cocoa's pure-ObjC source and fail. Either BuildTool needs scoping to merged-repos/, or the lint command needs to cd in. Out of scope here. - generate-matrices: outputs an Xcode-version matrix for the Xcode-based jobs below. Not useful until those jobs work. - build-and-test-xcode, build-release-configuration-xcode, code-coverage, check-example-app: all route through `swift run BuildTool {build-library, test-library, …}` which invokes `xcodebuild -scheme AblyLiveObjects -testPlan UnitTests`. AblyLiveObjects has no Xcode scheme in ably-cocoa's Ably.xcodeproj, and the test plans (UnitTests.xctestplan, AllTests.xctestplan) under merged-repos/.../TestPlans/ aren't wired into any project. Wiring needs either: an Xcode scheme addition for AblyLiveObjects in Ably.xcodeproj, or a refactor of BuildTool to call `swift test` directly. - check-documentation: needs the swift-docc-plugin dependency declared at the package level, plus the AWS-credentials and sdk-upload-action setup parameterised for ably-cocoa rather than ably-liveobjects-swift-plugin. - all-checks-completed: trivial once the rest are restored; just an aggregating job. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/check-liveobjects.yaml | 57 ++++ .../.github/workflows/check.yaml | 289 ------------------ 2 files changed, 57 insertions(+), 289 deletions(-) create mode 100644 .github/workflows/check-liveobjects.yaml delete mode 100644 merged-repos/ably-liveobjects-swift-plugin/.github/workflows/check.yaml diff --git a/.github/workflows/check-liveobjects.yaml b/.github/workflows/check-liveobjects.yaml new file mode 100644 index 000000000..51b7ba363 --- /dev/null +++ b/.github/workflows/check-liveobjects.yaml @@ -0,0 +1,57 @@ +# Check workflow for the AblyLiveObjects library product. Adapted from the +# former ably-liveobjects-swift-plugin repo's check.yaml. Only the +# SPM-based jobs are wired up here; the xcodebuild-based and Swift-DocC +# jobs from the original need further work to function in the merged +# repo (see commit message and merged-repos/.../.github/workflows/check.yaml +# for the original spec). + +name: Check LiveObjects + +on: + workflow_dispatch: + pull_request: + push: + branches: + - main + +jobs: + 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 test --filter AblyLiveObjectsTests --skip ObjectsIntegrationTests + + 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 diff --git a/merged-repos/ably-liveobjects-swift-plugin/.github/workflows/check.yaml b/merged-repos/ably-liveobjects-swift-plugin/.github/workflows/check.yaml deleted file mode 100644 index 79105d2b5..000000000 --- a/merged-repos/ably-liveobjects-swift-plugin/.github/workflows/check.yaml +++ /dev/null @@ -1,289 +0,0 @@ -name: Check - -on: - workflow_dispatch: - pull_request: - push: - branches: - - main -jobs: - lint: - runs-on: macos-15 - - # From actions/cache documentation linked to below - env: - MINT_PATH: .mint/lib - MINT_LINK_PATH: .mint/bin - - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - # This step can be removed once the runners' default version of Xcode is 16.4 or above - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 16.4 - - # We use caching for Mint because at the time of writing SwiftLint took about 5 minutes to build in CI, which is unacceptably slow. - # https://github.com/actions/cache/blob/40c3b67b2955d93d83b27ed164edd0756bc24049/examples.md#swift---mint - - uses: actions/cache@v4 - with: - path: .mint - key: ${{ runner.os }}-mint-${{ hashFiles('**/Mintfile') }} - restore-keys: | - ${{ runner.os }}-mint- - - - run: npm ci - - run: brew install mint - - run: mint bootstrap - - - run: swift run BuildTool lint - - # TODO: Restore in https://github.com/ably/ably-liveobjects-swift-plugin/issues/2 once we've seen what form the LiveObjects spec takes - # - # spec-coverage: - # runs-on: macos-15 - # steps: - # - uses: actions/checkout@v4 - # with: - # submodules: true - # - # # This step can be removed once the runners' default version of Xcode is 16.4 or above - # - uses: maxim-lobanov/setup-xcode@v1 - # with: - # xcode-version: 16.4 - # - # - name: Spec coverage - # run: swift run BuildTool spec-coverage --spec-commit-sha 2f88b1b - # env: - # GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - generate-matrices: - runs-on: macos-15 - outputs: - matrix: ${{ steps.generation-step.outputs.matrix }} - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - # This step can be removed once the runners' default version of Xcode is 16.4 or above - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 16.4 - - - id: generation-step - run: swift run BuildTool generate-matrices >> $GITHUB_OUTPUT - - build-and-test-spm: - name: SPM (Xcode ${{ matrix.tooling.xcodeVersion }}) - runs-on: macos-15 - needs: generate-matrices - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withoutPlatform }} - - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: ${{ matrix.tooling.xcodeVersion }} - - # https://forums.swift.org/t/warnings-as-errors-for-libraries-frameworks/58393/2 - - run: swift build -Xswiftc -warnings-as-errors - - run: swift test -Xswiftc -warnings-as-errors - - build-release-configuration-spm: - name: SPM, `release` configuration (Xcode ${{ matrix.tooling.xcodeVersion }}) - runs-on: macos-15 - needs: generate-matrices - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withoutPlatform }} - - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: ${{ matrix.tooling.xcodeVersion }} - - # https://forums.swift.org/t/warnings-as-errors-for-libraries-frameworks/58393/2 - - run: swift build -Xswiftc -warnings-as-errors --configuration release - - build-and-test-xcode: - name: Xcode, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) - runs-on: macos-15 - needs: generate-matrices - - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} - - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: ${{ matrix.tooling.xcodeVersion }} - - # We run these as two separate steps so that we can easily see the execution time of each step. - - - name: Build for testing - run: swift run BuildTool build-library-for-testing --platform ${{ matrix.platform }} - - - name: Run tests - run: swift run BuildTool test-library --platform ${{ matrix.platform }} --without-building - - code-coverage: - name: Generate code coverage - runs-on: macos-15 - - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - # This step can be removed once the runners' default version of Xcode is 16.4 or above - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 16.4 - - - run: swift run BuildTool generate-code-coverage --result-bundle-path CodeCoverage.xcresult - - # Generate a Markdown report of the code coverage information and add it to the workflow run. - # - # This tool is the best option that I could find after a brief look at the options. There are a few things that I wish it could do: - # - # - post a message on the pull request, like they do on Kotlin - # - offer more fine-grained configuration about which data to include in the report (I only care about code coverage, not test results, and I don't care about code coverage of the AblyLiveObjectsTests target) - # - # but it'll do for now (we can always fork or have another look for tooling later). - - uses: slidoapp/xcresulttool@v3.1.0 - with: - path: CodeCoverage.xcresult - # This title will be used for the sidebar item that this job adds to GitHub results page for this workflow - title: Code coverage results - # Turning off as much non-code-coverage information as it lets me - show-passed-tests: false - if: success() || failure() - - build-release-configuration-xcode: - name: Xcode, `release` configuration, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) - runs-on: macos-15 - needs: generate-matrices - - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} - - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: ${{ matrix.tooling.xcodeVersion }} - - - name: Build library - run: swift run BuildTool build-library --platform ${{ matrix.platform }} --configuration release - - check-example-app: - name: Example app, ${{matrix.platform}} (Xcode ${{ matrix.tooling.xcodeVersion }}) - runs-on: macos-15 - needs: generate-matrices - - strategy: - fail-fast: false - matrix: ${{ fromJson(needs.generate-matrices.outputs.matrix).withPlatform }} - - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: ${{ matrix.tooling.xcodeVersion }} - - - name: Build example app - run: swift run BuildTool build-example-app --platform ${{ matrix.platform }} - - check-documentation: - runs-on: macos-15 - - permissions: - deployments: write - id-token: write - contents: read - - steps: - - uses: actions/checkout@v4 - with: - submodules: true - - # This step can be removed once the runners' default version of Xcode is 16.4 or above - - uses: maxim-lobanov/setup-xcode@v1 - with: - xcode-version: 16.4 - - # Dry run upload-action to get base-path url - - name: Dry-Run Upload (to get url) - id: preupload - uses: ably/sdk-upload-action@v2 - with: - mode: preempt - sourcePath: .build/plugins/Swift-DocC/outputs/AblyLiveObjects.doccarchive # Path to the Swift DocC output folder - githubToken: ${{ secrets.GITHUB_TOKEN }} - artifactName: AblyLiveObjects - - # Build the documentation using Swift DocC - - name: Build documentation - run: | - swift package generate-documentation --target AblyLiveObjects --disable-indexing \ - --hosting-base-path "${{ steps.preupload.outputs.base-path }}" \ - --transform-for-static-hosting - working-directory: ${{ github.workspace }} - - # Configure AWS credentials for uploading documentation - - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-region: eu-west-2 - role-to-assume: arn:aws:iam::${{ secrets.ABLY_AWS_ACCOUNT_ID_SDK }}:role/ably-sdk-builds-ably-liveobjects-swift-plugin - role-session-name: "${{ github.run_id }}-${{ github.run_number }}" - - # Upload the generated documentation - - name: Upload Documentation - uses: ably/sdk-upload-action@v2 - with: - sourcePath: .build/plugins/Swift-DocC/outputs/AblyLiveObjects.doccarchive # Path to the Swift DocC output folder - githubToken: ${{ secrets.GITHUB_TOKEN }} - artifactName: AblyLiveObjects # Optional root-level directory name - landingPagePath: documentation/ablyliveobjects - - # We use this job as a marker that all of the required checks have completed. - # This allows us to configure a single required status check in our branch - # protection rules instead of having to type loads of different check names - # into the branch protection web UI (and keep this list up to date as we - # tweak the matrices). - all-checks-completed: - runs-on: ubuntu-latest - needs: - - lint - # - spec-coverage - - build-and-test-spm - - build-release-configuration-spm - - build-and-test-xcode - - build-release-configuration-xcode - - check-example-app - - check-documentation - - code-coverage - - steps: - - name: No-op - run: "true" From bcd407b239d4c04ba9e489231de6532ed1df7bb1 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 16:42:20 -0300 Subject: [PATCH 221/225] Add AblyLiveObjects shared Xcode scheme Adds a checked-in shared scheme at .swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme so that Xcode (and xcodebuild via Ably.xcworkspace) sees a stable AblyLiveObjects scheme rather than relying on auto-generation, which produces a scheme without a configured test action. The TestPlans block references the two .xctestplan files that came in from the plugin, now living at merged-repos/ably-liveobjects-swift-plugin/TestPlans/. The AllTests test plan is the default; the UnitTests plan declares .integration as a skippedTag so that BuildTool's --only-unit-tests flag works through `xcodebuild -testPlan UnitTests`. Based on the plugin's previous in-repo scheme (merged-repos/.../.swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme) with only the TestPlanReference paths updated to point into merged-repos/. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../xcschemes/AblyLiveObjects.xcscheme | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 .swiftpm/xcode/xcshareddata/xcschemes/AblyLiveObjects.xcscheme 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 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + From 8bd8f0699bed466a73d669aedd66c93cb26de4ed Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 16:42:20 -0300 Subject: [PATCH 222/225] BuildTool: target ably-cocoa's workspace when invoking xcodebuild MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit XcodeRunner now prepends `-workspace Ably.xcworkspace` to every xcodebuild invocation. Without this, xcodebuild's auto-discovery picks Ably.xcodeproj at the repo root, which has no AblyLiveObjects scheme — the AblyLiveObjects (and BuildTool) schemes are SPM- generated and only visible when xcodebuild operates against the workspace. This is a behavioural change away from how BuildTool worked in the standalone ably-liveobjects-swift-plugin repo (no workspace there; xcodebuild auto-discovered the SPM package). Since BuildTool now lives only in this consolidated repo, hardcoding the workspace path is the right trade — making it configurable would just be yagni. With this change, `swift run BuildTool test-library --platform macOS --only-unit-tests` runs the unit suite via the UnitTests test plan (271 tests across 85 suites) and the other xcodebuild-routed subcommands (build-library, build-library-for-testing) succeed too. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../Sources/BuildTool/XcodeRunner.swift | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/XcodeRunner.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/XcodeRunner.swift index 379fcb237..7d039de0b 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/XcodeRunner.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/XcodeRunner.swift @@ -9,6 +9,13 @@ enum XcodeRunner { 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]) } From c45283310a7a8dd037393c17606d76ef4393d484 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 16:42:20 -0300 Subject: [PATCH 223/225] Switch LiveObjects CI to BuildTool for unit tests + Xcode jobs Replaces the brittle `swift test --skip ObjectsIntegrationTests` invocation with `swift run BuildTool test-library --only-unit-tests`, which selects tests via the UnitTests.xctestplan's `.integration` skippedTag rather than by a hardcoded class name regex. Future integration tests added with the .integration tag will be filtered correctly regardless of their class name. Adds two new jobs translated from the original plugin CI: - build-and-test-xcode: builds for testing and runs the unit suite via BuildTool for each of macOS, iOS, and tvOS. - build-release-configuration-xcode: builds the library in release configuration via BuildTool, also matrixed over the three platforms. Still not ported, with the same reasons as before (see commit 7ab9a1e): lint, code-coverage, check-example-app, check-documentation, generate-matrices, and the all-checks-completed aggregator. The first three depend on tooling that needs scoping or extra dependencies; documentation needs swift-docc-plugin. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/check-liveobjects.yaml | 55 +++++++++++++++++++++--- 1 file changed, 49 insertions(+), 6 deletions(-) diff --git a/.github/workflows/check-liveobjects.yaml b/.github/workflows/check-liveobjects.yaml index 51b7ba363..31e5af2d5 100644 --- a/.github/workflows/check-liveobjects.yaml +++ b/.github/workflows/check-liveobjects.yaml @@ -1,9 +1,9 @@ # Check workflow for the AblyLiveObjects library product. Adapted from the -# former ably-liveobjects-swift-plugin repo's check.yaml. Only the -# SPM-based jobs are wired up here; the xcodebuild-based and Swift-DocC -# jobs from the original need further work to function in the merged -# repo (see commit message and merged-repos/.../.github/workflows/check.yaml -# for the original spec). +# 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 @@ -35,7 +35,7 @@ jobs: run: swift build --target AblyLiveObjects - name: Run AblyLiveObjects unit tests - run: swift test --filter AblyLiveObjectsTests --skip ObjectsIntegrationTests + run: swift run BuildTool test-library --platform macOS --only-unit-tests build-release-configuration-spm: name: SPM, `release` configuration (Xcode ${{ matrix.xcode-version }}) @@ -55,3 +55,46 @@ jobs: - name: Build the AblyLiveObjects library in release configuration run: swift build --target AblyLiveObjects --configuration release + + 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 BuildTool build-library-for-testing --platform ${{ matrix.platform }} + + - name: Run unit tests + run: swift run BuildTool test-library --platform ${{ matrix.platform }} --only-unit-tests --without-building + + 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 BuildTool build-library --platform ${{ matrix.platform }} --configuration release From 4142d8c5ef3eefb452135ece707b6d4d31bce346 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 16:47:19 -0300 Subject: [PATCH 224/225] Rename BuildTool to LiveObjectsBuildTool MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The plugin's standalone repo could call its developer tool plainly "BuildTool" because that repo contained only the LiveObjects code. In the consolidated ably-cocoa package the name is misleading — it implies a general-purpose build tool for the whole package, when in fact every subcommand operates on the AblyLiveObjects target only (test-library, build-library, build-example-app, build-documentation, etc.). Three rename touchpoints: - Package.swift: executableTarget name "BuildTool" -> "LiveObjectsBuildTool". - BuildTool.swift: struct BuildTool -> LiveObjectsBuildTool, with an explicit `commandName: "liveobjects-build-tool"` on the CommandConfiguration so that the kebab-case help text stays short rather than becoming "live-objects-build-tool" via ArgumentParser's automatic derivation. - .github/workflows/check-liveobjects.yaml: `swift run BuildTool` -> `swift run LiveObjectsBuildTool` across the four invocations. The source directory remains at merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/ — renaming the directory would just churn `git log --follow` for no real benefit. SPM target-name and directory-name are decoupled. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/check-liveobjects.yaml | 8 ++++---- Package.swift | 8 ++++---- .../Sources/BuildTool/BuildTool.swift | 3 ++- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/workflows/check-liveobjects.yaml b/.github/workflows/check-liveobjects.yaml index 31e5af2d5..71870fc77 100644 --- a/.github/workflows/check-liveobjects.yaml +++ b/.github/workflows/check-liveobjects.yaml @@ -35,7 +35,7 @@ jobs: run: swift build --target AblyLiveObjects - name: Run AblyLiveObjects unit tests - run: swift run BuildTool test-library --platform macOS --only-unit-tests + run: swift run LiveObjectsBuildTool test-library --platform macOS --only-unit-tests build-release-configuration-spm: name: SPM, `release` configuration (Xcode ${{ matrix.xcode-version }}) @@ -74,10 +74,10 @@ jobs: xcode-version: ${{ matrix.xcode-version }} - name: Build for testing - run: swift run BuildTool build-library-for-testing --platform ${{ matrix.platform }} + run: swift run LiveObjectsBuildTool build-library-for-testing --platform ${{ matrix.platform }} - name: Run unit tests - run: swift run BuildTool test-library --platform ${{ matrix.platform }} --only-unit-tests --without-building + run: swift run LiveObjectsBuildTool test-library --platform ${{ matrix.platform }} --only-unit-tests --without-building build-release-configuration-xcode: name: Xcode, `release` configuration, ${{ matrix.platform }} (Xcode ${{ matrix.xcode-version }}) @@ -97,4 +97,4 @@ jobs: xcode-version: ${{ matrix.xcode-version }} - name: Build library - run: swift run BuildTool build-library --platform ${{ matrix.platform }} --configuration release + run: swift run LiveObjectsBuildTool build-library --platform ${{ matrix.platform }} --configuration release diff --git a/Package.swift b/Package.swift index d6d449ec6..ba9b50c60 100644 --- a/Package.swift +++ b/Package.swift @@ -23,9 +23,9 @@ let package = Package( .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"), - // The next three are used only by the BuildTool executable target, - // which is a developer/CI tool. None of them are linked into the - // Ably or AblyLiveObjects library products. + // 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"), @@ -109,7 +109,7 @@ let package = Package( // (swift-argument-parser, swift-async-algorithms, Table) do not // affect them at build time. .executableTarget( - name: "BuildTool", + name: "LiveObjectsBuildTool", dependencies: [ .product(name: "ArgumentParser", package: "swift-argument-parser"), .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), diff --git a/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/BuildTool.swift b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/BuildTool.swift index 8e9fbb858..fabc37482 100644 --- a/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/BuildTool.swift +++ b/merged-repos/ably-liveobjects-swift-plugin/Sources/BuildTool/BuildTool.swift @@ -5,8 +5,9 @@ import Table @main @available(macOS 14, *) -struct BuildTool: AsyncParsableCommand { +struct LiveObjectsBuildTool: AsyncParsableCommand { static let configuration = CommandConfiguration( + commandName: "liveobjects-build-tool", subcommands: [ BuildLibrary.self, BuildLibraryForTesting.self, From a11bfe29634ebb5c83f9b33436f23f3aa5ebe634 Mon Sep 17 00:00:00 2001 From: Lawrence Forooghian Date: Thu, 14 May 2026 16:48:46 -0300 Subject: [PATCH 225/225] Prefix LiveObjects CI job IDs with liveobjects- Renames the four jobs in check-liveobjects.yaml so that their IDs carry the liveobjects- prefix: - build-and-test-spm -> liveobjects-build-and-test-spm - build-release-configuration-spm -> liveobjects-build-release-configuration-spm - build-and-test-xcode -> liveobjects-build-and-test-xcode - build-release-configuration-xcode -> liveobjects-build-release-configuration-xcode In the consolidated repo there will eventually be CI jobs from several different feature areas (ably-cocoa core, LiveObjects, maybe plugin-support specifics). Without a prefix, generic IDs like "build-and-test-spm" collide conceptually with whatever other workflow files name their own SPM jobs, and the GitHub Actions UI shows them all interleaved. The prefix makes it immediately clear which subsystem each job belongs to. The human-readable `name:` strings are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) --- .github/workflows/check-liveobjects.yaml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/check-liveobjects.yaml b/.github/workflows/check-liveobjects.yaml index 71870fc77..223841245 100644 --- a/.github/workflows/check-liveobjects.yaml +++ b/.github/workflows/check-liveobjects.yaml @@ -15,7 +15,7 @@ on: - main jobs: - build-and-test-spm: + liveobjects-build-and-test-spm: name: SPM (Xcode ${{ matrix.xcode-version }}) runs-on: macos-15 strategy: @@ -37,7 +37,7 @@ jobs: - name: Run AblyLiveObjects unit tests run: swift run LiveObjectsBuildTool test-library --platform macOS --only-unit-tests - build-release-configuration-spm: + liveobjects-build-release-configuration-spm: name: SPM, `release` configuration (Xcode ${{ matrix.xcode-version }}) runs-on: macos-15 strategy: @@ -56,7 +56,7 @@ jobs: - name: Build the AblyLiveObjects library in release configuration run: swift build --target AblyLiveObjects --configuration release - build-and-test-xcode: + liveobjects-build-and-test-xcode: name: Xcode, ${{ matrix.platform }} (Xcode ${{ matrix.xcode-version }}) runs-on: macos-15 strategy: @@ -79,7 +79,7 @@ jobs: - name: Run unit tests run: swift run LiveObjectsBuildTool test-library --platform ${{ matrix.platform }} --only-unit-tests --without-building - build-release-configuration-xcode: + liveobjects-build-release-configuration-xcode: name: Xcode, `release` configuration, ${{ matrix.platform }} (Xcode ${{ matrix.xcode-version }}) runs-on: macos-15 strategy: