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/Views/Tickets | |
| parent | 57e4f34b4613c09beb0cb757ac2ba2b43cc04daf (diff) | |
| download | hutch-3.3.1.tar.gz hutch-3.3.1.tar.bz2 hutch-3.3.1.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/Views/Tickets')
| -rw-r--r-- | Hutch/Views/Tickets/TicketListViewModel.swift | 43 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TrackerListViewModel.swift | 27 |
2 files changed, 59 insertions, 11 deletions
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")) + } } |
