summaryrefslogtreecommitdiff
path: root/Hutch/Views/Tickets
diff options
context:
space:
mode:
authorChristian Cleberg <[email protected]>2026-05-06 20:41:49 -0500
committerChristian Cleberg <[email protected]>2026-05-06 20:41:49 -0500
commit205cf2687ce48f3d004792ff223422a86cc6bdbc (patch)
tree7edb78cede354e79aa8abd764ed9c5047e0ee168 /Hutch/Views/Tickets
parent57e4f34b4613c09beb0cb757ac2ba2b43cc04daf (diff)
downloadhutch-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.swift43
-rw-r--r--Hutch/Views/Tickets/TrackerListViewModel.swift27
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"))
+ }
}