From b1eaf42adc22d1b9d0af7cd23127f46fd620c554 Mon Sep 17 00:00:00 2001 From: Di Wu Date: Tue, 2 Jan 2024 10:54:44 -0800 Subject: [PATCH] fix(datastore-v1): sync pending mutation events with latest synced metadata (#3402) --- ...atabaseAdapter+MutationEventIngester.swift | 18 - .../OutgoingMutationQueue.swift | 10 +- .../SyncMutationToCloudOperation.swift | 11 +- .../ReconcileAndLocalSaveOperation.swift | 144 ++++-- .../RemoteSyncReconciler.swift | 15 + .../Support/MutationEvent+Extensions.swift | 103 ----- .../DataStoreEndToEndTests.swift | 2 +- .../SyncMutationToCloudOperationTests.swift | 16 +- .../MutationEventExtensionsTests.swift | 415 ------------------ .../project.pbxproj | 10 +- 10 files changed, 154 insertions(+), 590 deletions(-) delete mode 100644 AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/Support/MutationEvent+Extensions.swift delete mode 100644 AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/Support/MutationEventExtensionsTests.swift diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift index c9dae7504f..e936f78dea 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/AWSMutationDatabaseAdapter/AWSMutationDatabaseAdapter+MutationEventIngester.swift @@ -34,22 +34,6 @@ extension AWSMutationDatabaseAdapter: MutationEventIngester { func resolveConflictsThenSave(mutationEvent: MutationEvent, storageAdapter: StorageEngineAdapter, completionPromise: @escaping Future.Promise) { - - // We don't want to query MutationSync because a) we already have the model, and b) delete mutations - // are submitted *after* the delete has already been applied to the local data store, meaning there is no model - // to query. - var mutationEvent = mutationEvent - do { - // swiftlint:disable:next todo - // TODO: Refactor this so that it's clear that the storage engine is not responsible for setting the version - // perhaps as simple as renaming to `submit(unversionedMutationEvent:)` or similar - let syncMetadata = try storageAdapter.queryMutationSyncMetadata(for: mutationEvent.modelId, - modelName: mutationEvent.modelName) - mutationEvent.version = syncMetadata?.version - } catch { - completionPromise(.failure(DataStoreError(error: error))) - } - MutationEvent.pendingMutationEvents( forMutationEvent: mutationEvent, storageAdapter: storageAdapter) { result in @@ -201,8 +185,6 @@ extension AWSMutationDatabaseAdapter: MutationEventIngester { } resolvedEvent.mutationType = updatedMutationType - resolvedEvent.version = candidate.version - return resolvedEvent } diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift index e9305d164a..0c1905288c 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/OutgoingMutationQueue.swift @@ -207,6 +207,9 @@ final class OutgoingMutationQueue: OutgoingMutationQueueBehavior { let syncMutationToCloudOperation = SyncMutationToCloudOperation( mutationEvent: mutationEvent, + getLatestSyncMetadata: { + try? self.storageAdapter.queryMutationSyncMetadata(for: mutationEvent.modelId, modelName: mutationEvent.modelName) + }, api: api, authModeStrategy: authModeStrategy ) { [weak self] result in @@ -259,12 +262,7 @@ final class OutgoingMutationQueue: OutgoingMutationQueueBehavior { return } reconciliationQueue.offer([mutationSync], modelName: mutationEvent.modelName) - MutationEvent.reconcilePendingMutationEventsVersion( - sent: mutationEvent, - received: mutationSync, - storageAdapter: storageAdapter) { _ in - self.completeProcessingEvent(mutationEvent, mutationSync: mutationSync) - } + completeProcessingEvent(mutationEvent, mutationSync: mutationSync) } else { completeProcessingEvent(mutationEvent) } diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift index 8c7a9fb568..0be465f6b0 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift @@ -19,6 +19,7 @@ class SyncMutationToCloudOperation: AsynchronousOperation { typealias MutationSyncCloudResult = GraphQLOperation>.OperationResult private weak var api: APICategoryGraphQLBehavior? + private let getLatestSyncMetadata: () -> MutationSyncMetadata? private let mutationEvent: MutationEvent private let completion: GraphQLOperation>.ResultListener private let requestRetryablePolicy: RequestRetryablePolicy @@ -32,6 +33,7 @@ class SyncMutationToCloudOperation: AsynchronousOperation { private var authTypesIterator: AWSAuthorizationTypeIterator? init(mutationEvent: MutationEvent, + getLatestSyncMetadata: @escaping () -> MutationSyncMetadata?, api: APICategoryGraphQLBehavior, authModeStrategy: AuthModeStrategy, networkReachabilityPublisher: AnyPublisher? = nil, @@ -39,6 +41,7 @@ class SyncMutationToCloudOperation: AsynchronousOperation { requestRetryablePolicy: RequestRetryablePolicy? = RequestRetryablePolicy(), completion: @escaping GraphQLOperation>.ResultListener) { self.mutationEvent = mutationEvent + self.getLatestSyncMetadata = getLatestSyncMetadata self.api = api self.networkReachabilityPublisher = networkReachabilityPublisher self.completion = completion @@ -109,7 +112,7 @@ class SyncMutationToCloudOperation: AsynchronousOperation { authType: AWSAuthorizationType? = nil ) -> GraphQLRequest>? { var request: GraphQLRequest> - + let latestSyncMetadata = getLatestSyncMetadata() do { var graphQLFilter: GraphQLFilter? if let graphQLFilterJSON = mutationEvent.graphQLFilterJSON { @@ -128,7 +131,7 @@ class SyncMutationToCloudOperation: AsynchronousOperation { request = GraphQLRequest.deleteMutation(of: model, modelSchema: modelSchema, where: graphQLFilter, - version: mutationEvent.version) + version: latestSyncMetadata?.version) case .update: let model = try mutationEvent.decodeModel() guard let modelSchema = ModelRegistry.modelSchema(from: mutationEvent.modelName) else { @@ -140,7 +143,7 @@ class SyncMutationToCloudOperation: AsynchronousOperation { request = GraphQLRequest.updateMutation(of: model, modelSchema: modelSchema, where: graphQLFilter, - version: mutationEvent.version) + version: latestSyncMetadata?.version) case .create: let model = try mutationEvent.decodeModel() guard let modelSchema = ModelRegistry.modelSchema(from: mutationEvent.modelName) else { @@ -151,7 +154,7 @@ class SyncMutationToCloudOperation: AsynchronousOperation { } request = GraphQLRequest.createMutation(of: model, modelSchema: modelSchema, - version: mutationEvent.version) + version: latestSyncMetadata?.version) } } catch { let apiError = APIError.unknown("Couldn't decode model", "", error) diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation.swift index 1e0ebf4a16..dec80a8b23 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/ReconcileAndLocalSaveOperation.swift @@ -106,6 +106,14 @@ class ReconcileAndLocalSaveOperation: AsynchronousOperation { // MARK: - Responder methods + /// The reconcile function incorporates incoming mutation events into the local database through the following steps: + /// 1. Retrieve the local metadata of the models. + /// 2. Generate dispositions based on incoming mutation events and local metadata. + /// 3. Categorize dispositions into: + /// 3.1 Apply metadata only for those with existing pending mutations. + /// 3.1.1 Notify the count of these incoming mutation events as dropped items. + /// 3.2 Apply incoming mutation and metadata for those without existing pending mutations. + /// 4. Notify the final result. func reconcile(remoteModels: [RemoteModel]) { guard !isCancelled else { log.info("\(#function) - cancelled, aborting") @@ -126,16 +134,24 @@ class ReconcileAndLocalSaveOperation: AsynchronousOperation { do { try storageAdapter.transaction { - queryPendingMutations(forModels: remoteModels.map(\.model)) + queryLocalMetadata(remoteModels) .subscribe(on: workQueue) - .flatMap { mutationEvents -> Future<([RemoteModel], [LocalMetadata]), DataStoreError> in - let remoteModelsToApply = self.reconcile(remoteModels, pendingMutations: mutationEvents) - return self.queryLocalMetadata(remoteModelsToApply) + .map { remoteModels, localMetadatas in + self.getDispositions(for: remoteModels, localMetadatas: localMetadatas) } - .flatMap { (remoteModelsToApply, localMetadatas) -> Future in - let dispositions = self.getDispositions(for: remoteModelsToApply, - localMetadatas: localMetadatas) - return self.applyRemoteModelsDispositions(dispositions) + .flatMap { dispositions in + self.queryPendingMutations(forModels: dispositions.map(\.remoteModel.model)).map { pendingMutations in + (pendingMutations, dispositions) + } + } + .map { pendingMutations, dispositions in + self.separateDispositions(pendingMutations: pendingMutations, dispositions: dispositions) + } + .flatMap { dispositions, dispositionsOnlyApplyMetadata in + self.waitAllPublisherFinishes(publishers: dispositionsOnlyApplyMetadata.map(self.saveMetadata(disposition:))) + .flatMap { _ in + self.applyRemoteModelsDispositions(dispositions) + } } .sink( receiveCompletion: { @@ -195,6 +211,28 @@ class ReconcileAndLocalSaveOperation: AsynchronousOperation { } } + func separateDispositions( + pendingMutations: [MutationEvent], + dispositions: [RemoteSyncReconciler.Disposition] + ) -> ([RemoteSyncReconciler.Disposition], [RemoteSyncReconciler.Disposition]) { + if dispositions.isEmpty { + return ([], []) + } + + let pendingMutationModelIds = Set(pendingMutations.map(\.modelId)) + + let dispositionsToApply = dispositions.filter { + !pendingMutationModelIds.contains($0.remoteModel.model.identifier) + } + + let dispositionsOnlyApplyMetadata = dispositions.filter { + pendingMutationModelIds.contains($0.remoteModel.model.identifier) + } + + notifyDropped(count: dispositionsOnlyApplyMetadata.count) + return (dispositionsToApply, dispositionsOnlyApplyMetadata) + } + func reconcile(_ remoteModels: [RemoteModel], pendingMutations: [MutationEvent]) -> [RemoteModel] { guard !remoteModels.isEmpty else { return [] @@ -284,8 +322,7 @@ class ReconcileAndLocalSaveOperation: AsynchronousOperation { } let publishers = dispositions.map { disposition -> - Publishers.FlatMap, - Future> in + AnyPublisher in switch disposition { case .create(let remoteModel): @@ -296,7 +333,8 @@ class ReconcileAndLocalSaveOperation: AsynchronousOperation { applyResult: applyResult, mutationType: .create) } - return publisher + + return publisher.eraseToAnyPublisher() case .update(let remoteModel): let publisher = self.save(storageAdapter: storageAdapter, remoteModel: remoteModel) @@ -305,7 +343,7 @@ class ReconcileAndLocalSaveOperation: AsynchronousOperation { applyResult: applyResult, mutationType: .update) } - return publisher + return publisher.eraseToAnyPublisher() case .delete(let remoteModel): let publisher = self.delete(storageAdapter: storageAdapter, remoteModel: remoteModel) @@ -314,7 +352,7 @@ class ReconcileAndLocalSaveOperation: AsynchronousOperation { applyResult: applyResult, mutationType: .delete) } - return publisher + return publisher.eraseToAnyPublisher() } } @@ -367,8 +405,10 @@ class ReconcileAndLocalSaveOperation: AsynchronousOperation { } } - private func save(storageAdapter: StorageEngineAdapter, - remoteModel: RemoteModel) -> Future { + private func save( + storageAdapter: StorageEngineAdapter, + remoteModel: RemoteModel + ) -> Future { Future { promise in storageAdapter.save(untypedModel: remoteModel.model.instance) { response in switch response { @@ -396,29 +436,69 @@ class ReconcileAndLocalSaveOperation: AsynchronousOperation { } } - private func saveMetadata(storageAdapter: StorageEngineAdapter, - applyResult: ApplyRemoteModelResult, - mutationType: MutationEvent.MutationType) -> Future { - Future { promise in - guard case let .applied(inProcessModel) = applyResult else { - promise(.successfulVoid) - return - } + private func saveMetadata( + disposition: RemoteSyncReconciler.Disposition + ) -> AnyPublisher { + guard let storageAdapter = self.storageAdapter else { + return Just(()).eraseToAnyPublisher() + } - storageAdapter.save(inProcessModel.syncMetadata, condition: nil) { result in - switch result { - case .failure(let dataStoreError): - self.notifyDropped(error: dataStoreError) - promise(.failure(dataStoreError)) - case .success(let syncMetadata): + return saveMetadata( + storageAdapter: storageAdapter, + remoteModel: disposition.remoteModel, + mutationType: disposition.mutationType + ) + .map { _ in () } + .catch { _ in Just(()) } + .eraseToAnyPublisher() + } + + private func saveMetadata( + storageAdapter: StorageEngineAdapter, + remoteModel: RemoteModel, + mutationType: MutationEvent.MutationType + ) -> Future { + Future { promise in + storageAdapter.save( + remoteModel.syncMetadata, + condition: nil) { result in + promise(result) + } + } + } + + private func saveMetadata( + storageAdapter: StorageEngineAdapter, + applyResult: ApplyRemoteModelResult, + mutationType: MutationEvent.MutationType + ) -> AnyPublisher { + if case .applied(let inProcessModel) = applyResult { + return self.saveMetadata(storageAdapter: storageAdapter, remoteModel: inProcessModel, mutationType: mutationType) + .handleEvents(receiveOutput: { syncMetadata in let appliedModel = MutationSync(model: inProcessModel.model, syncMetadata: syncMetadata) self.notify(savedModel: appliedModel, mutationType: mutationType) - promise(.successfulVoid) - } - } + }, receiveCompletion: { completion in + if case .failure(let error) = completion { + self.notifyDropped(error: error) + } + }) + .map { _ in () } + .eraseToAnyPublisher() } + return Just(()).setFailureType(to: DataStoreError.self).eraseToAnyPublisher() } + private func waitAllPublisherFinishes(publishers: [AnyPublisher]) -> Future { + Future { promise in + Publishers.MergeMany(publishers) + .collect() + .sink(receiveCompletion: { _ in + promise(.successfulVoid) + }, receiveValue: { _ in }) + .store(in: &self.cancellables) + } + } + private func notifyDropped(count: Int = 1, error: DataStoreError? = nil) { for _ in 0 ..< count { mutationEventPublisher.send(.mutationEventDropped(modelName: modelSchema.name, error: error)) diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/RemoteSyncReconciler.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/RemoteSyncReconciler.swift index 962e43cb63..903d354d4a 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/RemoteSyncReconciler.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/SubscriptionSync/ReconcileAndLocalSave/RemoteSyncReconciler.swift @@ -17,6 +17,21 @@ struct RemoteSyncReconciler { case create(RemoteModel) case update(RemoteModel) case delete(RemoteModel) + + var remoteModel: RemoteModel { + switch self { + case .create(let model), .update(let model), .delete(let model): + return model + } + } + + var mutationType: MutationEvent.MutationType { + switch self { + case .create: return .create + case .update: return .update + case .delete: return .delete + } + } } /// Filter the incoming `remoteModels` against the pending mutations. diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/Support/MutationEvent+Extensions.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/Support/MutationEvent+Extensions.swift deleted file mode 100644 index 3d99be0a03..0000000000 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/Support/MutationEvent+Extensions.swift +++ /dev/null @@ -1,103 +0,0 @@ -// -// Copyright Amazon.com Inc. or its affiliates. -// All Rights Reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// - -import Amplify -import Dispatch -import AWSPluginsCore - -extension MutationEvent { - // Consecutive operations that modify a model results in a sequence of pending mutation events that - // have the current version of the model. The first mutation event has the correct version of the model, - // while the subsequent events will have lower versions if the first mutation event is successfully synced - // to the cloud. By reconciling the pending mutation events after syncing the first mutation event, - // we attempt to update the pending version to the latest version from the response. - // The before and after conditions for consecutive update scenarios are as below: - // - Save, then immediately update - // Queue Before - [(version: nil, inprocess: true, type: .create), - // (version: nil, inprocess: false, type: .update)] - // Response - [version: 1, type: .create] - // Queue After - [(version: 1, inprocess: false, type: .update)] - // - Save, then immediately delete - // Queue Before - [(version: nil, inprocess: true, type: .create), - // (version: nil, inprocess: false, type: .delete)] - // Response - [version: 1, type: .create] - // Queue After - [(version: 1, inprocess: false, type: .delete)] - // - Save, sync, then immediately update and delete - // Queue Before (After save, sync) - // - [(version: 1, inprocess: true, type: .update), (version: 1, inprocess: false, type: .delete)] - // Response - [version: 2, type: .update] - // Queue After - [(version: 2, inprocess: false, type: .delete)] - // - // For a given model `id`, checks the version of the head of pending mutation event queue - // against the API response version in `mutationSync` and saves it in the mutation event table if - // the response version is a newer one - static func reconcilePendingMutationEventsVersion(sent mutationEvent: MutationEvent, - received mutationSync: MutationSync, - storageAdapter: StorageEngineAdapter, - completion: @escaping DataStoreCallback) { - MutationEvent.pendingMutationEvents( - forMutationEvent: mutationEvent, - storageAdapter: storageAdapter) { queryResult in - switch queryResult { - case .failure(let dataStoreError): - completion(.failure(dataStoreError)) - case .success(let localMutationEvents): - guard let existingEvent = localMutationEvents.first else { - completion(.success(())) - return - } - - guard let reconciledEvent = reconcile(pendingMutationEvent: existingEvent, - with: mutationEvent, - responseMutationSync: mutationSync) else { - completion(.success(())) - return - } - - storageAdapter.save(reconciledEvent, condition: nil) { result in - switch result { - case .failure(let dataStoreError): - completion(.failure(dataStoreError)) - case .success: - completion(.success(())) - } - } - } - } - } - - static func reconcile(pendingMutationEvent: MutationEvent, - with requestMutationEvent: MutationEvent, - responseMutationSync: MutationSync) -> MutationEvent? { - // return if version of the pending mutation event is not nil and - // is >= version contained in the response - if pendingMutationEvent.version != nil && - pendingMutationEvent.version! >= responseMutationSync.syncMetadata.version { - return nil - } - - do { - let responseModel = responseMutationSync.model.instance - let requestModel = try requestMutationEvent.decodeModel() - - // check if the data sent in the request is the same as the response - // if it is, update the pending mutation event version to the response version - guard let modelSchema = ModelRegistry.modelSchema(from: requestMutationEvent.modelName), - modelSchema.compare(responseModel, requestModel) else { - return nil - } - - var pendingMutationEvent = pendingMutationEvent - pendingMutationEvent.version = responseMutationSync.syncMetadata.version - return pendingMutationEvent - } catch { - Amplify.log.verbose("Error decoding models: \(error)") - return nil - } - } - -} diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreEndToEndTests.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreEndToEndTests.swift index 92e41f3c25..afc3c6ae75 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreEndToEndTests.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginIntegrationTests/DataStoreEndToEndTests.swift @@ -302,7 +302,7 @@ class DataStoreEndToEndTests: SyncEngineIntegrationTestBase { } else if payload.eventName == HubPayload.EventName.DataStore.conditionalSaveFailed { if mutationEvent.mutationType == GraphQLMutationType.update.rawValue { XCTAssertEqual(post.title, updatedPost.title) - XCTAssertEqual(mutationEvent.version, 1) + XCTAssertEqual(mutationEvent.version, nil) conditionalReceived.fulfill() return } diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/SyncMutationToCloudOperationTests.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/SyncMutationToCloudOperationTests.swift index 66b6b89760..4983fec08f 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/SyncMutationToCloudOperationTests.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/MutationQueue/SyncMutationToCloudOperationTests.swift @@ -46,8 +46,12 @@ class SyncMutationToCloudOperationTests: XCTestCase { var listenerFromSecondRequestOptional: GraphQLOperation>.ResultListener? var numberOfTimesEntered = 0 - let responder = MutateRequestListenerResponder> { _, eventListener in + let responder = MutateRequestListenerResponder> { request, eventListener in if numberOfTimesEntered == 0 { + let requestInputVersion = request.variables + .flatMap { $0["input"] as? [String: Any] } + .flatMap { $0["_version"] as? Int } + XCTAssertEqual(requestInputVersion, 12) listenerFromFirstRequestOptional = eventListener expectFirstCallToAPIMutate.fulfill() } else if numberOfTimesEntered == 1 { @@ -68,7 +72,14 @@ class SyncMutationToCloudOperationTests: XCTestCase { expectMutationRequestCompletion.fulfill() } + let model = MockSynced(id: "id-1") let operation = SyncMutationToCloudOperation(mutationEvent: mutationEvent, + getLatestSyncMetadata: { MutationSyncMetadata( + modelId: model.id, + modelName: model.modelName, + deleted: false, + lastChangedAt: Date().unixSeconds, + version: 12) }, api: mockAPIPlugin, authModeStrategy: AWSDefaultAuthModeStrategy(), networkReachabilityPublisher: publisher, @@ -91,7 +102,6 @@ class SyncMutationToCloudOperationTests: XCTestCase { return } - let model = MockSynced(id: "id-1") let anyModel = try model.eraseToAnyModel() let remoteSyncMetadata = MutationSyncMetadata(modelId: model.id, modelName: model.modelName, @@ -141,6 +151,7 @@ class SyncMutationToCloudOperationTests: XCTestCase { expectMutationRequestCompletion.fulfill() } let operation = SyncMutationToCloudOperation(mutationEvent: mutationEvent, + getLatestSyncMetadata: { nil }, api: mockAPIPlugin, authModeStrategy: AWSDefaultAuthModeStrategy(), networkReachabilityPublisher: publisher, @@ -214,6 +225,7 @@ class SyncMutationToCloudOperationTests: XCTestCase { } } let operation = SyncMutationToCloudOperation(mutationEvent: mutationEvent, + getLatestSyncMetadata: { nil }, api: mockAPIPlugin, authModeStrategy: AWSDefaultAuthModeStrategy(), networkReachabilityPublisher: publisher, diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/Support/MutationEventExtensionsTests.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/Support/MutationEventExtensionsTests.swift deleted file mode 100644 index 7c676d68cb..0000000000 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/Support/MutationEventExtensionsTests.swift +++ /dev/null @@ -1,415 +0,0 @@ -// -// Copyright Amazon.com Inc. or its affiliates. -// All Rights Reserved. -// -// SPDX-License-Identifier: Apache-2.0 -// - -import Foundation -import SQLite -import XCTest - -@testable import Amplify -@testable import AmplifyTestCommon -@testable import AWSDataStoreCategoryPlugin -@testable import AWSPluginsCore - -// swiftlint:disable:next todo -// TODO: This flaky test has been disabled, tracking issue: https://github.com/aws-amplify/amplify-ios/issues/1831 -// swiftlint:disable type_body_length -class MutationEventExtensionsTest: BaseDataStoreTests { - - /// - Given: A pending mutation events queue with event containing `nil` version, a sent mutation - /// event model that matches the received mutation sync model. The received mutation sync has version 1. - /// - When: The sent model matches the received model and the first pending mutation event version is `nil`. - /// - Then: The pending mutation event version should be updated to the received model version of 1. - func testSentModelWithNilVersion_Reconciled() throws { - throw XCTSkip("TODO: fix this test") - let modelId = UUID().uuidString - let post = Post(id: modelId, title: "title", content: "content", createdAt: .now()) - let requestMutationEvent = try createMutationEvent(model: post, - mutationType: .create, - createdAt: .now(), - version: nil, - inProcess: true) - let pendingMutationEvent = try createMutationEvent(model: post, - mutationType: .update, - createdAt: .now().add(value: 1, to: .second), - version: nil) - let responseMutationSync = createMutationSync(model: post, version: 1) - - setUpPendingMutationQueue(post, [requestMutationEvent, pendingMutationEvent], pendingMutationEvent) - - let reconciledEvent = MutationEvent.reconcile(pendingMutationEvent: pendingMutationEvent, - with: requestMutationEvent, - responseMutationSync: responseMutationSync) - XCTAssertNotNil(reconciledEvent) - XCTAssertEqual(reconciledEvent?.version, responseMutationSync.syncMetadata.version) - - let queryAfterUpdatingVersionExpectation = expectation(description: "update mutation should be latest version") - let updatingVersionExpectation = expectation(description: "update latest mutation event with response version") - - // update the version of head of mutation event table for given model id to the version of `mutationSync` - MutationEvent.reconcilePendingMutationEventsVersion(sent: requestMutationEvent, - received: responseMutationSync, - storageAdapter: storageAdapter) { result in - switch result { - case .failure(let error): - XCTFail("Error : \(error)") - case .success: - updatingVersionExpectation.fulfill() - } - } - wait(for: [updatingVersionExpectation], timeout: 1) - - // query for head of mutation event table for given model id and check if it has the updated version - MutationEvent.pendingMutationEvents( - forModel: post, - storageAdapter: storageAdapter - ) { result in - switch result { - case .failure(let error): - XCTFail("Error : \(error)") - case .success(let mutationEvents): - guard !mutationEvents.isEmpty, let head = mutationEvents.first else { - XCTFail("Failure while updating version") - return - } - XCTAssertEqual(head.version, responseMutationSync.syncMetadata.version) - XCTAssertEqual(head.mutationType, MutationEvent.MutationType.update.rawValue) - queryAfterUpdatingVersionExpectation.fulfill() - } - } - wait(for: [queryAfterUpdatingVersionExpectation], timeout: 1) - } - - /// - Given: A pending mutation events queue with two events(update and delete) containing `nil` version, - /// a sent mutation event model that matches the received mutation sync model. The received mutation - /// sync has version 1. - /// - When: The sent model matches the received model, the first pending mutation event(update) version is `nil` and - /// the second pending mutation event(delete) version is `nil`. - /// - Then: The first pending mutation event(update) version should be updated to the received model version of 1 - /// and the second pending mutation event version(delete) should not be updated. - func testSentModelWithNilVersion_SecondPendingEventNotReconciled() throws { - throw XCTSkip("TODO: fix this test") - let modelId = UUID().uuidString - let post = Post(id: modelId, title: "title", content: "content", createdAt: .now()) - let requestMutationEvent = try createMutationEvent(model: post, - mutationType: .create, - createdAt: .now(), - version: nil, - inProcess: true) - let pendingUpdateMutationEvent = try createMutationEvent(model: post, - mutationType: .update, - createdAt: .now().add(value: 1, to: .second), - version: nil) - let pendingDeleteMutationEvent = try createMutationEvent(model: post, - mutationType: .delete, - createdAt: .now().add(value: 2, to: .second), - version: nil) - let responseMutationSync = createMutationSync(model: post, version: 1) - - setUpPendingMutationQueue(post, - [requestMutationEvent, pendingUpdateMutationEvent, pendingDeleteMutationEvent], - pendingUpdateMutationEvent) - - let reconciledEvent = MutationEvent.reconcile(pendingMutationEvent: pendingUpdateMutationEvent, - with: requestMutationEvent, - responseMutationSync: responseMutationSync) - XCTAssertNotNil(reconciledEvent) - XCTAssertEqual(reconciledEvent?.version, responseMutationSync.syncMetadata.version) - - let queryAfterUpdatingVersionExpectation = expectation(description: "update mutation should be latest version") - let updatingVersionExpectation = expectation(description: "update latest mutation event with response version") - - // update the version of head of mutation event table for given model id to the version of `mutationSync` - MutationEvent.reconcilePendingMutationEventsVersion(sent: requestMutationEvent, - received: responseMutationSync, - storageAdapter: storageAdapter) { result in - switch result { - case .failure(let error): - XCTFail("Error : \(error)") - case .success: - updatingVersionExpectation.fulfill() - } - } - wait(for: [updatingVersionExpectation], timeout: 1) - - // query for head of mutation event table for given model id and check if it has the updated version - MutationEvent.pendingMutationEvents( - forModel: post, - storageAdapter: storageAdapter - ) { result in - switch result { - case .failure(let error): - XCTFail("Error : \(error)") - case .success(let mutationEvents): - guard !mutationEvents.isEmpty, let head = mutationEvents.first, let last = mutationEvents.last else { - XCTFail("Failure while updating version") - return - } - XCTAssertEqual(head.version, responseMutationSync.syncMetadata.version) - XCTAssertEqual(head.mutationType, MutationEvent.MutationType.update.rawValue) - XCTAssertEqual(last, pendingDeleteMutationEvent) - queryAfterUpdatingVersionExpectation.fulfill() - } - } - wait(for: [queryAfterUpdatingVersionExpectation], timeout: 1) - } - - /// - Given: A pending mutation events queue with event containing version 2, a sent mutation event model - /// that matches the received mutation sync model having version 2. The received mutation sync has - /// version 1. - /// - When: The sent model matches the received model and the first pending mutation event version is 2. - /// - Then: The first pending mutation event version should NOT be updated. - func testSentModelVersionNewerThanResponseVersion_PendingEventNotReconciled() throws { - throw XCTSkip("TODO: fix this test") - let modelId = UUID().uuidString - let post1 = Post(id: modelId, title: "title1", content: "content1", createdAt: .now()) - let post2 = Post(id: modelId, title: "title2", content: "content2", createdAt: .now()) - let requestMutationEvent = try createMutationEvent(model: post1, - mutationType: .create, - createdAt: .now(), - version: 2, - inProcess: true) - let pendingMutationEvent = try createMutationEvent(model: post2, - mutationType: .update, - createdAt: .now().add(value: 1, to: .second), - version: 2) - let responseMutationSync = createMutationSync(model: post1, version: 1) - - setUpPendingMutationQueue(post1, [requestMutationEvent, pendingMutationEvent], pendingMutationEvent) - - let reconciledEvent = MutationEvent.reconcile(pendingMutationEvent: pendingMutationEvent, - with: requestMutationEvent, - responseMutationSync: responseMutationSync) - XCTAssertNil(reconciledEvent) - - let queryAfterUpdatingVersionExpectation = expectation(description: "update mutation should have version 2") - let updatingVersionExpectation = - expectation(description: "don't update latest mutation event with response version") - - MutationEvent.reconcilePendingMutationEventsVersion(sent: requestMutationEvent, - received: responseMutationSync, - storageAdapter: storageAdapter) { result in - switch result { - case .failure(let error): - XCTFail("Error : \(error)") - case .success: - updatingVersionExpectation.fulfill() - } - } - wait(for: [updatingVersionExpectation], timeout: 1) - - // query for head of mutation event table for given model id and check if it has the correct version - MutationEvent.pendingMutationEvents( - forModel: post1, - storageAdapter: storageAdapter - ) { result in - switch result { - case .failure(let error): - XCTFail("Error : \(error)") - case .success(let mutationEvents): - guard !mutationEvents.isEmpty, let head = mutationEvents.first else { - XCTFail("Failure while updating version") - return - } - XCTAssertNotEqual(head.version, responseMutationSync.syncMetadata.version) - XCTAssertEqual(head, pendingMutationEvent) - queryAfterUpdatingVersionExpectation.fulfill() - } - } - wait(for: [queryAfterUpdatingVersionExpectation], timeout: 1) - } - - /// - Given: A pending mutation events queue with event containing version 1, a sent mutation event model - /// that doesn't match the received mutation sync model having version 1. The received mutation - /// sync has version 2. - /// - When: The sent model doesn't match the received model and the first pending mutation event version is 1. - /// - Then: The first pending mutation event version should NOT be updated. - func testSentModelNotEqualToResponseModel_PendingEventNotReconciled() throws { - throw XCTSkip("TODO: fix this test") - let modelId = UUID().uuidString - let post1 = Post(id: modelId, title: "title1", content: "content1", createdAt: .now()) - let post2 = Post(id: modelId, title: "title2", content: "content2", createdAt: .now()) - let post3 = Post(id: modelId, title: "title3", content: "content3", createdAt: .now()) - let requestMutationEvent = try createMutationEvent(model: post1, - mutationType: .update, - createdAt: .now(), - version: 1, - inProcess: true) - let pendingMutationEvent = try createMutationEvent(model: post2, - mutationType: .update, - createdAt: .now().add(value: 1, to: .second), - version: 1) - let responseMutationSync = createMutationSync(model: post3, version: 2) - - setUpPendingMutationQueue(post1, [requestMutationEvent, pendingMutationEvent], pendingMutationEvent) - - let reconciledEvent = MutationEvent.reconcile(pendingMutationEvent: pendingMutationEvent, - with: requestMutationEvent, - responseMutationSync: responseMutationSync) - XCTAssertNil(reconciledEvent) - - let queryAfterUpdatingVersionExpectation = expectation(description: "update mutation should have version 1") - let updatingVersionExpectation = - expectation(description: "don't update latest mutation event with response version") - - MutationEvent.reconcilePendingMutationEventsVersion(sent: requestMutationEvent, - received: responseMutationSync, - storageAdapter: storageAdapter) { result in - switch result { - case .failure(let error): - XCTFail("Error : \(error)") - case .success: - updatingVersionExpectation.fulfill() - } - } - wait(for: [updatingVersionExpectation], timeout: 1) - - // query for head of mutation event table for given model id and check if it has the correct version - MutationEvent.pendingMutationEvents( - forModel: post1, - storageAdapter: storageAdapter - ) { result in - switch result { - case .failure(let error): - XCTFail("Error : \(error)") - case .success(let mutationEvents): - guard !mutationEvents.isEmpty, let head = mutationEvents.first else { - XCTFail("Failure while updating version") - return - } - XCTAssertNotEqual(head.version, responseMutationSync.syncMetadata.version) - XCTAssertEqual(head, pendingMutationEvent) - queryAfterUpdatingVersionExpectation.fulfill() - } - } - wait(for: [queryAfterUpdatingVersionExpectation], timeout: 1) - } - - /// - Given: A pending mutation events queue with event containing version 1, a sent mutation event model - /// that matches the received mutation sync model having version 1. The received mutation sync - /// has version 2. - /// - When: The sent model matches the received model and the first pending mutation event version is 1. - /// - Then: The first pending mutation event version should be updated to received mutation sync version i.e. 2. - func testPendingVersionReconciledSuccess() throws { - throw XCTSkip("TODO: fix this test") - let modelId = UUID().uuidString - let post1 = Post(id: modelId, title: "title1", content: "content1", createdAt: .now()) - let post2 = Post(id: modelId, title: "title2", content: "content2", createdAt: .now()) - let requestMutationEvent = try createMutationEvent(model: post1, - mutationType: .update, - createdAt: .now(), - version: 1, - inProcess: true) - let pendingMutationEvent = try createMutationEvent(model: post2, - mutationType: .update, - createdAt: .now().add(value: 1, to: .second), - version: 1) - let responseMutationSync = createMutationSync(model: post1, version: 2) - - setUpPendingMutationQueue(post1, [requestMutationEvent, pendingMutationEvent], pendingMutationEvent) - - let reconciledEvent = MutationEvent.reconcile(pendingMutationEvent: pendingMutationEvent, - with: requestMutationEvent, - responseMutationSync: responseMutationSync) - XCTAssertNotNil(reconciledEvent) - XCTAssertEqual(reconciledEvent?.version, responseMutationSync.syncMetadata.version) - - let queryAfterUpdatingVersionExpectation = expectation(description: "update mutation should have version 2") - let updatingVersionExpectation = expectation(description: "update latest mutation event with response version") - - MutationEvent.reconcilePendingMutationEventsVersion(sent: requestMutationEvent, - received: responseMutationSync, - storageAdapter: storageAdapter) { result in - switch result { - case .failure(let error): - XCTFail("Error : \(error)") - case .success: - updatingVersionExpectation.fulfill() - } - } - wait(for: [updatingVersionExpectation], timeout: 1) - - // query for head of mutation event table for given model id and check if it has the correct version - MutationEvent.pendingMutationEvents( - forModel: post1, - storageAdapter: storageAdapter - ) { result in - switch result { - case .failure(let error): - XCTFail("Error : \(error)") - case .success(let mutationEvents): - guard !mutationEvents.isEmpty, let head = mutationEvents.first else { - XCTFail("Failure while updating version") - return - } - XCTAssertEqual(head.version, responseMutationSync.syncMetadata.version) - XCTAssertEqual(head.mutationType, MutationEvent.MutationType.update.rawValue) - queryAfterUpdatingVersionExpectation.fulfill() - } - } - wait(for: [queryAfterUpdatingVersionExpectation], timeout: 1) - } - - private func createMutationEvent(model: Model, - mutationType: MutationEvent.MutationType, - createdAt: Temporal.DateTime, - version: Int? = nil, - inProcess: Bool = false) throws -> MutationEvent { - return MutationEvent(id: UUID().uuidString, - modelId: model.identifier(schema: MutationEvent.schema).stringValue, - modelName: model.modelName, - json: try model.toJSON(), - mutationType: mutationType, - createdAt: createdAt, - version: version, - inProcess: inProcess) - } - - private func createMutationSync(model: Model, version: Int = 1) -> MutationSync { - let metadata = MutationSyncMetadata(modelId: model.identifier(schema: MutationEvent.schema).stringValue, - modelName: model.modelName, - deleted: false, - lastChangedAt: Int(Date().timeIntervalSince1970), - version: version) - return MutationSync(model: AnyModel(model), syncMetadata: metadata) - } - - private func setUpPendingMutationQueue(_ model: Model, - _ mutationEvents: [MutationEvent], - _ expectedHeadOfQueue: MutationEvent) { - for mutationEvent in mutationEvents { - let mutationEventSaveExpectation = expectation(description: "save mutation event success") - storageAdapter.save(mutationEvent) { result in - guard case .success = result else { - XCTFail("Failed to save metadata") - return - } - mutationEventSaveExpectation.fulfill() - } - wait(for: [mutationEventSaveExpectation], timeout: 1) - } - - // verify the head of queue is expected - let headOfQueueExpectation = expectation(description: "head of mutation event queue is as expected") - MutationEvent.pendingMutationEvents( - forModel: model, - storageAdapter: storageAdapter - ) { result in - switch result { - case .failure(let error): - XCTFail("Error : \(error)") - case .success(let events): - guard !events.isEmpty, let head = events.first else { - XCTFail("Failure while fetching mutation events") - return - } - XCTAssertEqual(head, expectedHeadOfQueue) - headOfQueueExpectation.fulfill() - } - } - wait(for: [headOfQueueExpectation], timeout: 1) - } -} // swiftlint:disable:this file_length diff --git a/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj b/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj index e07e2cfb95..b9b8078505 100644 --- a/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj +++ b/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -241,8 +241,6 @@ 9728F2B02683D98D00A506A8 /* DataStoreConsecutiveUpdatesTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9728F2AF2683D98D00A506A8 /* DataStoreConsecutiveUpdatesTests.swift */; }; 973AF1AF26E016EC00BED353 /* ModelCompareTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 973AF1AE26E016EC00BED353 /* ModelCompareTests.swift */; }; 97406B382666DC0200C41E19 /* DataStoreCustomPrimaryKeyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97406B372666DC0200C41E19 /* DataStoreCustomPrimaryKeyTests.swift */; }; - 97DB735426B49ED6004708B8 /* MutationEvent+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97DB735326B49ED6004708B8 /* MutationEvent+Extensions.swift */; }; - 97DB735B26B4A229004708B8 /* MutationEventExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97DB735A26B4A229004708B8 /* MutationEventExtensionsTests.swift */; }; 97ED948A26DEC90A0025FA43 /* Model+Compare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97ED948926DEC90A0025FA43 /* Model+Compare.swift */; }; 97F793BC27AC934D000153D6 /* SQLStatement+CreateIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97F793BB27AC934D000153D6 /* SQLStatement+CreateIndex.swift */; }; 97F793DC27BC7882000153D6 /* StorageEngineTestsSQLiteIndex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 97F793DB27BC7882000153D6 /* StorageEngineTestsSQLiteIndex.swift */; }; @@ -698,8 +696,6 @@ 9728F2AF2683D98D00A506A8 /* DataStoreConsecutiveUpdatesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStoreConsecutiveUpdatesTests.swift; sourceTree = ""; }; 973AF1AE26E016EC00BED353 /* ModelCompareTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelCompareTests.swift; sourceTree = ""; }; 97406B372666DC0200C41E19 /* DataStoreCustomPrimaryKeyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataStoreCustomPrimaryKeyTests.swift; sourceTree = ""; }; - 97DB735326B49ED6004708B8 /* MutationEvent+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "MutationEvent+Extensions.swift"; sourceTree = ""; }; - 97DB735A26B4A229004708B8 /* MutationEventExtensionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MutationEventExtensionsTests.swift; sourceTree = ""; }; 97ED948926DEC90A0025FA43 /* Model+Compare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Model+Compare.swift"; sourceTree = ""; }; 97F793BB27AC934D000153D6 /* SQLStatement+CreateIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "SQLStatement+CreateIndex.swift"; sourceTree = ""; }; 97F793DB27BC7882000153D6 /* StorageEngineTestsSQLiteIndex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageEngineTestsSQLiteIndex.swift; sourceTree = ""; }; @@ -1139,7 +1135,6 @@ children = ( 210E218126601C1C00D90ED8 /* MutationEventQueryTests.swift */, 214B6B67264B157500A9311D /* StopwatchTests.swift */, - 97DB735A26B4A229004708B8 /* MutationEventExtensionsTests.swift */, 973AF1AE26E016EC00BED353 /* ModelCompareTests.swift */, ); path = Support; @@ -1605,7 +1600,6 @@ FA8F4D212395B11700861D91 /* MutationEvent+Query.swift */, FA3841E823889D440070AD5B /* StateMachine.swift */, 214B6B64264B0D6700A9311D /* Stopwatch.swift */, - 97DB735326B49ED6004708B8 /* MutationEvent+Extensions.swift */, 212B4685269FB10500A0AEE7 /* SQLiteResultError.swift */, 97ED948926DEC90A0025FA43 /* Model+Compare.swift */, ); @@ -2672,7 +2666,6 @@ FACBA78F23949C75006349C8 /* AWSMutationDatabaseAdapter.swift in Sources */, FA55A54D2391F96E002AFF2D /* AWSMutationDatabaseAdapter+MutationEventSource.swift in Sources */, D88666A425070FC6000F7A14 /* OutboxMutationEvent.swift in Sources */, - 97DB735426B49ED6004708B8 /* MutationEvent+Extensions.swift in Sources */, 2102DD47260D87BC00B80FE2 /* ReconcileAndLocalSaveQueue.swift in Sources */, 2149E5CE2388684F00873955 /* SQLStatement+CreateTable.swift in Sources */, 6B3CC68023F87FA10008ECBC /* RemoteSyncEngine+Retryable.swift in Sources */, @@ -2727,7 +2720,6 @@ 6B7743E9259071F5001469F5 /* MockRemoteSyncEngine.swift in Sources */, 97F793DC27BC7882000153D6 /* StorageEngineTestsSQLiteIndex.swift in Sources */, FAE4146C239AA40600CE94C2 /* MockSQLiteStorageEngineAdapter.swift in Sources */, - 97DB735B26B4A229004708B8 /* MutationEventExtensionsTests.swift in Sources */, B4D9B9E424DF90CD0049484F /* DynamicModel.swift in Sources */, B912D1BA242984D10028F05C /* QueryPaginationInputTests.swift in Sources */, FA8D932F239EA5C4001ED336 /* NoOpInitialSyncOrchestrator.swift in Sources */,