Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
bc688c5
Add back catalog endpoints with full sync integration.
jaclync Oct 8, 2025
cf11514
Update catalog remote based on the latest API behavior.
jaclync Oct 21, 2025
87489b4
Update catalog remote to call one endpoint.
jaclync Oct 22, 2025
7acf2bb
Add feature flag for catalog API.
jaclync Oct 30, 2025
b5925ac
DI feature flag value to use catalog API. Add support for `force_gene…
jaclync Oct 30, 2025
572ab92
Merge branch 'trunk' into feat/WOOMOB-1609-catalog-api-behind-feature…
jaclync Oct 30, 2025
e12af58
Revert testing code for large catalog.
jaclync Oct 30, 2025
6235afe
Add `regenerateCatalog` parameter to force catalog generation from us…
jaclync Oct 30, 2025
8748b80
Move download URL logging to when the URL is ready for both cases (ca…
jaclync Oct 31, 2025
8694085
Remove periphery comments as the code is used.
jaclync Oct 31, 2025
a6a1c61
Merge branch 'feat/WOOMOB-1609-catalog-api-networking-layer' into fea…
jaclync Oct 31, 2025
d912408
Nit: update feature flag comment and remove unused constant.
jaclync Oct 31, 2025
ed9b1a8
Fix unit tests build failures.
jaclync Oct 31, 2025
527ad51
Filter variations without a parent product in the catalog, as the API…
jaclync Oct 31, 2025
4f183c1
Add test cases for `POSCatalogFullSyncService` changes.
jaclync Oct 31, 2025
1166f87
Add a test case for `regenerateCatalog` parameter being passed to the…
jaclync Oct 31, 2025
77e49ae
Merge branch 'trunk' into feat/WOOMOB-1609-catalog-api-behind-feature…
jaclync Nov 3, 2025
786a89a
Nit: update variation missing parent product comment to make it clear…
jaclync Nov 3, 2025
e1990ed
Only log polling info every 10th attempt to avoid flooding logs for l…
jaclync Nov 3, 2025
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
2 changes: 2 additions & 0 deletions Modules/Sources/Experiments/DefaultFeatureFlagService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
return buildConfig == .localDeveloper || buildConfig == .alpha
case .pointOfSaleSettingsCardReaderFlow:
return buildConfig == .localDeveloper || buildConfig == .alpha
case .pointOfSaleCatalogAPI:
return false
default:
return true
}
Expand Down
4 changes: 4 additions & 0 deletions Modules/Sources/Experiments/FeatureFlag.swift
Original file line number Diff line number Diff line change
Expand Up @@ -211,4 +211,8 @@ public enum FeatureFlag: Int {
/// Enables card reader connection flow within POS settings
///
case pointOfSaleSettingsCardReaderFlow

/// Enables using the catalog API endpoint for Point of Sale catalog full sync
///
case pointOfSaleCatalogAPI
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ final class POSSettingsLocalCatalogViewModel {
defer { isRefreshingCatalog = false }

do {
try await catalogSyncCoordinator.performFullSync(for: siteID)
try await catalogSyncCoordinator.performFullSync(for: siteID, regenerateCatalog: true)
await loadCatalogData()
} catch {
DDLogError("⛔️ POSSettingsLocalCatalog: Failed to refresh catalog: \(error)")
Expand Down
2 changes: 1 addition & 1 deletion Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -615,7 +615,7 @@ final class POSPreviewCatalogSettingsService: POSCatalogSettingsServiceProtocol
}

final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws {
func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, regenerateCatalog: Bool) async throws {
// Simulates a full sync operation with a 1 second delay.
try await Task.sleep(nanoseconds: 1_000_000_000)
}
Expand Down
88 changes: 80 additions & 8 deletions Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,17 @@ import class Networking.POSCatalogSyncRemote
import Storage
import struct Combine.AnyPublisher
import struct NetworkingCore.JetpackSite
import struct Networking.POSCatalogResponse

// TODO - remove the periphery ignore comment when the catalog is integrated with POS.
// periphery:ignore
public protocol POSCatalogFullSyncServiceProtocol {
/// Starts a full catalog sync process
/// - Parameter siteID: The site ID to sync catalog for
/// - Parameters:
/// - siteID: The site ID to sync catalog for
/// - regenerateCatalog: Whether to force the catalog generation
/// - Returns: The synced catalog containing products and variations
func startFullSync(for siteID: Int64) async throws -> POSCatalog
func startFullSync(for siteID: Int64, regenerateCatalog: Bool) async throws -> POSCatalog
}

/// POS catalog from full sync.
Expand All @@ -30,12 +33,14 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol
private let syncRemote: POSCatalogSyncRemoteProtocol
private let persistenceService: POSCatalogPersistenceServiceProtocol
private let batchedLoader: BatchedRequestLoader
private let usesCatalogAPI: Bool

public convenience init?(credentials: Credentials?,
selectedSite: AnyPublisher<JetpackSite?, Never>,
appPasswordSupportState: AnyPublisher<Bool, Never>,
batchSize: Int = 2,
grdbManager: GRDBManagerProtocol) {
grdbManager: GRDBManagerProtocol,
usesCatalogAPI: Bool = false) {
guard let credentials else {
DDLogError("⛔️ Could not create POSCatalogFullSyncService due missing credentials")
return nil
Expand All @@ -45,28 +50,35 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol
appPasswordSupportState: appPasswordSupportState)
let syncRemote = POSCatalogSyncRemote(network: network)
let persistenceService = POSCatalogPersistenceService(grdbManager: grdbManager)
self.init(syncRemote: syncRemote, batchSize: batchSize, persistenceService: persistenceService)
self.init(syncRemote: syncRemote, batchSize: batchSize, persistenceService: persistenceService, usesCatalogAPI: usesCatalogAPI)
}

init(
syncRemote: POSCatalogSyncRemoteProtocol,
batchSize: Int,
retryDelay: TimeInterval = 2.0,
persistenceService: POSCatalogPersistenceServiceProtocol
persistenceService: POSCatalogPersistenceServiceProtocol,
usesCatalogAPI: Bool = false
) {
self.syncRemote = syncRemote
self.persistenceService = persistenceService
self.batchedLoader = BatchedRequestLoader(batchSize: batchSize, retryDelay: retryDelay)
self.usesCatalogAPI = usesCatalogAPI
}

// MARK: - Protocol Conformance

public func startFullSync(for siteID: Int64) async throws -> POSCatalog {
DDLogInfo("🔄 Starting full catalog sync for site ID: \(siteID)")
public func startFullSync(for siteID: Int64, regenerateCatalog: Bool = false) async throws -> POSCatalog {
DDLogInfo("🔄 Starting full catalog sync for site ID: \(siteID) with regenerateCatalog: \(regenerateCatalog)")

do {
// Sync from network
let catalog = try await loadCatalog(for: siteID, syncRemote: syncRemote)
let catalog: POSCatalog
if usesCatalogAPI {
catalog = try await loadCatalogFromCatalogAPI(for: siteID, syncRemote: syncRemote, regenerateCatalog: regenerateCatalog)
} else {
catalog = try await loadCatalog(for: siteID, syncRemote: syncRemote)
}
DDLogInfo("✅ Loaded \(catalog.products.count) products and \(catalog.variations.count) variations for siteID \(siteID)")

// Persist to database
Expand Down Expand Up @@ -102,4 +114,64 @@ private extension POSCatalogFullSyncService {
return POSCatalog(products: products, variations: variations, syncDate: syncStartDate)
}

func loadCatalogFromCatalogAPI(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol, regenerateCatalog: Bool) async throws -> POSCatalog {
let downloadStartTime = CFAbsoluteTimeGetCurrent()
let catalog = try await downloadCatalog(for: siteID, syncRemote: syncRemote, regenerateCatalog: regenerateCatalog)
let downloadTime = CFAbsoluteTimeGetCurrent() - downloadStartTime
DDLogInfo("🟣 Catalog download completed - Time: \(String(format: "%.2f", downloadTime))s")

return .init(products: catalog.products, variations: catalog.variations, syncDate: .init())
}
}

private extension POSCatalogFullSyncService {
func downloadCatalog(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol, regenerateCatalog: Bool) async throws -> POSCatalogResponse {
DDLogInfo("🟣 Starting catalog request...")

// 1. Requests catalog until download URL is available.
let response = try await syncRemote.requestCatalogGeneration(for: siteID, forceGeneration: regenerateCatalog)
let downloadURL: String?
if let url = response.downloadURL {
downloadURL = url
} else {
// 2. Polls for completion until download URL is available.
downloadURL = try await pollForCatalogCompletion(siteID: siteID, syncRemote: syncRemote)
}

// 3. Downloads catalog using the provided URL.
guard let downloadURL else {
throw POSCatalogSyncError.invalidData
}
DDLogInfo("🟣 Catalog ready for download: \(downloadURL)")
return try await syncRemote.downloadCatalog(for: siteID, downloadURL: downloadURL)
}

func pollForCatalogCompletion(siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> String {
// Each attempt is made 1 second after the last one completes.
Copy link
Contributor

Choose a reason for hiding this comment

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

Perhaps we should have some backoff here – e.g. after a minute of checking every second, maybe every 5 seconds is fine even if it means someone's waiting 4 seconds longer than they would with 1s polling.

I also wonder whether we might have any rate limiting issues doing this with some hosts, WDYT?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Perhaps we should have some backoff here

Great idea, some progressive backoff (1s → 5s after 1 minute like you suggested) would be good here. Polling every second for potentially long-running catalog generations unnecessarily consumes server resources. I also plan to add some basic retry mechanism for the polling, so that one failing request doesn't end the full sync. I will implement both in a future PR.

I also wonder whether we might have any rate limiting issues doing this with some hosts, WDYT?

I did some search about rate limiting in WP/WC, there doesn't seem to be a built-in rate limiting system for the plugins and WC API. The WC Store API does have rate limiting support, with default maximum of 25 requests per 10 seconds. Some plugins like WordFence offer the rate limiting feature. Other findings:

  • Major hosting providers (WP Engine, Kinsta, SiteGround, Bluehost) don't seem to implement explicit REST API rate limiting but use resource-based limits (CPU, bandwidth, visits) instead.
  • WordPress VIP has global rate-limits (REST APIs aren't explicitly mentioned like XML-RPC and login endpoints, but I assume rate limiting applies to API requests too) of 10 requests per second.

Right now, the polling frequency is a bit under 1 request per second due to the overhead of the request (one request is made after the end of the previous one), and likely fine for the common default settings. However, the backoff implementation will further help reduce the risk of encountering server resource issues.

Copy link
Contributor

Choose a reason for hiding this comment

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

Thanks for the research!

let maxAttempts = 1000
var attempts = 0

while attempts < maxAttempts {
let response = try await syncRemote.requestCatalogGeneration(for: siteID, forceGeneration: false)

switch response.status {
case .complete:
guard let downloadURL = response.downloadURL else {
throw POSCatalogSyncError.invalidData
}
return downloadURL
case .pending, .processing:
// Only logs every 10th attempt to avoid flooding logs for large catalogs.
if attempts % 10 == 0 {
DDLogInfo("🟣 Catalog request \(response.status)... (attempt \(attempts + 1)/\(maxAttempts))")
}
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
attempts += 1
case .failed:
throw POSCatalogSyncError.generationFailed
}
}

throw POSCatalogSyncError.timeout
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,8 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol {
func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {
DDLogInfo("💾 Persisting catalog with \(catalog.products.count) products and \(catalog.variations.count) variations")

let catalog = filterOrphanedVariations(from: catalog)

try await grdbManager.databaseConnection.write { db in
DDLogInfo("🗑️ Clearing catalog data for site \(siteID)")
try PersistedSite.deleteOne(db, key: siteID)
Expand Down Expand Up @@ -154,6 +156,22 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol {
}
}

private extension POSCatalogPersistenceService {
/// Filters out variations whose parent products are not in the catalog.
/// This can happen when the API returns public variations but their parent products are not public.
func filterOrphanedVariations(from catalog: POSCatalog) -> POSCatalog {
let productIDs = catalog.products.map { $0.productID }
let variations = catalog.variations.filter { variation in
let parentExists = productIDs.contains { $0 == variation.productID }
if !parentExists {
DDLogWarn("Variation \(variation.productVariationID) references missing product \(variation.productID) - it will not be available in POS.")
}
return parentExists
}
return POSCatalog(products: catalog.products, variations: variations, syncDate: catalog.syncDate)
}
}

private extension POSCatalog {
var productsToPersist: [PersistedProduct] {
products.map { PersistedProduct(from: $0) }
Expand Down
17 changes: 10 additions & 7 deletions Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,9 @@ public protocol POSCatalogSyncCoordinatorProtocol {
/// - Parameters:
/// - siteID: The site ID to sync catalog for
/// - maxAge: Maximum age before a sync is considered stale
/// - regenerateCatalog: Whether to always generate a new catalog. If false, a cached catalog will be used if available.
/// - Throws: POSCatalogSyncError.syncAlreadyInProgress if a sync is already running for this site
func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws
func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, regenerateCatalog: Bool) async throws

/// Performs an incremental sync if applicable based on sync conditions
/// - Parameters:
Expand All @@ -37,8 +38,8 @@ public protocol POSCatalogSyncCoordinatorProtocol {
}

public extension POSCatalogSyncCoordinatorProtocol {
func performFullSync(for siteID: Int64) async throws {
try await performFullSyncIfApplicable(for: siteID, maxAge: .zero)
func performFullSync(for siteID: Int64, regenerateCatalog: Bool = false) async throws {
try await performFullSyncIfApplicable(for: siteID, maxAge: .zero, regenerateCatalog: regenerateCatalog)
}

func performIncrementalSync(for siteID: Int64) async throws {
Expand All @@ -56,6 +57,9 @@ public extension POSCatalogSyncCoordinatorProtocol {
public enum POSCatalogSyncError: Error, Equatable {
case syncAlreadyInProgress(siteID: Int64)
case negativeMaxAge
case invalidData
case timeout
case generationFailed
case requestCancelled
case shouldNotSync
}
Expand Down Expand Up @@ -85,7 +89,7 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
self.siteSettings = siteSettings ?? SiteSpecificAppSettingsStoreMethods(fileStorage: PListFileStorage())
}

public func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws {
public func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, regenerateCatalog: Bool) async throws {
guard maxAge >= 0 else {
throw POSCatalogSyncError.negativeMaxAge
}
Expand All @@ -104,7 +108,7 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
DDLogInfo("🔄 POSCatalogSyncCoordinator starting full sync for site \(siteID)")

do {
_ = try await fullSyncService.startFullSync(for: siteID)
_ = try await fullSyncService.startFullSync(for: siteID, regenerateCatalog: regenerateCatalog)
emitSyncState(.syncCompleted(siteID: siteID))
} catch AFError.explicitlyCancelled, is CancellationError {
emitSyncState(.syncFailed(siteID: siteID, error: POSCatalogSyncError.requestCancelled))
Expand All @@ -127,7 +131,7 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {

if Date().timeIntervalSince(lastFullSync) >= fullSyncMaxAge {
DDLogInfo("🔄 POSCatalogSyncCoordinator: Performing full sync for site \(siteID) (last full sync: \(lastFullSyncUTC) UTC)")
try await performFullSyncIfApplicable(for: siteID, maxAge: fullSyncMaxAge)
try await performFullSyncIfApplicable(for: siteID, maxAge: fullSyncMaxAge, regenerateCatalog: false)
} else {
DDLogInfo("🔄 POSCatalogSyncCoordinator: Performing incremental sync for site \(siteID) (last full sync: \(lastFullSyncUTC) UTC)")
try await performIncrementalSyncIfApplicable(for: siteID, maxAge: incrementalSyncMaxAge)
Expand Down Expand Up @@ -341,7 +345,6 @@ public enum POSCatalogSyncState: Equatable {

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {

var onPerformFullSyncCalled: (() -> Void)?

func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws {
func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, regenerateCatalog: Bool) async throws {
performFullSyncInvocationCount += 1
performFullSyncSiteID = siteID

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,11 @@
import Foundation

final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol {
// MARK: - replaceAllCatalogData tracking
private(set) var replaceAllCatalogDataCallCount = 0
private(set) var replaceAllCatalogDataLastPersistedCatalog: POSCatalog?
var replaceAllCatalogDataError: Error?

// MARK: - persistIncrementalCatalogData tracking
private(set) var persistIncrementalCatalogDataCallCount = 0
private(set) var persistIncrementalCatalogDataLastPersistedCatalog: POSCatalog?
Expand All @@ -10,7 +15,12 @@ final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtoc
// MARK: - Protocol Implementation

func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {
// Not used in current tests
replaceAllCatalogDataCallCount += 1
replaceAllCatalogDataLastPersistedCatalog = catalog

if let error = replaceAllCatalogDataError {
throw error
}
}

func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {
Expand Down
19 changes: 17 additions & 2 deletions Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,17 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol {
private(set) var incrementalProductResults: [Int: Result<PagedItems<POSProduct>, Error>] = [:]
private(set) var incrementalVariationResults: [Int: Result<PagedItems<POSProductVariation>, Error>] = [:]

var catalogRequestResult: Result<POSCatalogRequestResponse, Error> = .success(.init(status: .complete, downloadURL: "https://example.com/catalog.json"))
var catalogDownloadResult: Result<POSCatalogResponse, Error> = .success(.init(products: [], variations: []))

let loadProductsCallCount = Counter()
let loadProductVariationsCallCount = Counter()
let loadIncrementalProductsCallCount = Counter()
let loadIncrementalProductVariationsCallCount = Counter()

private(set) var lastIncrementalProductsModifiedAfter: Date?
private(set) var lastIncrementalVariationsModifiedAfter: Date?
private(set) var lastCatalogRequestForceGeneration: Bool?

// Fallback result when no specific page result is configured
private let fallbackResult = PagedItems(items: [] as [POSProduct], hasMorePages: false, totalItems: 0)
Expand Down Expand Up @@ -129,11 +133,22 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol {
// MARK: - Protocol Methods - Catalog API

func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse {
.init(status: .pending, downloadURL: nil)
lastCatalogRequestForceGeneration = forceGeneration
switch catalogRequestResult {
case .success(let response):
return response
case .failure(let error):
throw error
}
}

func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse {
.init(products: [], variations: [])
switch catalogDownloadResult {
case .success(let response):
return response
case .failure(let error):
throw error
}
}

// MARK: - Protocol Methods - Catalog size
Expand Down
Loading