diff options
| author | Christian Cleberg <[email protected]> | 2026-05-06 20:41:49 -0500 |
|---|---|---|
| committer | Christian Cleberg <[email protected]> | 2026-05-06 20:41:49 -0500 |
| commit | 205cf2687ce48f3d004792ff223422a86cc6bdbc (patch) | |
| tree | 7edb78cede354e79aa8abd764ed9c5047e0ee168 /Hutch | |
| parent | 57e4f34b4613c09beb0cb757ac2ba2b43cc04daf (diff) | |
| download | hutch-205cf2687ce48f3d004792ff223422a86cc6bdbc.tar.gz hutch-205cf2687ce48f3d004792ff223422a86cc6bdbc.tar.bz2 hutch-205cf2687ce48f3d004792ff223422a86cc6bdbc.zip | |
feat(cache): persist read-only API responsesv3.3.1
Add a bounded stale-while-revalidate cache at the Sourcehut API boundary with
stable keys, centralized TTLs, request coalescing, payload hashing, and LRU disk
pruning.
Cache high-value read-only repo, build, ticket, project, profile, paste, and
Home/Work Queue data while keeping mutations network-only and invalidating
related prefixes after successful writes.
Add focused cache tests and implementation notes.
Diffstat (limited to 'Hutch')
| -rw-r--r-- | Hutch/Networking/APICache.swift | 30 | ||||
| -rw-r--r-- | Hutch/Networking/APICacheKeys.swift | 43 | ||||
| -rw-r--r-- | Hutch/Networking/PasteService.swift | 19 | ||||
| -rw-r--r-- | Hutch/Networking/ProjectService.swift | 23 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildListViewModel.swift | 23 | ||||
| -rw-r--r-- | Hutch/Views/Home/HomeViewModel.swift | 63 | ||||
| -rw-r--r-- | Hutch/Views/Lookup/UserProfileViewModel.swift | 20 | ||||
| -rw-r--r-- | Hutch/Views/Pastes/PasteListViewModel.swift | 2 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryListViewModel.swift | 96 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketListViewModel.swift | 43 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TrackerListViewModel.swift | 27 |
11 files changed, 295 insertions, 94 deletions
diff --git a/Hutch/Networking/APICache.swift b/Hutch/Networking/APICache.swift index 9c1a262..05cf80e 100644 --- a/Hutch/Networking/APICache.swift +++ b/Hutch/Networking/APICache.swift @@ -35,7 +35,7 @@ struct CacheEntryMetadata: Codable, Sendable, Equatable { let schemaVersion: Int let payloadSize: Int - func isExpired(now: Date = Date()) -> Bool { + nonisolated func isExpired(now: Date = Date()) -> Bool { expiresAt <= now } } @@ -83,7 +83,7 @@ struct APICacheConfiguration: Sendable { var memoryEntryLimit: Int var schemaVersion: Int - static func accountScoped(accountID: String) -> APICacheConfiguration { + nonisolated static func accountScoped(accountID: String) -> APICacheConfiguration { let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) return APICacheConfiguration( @@ -98,7 +98,7 @@ struct APICacheConfiguration: Sendable { ) } - static func temporary(directory: URL) -> APICacheConfiguration { + nonisolated static func temporary(directory: URL) -> APICacheConfiguration { APICacheConfiguration( directory: directory, maxCacheSizeBytes: 4 * 1024 * 1024, @@ -126,15 +126,13 @@ actor PersistentAPICache: APICache { } private let configuration: APICacheConfiguration - private let fileManager: FileManager private var memoryEntries: [String: APICacheEntry] = [:] private var memoryOrder: [String] = [] private var knownMetadata: [String: CacheEntryMetadata] = [:] private var writeCountSincePrune = 0 - init(configuration: APICacheConfiguration, fileManager: FileManager = .default) { + init(configuration: APICacheConfiguration) { self.configuration = configuration - self.fileManager = fileManager } func read(cacheKey: String) async throws -> APICacheEntry { @@ -147,13 +145,13 @@ actor PersistentAPICache: APICache { } let url = fileURL(for: cacheKey) - guard fileManager.fileExists(atPath: url.path) else { + guard FileManager.default.fileExists(atPath: url.path) else { throw APICacheError.miss } var stored = try decodeEntry(from: url) guard stored.metadata.schemaVersion == configuration.schemaVersion else { - try? fileManager.removeItem(at: url) + try? FileManager.default.removeItem(at: url) throw APICacheError.miss } @@ -223,7 +221,7 @@ actor PersistentAPICache: APICache { memoryEntries.removeValue(forKey: cacheKey) memoryOrder.removeAll { $0 == cacheKey } knownMetadata.removeValue(forKey: cacheKey) - try? fileManager.removeItem(at: fileURL(for: cacheKey)) + try? FileManager.default.removeItem(at: fileURL(for: cacheKey)) } func removeByPrefix(_ prefix: String) async { @@ -237,7 +235,7 @@ actor PersistentAPICache: APICache { memoryEntries.removeAll() memoryOrder.removeAll() knownMetadata.removeAll() - try? fileManager.removeItem(at: configuration.directory) + try? FileManager.default.removeItem(at: configuration.directory) } func pruneExpired(now: Date = Date()) async { @@ -289,7 +287,7 @@ actor PersistentAPICache: APICache { private func loadKnownMetadataIfNeeded() async { guard knownMetadata.isEmpty else { return } - guard let urls = try? fileManager.contentsOfDirectory( + guard let urls = try? FileManager.default.contentsOfDirectory( at: configuration.directory, includingPropertiesForKeys: nil ) else { return } @@ -301,8 +299,8 @@ actor PersistentAPICache: APICache { } private func ensureDirectoryExists() throws { - if !fileManager.fileExists(atPath: configuration.directory.path) { - try fileManager.createDirectory( + if !FileManager.default.fileExists(atPath: configuration.directory.path) { + try FileManager.default.createDirectory( at: configuration.directory, withIntermediateDirectories: true ) @@ -315,13 +313,13 @@ actor PersistentAPICache: APICache { .appendingPathExtension("json") } - private static func payloadHash(_ data: Data) -> String { + nonisolated private static func payloadHash(_ data: Data) -> String { SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() } } extension JSONEncoder { - static var srhtCache: JSONEncoder { + nonisolated static var srhtCache: JSONEncoder { let encoder = JSONEncoder() encoder.dateEncodingStrategy = .iso8601 return encoder @@ -329,7 +327,7 @@ extension JSONEncoder { } extension JSONDecoder { - static var srhtCache: JSONDecoder { + nonisolated static var srhtCache: JSONDecoder { let decoder = JSONDecoder() decoder.dateDecodingStrategy = .iso8601 return decoder diff --git a/Hutch/Networking/APICacheKeys.swift b/Hutch/Networking/APICacheKeys.swift index 5e57a51..d0cffa6 100644 --- a/Hutch/Networking/APICacheKeys.swift +++ b/Hutch/Networking/APICacheKeys.swift @@ -51,6 +51,14 @@ enum APICacheKeys { make([SRHTService.todo.rawValue, "tracker-labels", "tracker:\(trackerRid)"]) } + static func trackers(cursor: String? = nil) -> String { + make([SRHTService.todo.rawValue, "trackers", cursor.map { "cursor:\($0)" }]) + } + + static func tickets(trackerRid: String, cursor: String? = nil) -> String { + make([SRHTService.todo.rawValue, "tickets", "tracker:\(trackerRid)", cursor.map { "cursor:\($0)" }]) + } + static func builds(cursor: String? = nil, filter: String? = nil) -> String { make([SRHTService.builds.rawValue, "jobs", cursor.map { "cursor:\($0)" }, filter.map { "filter:\($0)" }]) } @@ -71,6 +79,37 @@ enum APICacheKeys { make([SRHTService.todo.rawValue, "user-trackers", normalize(owner), cursor.map { "cursor:\($0)" }]) } + static func projects(cursor: String? = nil) -> String { + make([SRHTService.hub.rawValue, "projects", cursor.map { "cursor:\($0)" }]) + } + + static func projectDetail(rid: String, mailingListsCursor: String? = nil, sourcesCursor: String? = nil, trackersCursor: String? = nil) -> String { + make([ + SRHTService.hub.rawValue, + "project", + "rid:\(rid)", + mailingListsCursor.map { "ml:\($0)" }, + sourcesCursor.map { "src:\($0)" }, + trackersCursor.map { "trk:\($0)" } + ]) + } + + static func homeJobs(actor: String) -> String { + make(["home", "jobs", normalize(actor)]) + } + + static func homeTrackerTickets(owner: String, tracker: String) -> String { + make(["home", "tickets", normalize(owner), normalize(tracker)]) + } + + static func inboxSubscriptions(cursor: String? = nil) -> String { + make([SRHTService.lists.rawValue, "subscriptions", cursor.map { "cursor:\($0)" }]) + } + + static func inboxThreads(listRid: String, cursor: String? = nil) -> String { + make([SRHTService.lists.rawValue, "threads", "list:\(listRid)", cursor.map { "cursor:\($0)" }]) + } + static func pasteList(cursor: String? = nil) -> String { make([SRHTService.paste.rawValue, "pastes", cursor.map { "cursor:\($0)" }]) } @@ -102,4 +141,8 @@ enum APICacheTTLs { static let movingRefFileContent: TimeInterval = 10 * 60 static let userProfile: TimeInterval = 30 * 60 static let status: TimeInterval = 5 * 60 + static let homeDashboard: TimeInterval = 2 * 60 + static let inboxSummary: TimeInterval = 2 * 60 + static let projectList: TimeInterval = 10 * 60 + static let projectDetail: TimeInterval = 10 * 60 } diff --git a/Hutch/Networking/PasteService.swift b/Hutch/Networking/PasteService.swift index b9aa530..92a618e 100644 --- a/Hutch/Networking/PasteService.swift +++ b/Hutch/Networking/PasteService.swift @@ -109,13 +109,17 @@ final class PasteService: Sendable { let variables = cursor.map { ["cursor": $0 as any Sendable] } let result: PasteListResponse if useCache, cursor == nil { - result = try await client.executeAndCache( + let cached = try await client.executeCached( service: .paste, query: Self.listQuery, variables: variables, responseType: PasteListResponse.self, - cacheKey: Self.cacheKey + cacheKey: APICacheKeys.pasteList(cursor: cursor), + resourceType: .pasteList, + ttl: APICacheTTLs.ticketList, + policy: .cacheFirstThenRefresh ) + result = cached.value } else { result = try await client.execute( service: .paste, @@ -127,8 +131,8 @@ final class PasteService: Sendable { return result.pastes ?? PasteListPage(results: [], cursor: nil) } - func loadCachedPastes() -> PasteListPage? { - guard let data = client.responseCache.get(forKey: Self.cacheKey) else { + func loadCachedPastes() async -> PasteListPage? { + guard let data = await client.cachedPayload(forKey: APICacheKeys.pasteList()) ?? client.responseCache.get(forKey: Self.cacheKey) else { return nil } @@ -171,6 +175,7 @@ final class PasteService: Sendable { }, responseType: CreatePasteResponse.self ) + await invalidatePasteCaches() return result.create } @@ -181,6 +186,7 @@ final class PasteService: Sendable { variables: ["id": id, "visibility": visibility.rawValue], responseType: UpdatePasteResponse.self ) + await invalidatePasteCaches() return result.update } @@ -191,6 +197,7 @@ final class PasteService: Sendable { variables: ["id": id], responseType: DeletePasteResponse.self ) + await invalidatePasteCaches() return result.delete } @@ -208,6 +215,10 @@ final class PasteService: Sendable { return (draft.filename, data) } } + + private func invalidatePasteCaches() async { + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.paste.rawValue, "pastes")) + } } private struct PasteListResponse: Decodable, Sendable { diff --git a/Hutch/Networking/ProjectService.swift b/Hutch/Networking/ProjectService.swift index 5e0e061..44320ca 100644 --- a/Hutch/Networking/ProjectService.swift +++ b/Hutch/Networking/ProjectService.swift @@ -287,12 +287,17 @@ struct ProjectService: Sendable { variables["cursor"] = cursor } - let response = try await client.execute( + let cached = try await client.executeCached( service: .hub, query: Self.projectsQuery, variables: variables.isEmpty ? nil : variables, - responseType: ProjectPageResponse.self + responseType: ProjectPageResponse.self, + cacheKey: APICacheKeys.projects(cursor: cursor), + resourceType: .userProfile, + ttl: APICacheTTLs.projectList, + policy: .cacheFirstThenRefresh ) + let response = cached.value results.append(contentsOf: response.me.projects.results) guard let nextCursor = response.me.projects.cursor else { @@ -324,12 +329,22 @@ struct ProjectService: Sendable { variables["trackersCursor"] = trackersCursor } - let response = try await client.execute( + let cached = try await client.executeCached( service: .hub, query: Self.projectDetailQuery, variables: variables, - responseType: ProjectDetailResponse.self + responseType: ProjectDetailResponse.self, + cacheKey: APICacheKeys.projectDetail( + rid: rid, + mailingListsCursor: mailingListsCursor, + sourcesCursor: sourcesCursor, + trackersCursor: trackersCursor + ), + resourceType: .userProfile, + ttl: APICacheTTLs.projectDetail, + policy: .cacheFirstThenRefresh ) + let response = cached.value guard let project = response.project else { throw SRHTError.decodingError( diff --git a/Hutch/Views/Builds/BuildListViewModel.swift b/Hutch/Views/Builds/BuildListViewModel.swift index 14f50fd..42500a8 100644 --- a/Hutch/Views/Builds/BuildListViewModel.swift +++ b/Hutch/Views/Builds/BuildListViewModel.swift @@ -188,7 +188,7 @@ final class BuildListViewModel { func loadJobs() async { // Show cached data immediately on first load (may populate `jobs` from cache). if jobs.isEmpty { - loadFromCache() + await loadFromCache() } let treatAsInitialLoad = jobs.isEmpty @@ -279,6 +279,7 @@ final class BuildListViewModel { variables: variables, responseType: SubmitJobResponse.self ) + await invalidateBuildListCache() await loadJobs() return result.submit.id } catch { @@ -297,6 +298,7 @@ final class BuildListViewModel { variables: ["id": job.id], responseType: CancelResponse.self ) + await invalidateBuildListCache() if let index = jobs.firstIndex(where: { $0.id == job.id }) { let updated = JobSummary( id: job.id, @@ -345,14 +347,17 @@ final class BuildListViewModel { } if useCache && cursor == nil { - let result = try await client.executeAndCache( + let cached = try await client.executeCached( service: .builds, query: Self.query, variables: variables.isEmpty ? nil : variables, responseType: JobsResponse.self, - cacheKey: Self.cacheKey + cacheKey: APICacheKeys.builds(cursor: cursor), + resourceType: .buildList, + ttl: APICacheTTLs.activeBuild, + policy: .cacheFirstThenRefresh ) - return result.jobs + return cached.value.jobs } else { let result = try await client.execute( service: .builds, @@ -364,8 +369,14 @@ final class BuildListViewModel { } } - private func loadFromCache() { - guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return } + private func invalidateBuildListCache() async { + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "jobs")) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "job")) + await client.invalidateCache(prefix: APICacheKeys.prefix("home")) + } + + private func loadFromCache() async { + guard let data = await client.cachedPayload(forKey: APICacheKeys.builds()) ?? client.responseCache.get(forKey: Self.cacheKey) else { return } let decoder = JSONDecoder() decoder.dateDecodingStrategy = .srhtFlexible if let response = try? decoder.decode( diff --git a/Hutch/Views/Home/HomeViewModel.swift b/Hutch/Views/Home/HomeViewModel.swift index 142eee4..2cd4869 100644 --- a/Hutch/Views/Home/HomeViewModel.swift +++ b/Hutch/Views/Home/HomeViewModel.swift @@ -578,6 +578,7 @@ final class HomeViewModel { ], responseType: UnassignResponse.self ) + await invalidateHomeRelatedCaches() assignedTickets.removeAll { $0.id == ticket.id } persistNeedsAttentionSnapshot() } catch { @@ -595,6 +596,7 @@ final class HomeViewModel { variables: ["id": build.job.id], responseType: CancelBuildResponse.self ) + await invalidateHomeRelatedCaches() if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) { let updatedJob = JobSummary( id: build.job.id, @@ -684,12 +686,16 @@ final class HomeViewModel { private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> { do { - let response = try await client.execute( + let cached = try await client.executeCached( service: .builds, query: Self.jobsQuery, - responseType: HomeJobsResponse.self + responseType: HomeJobsResponse.self, + cacheKey: APICacheKeys.homeJobs(actor: currentUser.canonicalName), + resourceType: .buildList, + ttl: APICacheTTLs.homeDashboard, + policy: .cacheFirstThenRefresh ) - return .success(response.jobs.results) + return .success(cached.value.jobs.results) } catch { return .failure(error) } @@ -780,12 +786,17 @@ final class HomeViewModel { variables["cursor"] = cursor } - let response = try await client.execute( + let cached = try await client.executeCached( service: .lists, query: Self.inboxSubscriptionsQuery, variables: variables.isEmpty ? nil : variables, - responseType: HomeInboxSubscriptionsResponse.self + responseType: HomeInboxSubscriptionsResponse.self, + cacheKey: APICacheKeys.inboxSubscriptions(cursor: cursor), + resourceType: .ticketList, + ttl: APICacheTTLs.inboxSummary, + policy: .cacheFirstThenRefresh ) + let response = cached.value subscriptions.append(contentsOf: response.subscriptions.results) guard let nextCursor = response.subscriptions.cursor else { @@ -809,12 +820,17 @@ final class HomeViewModel { variables["cursor"] = cursor } - let response = try await client.execute( + let cached = try await client.executeCached( service: .lists, query: Self.inboxListThreadsQuery, variables: variables, - responseType: HomeInboxListThreadsResponse.self + responseType: HomeInboxListThreadsResponse.self, + cacheKey: APICacheKeys.inboxThreads(listRid: mailingList.rid, cursor: cursor), + resourceType: .ticketList, + ttl: APICacheTTLs.inboxSummary, + policy: .cacheFirstThenRefresh ) + let response = cached.value let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in let summary = InboxThreadSummary( @@ -876,12 +892,17 @@ final class HomeViewModel { variables["cursor"] = cursor } - let response = try await client.execute( + let cached = try await client.executeCached( service: .todo, query: Self.trackersQuery, variables: variables.isEmpty ? nil : variables, - responseType: HomeTrackersResponse.self + responseType: HomeTrackersResponse.self, + cacheKey: APICacheKeys.trackers(cursor: cursor), + resourceType: .ticketList, + ttl: APICacheTTLs.ticketList, + policy: .cacheFirstThenRefresh ) + let response = cached.value allTrackers.append(contentsOf: response.trackers.results) guard let nextCursor = response.trackers.cursor else { @@ -925,7 +946,7 @@ final class HomeViewModel { } private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] { - let response = try await client.execute( + let cached = try await client.executeCached( service: .todo, query: Self.trackerTicketsQuery, variables: [ @@ -934,8 +955,18 @@ final class HomeViewModel { : tracker.owner.canonicalName, "tracker": tracker.name ], - responseType: HomeTrackerTicketsResponse.self + responseType: HomeTrackerTicketsResponse.self, + cacheKey: APICacheKeys.homeTrackerTickets( + owner: tracker.owner.canonicalName.hasPrefix("~") + ? String(tracker.owner.canonicalName.dropFirst()) + : tracker.owner.canonicalName, + tracker: tracker.name + ), + resourceType: .ticketList, + ttl: APICacheTTLs.ticketList, + policy: .cacheFirstThenRefresh ) + let response = cached.value return response.user.tracker.tickets.results.compactMap { payload in guard payload.status.isOpen else { @@ -970,6 +1001,7 @@ final class HomeViewModel { ], responseType: StatusEventResponse.self ) + await invalidateHomeRelatedCaches() assignedTickets.removeAll { $0.id == ticket.id } persistNeedsAttentionSnapshot() } catch { @@ -977,6 +1009,15 @@ final class HomeViewModel { } } + private func invalidateHomeRelatedCaches() async { + await client.invalidateCache(prefix: APICacheKeys.prefix("home")) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets")) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket")) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "trackers")) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "jobs")) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "job")) + } + private func persistNeedsAttentionSnapshot() { let failedBuildCount = recentFailedBuilds().count NeedsAttentionSnapshotStore.save( diff --git a/Hutch/Views/Lookup/UserProfileViewModel.swift b/Hutch/Views/Lookup/UserProfileViewModel.swift index 0e503ce..c482d6f 100644 --- a/Hutch/Views/Lookup/UserProfileViewModel.swift +++ b/Hutch/Views/Lookup/UserProfileViewModel.swift @@ -49,13 +49,17 @@ final class UserProfileViewModel { defer { isLoadingRepositories = false } do { - let result = try await client.execute( + let cached = try await client.executeCached( service: .git, query: Self.repositoriesQuery, variables: ["owner": ownerUsername], - responseType: UserRepositoriesResponse.self + responseType: UserRepositoriesResponse.self, + cacheKey: APICacheKeys.userRepositories(owner: ownerUsername), + resourceType: .userProfile, + ttl: APICacheTTLs.userProfile, + policy: .cacheFirstThenRefresh ) - repositories = result.user.repositories.results.map { $0.repositorySummary(service: .git) } + repositories = cached.value.user.repositories.results.map { $0.repositorySummary(service: .git) } } catch { repositoriesError = error.userFacingMessage } @@ -78,13 +82,17 @@ final class UserProfileViewModel { defer { isLoadingTrackers = false } do { - let result = try await client.execute( + let cached = try await client.executeCached( service: .todo, query: Self.trackersQuery, variables: ["owner": ownerUsername], - responseType: UserTrackersResponse.self + responseType: UserTrackersResponse.self, + cacheKey: APICacheKeys.userTrackers(owner: ownerUsername), + resourceType: .userProfile, + ttl: APICacheTTLs.userProfile, + policy: .cacheFirstThenRefresh ) - trackers = result.user.trackers.results + trackers = cached.value.user.trackers.results } catch { trackersError = error.userFacingMessage } diff --git a/Hutch/Views/Pastes/PasteListViewModel.swift b/Hutch/Views/Pastes/PasteListViewModel.swift index 640e31b..059dfa0 100644 --- a/Hutch/Views/Pastes/PasteListViewModel.swift +++ b/Hutch/Views/Pastes/PasteListViewModel.swift @@ -43,7 +43,7 @@ final class PasteListViewModel { } func loadPastes() async { - if pastes.isEmpty, let cached = service.loadCachedPastes() { + if pastes.isEmpty, let cached = await service.loadCachedPastes() { pastes = cached.results cursor = cached.cursor hasMore = cached.cursor != nil diff --git a/Hutch/Views/Repositories/RepositoryListViewModel.swift b/Hutch/Views/Repositories/RepositoryListViewModel.swift index 61053a0..695abf8 100644 --- a/Hutch/Views/Repositories/RepositoryListViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryListViewModel.swift @@ -155,7 +155,7 @@ final class RepositoryListViewModel { // Only use cache for non-search, initial loads if !isSearch, repositories.isEmpty { - loadFromCache() + await loadFromCache() } // During search, never show the full-screen loading overlay (which @@ -282,6 +282,8 @@ final class RepositoryListViewModel { ) repository = result.createRepository.repositorySummary(service: .hg) } + await client.invalidateCache(prefix: APICacheKeys.prefix(repository.service.rawValue, "repositories")) + await client.invalidateCache(prefix: APICacheKeys.prefix("home")) repositories.insert(repository, at: 0) insertIntoSearchIndex(repository) scheduleBuildStatusRefresh() @@ -469,13 +471,17 @@ final class RepositoryListViewModel { if useCache && cursor == nil { if service == .hg { let hgVariables = cursor.map { ["cursor": $0 as any Sendable] } - let result = try await client.executeAndCache( + let cached = try await client.executeCached( service: service, query: Self.hgQuery, variables: hgVariables, responseType: HGRepositoriesResponse.self, - cacheKey: cacheKey(for: service) + cacheKey: cacheKey(for: service), + resourceType: .repositoryList, + ttl: APICacheTTLs.repositoryList, + policy: .cacheFirstThenRefresh ) + let result = cached.value return Page( results: result.repositories?.results.map { RepositoryPayload( @@ -492,14 +498,17 @@ final class RepositoryListViewModel { cursor: result.repositories?.cursor ) } - let result = try await client.executeAndCache( + let cached = try await client.executeCached( service: service, query: Self.gitQuery, variables: variables.isEmpty ? nil : variables, responseType: RepositoriesResponse.self, - cacheKey: cacheKey(for: service) + cacheKey: cacheKey(for: service), + resourceType: .repositoryList, + ttl: APICacheTTLs.repositoryList, + policy: .cacheFirstThenRefresh ) - return result.repositories ?? Self.emptyPage + return cached.value.repositories ?? Self.emptyPage } else { if service == .hg { let hgVariables = cursor.map { ["cursor": $0 as any Sendable] } @@ -535,31 +544,14 @@ final class RepositoryListViewModel { } } - private func loadFromCache() { - let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in - guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] } - let decoder = JSONDecoder() - decoder.dateDecodingStrategy = .srhtFlexible - switch service { - case .git: - if let response = try? decoder.decode( - GraphQLResponse<RepositoriesResponse>.self, - from: data - ), let repos = response.data?.repositories { - return repos.results.map { $0.repositorySummary(service: service) } - } - case .hg: - if let response = try? decoder.decode( - GraphQLResponse<HGRepositoriesResponse>.self, - from: data - ), let repos = response.data?.repositories { - return repos.results.map { $0.repositorySummary(service: service) } - } - default: - break + private func loadFromCache() async { + var persistedRepositories: [RepositorySummary] = [] + for service in [SRHTService.git, .hg] { + if let data = await client.cachedPayload(forKey: cacheKey(for: service)) { + persistedRepositories.append(contentsOf: Self.decodeCachedRepositories(data, service: service)) } - return [] } + let cachedRepositories = persistedRepositories.isEmpty ? legacyCachedRepositories() : persistedRepositories if !cachedRepositories.isEmpty { let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder) repositories = sortedRepositories @@ -568,6 +560,37 @@ final class RepositoryListViewModel { } } + private func legacyCachedRepositories() -> [RepositorySummary] { + [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in + guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] } + return Self.decodeCachedRepositories(data, service: service) + } + } + + private static func decodeCachedRepositories(_ data: Data, service: SRHTService) -> [RepositorySummary] { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .srhtFlexible + switch service { + case .git: + if let response = try? decoder.decode( + GraphQLResponse<RepositoriesResponse>.self, + from: data + ), let repos = response.data?.repositories { + return repos.results.map { $0.repositorySummary(service: service) } + } + case .hg: + if let response = try? decoder.decode( + GraphQLResponse<HGRepositoriesResponse>.self, + from: data + ), let repos = response.data?.repositories { + return repos.results.map { $0.repositorySummary(service: service) } + } + default: + break + } + return [] + } + private func scheduleBuildStatusRefresh(force: Bool = false) { // Skip if we already refreshed recently (120-second TTL). Pull-to-refresh // passes force: true to bypass this check. @@ -640,14 +663,17 @@ final class RepositoryListViewModel { } if useCache && cursor == nil { - let result = try await client.executeAndCache( + let cached = try await client.executeCached( service: .builds, query: Self.buildsQuery, variables: variables.isEmpty ? nil : variables, responseType: BuildJobsResponse.self, - cacheKey: Self.buildsCacheKey + cacheKey: APICacheKeys.builds(cursor: cursor, filter: "repository-status"), + resourceType: .buildList, + ttl: APICacheTTLs.activeBuild, + policy: .cacheFirstThenRefresh ) - return result.jobs + return cached.value.jobs } let result = try await client.execute( @@ -681,11 +707,11 @@ final class RepositoryListViewModel { private func cacheKey(for service: SRHTService) -> String { switch service { case .git: - Self.gitCacheKey + APICacheKeys.repositories(service: .git) case .hg: - Self.hgCacheKey + APICacheKeys.repositories(service: .hg) default: - "\(service.rawValue).repositories" + APICacheKeys.repositories(service: service) } } diff --git a/Hutch/Views/Tickets/TicketListViewModel.swift b/Hutch/Views/Tickets/TicketListViewModel.swift index 18a0a5b..1f44993 100644 --- a/Hutch/Views/Tickets/TicketListViewModel.swift +++ b/Hutch/Views/Tickets/TicketListViewModel.swift @@ -299,6 +299,13 @@ final class TicketListViewModel { hasMore = true do { + if tickets.isEmpty, let cachedPage = try? await fetchPage(cursor: nil, policy: .cacheOnly) { + tickets = cachedPage.results + cursor = cachedPage.cursor + hasMore = cachedPage.cursor != nil + reconcileSelectionWithLoadedTickets() + isLoading = false + } // todo.sr.ht exposes `tickets(cursor:)` only (see Docs/API/todo.json) — no server-side // status filter. The Open tab filters client-side, so we paginate until the cursor is // exhausted; otherwise older open tickets never appear in the first page (25 items). @@ -405,6 +412,7 @@ final class TicketListViewModel { responseType: SubmitTicketResponse.self ) let ticket = result.submitTicket + await invalidateTicketCaches() tickets.insert(ticket, at: 0) return ticket } catch { @@ -460,6 +468,7 @@ final class TicketListViewModel { ], responseType: AssignmentMutationResponse.self ) + await invalidateTicketCaches() } catch { tickets = original self.error = error.userFacingMessage @@ -502,6 +511,7 @@ final class TicketListViewModel { ], responseType: AssignmentMutationResponse.self ) + await invalidateTicketCaches() } catch { tickets = original self.error = error.userFacingMessage @@ -512,13 +522,17 @@ final class TicketListViewModel { func loadTrackerLabels() async { do { - let result = try await client.execute( + let cached = try await client.executeCached( service: .todo, query: Self.trackerLabelsQuery, variables: ["rid": trackerRid], - responseType: TrackerLabelsResponse.self + responseType: TrackerLabelsResponse.self, + cacheKey: APICacheKeys.trackerLabels(trackerRid: trackerRid), + resourceType: .ticketList, + ttl: APICacheTTLs.ticketList, + policy: .cacheFirstThenRefresh ) - syncTrackerLabels(result.tracker.labels.results) + syncTrackerLabels(cached.value.tracker.labels.results) } catch { self.error = error.userFacingMessage } @@ -652,6 +666,7 @@ final class TicketListViewModel { ], responseType: LabelMutationResponse.self ) + await invalidateTicketCaches() } catch { tickets = original self.error = error.userFacingMessage @@ -692,6 +707,7 @@ final class TicketListViewModel { ], responseType: LabelMutationResponse.self ) + await invalidateTicketCaches() } catch { tickets = original self.error = error.userFacingMessage @@ -790,6 +806,7 @@ final class TicketListViewModel { responseType: UpdateStatusResponse.self ) _ = result.updateTicketStatus + await invalidateTicketCaches() if let index = tickets.firstIndex(where: { $0.id == ticket.id }) { tickets[index] = updatedTicket(from: ticket, input: input) } @@ -800,18 +817,22 @@ final class TicketListViewModel { isPerformingAction = false } - private func fetchPage(cursor: String?) async throws -> TicketsPage { + private func fetchPage(cursor: String?, policy: CachePolicy = .cacheFirstThenRefresh) async throws -> TicketsPage { var variables: [String: any Sendable] = ["rid": trackerRid] if let cursor { variables["cursor"] = cursor } - let result = try await client.execute( + let cached = try await client.executeCached( service: .todo, query: Self.query, variables: variables, - responseType: TrackerTicketsResponse.self + responseType: TrackerTicketsResponse.self, + cacheKey: APICacheKeys.tickets(trackerRid: trackerRid, cursor: cursor), + resourceType: .ticketList, + ttl: APICacheTTLs.ticketList, + policy: policy ) - return result.tracker.tickets + return cached.value.tracker.tickets } private struct SubmitTicketResponse: Decodable, Sendable { @@ -919,6 +940,7 @@ final class TicketListViewModel { ) if failedTicketIDs.isEmpty { + await invalidateTicketCaches() clearTicketSelection() isSelectionMode = false } else { @@ -960,6 +982,13 @@ final class TicketListViewModel { ) } + private func invalidateTicketCaches() async { + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets")) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket")) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker")) + await client.invalidateCache(prefix: APICacheKeys.prefix("home")) + } + private static func bulkStatusUpdateInput(resolution: TicketResolution) -> [String: any Sendable] { [ "status": TicketStatus.resolved.rawValue, diff --git a/Hutch/Views/Tickets/TrackerListViewModel.swift b/Hutch/Views/Tickets/TrackerListViewModel.swift index f18bb7b..e4db23c 100644 --- a/Hutch/Views/Tickets/TrackerListViewModel.swift +++ b/Hutch/Views/Tickets/TrackerListViewModel.swift @@ -118,6 +118,12 @@ final class TrackerListViewModel { hasMore = true do { + if trackers.isEmpty, let cached = try? await fetchPage(cursor: nil, policy: .cacheOnly) { + trackers = cached.results + cursor = cached.cursor + hasMore = cached.cursor != nil + isLoading = false + } let page = try await fetchPage(cursor: nil) trackers = page.results cursor = page.cursor @@ -181,6 +187,7 @@ final class TrackerListViewModel { responseType: CreateTrackerResponse.self ) let tracker = result.createTracker + await invalidateTrackerCaches() trackers.insert(tracker, at: 0) return tracker } catch { @@ -224,6 +231,7 @@ final class TrackerListViewModel { ], responseType: UpdateTrackerResponse.self ) + await invalidateTrackerCaches() applyTrackerUpdate(result.updateTracker) return result.updateTracker } catch { @@ -246,6 +254,7 @@ final class TrackerListViewModel { variables: ["id": tracker.id], responseType: DeleteTrackerResponse.self ) + await invalidateTrackerCaches() trackers.removeAll { $0.id == tracker.id } await loadTrackers() return true @@ -269,18 +278,22 @@ final class TrackerListViewModel { // MARK: - Private - private func fetchPage(cursor: String?) async throws -> TrackersPage { + private func fetchPage(cursor: String?, policy: CachePolicy = .cacheFirstThenRefresh) async throws -> TrackersPage { var variables: [String: any Sendable] = [:] if let cursor { variables["cursor"] = cursor } - let result = try await client.execute( + let cached = try await client.executeCached( service: .todo, query: Self.query, variables: variables.isEmpty ? nil : variables, - responseType: TrackersResponse.self + responseType: TrackersResponse.self, + cacheKey: APICacheKeys.trackers(cursor: cursor), + resourceType: .ticketList, + ttl: APICacheTTLs.ticketList, + policy: policy ) - return result.trackers + return cached.value.trackers } private struct CreateTrackerResponse: Decodable, Sendable { @@ -290,4 +303,10 @@ final class TrackerListViewModel { private func trackerCreationErrorMessage(for error: Error) -> String { "Couldn’t create the tracker. \(error.userFacingMessage)" } + + private func invalidateTrackerCaches() async { + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "trackers")) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker")) + await client.invalidateCache(prefix: APICacheKeys.prefix("home")) + } } |
