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
36 changes: 32 additions & 4 deletions Modules/Sources/Networking/Remote/POSCatalogSyncRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,14 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol {
ParameterKey.fields: POSProduct.requestFields.joined(separator: ",")
]

let request = JetpackRequest(wooApiVersion: .mark3, method: .get, siteID: siteID, path: path, parameters: parameters)
let request = JetpackRequest(
wooApiVersion: .mark3,
method: .get,
siteID: siteID,
path: path,
parameters: parameters,
availableAsRESTRequest: true
Copy link
Contributor Author

@jaclync jaclync Sep 8, 2025

Choose a reason for hiding this comment

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

Adding availableAsRESTRequest: true as an oversight from the previous remote implementation.

)
let mapper = ListMapper<POSProduct>(siteID: siteID)
let (products, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper)

Expand All @@ -92,7 +99,14 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol {
ParameterKey.fields: POSProductVariation.requestFields.joined(separator: ",")
]

let request = JetpackRequest(wooApiVersion: .wcAnalytics, method: .get, siteID: siteID, path: path, parameters: parameters)
let request = JetpackRequest(
wooApiVersion: .wcAnalytics,
method: .get,
siteID: siteID,
path: path,
parameters: parameters,
availableAsRESTRequest: true
)
let mapper = ListMapper<POSProductVariation>(siteID: siteID)
let (variations, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper)

Expand All @@ -117,7 +131,14 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol {
ParameterKey.fields: POSProduct.requestFields.joined(separator: ",")
]

let request = JetpackRequest(wooApiVersion: .mark3, method: .get, siteID: siteID, path: path, parameters: parameters)
let request = JetpackRequest(
wooApiVersion: .mark3,
method: .get,
siteID: siteID,
path: path,
parameters: parameters,
availableAsRESTRequest: true
)
let mapper = ListMapper<POSProduct>(siteID: siteID)
let (products, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper)

Expand All @@ -140,7 +161,14 @@ public class POSCatalogSyncRemote: Remote, POSCatalogSyncRemoteProtocol {
ParameterKey.fields: POSProductVariation.requestFields.joined(separator: ",")
]

let request = JetpackRequest(wooApiVersion: .wcAnalytics, method: .get, siteID: siteID, path: path, parameters: parameters)
let request = JetpackRequest(
wooApiVersion: .wcAnalytics,
method: .get,
siteID: siteID,
path: path,
parameters: parameters,
availableAsRESTRequest: true
)
let mapper = ListMapper<POSProductVariation>(siteID: siteID)
let (variations, responseHeaders) = try await enqueueWithResponseHeaders(request, mapper: mapper)

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

/// Generic utility for loading paginated data with batch processing.
final class BatchedRequestLoader {
private let batchSize: Int

init(batchSize: Int) {
self.batchSize = batchSize
}

/// Loads all items using a paginated request function.
/// - Parameters:
/// - makeRequest: Function that takes a page number and returns PagedItems<T>.
/// - Returns: Array of all loaded items.
func loadAll<T>(makeRequest: @escaping (Int) async throws -> PagedItems<T>) async throws -> [T] {
var allItems: [T] = []
var currentPage = 1
var hasMorePages = true

while hasMorePages {
let pagesToFetch = Array(currentPage..<(currentPage + batchSize))

let batchResults = try await withThrowingTaskGroup(of: PageResult<T>.self) { group in
for pageNumber in pagesToFetch {
group.addTask {
let result = try await makeRequest(pageNumber)
return PageResult(pageNumber: pageNumber, items: result)
}
}

var results: [PageResult<T>] = []
for try await result in group {
results.append(result)
}
return results.sorted(by: { $0.pageNumber < $1.pageNumber })
}

// Processes results in order and checks if there are more pages.
let newItems = batchResults.flatMap { $0.items.items }
allItems.append(contentsOf: newItems)

let highestPageResult = batchResults.last?.items
hasMorePages = (highestPageResult?.hasMorePages ?? false) && !newItems.isEmpty
currentPage += batchSize
}

return allItems
}
}

