Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
64 changes: 64 additions & 0 deletions Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,18 @@ public protocol POSCatalogSyncRemoteProtocol {
/// - pageNumber: Page number for pagination.
/// - Returns: Paginated list of POS product variations.
func loadProductVariations(siteID: Int64, pageNumber: Int) async throws -> PagedItems<POSProductVariation>

/// Gets the total count of products for the specified site.
///
/// - Parameter siteID: Site ID to get product count for.
/// - Returns: Total number of products.
func getProductCount(siteID: Int64) async throws -> Int

/// Gets the total count of product variations for the specified site.
///
/// - Parameter siteID: Site ID to get variation count for.
/// - Returns: Total number of variations.
func getProductVariationCount(siteID: Int64) async throws -> Int
}

/// POS Catalog Sync: Remote Endpoints
Expand Down Expand Up @@ -174,6 +186,58 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol {

return createPagedItems(items: variations, responseHeaders: responseHeaders, currentPageNumber: pageNumber)
}

// MARK: - Count Endpoints

/// Gets the total count of products for the specified site.
///
/// - Parameter siteID: Site ID to get product count for.
/// - Returns: Total number of products.
public func getProductCount(siteID: Int64) async throws -> Int {
let path = Path.products
let parameters = [
ParameterKey.page: String(1),
ParameterKey.perPage: String(1),
ParameterKey.fields: POSProductVariation.requestFields.first ?? ""
]

let request = JetpackRequest(
wooApiVersion: .mark3,
method: .get,
siteID: siteID,
path: path,
parameters: parameters,
availableAsRESTRequest: true
)
let responseHeaders = try await enqueueWithResponseHeaders(request)

return totalItemsCount(from: responseHeaders) ?? 0
}

/// Gets the total count of product variations for the specified site.
///
/// - Parameter siteID: Site ID to get variation count for.
/// - Returns: Total number of variations.
public func getProductVariationCount(siteID: Int64) async throws -> Int {
let path = Path.variations
let parameters = [
ParameterKey.page: String(1),
ParameterKey.perPage: String(1),
ParameterKey.fields: POSProductVariation.requestFields.first ?? ""
]

let request = JetpackRequest(
wooApiVersion: .wcAnalytics,
method: .get,
siteID: siteID,
path: path,
parameters: parameters,
availableAsRESTRequest: true
)
let responseHeaders = try await enqueueWithResponseHeaders(request)

return totalItemsCount(from: responseHeaders) ?? 0
}
}

// MARK: - Constants
Expand Down
27 changes: 24 additions & 3 deletions Modules/Sources/NetworkingCore/Remote/Remote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,23 @@ open class Remote: NSObject {
throw mapNetworkError(error: error, for: request)
}
}

/// Enqueues the specified Network Request using Swift Concurrency, for fetching the headers
///
/// - Important:
/// - No data will be parsed. This is intended for use with `HEAD` requests, but will make whatever request you specify
///
/// - Parameter request: Request that should be performed.
/// - Returns: The headers from the response
public func enqueueWithResponseHeaders(_ request: Request) async throws -> [String: String] {
do {
let (_, headers) = try await network.responseDataAndHeaders(for: request)
return headers ?? [:]
} catch {
handleResponseError(error: error, for: request)
throw mapNetworkError(error: error, for: request)
}
}
}

