diff --git a/Amplify.xcodeproj/project.pbxproj b/Amplify.xcodeproj/project.pbxproj index 631f032032..fbedf694ee 100644 --- a/Amplify.xcodeproj/project.pbxproj +++ b/Amplify.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 51; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -293,6 +293,7 @@ 5C763DAE26F2D00F006650E7 /* Geo+ResultsHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C763DAD26F2D00F006650E7 /* Geo+ResultsHandler.swift */; }; 5CB5DD27271707780078CCA2 /* Geo+SearchOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CB5DD26271707780078CCA2 /* Geo+SearchOptions.swift */; }; 5CF43D092728C64100F636E1 /* Geo+Error.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5CF43D082728C64100F636E1 /* Geo+Error.swift */; }; + 609A3CAC2B290344006830C7 /* TimeZone+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 609A3CAB2B290344006830C7 /* TimeZone+Extension.swift */; }; 6B33896823AAACC900561E5B /* ReachabilityUpdate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B33896723AAACC900561E5B /* ReachabilityUpdate.swift */; }; 6B452B8225A7D0F600A1A811 /* Array+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B452B8125A7D0F600A1A811 /* Array+Extensions.swift */; }; 6B5087BD2565E5AD000AB673 /* QueryPredicateEvaluateGeneratedDoubleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B5087BC2565E5AD000AB673 /* QueryPredicateEvaluateGeneratedDoubleTests.swift */; }; @@ -1262,6 +1263,7 @@ 5C763DAD26F2D00F006650E7 /* Geo+ResultsHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Geo+ResultsHandler.swift"; sourceTree = ""; }; 5CB5DD26271707780078CCA2 /* Geo+SearchOptions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Geo+SearchOptions.swift"; sourceTree = ""; }; 5CF43D082728C64100F636E1 /* Geo+Error.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Geo+Error.swift"; sourceTree = ""; }; + 609A3CAB2B290344006830C7 /* TimeZone+Extension.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "TimeZone+Extension.swift"; sourceTree = ""; }; 614D1E66BBE236DDD4F8E2E0 /* Pods-Amplify-AWSPluginsCore-AWSPluginsTestConfigs-AWSPluginsCoreTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Amplify-AWSPluginsCore-AWSPluginsTestConfigs-AWSPluginsCoreTests.debug.xcconfig"; path = "Target Support Files/Pods-Amplify-AWSPluginsCore-AWSPluginsTestConfigs-AWSPluginsCoreTests/Pods-Amplify-AWSPluginsCore-AWSPluginsTestConfigs-AWSPluginsCoreTests.debug.xcconfig"; sourceTree = ""; }; 6B33896723AAACC900561E5B /* ReachabilityUpdate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReachabilityUpdate.swift; sourceTree = ""; }; 6B452B8125A7D0F600A1A811 /* Array+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Array+Extensions.swift"; sourceTree = ""; }; @@ -2872,6 +2874,7 @@ 902AE04B281304B800CD12CA /* Temporal */ = { isa = PBXGroup; children = ( + 609A3CAB2B290344006830C7 /* TimeZone+Extension.swift */, 902AE0702813052F00CD12CA /* DataStoreError+Temporal.swift */, 9091FF6A2820771B0021D8E1 /* Date.swift */, 9091FF762820771B0021D8E1 /* Date+Operation.swift */, @@ -5606,6 +5609,7 @@ 9091FF8B2820771C0021D8E1 /* TemporalOperation.swift in Sources */, 769CF2242669B1B9007843A0 /* RetryableGraphQLOperation.swift in Sources */, B4251A0124250369007F59EF /* AuthConfirmResetPasswordRequest.swift in Sources */, + 609A3CAC2B290344006830C7 /* TimeZone+Extension.swift in Sources */, FAAFAF2F23904B14002CF932 /* AtomicValue+Bool.swift in Sources */, 211FFEE326CD650500F0DB75 /* DataStoreQuerySnapshot.swift in Sources */, FA249EEB24C5FE66009B3CE8 /* AmplifyAPICategory+GraphQLBehavior+Combine.swift in Sources */, diff --git a/Amplify/Categories/DataStore/Model/Internal/Model+DateFormatting.swift b/Amplify/Categories/DataStore/Model/Internal/Model+DateFormatting.swift index bfe1717840..8540f066a9 100644 --- a/Amplify/Categories/DataStore/Model/Internal/Model+DateFormatting.swift +++ b/Amplify/Categories/DataStore/Model/Internal/Model+DateFormatting.swift @@ -27,7 +27,7 @@ public struct ModelDateFormatting { public static let encodingStrategy: JSONEncoder.DateEncodingStrategy = { let strategy = JSONEncoder.DateEncodingStrategy.custom { date, encoder in var container = encoder.singleValueContainer() - try container.encode(Temporal.DateTime(date).iso8601String) + try container.encode(Temporal.DateTime(date, timeZone: .utc).iso8601String) } return strategy }() diff --git a/Amplify/Categories/DataStore/Model/Temporal/Date.swift b/Amplify/Categories/DataStore/Model/Temporal/Date.swift index ac8cfcdb1d..764572663e 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/Date.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/Date.swift @@ -21,13 +21,16 @@ extension Temporal { // Inherits documentation from `TemporalSpec` public let foundationDate: Foundation.Date + // Inherits documentation from `TemporalSpec` + public let timeZone: TimeZone? = .utc + // Inherits documentation from `TemporalSpec` public static func now() -> Self { - Temporal.Date(Foundation.Date()) + Temporal.Date(Foundation.Date(), timeZone: .utc) } // Inherits documentation from `TemporalSpec` - public init(_ date: Foundation.Date) { + public init(_ date: Foundation.Date, timeZone: TimeZone?) { self.foundationDate = Temporal .iso8601Calendar .startOfDay(for: date) diff --git a/Amplify/Categories/DataStore/Model/Temporal/DateTime.swift b/Amplify/Categories/DataStore/Model/Temporal/DateTime.swift index 170e7598fd..b297988357 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/DateTime.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/DateTime.swift @@ -18,25 +18,29 @@ extension Temporal { // Inherits documentation from `TemporalSpec` public let foundationDate: Foundation.Date + // Inherits documentation from `TemporalSpec` + public let timeZone: TimeZone? + // Inherits documentation from `TemporalSpec` public static func now() -> Self { - Temporal.DateTime(Foundation.Date()) + Temporal.DateTime(Foundation.Date(), timeZone: .utc) } /// `Temporal.Time` of this `Temporal.DateTime`. public var time: Time { - Time(foundationDate) + Time(foundationDate, timeZone: timeZone) } // Inherits documentation from `TemporalSpec` - public init(_ date: Foundation.Date) { + public init(_ date: Foundation.Date, timeZone: TimeZone?) { let calendar = Temporal.iso8601Calendar let components = calendar.dateComponents( DateTime.iso8601DateComponents, from: date ) - foundationDate = calendar + self.timeZone = timeZone + self.foundationDate = calendar .date(from: components) ?? date } diff --git a/Amplify/Categories/DataStore/Model/Temporal/SpecBasedDateConverting.swift b/Amplify/Categories/DataStore/Model/Temporal/SpecBasedDateConverting.swift index ef43617c29..7d87806a7f 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/SpecBasedDateConverting.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/SpecBasedDateConverting.swift @@ -12,7 +12,7 @@ import Foundation @usableFromInline internal struct SpecBasedDateConverting { @usableFromInline - internal typealias DateConverter = (_ string: String, _ format: TemporalFormat?) throws -> Date + internal typealias DateConverter = (_ string: String, _ format: TemporalFormat?) throws -> (Date, TimeZone) @usableFromInline internal let convert: DateConverter @@ -28,8 +28,9 @@ internal struct SpecBasedDateConverting { internal static func `default`( iso8601String: String, format: TemporalFormat? = nil - ) throws -> Date { + ) throws -> (Date, TimeZone) { let date: Foundation.Date + let tz = TimeZone(iso8601DateString: iso8601String) ?? .utc if let format = format { date = try Temporal.date( from: iso8601String, @@ -41,6 +42,6 @@ internal struct SpecBasedDateConverting { with: TemporalFormat.sortedFormats(for: Spec.self) ) } - return date + return (date, tz) } } diff --git a/Amplify/Categories/DataStore/Model/Temporal/Temporal+Comparable.swift b/Amplify/Categories/DataStore/Model/Temporal/Temporal+Comparable.swift index 25d4674e53..f9f6dffcd4 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/Temporal+Comparable.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/Temporal+Comparable.swift @@ -15,11 +15,13 @@ import Foundation extension TemporalSpec where Self: Comparable { public static func == (lhs: Self, rhs: Self) -> Bool { - return lhs.iso8601String == rhs.iso8601String + return lhs.iso8601FormattedString(format: .full, timeZone: .utc) + == rhs.iso8601FormattedString(format: .full, timeZone: .utc) } public static func < (lhs: Self, rhs: Self) -> Bool { - return lhs.iso8601String < rhs.iso8601String + return lhs.iso8601FormattedString(format: .full, timeZone: .utc) + < rhs.iso8601FormattedString(format: .full, timeZone: .utc) } } diff --git a/Amplify/Categories/DataStore/Model/Temporal/Temporal.swift b/Amplify/Categories/DataStore/Model/Temporal/Temporal.swift index a1be6cdb2e..e92f4f9435 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/Temporal.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/Temporal.swift @@ -27,6 +27,10 @@ public protocol TemporalSpec { /// by a Foundation `Date` instance. var foundationDate: Foundation.Date { get } + /// The timezone field is an optional field used to specify the timezone associated + /// with a particular date. + var timeZone: TimeZone? { get } + /// The ISO-8601 formatted string in the UTC `TimeZone`. /// - SeeAlso: `iso8601FormattedString(TemporalFormat, TimeZone) -> String` var iso8601String: String { get } @@ -57,7 +61,7 @@ public protocol TemporalSpec { /// Constructs a `TemporalSpec` from a `Date` object. /// - Parameter date: The `Date` instance that will be used as the reference of the /// `TemporalSpec` instance. - init(_ date: Foundation.Date) + init(_ date: Foundation.Date, timeZone: TimeZone?) /// A string representation of the underlying date formatted using ISO8601 rules. /// @@ -90,25 +94,25 @@ extension TemporalSpec { /// The ISO8601 representation of the scalar using `.full` as the format and `.utc` as `TimeZone`. /// - SeeAlso: `iso8601FormattedString(format:timeZone:)` public var iso8601String: String { - iso8601FormattedString(format: .full) + iso8601FormattedString(format: .full, timeZone: timeZone ?? .utc) } @inlinable public init(iso8601String: String, format: TemporalFormat) throws { - let date = try SpecBasedDateConverting() + let (date, tz) = try SpecBasedDateConverting() .convert(iso8601String, format) - self.init(date) + self.init(date, timeZone: tz) } @inlinable public init( iso8601String: String ) throws { - let date = try SpecBasedDateConverting() + let (date, tz) = try SpecBasedDateConverting() .convert(iso8601String, nil) - self.init(date) + self.init(date, timeZone: tz) } } diff --git a/Amplify/Categories/DataStore/Model/Temporal/TemporalOperation.swift b/Amplify/Categories/DataStore/Model/Temporal/TemporalOperation.swift index fe7ae56e4f..a413ab566d 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/TemporalOperation.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/TemporalOperation.swift @@ -33,6 +33,6 @@ extension TemporalSpec { """ ) } - return Self.init(date) + return Self.init(date, timeZone: timeZone) } } diff --git a/Amplify/Categories/DataStore/Model/Temporal/Time.swift b/Amplify/Categories/DataStore/Model/Temporal/Time.swift index 9d621ddb6e..619e2fd4c1 100644 --- a/Amplify/Categories/DataStore/Model/Temporal/Time.swift +++ b/Amplify/Categories/DataStore/Model/Temporal/Time.swift @@ -18,13 +18,16 @@ extension Temporal { // Inherits documentation from `TemporalSpec` public let foundationDate: Foundation.Date + // Inherits documentation from `TemporalSpec` + public let timeZone: TimeZone? = .utc + // Inherits documentation from `TemporalSpec` public static func now() -> Self { - Temporal.Time(Foundation.Date()) + Temporal.Time(Foundation.Date(), timeZone: .utc) } // Inherits documentation from `TemporalSpec` - public init(_ date: Foundation.Date) { + public init(_ date: Foundation.Date, timeZone: TimeZone?) { // Sets the date to a fixed instant so time-only operations are safe let calendar = Temporal.iso8601Calendar var components = calendar.dateComponents( diff --git a/Amplify/Categories/DataStore/Model/Temporal/TimeZone+Extension.swift b/Amplify/Categories/DataStore/Model/Temporal/TimeZone+Extension.swift new file mode 100644 index 0000000000..a08b31ec63 --- /dev/null +++ b/Amplify/Categories/DataStore/Model/Temporal/TimeZone+Extension.swift @@ -0,0 +1,148 @@ +// +// Copyright Amazon.com Inc. or its affiliates. +// All Rights Reserved. +// +// SPDX-License-Identifier: Apache-2.0 +// + +import Foundation + +extension TimeZone { + + @usableFromInline + internal init?(iso8601DateString: String) { + switch ISO8601TimeZonePart.from(iso8601DateString: iso8601DateString) { + case .some(.utc): + self.init(abbreviation: "UTC") + case let .some(.hh(hours: hours)): + self.init(secondsFromGMT: hours * 60 * 60) + case let .some(.hhmm(hours: hours, minutes: minutes)), + let .some(.HHMM(hours: hours, minuts: minutes)): + self.init(secondsFromGMT: hours * 60 * 60 + + (hours > 0 ? 1 : -1) * minutes * 60) + case let .some(.HHMMSS(hours: hours, minutes: minutes, seconds: seconds)): + self.init(secondsFromGMT: hours * 60 * 60 + + (hours > 0 ? 1 : -1) * minutes * 60 + + (hours > 0 ? 1 : -1) * seconds) + case .none: + return nil + } + } +} + + +/// ISO8601 Time Zone formats +/// - Note: +/// `±hh:mm:ss` is not a standard of ISO8601 date formate. It's supported by `AWSDateTime` exclusively. +/// +/// references: +/// https://en.wikipedia.org/wiki/ISO_8601#Time_zone_designators +/// https://docs.aws.amazon.com/appsync/latest/devguide/scalars.html#graph-ql-aws-appsync-scalars +private enum ISO8601TimeZoneFormat { + case utc, hh, hhmm, HHMM, HHMMSS + + var format: String { + switch self { + case .utc: + return "Z" + case .hh: + return "±hh" + case .hhmm: + return "±hhmm" + case .HHMM: + return "±hh:mm" + case .HHMMSS: + return "±hh:mm:ss" + } + } + + var regex: NSRegularExpression? { + switch self { + case .utc: + return try? NSRegularExpression(pattern: "^Z$") + case .hh: + return try? NSRegularExpression(pattern: "^[+-]\\d{2}$") + case .hhmm: + return try? NSRegularExpression(pattern: "^[+-]\\d{2}\\d{2}$") + case .HHMM: + return try? NSRegularExpression(pattern: "^[+-]\\d{2}:\\d{2}$") + case .HHMMSS: + return try? NSRegularExpression(pattern: "^[+-]\\d{2}:\\d{2}:\\d{2}$") + } + } + + var parts: [NSRange] { + switch self { + case .utc: + return [] + case .hh: + return [NSRange(location: 0, length: 3)] + case .hhmm: + return [ + NSRange(location: 0, length: 3), + NSRange(location: 3, length: 2) + ] + case .HHMM: + return [ + NSRange(location: 0, length: 3), + NSRange(location: 4, length: 2) + ] + case .HHMMSS: + return [ + NSRange(location: 0, length: 3), + NSRange(location: 4, length: 2), + NSRange(location: 7, length: 2) + ] + } + } +} + +private enum ISO8601TimeZonePart { + case utc + case hh(hours: Int) + case hhmm(hours: Int, minutes: Int) + case HHMM(hours: Int, minuts: Int) + case HHMMSS(hours: Int, minutes: Int, seconds: Int) + + static func from(iso8601DateString: String) -> ISO8601TimeZonePart? { + return tryExtract(from: iso8601DateString, with: .utc) + ?? tryExtract(from: iso8601DateString, with: .hh) + ?? tryExtract(from: iso8601DateString, with: .hhmm) + ?? tryExtract(from: iso8601DateString, with: .HHMM) + ?? tryExtract(from: iso8601DateString, with: .HHMMSS) + ?? nil + } +} + +private func tryExtract( + from dateString: String, + with format: ISO8601TimeZoneFormat +) -> ISO8601TimeZonePart? { + guard dateString.count > format.format.count else { + return nil + } + + let tz = String(dateString.dropFirst(dateString.count - format.format.count)) + + guard format.regex.flatMap({ + $0.firstMatch(in: tz, range: NSRange(location: 0, length: tz.count)) + }) != nil else { + return nil + } + + let parts = format.parts.compactMap { range in + Range(range, in: tz).flatMap { Int(tz[$0]) } + } + + guard parts.count == format.parts.count else { + return nil + } + + switch format { + case .utc: return .utc + case .hh: return .hh(hours: parts[0]) + case .hhmm: return .hhmm(hours: parts[0], minutes: parts[1]) + case .HHMM: return .HHMM(hours: parts[0], minuts: parts[1]) + case .HHMMSS: return .HHMMSS(hours: parts[0], minutes: parts[1], seconds: parts[2]) + } +} 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..9d9bcd5d2c 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 @@ -262,7 +265,8 @@ final class OutgoingMutationQueue: OutgoingMutationQueueBehavior { MutationEvent.reconcilePendingMutationEventsVersion( sent: mutationEvent, received: mutationSync, - storageAdapter: storageAdapter) { _ in + storageAdapter: storageAdapter + ) { _ in self.completeProcessingEvent(mutationEvent, mutationSync: mutationSync) } } else { diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPlugin/Sync/MutationSync/OutgoingMutationQueue/SyncMutationToCloudOperation.swift index 8c7a9fb568..6f7001f306 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 @@ -99,6 +102,33 @@ class SyncMutationToCloudOperation: AsynchronousOperation { } } + /// Always retrieve and use the largest version when available. The source of the version comes + /// from either the MutationEvent itself, which represents the queue request, or the persisted version + /// from the metadata table. + /// + /// **Version in the Mutation Event**. If there are mulitple mutation events pending, each outgoing + /// mutation processing will result in synchronously updating the pending mutation's version + /// before enqueuing the mutation response for reconciliation. + /// + /// **Version persisted in the metadata table**: Reconciliation will persist the latest version in the + /// metadata table. In cases of quick consecutive updates, the MutationEvent's version could + /// be greater than the persisted since the MutationEvent is updated from the original thread that + /// processed the outgoing mutation. + private func getLatestVersion(_ mutationEvent: MutationEvent) -> Int? { + let latestSyncedMetadataVersion = getLatestSyncMetadata()?.version + let mutationEventVersion = mutationEvent.version + switch (latestSyncedMetadataVersion, mutationEventVersion) { + case let (.some(syncedVersion), .some(version)): + return max(syncedVersion, version) + case let (.some(syncedVersion), .none): + return syncedVersion + case let (.none, .some(version)): + return version + case (.none, .none): + return nil + } + } + /// Creates a GraphQLRequest based on given `mutationType` /// - Parameters: /// - mutationType: mutation type @@ -109,7 +139,7 @@ class SyncMutationToCloudOperation: AsynchronousOperation { authType: AWSAuthorizationType? = nil ) -> GraphQLRequest>? { var request: GraphQLRequest> - + let version = getLatestVersion(mutationEvent) do { var graphQLFilter: GraphQLFilter? if let graphQLFilterJSON = mutationEvent.graphQLFilterJSON { @@ -128,7 +158,7 @@ class SyncMutationToCloudOperation: AsynchronousOperation { request = GraphQLRequest.deleteMutation(of: model, modelSchema: modelSchema, where: graphQLFilter, - version: mutationEvent.version) + version: version) case .update: let model = try mutationEvent.decodeModel() guard let modelSchema = ModelRegistry.modelSchema(from: mutationEvent.modelName) else { @@ -140,7 +170,7 @@ class SyncMutationToCloudOperation: AsynchronousOperation { request = GraphQLRequest.updateMutation(of: model, modelSchema: modelSchema, where: graphQLFilter, - version: mutationEvent.version) + version: version) case .create: let model = try mutationEvent.decodeModel() guard let modelSchema = ModelRegistry.modelSchema(from: mutationEvent.modelName) else { @@ -151,7 +181,7 @@ class SyncMutationToCloudOperation: AsynchronousOperation { } request = GraphQLRequest.createMutation(of: model, modelSchema: modelSchema, - version: mutationEvent.version) + version: 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/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/ModelCompareTests.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/Support/ModelCompareTests.swift index 72857de9a6..c6a729fb90 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/Support/ModelCompareTests.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/Support/ModelCompareTests.swift @@ -211,8 +211,8 @@ class ModelCompareTests: BaseDataStoreTests { let name = "QPredGenName" let formatter = DateFormatter() formatter.dateFormat = TemporalFormat.short.dateFormat - let dateTime1 = Temporal.DateTime(formatter.date(from: "2021-09-01")!) - let dateTime2 = Temporal.DateTime(formatter.date(from: "2020-09-01")!) + let dateTime1 = Temporal.DateTime(formatter.date(from: "2021-09-01")!, timeZone: .utc) + let dateTime2 = Temporal.DateTime(formatter.date(from: "2020-09-01")!, timeZone: .utc) let qPredGen1 = QPredGen(id: id, name: name, myDateTime: dateTime1) let qPredGen2 = QPredGen(id: id, name: name, myDateTime: dateTime2) XCTAssertFalse(QPredGen.schema.compare(qPredGen1, qPredGen2)) @@ -340,8 +340,8 @@ class ModelCompareTests: BaseDataStoreTests { let artist = "Artist" let formatter = DateFormatter() formatter.dateFormat = TemporalFormat.short.dateFormat - let createdAt1 = Temporal.DateTime(formatter.date(from: "2021-09-01")!) - let createdAt2 = Temporal.DateTime(formatter.date(from: "2020-09-01")!) + let createdAt1 = Temporal.DateTime(formatter.date(from: "2021-09-01")!, timeZone: .utc) + let createdAt2 = Temporal.DateTime(formatter.date(from: "2020-09-01")!, timeZone: .utc) let recordCover1 = RecordCover(id: id, artist: artist, createdAt: createdAt1) let recordCover2 = RecordCover(id: id, artist: artist, createdAt: createdAt2) XCTAssertTrue(RecordCover.schema.compare(recordCover1, recordCover2)) diff --git a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/Support/MutationEventExtensionsTests.swift b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/Support/MutationEventExtensionsTests.swift index 7c676d68cb..82b76780bf 100644 --- a/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/Support/MutationEventExtensionsTests.swift +++ b/AmplifyPlugins/DataStore/AWSDataStoreCategoryPluginTests/Sync/Support/MutationEventExtensionsTests.swift @@ -14,8 +14,6 @@ import XCTest @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 { @@ -24,7 +22,6 @@ class MutationEventExtensionsTest: BaseDataStoreTests { /// - 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, @@ -91,7 +88,6 @@ class MutationEventExtensionsTest: BaseDataStoreTests { /// - 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, @@ -163,7 +159,6 @@ class MutationEventExtensionsTest: BaseDataStoreTests { /// - 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()) @@ -228,7 +223,6 @@ class MutationEventExtensionsTest: BaseDataStoreTests { /// - 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()) @@ -294,7 +288,6 @@ class MutationEventExtensionsTest: BaseDataStoreTests { /// - 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()) diff --git a/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj b/AmplifyPlugins/DataStore/DataStoreCategoryPlugin.xcodeproj/project.pbxproj index e07e2cfb95..1fed9a912a 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 */ @@ -126,6 +126,8 @@ 21FDBBDB2587DB7A0086FCDC /* DataStoreConnectionScenario6Tests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21FDBBDA2587DB7A0086FCDC /* DataStoreConnectionScenario6Tests.swift */; }; 21FE027E25890B2F00B81D72 /* DataStoreListProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 21FE027D25890B2F00B81D72 /* DataStoreListProvider.swift */; }; 3AC75F1100634561C7A480BE /* Pods_HostApp_AWSDataStoreCategoryPluginFlutterIntegrationTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3689EBBD76A66F01A7ABFF16 /* Pods_HostApp_AWSDataStoreCategoryPluginFlutterIntegrationTests.framework */; }; + 6037DBF22B4763A900DB0742 /* MutationEvent+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6037DBF12B4763A900DB0742 /* MutationEvent+Extensions.swift */; }; + 6037DBF42B4763E300DB0742 /* MutationEventExtensionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6037DBF32B4763E300DB0742 /* MutationEventExtensionsTests.swift */; }; 6B01B71D23A4615900AD0E97 /* SyncMutationToCloudOperationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B01B71C23A4615900AD0E97 /* SyncMutationToCloudOperationTests.swift */; }; 6B01B72023A4672500AD0E97 /* RequestRetryable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B01B71E23A4672500AD0E97 /* RequestRetryable.swift */; }; 6B01B72223A4672500AD0E97 /* RequestRetryablePolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6B01B71F23A4672500AD0E97 /* RequestRetryablePolicy.swift */; }; @@ -241,8 +243,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 */; }; @@ -570,6 +570,8 @@ 32AD9436C9FB473423CA9786 /* Pods-AWSDataStoreCategoryPlugin.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-AWSDataStoreCategoryPlugin.release.xcconfig"; path = "Target Support Files/Pods-AWSDataStoreCategoryPlugin/Pods-AWSDataStoreCategoryPlugin.release.xcconfig"; sourceTree = ""; }; 3689EBBD76A66F01A7ABFF16 /* Pods_HostApp_AWSDataStoreCategoryPluginFlutterIntegrationTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_HostApp_AWSDataStoreCategoryPluginFlutterIntegrationTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 5B62423E457D3149264ADA1F /* Pods-HostApp-AWSDataStoreCategoryPluginFlutterIntegrationTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-HostApp-AWSDataStoreCategoryPluginFlutterIntegrationTests.debug.xcconfig"; path = "Target Support Files/Pods-HostApp-AWSDataStoreCategoryPluginFlutterIntegrationTests/Pods-HostApp-AWSDataStoreCategoryPluginFlutterIntegrationTests.debug.xcconfig"; sourceTree = ""; }; + 6037DBF12B4763A900DB0742 /* MutationEvent+Extensions.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "MutationEvent+Extensions.swift"; sourceTree = ""; }; + 6037DBF32B4763E300DB0742 /* MutationEventExtensionsTests.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = MutationEventExtensionsTests.swift; sourceTree = ""; }; 6A1D332BE6CF885805360B3D /* Pods_AWSDataStoreCategoryPlugin.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_AWSDataStoreCategoryPlugin.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6B01B71C23A4615900AD0E97 /* SyncMutationToCloudOperationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SyncMutationToCloudOperationTests.swift; sourceTree = ""; }; 6B01B71E23A4672500AD0E97 /* RequestRetryable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = RequestRetryable.swift; sourceTree = ""; }; @@ -698,8 +700,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 = ""; }; @@ -1137,9 +1137,9 @@ 214B6B66264B156200A9311D /* Support */ = { isa = PBXGroup; children = ( + 6037DBF32B4763E300DB0742 /* MutationEventExtensionsTests.swift */, 210E218126601C1C00D90ED8 /* MutationEventQueryTests.swift */, 214B6B67264B157500A9311D /* StopwatchTests.swift */, - 97DB735A26B4A229004708B8 /* MutationEventExtensionsTests.swift */, 973AF1AE26E016EC00BED353 /* ModelCompareTests.swift */, ); path = Support; @@ -1600,12 +1600,12 @@ FA8F4D1C2395AF5E00861D91 /* Support */ = { isa = PBXGroup; children = ( + 6037DBF12B4763A900DB0742 /* MutationEvent+Extensions.swift */, FAAA588F2396BC5A008A4DB6 /* CancelAwareBlockOperation.swift */, FA8F4D1D2395AF7600861D91 /* DataStoreError+Plugin.swift */, FA8F4D212395B11700861D91 /* MutationEvent+Query.swift */, FA3841E823889D440070AD5B /* StateMachine.swift */, 214B6B64264B0D6700A9311D /* Stopwatch.swift */, - 97DB735326B49ED6004708B8 /* MutationEvent+Extensions.swift */, 212B4685269FB10500A0AEE7 /* SQLiteResultError.swift */, 97ED948926DEC90A0025FA43 /* Model+Compare.swift */, ); @@ -2606,6 +2606,7 @@ 2149E5CD2388684F00873955 /* SQLStatement+Update.swift in Sources */, FAF7CEC9238C6A940095547B /* SyncMutationToCloudOperation.swift in Sources */, 762383A527501EBC00EAF1C7 /* RemoteSyncEngine+AuthModeStrategyDelegate.swift in Sources */, + 6037DBF22B4763A900DB0742 /* MutationEvent+Extensions.swift in Sources */, 21DDCDFE272C861F00D9B297 /* MutationSyncMetadataMigrationDelegate+SQLiteValidation.swift in Sources */, FA8F4D1E2395AF7600861D91 /* DataStoreError+Plugin.swift in Sources */, FA6C3FEE239890B500A73110 /* AWSMutationEventPublisher.swift in Sources */, @@ -2672,7 +2673,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 */, @@ -2717,6 +2717,7 @@ 21DDCE0427303E5300D9B297 /* AWSDataStorePluginBaseBehaviorTests.swift in Sources */, 2149E5FF238869CF00873955 /* SQLStatementTests.swift in Sources */, 6B64027923E3584300001FD7 /* MockAWSIncomingEventReconciliationQueue.swift in Sources */, + 6037DBF42B4763E300DB0742 /* MutationEventExtensionsTests.swift in Sources */, 7638898F26AB56580061AF0B /* StorageEngineSyncRequirementsTests.swift in Sources */, FA0427D02396CDD800D25AB0 /* DataStoreHubTests.swift in Sources */, FA5113AE26A9E808007B1F25 /* OutgoingMutationQueueNetworkTests.swift in Sources */, @@ -2727,7 +2728,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 */, diff --git a/AmplifyPlugins/DataStore/Podfile.lock b/AmplifyPlugins/DataStore/Podfile.lock index a7f876e58b..49bfdb23bc 100644 --- a/AmplifyPlugins/DataStore/Podfile.lock +++ b/AmplifyPlugins/DataStore/Podfile.lock @@ -128,4 +128,4 @@ SPEC CHECKSUMS: PODFILE CHECKSUM: 0bab7193bebdf470839514f327440893b0d26090 -COCOAPODS: 1.11.3 +COCOAPODS: 1.14.3 diff --git a/AmplifyTests/CategoryTests/DataStore/TemporalTests.swift b/AmplifyTests/CategoryTests/DataStore/TemporalTests.swift index 5271453793..9f37d1a152 100644 --- a/AmplifyTests/CategoryTests/DataStore/TemporalTests.swift +++ b/AmplifyTests/CategoryTests/DataStore/TemporalTests.swift @@ -145,7 +145,7 @@ class TemporalTests: XCTestCase { func testFullDateTimeParsingOnPST() { do { let datetime = try Temporal.DateTime(iso8601String: "2020-01-20T08:00:00.180-08:00") - XCTAssertEqual(datetime.iso8601String, "2020-01-20T16:00:00.180Z") + XCTAssertEqual(datetime.iso8601String, "2020-01-20T08:00:00.180-08:00") XCTAssertEqual(datetime.iso8601FormattedString(format: .short, timeZone: pst), "2020-01-20T08:00") XCTAssertEqual(datetime.iso8601FormattedString(format: .short, timeZone: .utc), "2020-01-20T16:00") XCTAssertEqual(datetime.iso8601FormattedString(format: .medium, timeZone: pst), "2020-01-20T08:00:00") @@ -161,6 +161,50 @@ class TemporalTests: XCTestCase { } } + /// - Given: a `DateTime` string in ISO8601 format + /// - When: + /// - the input has time zone info + /// - Then: + /// - DateTime should be parsed correctly with time zone info + /// - Date should be parsed with utc time zone + /// - Time should be parsed with utc time zone + func testConvertToIso8601String() { + do { + let datetime = try Temporal.DateTime(iso8601String: "2023-11-30T11:04:03-08:00") + XCTAssertEqual(datetime.iso8601String, "2023-11-30T11:04:03.000-08:00") + let datetime0 = try Temporal.DateTime(iso8601String: "2023-11-30T11:04:03+08:00") + XCTAssertEqual(datetime0.iso8601String, "2023-11-30T11:04:03.000+08:00") + let datetime1 = try Temporal.DateTime(iso8601String: "2023-11-30T11:04:03.322-0800") + XCTAssertEqual(datetime1.iso8601String, "2023-11-30T11:04:03.322-08:00") + let datetime2 = try Temporal.DateTime(iso8601String: "2023-11-30T14:09:27.128-0830") + XCTAssertEqual(datetime2.iso8601String, "2023-11-30T14:09:27.128-08:30") + let datetime3 = try Temporal.DateTime(iso8601String: "2023-11-30T14:09:27.128-0339") + XCTAssertEqual(datetime3.iso8601String, "2023-11-30T14:09:27.128-03:39") + let datetime4 = try Temporal.DateTime(iso8601String: "2023-11-30T14:09:27.128-0000") + XCTAssertEqual(datetime4.iso8601String, "2023-11-30T14:09:27.128Z") + let datetime5 = try Temporal.DateTime(iso8601String: "2023-11-30T11:04:03+08:00:21") + XCTAssertEqual(datetime5.iso8601String, "2023-11-30T11:03:42.000+08:00") + let datetime6 = try Temporal.DateTime(iso8601String: "2023-11-30T11:04:03-08:00:21") + XCTAssertEqual(datetime6.iso8601String, "2023-11-30T11:04:24.000-08:00") + let datetime7 = try Temporal.DateTime(iso8601String: "2023-11-30T14:09:27.128Z") + XCTAssertEqual(datetime7.iso8601String, "2023-11-30T14:09:27.128Z") + if #available(iOS 15.0, tvOS 15.0, *) { + let now = Date.now + let dateFormatter = DateFormatter() + dateFormatter.timeZone = .init(abbreviation: "HKT") + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSS" + let datetime7 = Temporal.DateTime(now, timeZone: .init(abbreviation: "HKT")) + XCTAssertEqual(datetime7.iso8601String, "\(dateFormatter.string(from: now))+08:00") + } + let date = try Temporal.Date(iso8601String: "2023-11-30-08:00") + XCTAssertEqual(date.iso8601String, "2023-11-30Z") + let time = try Temporal.Time(iso8601String: "11:00:00.000-08:00") + XCTAssertEqual(time.iso8601String, "19:00:00.000Z") + } catch { + XCTFail(error.localizedDescription) + } + } + // MARK: - Date /// - Given: a `Date` string diff --git a/AmplifyTests/CoreTests/Model+CodableTests.swift b/AmplifyTests/CoreTests/Model+CodableTests.swift index b933d2552f..16fff6389e 100644 --- a/AmplifyTests/CoreTests/Model+CodableTests.swift +++ b/AmplifyTests/CoreTests/Model+CodableTests.swift @@ -24,7 +24,7 @@ class ModelCodableTests: XCTestCase { } func testToJSON() { - let createdAt = Temporal.DateTime(Date(timeIntervalSince1970: 1_000_000.123)) + let createdAt = Temporal.DateTime(Date(timeIntervalSince1970: 1_000_000.123), timeZone: .utc) let post = Post(id: "post-1", title: "title", content: "content", @@ -37,7 +37,7 @@ class ModelCodableTests: XCTestCase { XCTAssertEqual(post?.id, "post-1") XCTAssertEqual(post?.title, "title") XCTAssertEqual(post?.content, "content") - XCTAssertEqual(post?.createdAt, Temporal.DateTime(Date(timeIntervalSince1970: 1_000_000.123))) + XCTAssertEqual(post?.createdAt, Temporal.DateTime(Date(timeIntervalSince1970: 1_000_000.123), timeZone: .utc)) } func testDecodeWithoutFractionalSeconds() { @@ -45,6 +45,6 @@ class ModelCodableTests: XCTestCase { XCTAssertEqual(post?.id, "post-1") XCTAssertEqual(post?.title, "title") XCTAssertEqual(post?.content, "content") - XCTAssertEqual(post?.createdAt, Temporal.DateTime(Date(timeIntervalSince1970: 1_000_000))) + XCTAssertEqual(post?.createdAt, Temporal.DateTime(Date(timeIntervalSince1970: 1_000_000), timeZone: .utc)) } }