private struct PageResult<T> {
let pageNumber: Int
let items: PagedItems<T>
}
98 changes: 12 additions & 86 deletions Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol
private let syncRemote: POSCatalogSyncRemoteProtocol
private let batchSize: Int
private let persistenceService: POSCatalogPersistenceServiceProtocol
private let batchedLoader: BatchedRequestLoader

public convenience init?(credentials: Credentials?, batchSize: Int = 2, grdbManager: GRDBManagerProtocol) {
guard let credentials else {
Expand All @@ -44,6 +45,7 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol
self.syncRemote = syncRemote
self.batchSize = batchSize
self.persistenceService = persistenceService
self.batchedLoader = BatchedRequestLoader(batchSize: batchSize)
}

// MARK: - Protocol Conformance
Expand Down Expand Up @@ -73,95 +75,19 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol
private extension POSCatalogFullSyncService {
func loadCatalog(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> POSCatalog {
// Loads products and variations in batches in parallel.
async let productsTask = loadAllProducts(for: siteID, syncRemote: syncRemote)
async let variationsTask = loadAllProductVariations(for: siteID, syncRemote: syncRemote)

let (products, variations) = try await (productsTask, variationsTask)
return POSCatalog(products: products, variations: variations)
}

func loadAllProducts(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> [POSProduct] {
DDLogInfo("🔄 Starting products sync for site ID: \(siteID)")

var allProducts: [POSProduct] = []
var currentPage = 1
var hasMorePages = true

while hasMorePages {
let pagesToFetch = Array(currentPage..<(currentPage + batchSize))

let batchResults = try await withThrowingTaskGroup(of: PageResult<POSProduct>.self) { group in
for pageNumber in pagesToFetch {
group.addTask {
let result = try await syncRemote.loadProducts(siteID: siteID, pageNumber: pageNumber)
return PageResult(pageNumber: pageNumber, items: result)
}
}

var results: [PageResult<POSProduct>] = []
for try await result in group {
results.append(result)
}
return results.sorted(by: { $0.pageNumber < $1.pageNumber })
async let productsTask = batchedLoader.loadAll(
makeRequest: { pageNumber in
try await syncRemote.loadProducts(siteID: siteID, pageNumber: pageNumber)
}

// Processes results in order and checks if there are more pages.
let newProducts = batchResults.flatMap { $0.items.items }
allProducts.append(contentsOf: newProducts)

let highestPageResult = batchResults.last?.items
hasMorePages = (highestPageResult?.hasMorePages ?? false) && !newProducts.isEmpty
currentPage += batchSize

DDLogInfo("📥 Loaded batch: \(batchResults.count) pages, total products: \(allProducts.count), hasMorePages: \(hasMorePages)")
}

DDLogInfo("✅ Products sync complete: \(allProducts.count) products loaded")
return allProducts
}

func loadAllProductVariations(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> [POSProductVariation] {
DDLogInfo("🔄 Starting variations sync for site ID: \(siteID)")

var allVariations: [POSProductVariation] = []
var currentPage = 1
var hasMorePages = true

while hasMorePages {
let pagesToFetch = Array(currentPage..<(currentPage + batchSize))

let batchResults = try await withThrowingTaskGroup(of: PageResult<POSProductVariation>.self) { group in
for pageNumber in pagesToFetch {
group.addTask {
let result = try await syncRemote.loadProductVariations(siteID: siteID, pageNumber: pageNumber)
return PageResult(pageNumber: pageNumber, items: result)
}
}

var results: [PageResult<POSProductVariation>] = []
for try await result in group {
results.append(result)
}
return results.sorted(by: { $0.pageNumber < $1.pageNumber })
)
async let variationsTask = batchedLoader.loadAll(
makeRequest: { pageNumber in
try await syncRemote.loadProductVariations(siteID: siteID, pageNumber: pageNumber)
}
)

// Processes results in order and checks if there are more pages.
let newVariations = batchResults.flatMap { $0.items.items }
allVariations.append(contentsOf: newVariations)

let highestPageResult = batchResults.last?.items
hasMorePages = (highestPageResult?.hasMorePages ?? false) && !newVariations.isEmpty
currentPage += batchSize

DDLogInfo("📥 Loaded batch: \(batchResults.count) pages, total variations: \(allVariations.count), hasMorePages: \(hasMorePages)")
}

DDLogInfo("✅ Variations sync complete: \(allVariations.count) variations loaded")
return allVariations
let (products, variations) = try await (productsTask, variationsTask)
return POSCatalog(products: products, variations: variations)
}
}

private struct PageResult<T> {
let pageNumber: Int
let items: PagedItems<T>
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import Foundation
import protocol Networking.POSCatalogSyncRemoteProtocol
import class Networking.AlamofireNetwork
import class Networking.POSCatalogSyncRemote
import CocoaLumberjackSwift
import protocol Storage.GRDBManagerProtocol

// TODO - remove the periphery ignore comment when the service is integrated with POS.
// periphery:ignore
public protocol POSCatalogIncrementalSyncServiceProtocol {
/// Starts an incremental catalog sync process.
/// - Parameters:
/// - siteID: The site ID to sync catalog for.
/// - lastFullSyncDate: The date of the last full sync to use if no incremental sync date exists.
func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date) async throws
}

// TODO - remove the periphery ignore comment when the service is integrated with POS.
// periphery:ignore
public final class POSCatalogIncrementalSyncService: POSCatalogIncrementalSyncServiceProtocol {
private let syncRemote: POSCatalogSyncRemoteProtocol
private let batchSize: Int
private var lastIncrementalSyncDates: [Int64: Date] = [:]
private let batchedLoader: BatchedRequestLoader

public convenience init?(credentials: Credentials?, batchSize: Int = 1) {
guard let credentials else {
DDLogError("⛔️ Could not create POSCatalogIncrementalSyncService due missing credentials")
return nil
}
let network = AlamofireNetwork(credentials: credentials, ensuresSessionManagerIsInitialized: true)
let syncRemote = POSCatalogSyncRemote(network: network)
self.init(syncRemote: syncRemote, batchSize: batchSize)
}

init(syncRemote: POSCatalogSyncRemoteProtocol, batchSize: Int) {
self.syncRemote = syncRemote
self.batchSize = batchSize
self.batchedLoader = BatchedRequestLoader(batchSize: batchSize)
}

// MARK: - Protocol Conformance

public func startIncrementalSync(for siteID: Int64, lastFullSyncDate: Date) async throws {
let modifiedAfter = lastIncrementalSyncDates[siteID] ?? lastFullSyncDate

DDLogInfo("🔄 Starting incremental catalog sync for site ID: \(siteID), modifiedAfter: \(modifiedAfter)")

do {
let syncStartDate = Date()
let catalog = try await loadCatalog(for: siteID, modifiedAfter: modifiedAfter, syncRemote: syncRemote)
DDLogInfo("✅ Loaded \(catalog.products.count) products and \(catalog.variations.count) variations for siteID \(siteID)")

// TODO: WOOMOB-1298 - persist to database

// TODO: WOOMOB-1289 - replace with store settings persistence
lastIncrementalSyncDates[siteID] = syncStartDate
DDLogInfo("✅ Updated last incremental sync date to \(syncStartDate) for siteID \(siteID)")
} catch {
DDLogError("❌ Failed to sync and persist catalog incrementally: \(error)")
throw error
}
}
}

// MARK: - Remote Loading

private extension POSCatalogIncrementalSyncService {
func loadCatalog(for siteID: Int64, modifiedAfter: Date, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> POSCatalog {
async let productsTask = batchedLoader.loadAll(
makeRequest: { pageNumber in
try await syncRemote.loadProducts(modifiedAfter: modifiedAfter, siteID: siteID, pageNumber: pageNumber)
}
)
async let variationsTask = batchedLoader.loadAll(
makeRequest: { pageNumber in
try await syncRemote.loadProductVariations(modifiedAfter: modifiedAfter, siteID: siteID, pageNumber: pageNumber)
}
)

let (products, variations) = try await (productsTask, variationsTask)
return POSCatalog(products: products, variations: variations)
}
}
Loading