private extension Remote {
Expand Down Expand Up @@ -382,12 +399,16 @@ public extension Remote {

let hasMorePages = totalPages.map { currentPageNumber < $0 } ?? true

let totalItems = totalItemsCount(from: responseHeaders)

return PagedItems(items: items, hasMorePages: hasMorePages, totalItems: totalItems)
}

func totalItemsCount(from responseHeaders: [String: String]?) -> Int? {
// Extract total count from response headers (case insensitive)
let totalItems = responseHeaders?.first(where: {
responseHeaders?.first(where: {
$0.key.lowercased() == PaginationHeaderKey.totalCount.lowercased()
}).flatMap { Int($0.value) }

return PagedItems(items: items, hasMorePages: hasMorePages, totalItems: totalItems)
}
}

Expand Down
56 changes: 56 additions & 0 deletions Modules/Sources/Yosemite/Tools/POS/POSCatalogSizeChecker.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import Foundation
import Networking

/// Protocol for checking the size of a remote POS catalog
public protocol POSCatalogSizeCheckerProtocol {
/// Checks the size of the remote catalog for the specified site
/// - Parameter siteID: The site ID to check catalog size for
/// - Returns: The size information of the catalog
/// - Throws: Network or parsing errors
func checkCatalogSize(for siteID: Int64) async throws -> POSCatalogSize
}

/// Implementation of catalog size checker that uses the sync remote to get counts
public struct POSCatalogSizeChecker: POSCatalogSizeCheckerProtocol {
Comment on lines +5 to +14
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the catalog size checker seems like an internal tool within Yosemite, do you foresee this being used in the app layer?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. For example, we'll want to check the size when we decide whether to use the existing approach or the local catalog, which needs to be decided in the app layer.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that there are other sync/async conditions for the local catalog feature as in p1757587795454369/1757587788.433699-slack-C070SJRA8DP, I thought we might have another high-level checker for all the conditions (and implement caching if we decide to). So that this class is for internal use in Yosemite. WDYT?

private let syncRemote: POSCatalogSyncRemoteProtocol

public init(syncRemote: POSCatalogSyncRemoteProtocol) {
self.syncRemote = syncRemote
}

public func checkCatalogSize(for siteID: Int64) async throws -> POSCatalogSize {
// Make concurrent requests to get both counts
async let productCount = syncRemote.getProductCount(siteID: siteID)
async let variationCount = syncRemote.getProductVariationCount(siteID: siteID)

do {
return try await POSCatalogSize(
productCount: productCount,
variationCount: variationCount
)
} catch {
DDLogError(
"⚠️ Failed to check POS catalog size for site \(siteID): \(error)"
)
throw error
}
}
}

public struct POSCatalogSize: Equatable {
/// Number of products in the catalog
public let productCount: Int

/// Number of product variations in the catalog
public let variationCount: Int

/// Total number of items (products + variations)
public var totalCount: Int {
productCount + variationCount
}

public init(productCount: Int, variationCount: Int) {
self.productCount = productCount
self.variationCount = variationCount
}
}
68 changes: 65 additions & 3 deletions Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
private let incrementalSyncService: POSCatalogIncrementalSyncServiceProtocol
private let grdbManager: GRDBManagerProtocol
private let maxIncrementalSyncAge: TimeInterval
private let catalogSizeLimit: Int
private let catalogSizeChecker: POSCatalogSizeCheckerProtocol

/// Tracks ongoing full syncs by site ID to prevent duplicates
private var ongoingSyncs: Set<Int64> = []
Expand All @@ -43,11 +45,15 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
public init(fullSyncService: POSCatalogFullSyncServiceProtocol,
incrementalSyncService: POSCatalogIncrementalSyncServiceProtocol,
grdbManager: GRDBManagerProtocol,
maxIncrementalSyncAge: TimeInterval = 300) {
maxIncrementalSyncAge: TimeInterval = 300,
catalogSizeLimit: Int? = nil,
catalogSizeChecker: POSCatalogSizeCheckerProtocol) {
self.fullSyncService = fullSyncService
self.incrementalSyncService = incrementalSyncService
self.grdbManager = grdbManager
self.maxIncrementalSyncAge = maxIncrementalSyncAge
self.catalogSizeLimit = catalogSizeLimit ?? Constants.defaultSizeLimitForPOSCatalog
self.catalogSizeChecker = catalogSizeChecker
}

public func performFullSync(for siteID: Int64) async throws {
Expand All @@ -71,7 +77,20 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
DDLogInfo("✅ POSCatalogSyncCoordinator completed full sync for site \(siteID)")
}

/// Determines if a full sync should be performed based on the age of the last sync
/// - Parameters:
/// - siteID: The site ID to check
/// - maxAge: Maximum age before a sync is considered stale
/// - Returns: True if a sync should be performed
public func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval) async -> Bool {
return await shouldPerformFullSync(for: siteID, maxAge: maxAge, maxCatalogSize: catalogSizeLimit)
}

private func shouldPerformFullSync(for siteID: Int64, maxAge: TimeInterval, maxCatalogSize: Int) async -> Bool {
guard await isCatalogSizeWithinLimit(for: siteID, maxCatalogSize: maxCatalogSize) else {
return false
}

if !siteExistsInDatabase(siteID: siteID) {
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) not found in database, sync needed")
return true
Expand All @@ -86,20 +105,35 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
let shouldSync = age > maxAge

if shouldSync {
DDLogInfo("📋 POSCatalogSyncCoordinator: Last sync for site \(siteID) was \(Int(age))s ago (max: \(Int(maxAge))s), sync needed")
DDLogInfo("📋 POSCatalogSyncCoordinator: Last sync for site \(siteID) was \(Int(age))s ago " +
"(max: \(Int(maxAge))s), sync needed")
} else {
DDLogInfo("📋 POSCatalogSyncCoordinator: Last sync for site \(siteID) was \(Int(age))s ago (max: \(Int(maxAge))s), sync not needed")
DDLogInfo("📋 POSCatalogSyncCoordinator: Last sync for site \(siteID) was \(Int(age))s ago " +
"(max: \(Int(maxAge))s), sync not needed")
}

return shouldSync
}

/// Performs an incremental sync if applicable based on sync conditions
/// - Parameters:
/// - siteID: The site ID to sync catalog for
/// - forceSync: Whether to bypass age checks and always sync
/// - Throws: POSCatalogSyncError.syncAlreadyInProgress if a sync is already running for this site
public func performIncrementalSyncIfApplicable(for siteID: Int64, forceSync: Bool) async throws {
try await performIncrementalSyncIfApplicable(for: siteID, forceSync: forceSync, maxCatalogSize: catalogSizeLimit)
}

private func performIncrementalSyncIfApplicable(for siteID: Int64, forceSync: Bool, maxCatalogSize: Int) async throws {
if ongoingIncrementalSyncs.contains(siteID) {
DDLogInfo("⚠️ POSCatalogSyncCoordinator: Incremental sync already in progress for site \(siteID)")
throw POSCatalogSyncError.syncAlreadyInProgress(siteID: siteID)
}

guard await isCatalogSizeWithinLimit(for: siteID, maxCatalogSize: maxCatalogSize) else {
return
}

guard let lastFullSyncDate = await lastFullSyncDate(for: siteID) else {
DDLogInfo("📋 POSCatalogSyncCoordinator: No full sync performed yet for site \(siteID), skipping incremental sync")
return
Expand Down Expand Up @@ -130,6 +164,28 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {

// MARK: - Private

/// Checks if the catalog size is within the specified sync limit
/// - Parameters:
/// - siteID: The site ID to check
/// - maxCatalogSize: Maximum allowed catalog size for syncing
/// - Returns: True if catalog size is within limit or if size cannot be determined
private func isCatalogSizeWithinLimit(for siteID: Int64, maxCatalogSize: Int) async -> Bool {
guard let catalogSize = try? await catalogSizeChecker.checkCatalogSize(for: siteID) else {
DDLogError("📋 POSCatalogSyncCoordinator: Could not get catalog size for site \(siteID)")
return false
}

guard catalogSize.totalCount <= maxCatalogSize else {
DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) has catalog size \(catalogSize.totalCount), " +
"greater than \(maxCatalogSize), should not sync.")
return false
}

DDLogInfo("📋 POSCatalogSyncCoordinator: Site \(siteID) has catalog size \(catalogSize.totalCount), with " +
"\(catalogSize.productCount) products and \(catalogSize.variationCount) variations")
return true
}

private func lastFullSyncDate(for siteID: Int64) async -> Date? {
do {
return try await grdbManager.databaseConnection.read { db in
Expand Down Expand Up @@ -164,3 +220,9 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
}
}
}

private extension POSCatalogSyncCoordinator {
enum Constants {
static let defaultSizeLimitForPOSCatalog = 1000
}
}
Loading