summaryrefslogtreecommitdiff
path: root/Hutch
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
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')
-rw-r--r--Hutch/Networking/APICache.swift30
-rw-r--r--Hutch/Networking/APICacheKeys.swift43
-rw-r--r--Hutch/Networking/PasteService.swift19
-rw-r--r--Hutch/Networking/ProjectService.swift23
-rw-r--r--Hutch/Views/Builds/BuildListViewModel.swift23
-rw-r--r--Hutch/Views/Home/HomeViewModel.swift63
-rw-r--r--Hutch/Views/Lookup/UserProfileViewModel.swift20
-rw-r--r--Hutch/Views/Pastes/PasteListViewModel.swift2
-rw-r--r--Hutch/Views/Repositories/RepositoryListViewModel.swift96
-rw-r--r--Hutch/Views/Tickets/TicketListViewModel.swift43
-rw-r--r--Hutch/Views/Tickets/TrackerListViewModel.swift27
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"))
+ }
}