Skip to content

Commit df2e5eb

Browse files
authored
[Local Catalog] Integrate catalog API behind a feature flag (#16301)
2 parents d9ffd67 + e1990ed commit df2e5eb

16 files changed

+302
-24
lines changed

Modules/Sources/Experiments/DefaultFeatureFlagService.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,8 @@ public struct DefaultFeatureFlagService: FeatureFlagService {
102102
return buildConfig == .localDeveloper || buildConfig == .alpha
103103
case .pointOfSaleSettingsCardReaderFlow:
104104
return buildConfig == .localDeveloper || buildConfig == .alpha
105+
case .pointOfSaleCatalogAPI:
106+
return false
105107
default:
106108
return true
107109
}

Modules/Sources/Experiments/FeatureFlag.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,4 +211,8 @@ public enum FeatureFlag: Int {
211211
/// Enables card reader connection flow within POS settings
212212
///
213213
case pointOfSaleSettingsCardReaderFlow
214+
215+
/// Enables using the catalog API endpoint for Point of Sale catalog full sync
216+
///
217+
case pointOfSaleCatalogAPI
214218
}

Modules/Sources/PointOfSale/Presentation/Settings/POSSettingsLocalCatalogViewModel.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ final class POSSettingsLocalCatalogViewModel {
5353
defer { isRefreshingCatalog = false }
5454

5555
do {
56-
try await catalogSyncCoordinator.performFullSync(for: siteID)
56+
try await catalogSyncCoordinator.performFullSync(for: siteID, regenerateCatalog: true)
5757
await loadCatalogData()
5858
} catch {
5959
DDLogError("⛔️ POSSettingsLocalCatalog: Failed to refresh catalog: \(error)")

Modules/Sources/PointOfSale/Utils/PreviewHelpers.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -615,7 +615,7 @@ final class POSPreviewCatalogSettingsService: POSCatalogSettingsServiceProtocol
615615
}
616616

617617
final class POSPreviewCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
618-
func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws {
618+
func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, regenerateCatalog: Bool) async throws {
619619
// Simulates a full sync operation with a 1 second delay.
620620
try await Task.sleep(nanoseconds: 1_000_000_000)
621621
}

Modules/Sources/Yosemite/Tools/POS/POSCatalogFullSyncService.swift

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -5,14 +5,17 @@ import class Networking.POSCatalogSyncRemote
55
import Storage
66
import struct Combine.AnyPublisher
77
import struct NetworkingCore.JetpackSite
8+
import struct Networking.POSCatalogResponse
89

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

1821
/// POS catalog from full sync.
@@ -30,12 +33,14 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol
3033
private let syncRemote: POSCatalogSyncRemoteProtocol
3134
private let persistenceService: POSCatalogPersistenceServiceProtocol
3235
private let batchedLoader: BatchedRequestLoader
36+
private let usesCatalogAPI: Bool
3337

3438
public convenience init?(credentials: Credentials?,
3539
selectedSite: AnyPublisher<JetpackSite?, Never>,
3640
appPasswordSupportState: AnyPublisher<Bool, Never>,
3741
batchSize: Int = 2,
38-
grdbManager: GRDBManagerProtocol) {
42+
grdbManager: GRDBManagerProtocol,
43+
usesCatalogAPI: Bool = false) {
3944
guard let credentials else {
4045
DDLogError("⛔️ Could not create POSCatalogFullSyncService due missing credentials")
4146
return nil
@@ -45,28 +50,35 @@ public final class POSCatalogFullSyncService: POSCatalogFullSyncServiceProtocol
4550
appPasswordSupportState: appPasswordSupportState)
4651
let syncRemote = POSCatalogSyncRemote(network: network)
4752
let persistenceService = POSCatalogPersistenceService(grdbManager: grdbManager)
48-
self.init(syncRemote: syncRemote, batchSize: batchSize, persistenceService: persistenceService)
53+
self.init(syncRemote: syncRemote, batchSize: batchSize, persistenceService: persistenceService, usesCatalogAPI: usesCatalogAPI)
4954
}
5055

5156
init(
5257
syncRemote: POSCatalogSyncRemoteProtocol,
5358
batchSize: Int,
5459
retryDelay: TimeInterval = 2.0,
55-
persistenceService: POSCatalogPersistenceServiceProtocol
60+
persistenceService: POSCatalogPersistenceServiceProtocol,
61+
usesCatalogAPI: Bool = false
5662
) {
5763
self.syncRemote = syncRemote
5864
self.persistenceService = persistenceService
5965
self.batchedLoader = BatchedRequestLoader(batchSize: batchSize, retryDelay: retryDelay)
66+
self.usesCatalogAPI = usesCatalogAPI
6067
}
6168

6269
// MARK: - Protocol Conformance
6370

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

6774
do {
6875
// Sync from network
69-
let catalog = try await loadCatalog(for: siteID, syncRemote: syncRemote)
76+
let catalog: POSCatalog
77+
if usesCatalogAPI {
78+
catalog = try await loadCatalogFromCatalogAPI(for: siteID, syncRemote: syncRemote, regenerateCatalog: regenerateCatalog)
79+
} else {
80+
catalog = try await loadCatalog(for: siteID, syncRemote: syncRemote)
81+
}
7082
DDLogInfo("✅ Loaded \(catalog.products.count) products and \(catalog.variations.count) variations for siteID \(siteID)")
7183

7284
// Persist to database
@@ -102,4 +114,64 @@ private extension POSCatalogFullSyncService {
102114
return POSCatalog(products: products, variations: variations, syncDate: syncStartDate)
103115
}
104116

117+
func loadCatalogFromCatalogAPI(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol, regenerateCatalog: Bool) async throws -> POSCatalog {
118+
let downloadStartTime = CFAbsoluteTimeGetCurrent()
119+
let catalog = try await downloadCatalog(for: siteID, syncRemote: syncRemote, regenerateCatalog: regenerateCatalog)
120+
let downloadTime = CFAbsoluteTimeGetCurrent() - downloadStartTime
121+
DDLogInfo("🟣 Catalog download completed - Time: \(String(format: "%.2f", downloadTime))s")
122+
123+
return .init(products: catalog.products, variations: catalog.variations, syncDate: .init())
124+
}
125+
}
126+
127+
private extension POSCatalogFullSyncService {
128+
func downloadCatalog(for siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol, regenerateCatalog: Bool) async throws -> POSCatalogResponse {
129+
DDLogInfo("🟣 Starting catalog request...")
130+
131+
// 1. Requests catalog until download URL is available.
132+
let response = try await syncRemote.requestCatalogGeneration(for: siteID, forceGeneration: regenerateCatalog)
133+
let downloadURL: String?
134+
if let url = response.downloadURL {
135+
downloadURL = url
136+
} else {
137+
// 2. Polls for completion until download URL is available.
138+
downloadURL = try await pollForCatalogCompletion(siteID: siteID, syncRemote: syncRemote)
139+
}
140+
141+
// 3. Downloads catalog using the provided URL.
142+
guard let downloadURL else {
143+
throw POSCatalogSyncError.invalidData
144+
}
145+
DDLogInfo("🟣 Catalog ready for download: \(downloadURL)")
146+
return try await syncRemote.downloadCatalog(for: siteID, downloadURL: downloadURL)
147+
}
148+
149+
func pollForCatalogCompletion(siteID: Int64, syncRemote: POSCatalogSyncRemoteProtocol) async throws -> String {
150+
// Each attempt is made 1 second after the last one completes.
151+
let maxAttempts = 1000
152+
var attempts = 0
153+
154+
while attempts < maxAttempts {
155+
let response = try await syncRemote.requestCatalogGeneration(for: siteID, forceGeneration: false)
156+
157+
switch response.status {
158+
case .complete:
159+
guard let downloadURL = response.downloadURL else {
160+
throw POSCatalogSyncError.invalidData
161+
}
162+
return downloadURL
163+
case .pending, .processing:
164+
// Only logs every 10th attempt to avoid flooding logs for large catalogs.
165+
if attempts % 10 == 0 {
166+
DDLogInfo("🟣 Catalog request \(response.status)... (attempt \(attempts + 1)/\(maxAttempts))")
167+
}
168+
try await Task.sleep(nanoseconds: 1_000_000_000) // 1 second
169+
attempts += 1
170+
case .failed:
171+
throw POSCatalogSyncError.generationFailed
172+
}
173+
}
174+
175+
throw POSCatalogSyncError.timeout
176+
}
105177
}

Modules/Sources/Yosemite/Tools/POS/POSCatalogPersistenceService.swift

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol {
2727
func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {
2828
DDLogInfo("💾 Persisting catalog with \(catalog.products.count) products and \(catalog.variations.count) variations")
2929

30+
let catalog = filterOrphanedVariations(from: catalog)
31+
3032
try await grdbManager.databaseConnection.write { db in
3133
DDLogInfo("🗑️ Clearing catalog data for site \(siteID)")
3234
try PersistedSite.deleteOne(db, key: siteID)
@@ -154,6 +156,22 @@ final class POSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol {
154156
}
155157
}
156158

159+
private extension POSCatalogPersistenceService {
160+
/// Filters out variations whose parent products are not in the catalog.
161+
/// This can happen when the API returns public variations but their parent products are not public.
162+
func filterOrphanedVariations(from catalog: POSCatalog) -> POSCatalog {
163+
let productIDs = catalog.products.map { $0.productID }
164+
let variations = catalog.variations.filter { variation in
165+
let parentExists = productIDs.contains { $0 == variation.productID }
166+
if !parentExists {
167+
DDLogWarn("Variation \(variation.productVariationID) references missing product \(variation.productID) - it will not be available in POS.")
168+
}
169+
return parentExists
170+
}
171+
return POSCatalog(products: catalog.products, variations: variations, syncDate: catalog.syncDate)
172+
}
173+
}
174+
157175
private extension POSCatalog {
158176
var productsToPersist: [PersistedProduct] {
159177
products.map { PersistedProduct(from: $0) }

Modules/Sources/Yosemite/Tools/POS/POSCatalogSyncCoordinator.swift

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,9 @@ public protocol POSCatalogSyncCoordinatorProtocol {
99
/// - Parameters:
1010
/// - siteID: The site ID to sync catalog for
1111
/// - maxAge: Maximum age before a sync is considered stale
12+
/// - regenerateCatalog: Whether to always generate a new catalog. If false, a cached catalog will be used if available.
1213
/// - Throws: POSCatalogSyncError.syncAlreadyInProgress if a sync is already running for this site
13-
func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval) async throws
14+
func performFullSyncIfApplicable(for siteID: Int64, maxAge: TimeInterval, regenerateCatalog: Bool) async throws
1415

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

3940
public extension POSCatalogSyncCoordinatorProtocol {
40-
func performFullSync(for siteID: Int64) async throws {
41-
try await performFullSyncIfApplicable(for: siteID, maxAge: .zero)
41+
func performFullSync(for siteID: Int64, regenerateCatalog: Bool = false) async throws {
42+
try await performFullSyncIfApplicable(for: siteID, maxAge: .zero, regenerateCatalog: regenerateCatalog)
4243
}
4344

4445
func performIncrementalSync(for siteID: Int64) async throws {
@@ -56,6 +57,9 @@ public extension POSCatalogSyncCoordinatorProtocol {
5657
public enum POSCatalogSyncError: Error, Equatable {
5758
case syncAlreadyInProgress(siteID: Int64)
5859
case negativeMaxAge
60+
case invalidData
61+
case timeout
62+
case generationFailed
5963
case requestCancelled
6064
case shouldNotSync
6165
}
@@ -85,7 +89,7 @@ public actor POSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
8589
self.siteSettings = siteSettings ?? SiteSpecificAppSettingsStoreMethods(fileStorage: PListFileStorage())
8690
}
8791

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

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

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

342346
private extension POSCatalogSyncCoordinator {
343347
enum Constants {
344-
static let defaultSizeLimitForPOSCatalog = 1000
345348
static let maxDaysSinceLastOpened = 30
346349
}
347350

Modules/Tests/PointOfSaleTests/Mocks/MockPOSCatalogSyncCoordinator.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ final class MockPOSCatalogSyncCoordinator: POSCatalogSyncCoordinatorProtocol {
2121

2222
var onPerformFullSyncCalled: (() -> Void)?
2323

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

Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogPersistenceService.swift

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,11 @@
22
import Foundation
33

44
final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtocol {
5+
// MARK: - replaceAllCatalogData tracking
6+
private(set) var replaceAllCatalogDataCallCount = 0
7+
private(set) var replaceAllCatalogDataLastPersistedCatalog: POSCatalog?
8+
var replaceAllCatalogDataError: Error?
9+
510
// MARK: - persistIncrementalCatalogData tracking
611
private(set) var persistIncrementalCatalogDataCallCount = 0
712
private(set) var persistIncrementalCatalogDataLastPersistedCatalog: POSCatalog?
@@ -10,7 +15,12 @@ final class MockPOSCatalogPersistenceService: POSCatalogPersistenceServiceProtoc
1015
// MARK: - Protocol Implementation
1116

1217
func replaceAllCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {
13-
// Not used in current tests
18+
replaceAllCatalogDataCallCount += 1
19+
replaceAllCatalogDataLastPersistedCatalog = catalog
20+
21+
if let error = replaceAllCatalogDataError {
22+
throw error
23+
}
1424
}
1525

1626
func persistIncrementalCatalogData(_ catalog: POSCatalog, siteID: Int64) async throws {

Modules/Tests/YosemiteTests/Mocks/MockPOSCatalogSyncRemote.swift

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,17 @@ final class MockPOSCatalogSyncRemote: POSCatalogSyncRemoteProtocol {
88
private(set) var incrementalProductResults: [Int: Result<PagedItems<POSProduct>, Error>] = [:]
99
private(set) var incrementalVariationResults: [Int: Result<PagedItems<POSProductVariation>, Error>] = [:]
1010

11+
var catalogRequestResult: Result<POSCatalogRequestResponse, Error> = .success(.init(status: .complete, downloadURL: "https://example.com/catalog.json"))
12+
var catalogDownloadResult: Result<POSCatalogResponse, Error> = .success(.init(products: [], variations: []))
13+
1114
let loadProductsCallCount = Counter()
1215
let loadProductVariationsCallCount = Counter()
1316
let loadIncrementalProductsCallCount = Counter()
1417
let loadIncrementalProductVariationsCallCount = Counter()
1518

1619
private(set) var lastIncrementalProductsModifiedAfter: Date?
1720
private(set) var lastIncrementalVariationsModifiedAfter: Date?
21+
private(set) var lastCatalogRequestForceGeneration: Bool?
1822

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

131135
func requestCatalogGeneration(for siteID: Int64, forceGeneration: Bool) async throws -> POSCatalogRequestResponse {
132-
.init(status: .pending, downloadURL: nil)
136+
lastCatalogRequestForceGeneration = forceGeneration
137+
switch catalogRequestResult {
138+
case .success(let response):
139+
return response
140+
case .failure(let error):
141+
throw error
142+
}
133143
}
134144

135145
func downloadCatalog(for siteID: Int64, downloadURL: String) async throws -> POSCatalogResponse {
136-
.init(products: [], variations: [])
146+
switch catalogDownloadResult {
147+
case .success(let response):
148+
return response
149+
case .failure(let error):
150+
throw error
151+
}
137152
}
138153

139154
// MARK: - Protocol Methods - Catalog size

0 commit comments

Comments
 (0)