Commit 7ed0e7f12f

7ed0e7f12ff21ed31e7ecd5e72d3eeced14e467a

parent: d67a51cd1e

Unsigned

cmc <hello@cleberg.net> · 2026-05-07 01:41 UTC

feat(cache): persist read-only API responses

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.

Layout: unified · split

Docs/API_CACHE.md +1 −1
@@ -8,4 +8,4 @@ TTLs live in `APICacheTTLs`. Active build data uses a very short TTL, mutable ti
8 8
9Invalidation is intentionally prefix-based. Successful ticket mutations remove ticket, ticket-list, tracker, and Home prefixes. Build retry/cancel/resubmit actions remove build detail, build-list, build-log, and Home prefixes. This avoids a dependency graph while keeping stale post-mutation data out of the high-risk paths. 9Invalidation is intentionally prefix-based. Successful ticket mutations remove ticket, ticket-list, tracker, and Home prefixes. Build retry/cancel/resubmit actions remove build detail, build-list, build-log, and Home prefixes. This avoids a dependency graph while keeping stale post-mutation data out of the high-risk paths.
10 10
11Known limitations: the first pass is integrated into build detail/logs, ticket detail, README lookup, and repository tree/blob/file reads. Some older list-level paths still use the legacy in-memory cache facade and should move to `APICacheKeys` plus `executeCached` next. Recommended next targets are repository lists, build lists, ticket lists, profile repositories/trackers, and Home/Work Queue sections. 11Known limitations: caching now covers the high-value detail paths plus repository/build/ticket/tracker/paste lists, profile repositories/trackers, projects, and Home dashboard fetches. Some services still perform background refresh one request at a time rather than streaming partial refreshed list pages into the UI, and Work Queue-specific surfaces should be reviewed as a follow-up if they grow beyond the Home dashboard data model.
Hutch.xcodeproj/project.pbxproj +8 −8
@@ -517,7 +517,7 @@
517 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 517 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
518 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 518 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
519 CODE_SIGN_STYLE = Automatic; 519 CODE_SIGN_STYLE = Automatic;
520 CURRENT_PROJECT_VERSION = 83; 520 CURRENT_PROJECT_VERSION = 84;
521 DEVELOPMENT_TEAM = ZCNAX3VL9D; 521 DEVELOPMENT_TEAM = ZCNAX3VL9D;
522 ENABLE_PREVIEWS = YES; 522 ENABLE_PREVIEWS = YES;
523 GENERATE_INFOPLIST_FILE = YES; 523 GENERATE_INFOPLIST_FILE = YES;
@@ -534,7 +534,7 @@
534 "$(inherited)", 534 "$(inherited)",
535 "@executable_path/Frameworks", 535 "@executable_path/Frameworks",
536 ); 536 );
537 MARKETING_VERSION = 3.3.0; 537 MARKETING_VERSION = 3.3.1;
538 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 538 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
539 PRODUCT_NAME = "$(TARGET_NAME)"; 539 PRODUCT_NAME = "$(TARGET_NAME)";
540 STRING_CATALOG_GENERATE_SYMBOLS = YES; 540 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -554,7 +554,7 @@
554 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 554 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
555 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; 555 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
556 CODE_SIGN_STYLE = Automatic; 556 CODE_SIGN_STYLE = Automatic;
557 CURRENT_PROJECT_VERSION = 83; 557 CURRENT_PROJECT_VERSION = 84;
558 DEVELOPMENT_TEAM = ZCNAX3VL9D; 558 DEVELOPMENT_TEAM = ZCNAX3VL9D;
559 ENABLE_PREVIEWS = YES; 559 ENABLE_PREVIEWS = YES;
560 GENERATE_INFOPLIST_FILE = YES; 560 GENERATE_INFOPLIST_FILE = YES;
@@ -571,7 +571,7 @@
571 "$(inherited)", 571 "$(inherited)",
572 "@executable_path/Frameworks", 572 "@executable_path/Frameworks",
573 ); 573 );
574 MARKETING_VERSION = 3.3.0; 574 MARKETING_VERSION = 3.3.1;
575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; 575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
576 PRODUCT_NAME = "$(TARGET_NAME)"; 576 PRODUCT_NAME = "$(TARGET_NAME)";
577 STRING_CATALOG_GENERATE_SYMBOLS = YES; 577 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
634 APPLICATION_EXTENSION_API_ONLY = YES; 634 APPLICATION_EXTENSION_API_ONLY = YES;
635 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 635 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
636 CODE_SIGN_STYLE = Automatic; 636 CODE_SIGN_STYLE = Automatic;
637 CURRENT_PROJECT_VERSION = 83; 637 CURRENT_PROJECT_VERSION = 84;
638 DEVELOPMENT_TEAM = ZCNAX3VL9D; 638 DEVELOPMENT_TEAM = ZCNAX3VL9D;
639 GENERATE_INFOPLIST_FILE = NO; 639 GENERATE_INFOPLIST_FILE = NO;
640 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 640 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -644,7 +644,7 @@
644 "@executable_path/Frameworks", 644 "@executable_path/Frameworks",
645 "@executable_path/../../Frameworks", 645 "@executable_path/../../Frameworks",
646 ); 646 );
647 MARKETING_VERSION = 3.3.0; 647 MARKETING_VERSION = 3.3.1;
648 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 648 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
649 PRODUCT_NAME = "$(TARGET_NAME)"; 649 PRODUCT_NAME = "$(TARGET_NAME)";
650 SKIP_INSTALL = YES; 650 SKIP_INSTALL = YES;
@@ -663,7 +663,7 @@
663 APPLICATION_EXTENSION_API_ONLY = YES; 663 APPLICATION_EXTENSION_API_ONLY = YES;
664 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; 664 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
665 CODE_SIGN_STYLE = Automatic; 665 CODE_SIGN_STYLE = Automatic;
666 CURRENT_PROJECT_VERSION = 83; 666 CURRENT_PROJECT_VERSION = 84;
667 DEVELOPMENT_TEAM = ZCNAX3VL9D; 667 DEVELOPMENT_TEAM = ZCNAX3VL9D;
668 GENERATE_INFOPLIST_FILE = NO; 668 GENERATE_INFOPLIST_FILE = NO;
669 INFOPLIST_FILE = HutchWidgetExtension/Info.plist; 669 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -673,7 +673,7 @@
673 "@executable_path/Frameworks", 673 "@executable_path/Frameworks",
674 "@executable_path/../../Frameworks", 674 "@executable_path/../../Frameworks",
675 ); 675 );
676 MARKETING_VERSION = 3.3.0; 676 MARKETING_VERSION = 3.3.1;
677 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; 677 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
678 PRODUCT_NAME = "$(TARGET_NAME)"; 678 PRODUCT_NAME = "$(TARGET_NAME)";
679 SKIP_INSTALL = YES; 679 SKIP_INSTALL = YES;
Hutch/Networking/APICache.swift +14 −16
@@ -35,7 +35,7 @@ struct CacheEntryMetadata: Codable, Sendable, Equatable {
35 let schemaVersion: Int 35 let schemaVersion: Int
36 let payloadSize: Int 36 let payloadSize: Int
37 37
38 func isExpired(now: Date = Date()) -> Bool { 38 nonisolated func isExpired(now: Date = Date()) -> Bool {
39 expiresAt <= now 39 expiresAt <= now
40 } 40 }
41} 41}
@@ -83,7 +83,7 @@ struct APICacheConfiguration: Sendable {
83 var memoryEntryLimit: Int 83 var memoryEntryLimit: Int
84 var schemaVersion: Int 84 var schemaVersion: Int
85 85
86 static func accountScoped(accountID: String) -> APICacheConfiguration { 86 nonisolated static func accountScoped(accountID: String) -> APICacheConfiguration {
87 let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first 87 let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
88 ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) 88 ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
89 return APICacheConfiguration( 89 return APICacheConfiguration(
@@ -98,7 +98,7 @@ struct APICacheConfiguration: Sendable {
98 ) 98 )
99 } 99 }
100 100
101 static func temporary(directory: URL) -> APICacheConfiguration { 101 nonisolated static func temporary(directory: URL) -> APICacheConfiguration {
102 APICacheConfiguration( 102 APICacheConfiguration(
103 directory: directory, 103 directory: directory,
104 maxCacheSizeBytes: 4 * 1024 * 1024, 104 maxCacheSizeBytes: 4 * 1024 * 1024,
@@ -126,15 +126,13 @@ actor PersistentAPICache: APICache {
126 } 126 }
127 127
128 private let configuration: APICacheConfiguration 128 private let configuration: APICacheConfiguration
129 private let fileManager: FileManager
130 private var memoryEntries: [String: APICacheEntry] = [:] 129 private var memoryEntries: [String: APICacheEntry] = [:]
131 private var memoryOrder: [String] = [] 130 private var memoryOrder: [String] = []
132 private var knownMetadata: [String: CacheEntryMetadata] = [:] 131 private var knownMetadata: [String: CacheEntryMetadata] = [:]
133 private var writeCountSincePrune = 0 132 private var writeCountSincePrune = 0
134 133
135 init(configuration: APICacheConfiguration, fileManager: FileManager = .default) { 134 init(configuration: APICacheConfiguration) {
136 self.configuration = configuration 135 self.configuration = configuration
137 self.fileManager = fileManager
138 } 136 }
139 137
140 func read(cacheKey: String) async throws -> APICacheEntry { 138 func read(cacheKey: String) async throws -> APICacheEntry {
@@ -147,13 +145,13 @@ actor PersistentAPICache: APICache {
147 } 145 }
148 146
149 let url = fileURL(for: cacheKey) 147 let url = fileURL(for: cacheKey)
150 guard fileManager.fileExists(atPath: url.path) else { 148 guard FileManager.default.fileExists(atPath: url.path) else {
151 throw APICacheError.miss 149 throw APICacheError.miss
152 } 150 }
153 151
154 var stored = try decodeEntry(from: url) 152 var stored = try decodeEntry(from: url)
155 guard stored.metadata.schemaVersion == configuration.schemaVersion else { 153 guard stored.metadata.schemaVersion == configuration.schemaVersion else {
156 try? fileManager.removeItem(at: url) 154 try? FileManager.default.removeItem(at: url)
157 throw APICacheError.miss 155 throw APICacheError.miss
158 } 156 }
159 157
@@ -223,7 +221,7 @@ actor PersistentAPICache: APICache {
223 memoryEntries.removeValue(forKey: cacheKey) 221 memoryEntries.removeValue(forKey: cacheKey)
224 memoryOrder.removeAll { $0 == cacheKey } 222 memoryOrder.removeAll { $0 == cacheKey }
225 knownMetadata.removeValue(forKey: cacheKey) 223 knownMetadata.removeValue(forKey: cacheKey)
226 try? fileManager.removeItem(at: fileURL(for: cacheKey)) 224 try? FileManager.default.removeItem(at: fileURL(for: cacheKey))
227 } 225 }
228 226
229 func removeByPrefix(_ prefix: String) async { 227 func removeByPrefix(_ prefix: String) async {
@@ -237,7 +235,7 @@ actor PersistentAPICache: APICache {
237 memoryEntries.removeAll() 235 memoryEntries.removeAll()
238 memoryOrder.removeAll() 236 memoryOrder.removeAll()
239 knownMetadata.removeAll() 237 knownMetadata.removeAll()
240 try? fileManager.removeItem(at: configuration.directory) 238 try? FileManager.default.removeItem(at: configuration.directory)
241 } 239 }
242 240
243 func pruneExpired(now: Date = Date()) async { 241 func pruneExpired(now: Date = Date()) async {
@@ -289,7 +287,7 @@ actor PersistentAPICache: APICache {
289 287
290 private func loadKnownMetadataIfNeeded() async { 288 private func loadKnownMetadataIfNeeded() async {
291 guard knownMetadata.isEmpty else { return } 289 guard knownMetadata.isEmpty else { return }
292 guard let urls = try? fileManager.contentsOfDirectory( 290 guard let urls = try? FileManager.default.contentsOfDirectory(
293 at: configuration.directory, 291 at: configuration.directory,
294 includingPropertiesForKeys: nil 292 includingPropertiesForKeys: nil
295 ) else { return } 293 ) else { return }
@@ -301,8 +299,8 @@ actor PersistentAPICache: APICache {
301 } 299 }
302 300
303 private func ensureDirectoryExists() throws { 301 private func ensureDirectoryExists() throws {
304 if !fileManager.fileExists(atPath: configuration.directory.path) { 302 if !FileManager.default.fileExists(atPath: configuration.directory.path) {
305 try fileManager.createDirectory( 303 try FileManager.default.createDirectory(
306 at: configuration.directory, 304 at: configuration.directory,
307 withIntermediateDirectories: true 305 withIntermediateDirectories: true
308 ) 306 )
@@ -315,13 +313,13 @@ actor PersistentAPICache: APICache {
315 .appendingPathExtension("json") 313 .appendingPathExtension("json")
316 } 314 }
317 315
318 private static func payloadHash(_ data: Data) -> String { 316 nonisolated private static func payloadHash(_ data: Data) -> String {
319 SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() 317 SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
320 } 318 }
321} 319}
322 320
323extension JSONEncoder { 321extension JSONEncoder {
324 static var srhtCache: JSONEncoder { 322 nonisolated static var srhtCache: JSONEncoder {
325 let encoder = JSONEncoder() 323 let encoder = JSONEncoder()
326 encoder.dateEncodingStrategy = .iso8601 324 encoder.dateEncodingStrategy = .iso8601
327 return encoder 325 return encoder
@@ -329,7 +327,7 @@ extension JSONEncoder {
329} 327}
330 328
331extension JSONDecoder { 329extension JSONDecoder {
332 static var srhtCache: JSONDecoder { 330 nonisolated static var srhtCache: JSONDecoder {
333 let decoder = JSONDecoder() 331 let decoder = JSONDecoder()
334 decoder.dateDecodingStrategy = .iso8601 332 decoder.dateDecodingStrategy = .iso8601
335 return decoder 333 return decoder
Hutch/Networking/APICacheKeys.swift +43
@@ -51,6 +51,14 @@ enum APICacheKeys {
51 make([SRHTService.todo.rawValue, "tracker-labels", "tracker:\(trackerRid)"]) 51 make([SRHTService.todo.rawValue, "tracker-labels", "tracker:\(trackerRid)"])
52 } 52 }
53 53
54 static func trackers(cursor: String? = nil) -> String {
55 make([SRHTService.todo.rawValue, "trackers", cursor.map { "cursor:\($0)" }])
56 }
57
58 static func tickets(trackerRid: String, cursor: String? = nil) -> String {
59 make([SRHTService.todo.rawValue, "tickets", "tracker:\(trackerRid)", cursor.map { "cursor:\($0)" }])
60 }
61
54 static func builds(cursor: String? = nil, filter: String? = nil) -> String { 62 static func builds(cursor: String? = nil, filter: String? = nil) -> String {
55 make([SRHTService.builds.rawValue, "jobs", cursor.map { "cursor:\($0)" }, filter.map { "filter:\($0)" }]) 63 make([SRHTService.builds.rawValue, "jobs", cursor.map { "cursor:\($0)" }, filter.map { "filter:\($0)" }])
56 } 64 }
@@ -71,6 +79,37 @@ enum APICacheKeys {
71 make([SRHTService.todo.rawValue, "user-trackers", normalize(owner), cursor.map { "cursor:\($0)" }]) 79 make([SRHTService.todo.rawValue, "user-trackers", normalize(owner), cursor.map { "cursor:\($0)" }])
72 } 80 }
73 81
82 static func projects(cursor: String? = nil) -> String {
83 make([SRHTService.hub.rawValue, "projects", cursor.map { "cursor:\($0)" }])
84 }
85
86 static func projectDetail(rid: String, mailingListsCursor: String? = nil, sourcesCursor: String? = nil, trackersCursor: String? = nil) -> String {
87 make([
88 SRHTService.hub.rawValue,
89 "project",
90 "rid:\(rid)",
91 mailingListsCursor.map { "ml:\($0)" },
92 sourcesCursor.map { "src:\($0)" },
93 trackersCursor.map { "trk:\($0)" }
94 ])
95 }
96
97 static func homeJobs(actor: String) -> String {
98 make(["home", "jobs", normalize(actor)])
99 }
100
101 static func homeTrackerTickets(owner: String, tracker: String) -> String {
102 make(["home", "tickets", normalize(owner), normalize(tracker)])
103 }
104
105 static func inboxSubscriptions(cursor: String? = nil) -> String {
106 make([SRHTService.lists.rawValue, "subscriptions", cursor.map { "cursor:\($0)" }])
107 }
108
109 static func inboxThreads(listRid: String, cursor: String? = nil) -> String {
110 make([SRHTService.lists.rawValue, "threads", "list:\(listRid)", cursor.map { "cursor:\($0)" }])
111 }
112
74 static func pasteList(cursor: String? = nil) -> String { 113 static func pasteList(cursor: String? = nil) -> String {
75 make([SRHTService.paste.rawValue, "pastes", cursor.map { "cursor:\($0)" }]) 114 make([SRHTService.paste.rawValue, "pastes", cursor.map { "cursor:\($0)" }])
76 } 115 }
@@ -102,4 +141,8 @@ enum APICacheTTLs {
102 static let movingRefFileContent: TimeInterval = 10 * 60 141 static let movingRefFileContent: TimeInterval = 10 * 60
103 static let userProfile: TimeInterval = 30 * 60 142 static let userProfile: TimeInterval = 30 * 60
104 static let status: TimeInterval = 5 * 60 143 static let status: TimeInterval = 5 * 60
144 static let homeDashboard: TimeInterval = 2 * 60
145 static let inboxSummary: TimeInterval = 2 * 60
146 static let projectList: TimeInterval = 10 * 60
147 static let projectDetail: TimeInterval = 10 * 60
105} 148}
Hutch/Networking/PasteService.swift +15 −4
@@ -109,13 +109,17 @@ final class PasteService: Sendable {
109 let variables = cursor.map { ["cursor": $0 as any Sendable] } 109 let variables = cursor.map { ["cursor": $0 as any Sendable] }
110 let result: PasteListResponse 110 let result: PasteListResponse
111 if useCache, cursor == nil { 111 if useCache, cursor == nil {
112 result = try await client.executeAndCache( 112 let cached = try await client.executeCached(
113 service: .paste, 113 service: .paste,
114 query: Self.listQuery, 114 query: Self.listQuery,
115 variables: variables, 115 variables: variables,
116 responseType: PasteListResponse.self, 116 responseType: PasteListResponse.self,
117 cacheKey: Self.cacheKey 117 cacheKey: APICacheKeys.pasteList(cursor: cursor),
118 resourceType: .pasteList,
119 ttl: APICacheTTLs.ticketList,
120 policy: .cacheFirstThenRefresh
118 ) 121 )
122 result = cached.value
119 } else { 123 } else {
120 result = try await client.execute( 124 result = try await client.execute(
121 service: .paste, 125 service: .paste,
@@ -127,8 +131,8 @@ final class PasteService: Sendable {
127 return result.pastes ?? PasteListPage(results: [], cursor: nil) 131 return result.pastes ?? PasteListPage(results: [], cursor: nil)
128 } 132 }
129 133
130 func loadCachedPastes() -> PasteListPage? { 134 func loadCachedPastes() async -> PasteListPage? {
131 guard let data = client.responseCache.get(forKey: Self.cacheKey) else { 135 guard let data = await client.cachedPayload(forKey: APICacheKeys.pasteList()) ?? client.responseCache.get(forKey: Self.cacheKey) else {
132 return nil 136 return nil
133 } 137 }
134 138
@@ -171,6 +175,7 @@ final class PasteService: Sendable {
171 }, 175 },
172 responseType: CreatePasteResponse.self 176 responseType: CreatePasteResponse.self
173 ) 177 )
178 await invalidatePasteCaches()
174 return result.create 179 return result.create
175 } 180 }
176 181
@@ -181,6 +186,7 @@ final class PasteService: Sendable {
181 variables: ["id": id, "visibility": visibility.rawValue], 186 variables: ["id": id, "visibility": visibility.rawValue],
182 responseType: UpdatePasteResponse.self 187 responseType: UpdatePasteResponse.self
183 ) 188 )
189 await invalidatePasteCaches()
184 return result.update 190 return result.update
185 } 191 }
186 192
@@ -191,6 +197,7 @@ final class PasteService: Sendable {
191 variables: ["id": id], 197 variables: ["id": id],
192 responseType: DeletePasteResponse.self 198 responseType: DeletePasteResponse.self
193 ) 199 )
200 await invalidatePasteCaches()
194 return result.delete 201 return result.delete
195 } 202 }
196 203
@@ -208,6 +215,10 @@ final class PasteService: Sendable {
208 return (draft.filename, data) 215 return (draft.filename, data)
209 } 216 }
210 } 217 }
218
219 private func invalidatePasteCaches() async {
220 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.paste.rawValue, "pastes"))
221 }
211} 222}
212 223
213private struct PasteListResponse: Decodable, Sendable { 224private struct PasteListResponse: Decodable, Sendable {
Hutch/Networking/ProjectService.swift +19 −4
@@ -287,12 +287,17 @@ struct ProjectService: Sendable {
287 variables["cursor"] = cursor 287 variables["cursor"] = cursor
288 } 288 }
289 289
290 let response = try await client.execute( 290 let cached = try await client.executeCached(
291 service: .hub, 291 service: .hub,
292 query: Self.projectsQuery, 292 query: Self.projectsQuery,
293 variables: variables.isEmpty ? nil : variables, 293 variables: variables.isEmpty ? nil : variables,
294 responseType: ProjectPageResponse.self 294 responseType: ProjectPageResponse.self,
295 cacheKey: APICacheKeys.projects(cursor: cursor),
296 resourceType: .userProfile,
297 ttl: APICacheTTLs.projectList,
298 policy: .cacheFirstThenRefresh
295 ) 299 )
300 let response = cached.value
296 301
297 results.append(contentsOf: response.me.projects.results) 302 results.append(contentsOf: response.me.projects.results)
298 guard let nextCursor = response.me.projects.cursor else { 303 guard let nextCursor = response.me.projects.cursor else {
@@ -324,12 +329,22 @@ struct ProjectService: Sendable {
324 variables["trackersCursor"] = trackersCursor 329 variables["trackersCursor"] = trackersCursor
325 } 330 }
326 331
327 let response = try await client.execute( 332 let cached = try await client.executeCached(
328 service: .hub, 333 service: .hub,
329 query: Self.projectDetailQuery, 334 query: Self.projectDetailQuery,
330 variables: variables, 335 variables: variables,
331 responseType: ProjectDetailResponse.self 336 responseType: ProjectDetailResponse.self,
337 cacheKey: APICacheKeys.projectDetail(
338 rid: rid,
339 mailingListsCursor: mailingListsCursor,
340 sourcesCursor: sourcesCursor,
341 trackersCursor: trackersCursor
342 ),
343 resourceType: .userProfile,
344 ttl: APICacheTTLs.projectDetail,
345 policy: .cacheFirstThenRefresh
332 ) 346 )
347 let response = cached.value
333 348
334 guard let project = response.project else { 349 guard let project = response.project else {
335 throw SRHTError.decodingError( 350 throw SRHTError.decodingError(
Hutch/Views/Builds/BuildListViewModel.swift +17 −6
@@ -188,7 +188,7 @@ final class BuildListViewModel {
188 func loadJobs() async { 188 func loadJobs() async {
189 // Show cached data immediately on first load (may populate `jobs` from cache). 189 // Show cached data immediately on first load (may populate `jobs` from cache).
190 if jobs.isEmpty { 190 if jobs.isEmpty {
191 loadFromCache() 191 await loadFromCache()
192 } 192 }
193 193
194 let treatAsInitialLoad = jobs.isEmpty 194 let treatAsInitialLoad = jobs.isEmpty
@@ -279,6 +279,7 @@ final class BuildListViewModel {
279 variables: variables, 279 variables: variables,
280 responseType: SubmitJobResponse.self 280 responseType: SubmitJobResponse.self
281 ) 281 )
282 await invalidateBuildListCache()
282 await loadJobs() 283 await loadJobs()
283 return result.submit.id 284 return result.submit.id
284 } catch { 285 } catch {
@@ -297,6 +298,7 @@ final class BuildListViewModel {
297 variables: ["id": job.id], 298 variables: ["id": job.id],
298 responseType: CancelResponse.self 299 responseType: CancelResponse.self
299 ) 300 )
301 await invalidateBuildListCache()
300 if let index = jobs.firstIndex(where: { $0.id == job.id }) { 302 if let index = jobs.firstIndex(where: { $0.id == job.id }) {
301 let updated = JobSummary( 303 let updated = JobSummary(
302 id: job.id, 304 id: job.id,
@@ -345,14 +347,17 @@ final class BuildListViewModel {
345 } 347 }
346 348
347 if useCache && cursor == nil { 349 if useCache && cursor == nil {
348 let result = try await client.executeAndCache( 350 let cached = try await client.executeCached(
349 service: .builds, 351 service: .builds,
350 query: Self.query, 352 query: Self.query,
351 variables: variables.isEmpty ? nil : variables, 353 variables: variables.isEmpty ? nil : variables,
352 responseType: JobsResponse.self, 354 responseType: JobsResponse.self,
353 cacheKey: Self.cacheKey 355 cacheKey: APICacheKeys.builds(cursor: cursor),
356 resourceType: .buildList,
357 ttl: APICacheTTLs.activeBuild,
358 policy: .cacheFirstThenRefresh
354 ) 359 )
355 return result.jobs 360 return cached.value.jobs
356 } else { 361 } else {
357 let result = try await client.execute( 362 let result = try await client.execute(
358 service: .builds, 363 service: .builds,
@@ -364,8 +369,14 @@ final class BuildListViewModel {
364 } 369 }
365 } 370 }
366 371
367 private func loadFromCache() { 372 private func invalidateBuildListCache() async {
368 guard let data = client.responseCache.get(forKey: Self.cacheKey) else { return } 373 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "jobs"))
374 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "job"))
375 await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
376 }
377
378 private func loadFromCache() async {
379 guard let data = await client.cachedPayload(forKey: APICacheKeys.builds()) ?? client.responseCache.get(forKey: Self.cacheKey) else { return }
369 let decoder = JSONDecoder() 380 let decoder = JSONDecoder()
370 decoder.dateDecodingStrategy = .srhtFlexible 381 decoder.dateDecodingStrategy = .srhtFlexible
371 if let response = try? decoder.decode( 382 if let response = try? decoder.decode(
Hutch/Views/Home/HomeViewModel.swift +52 −11
@@ -578,6 +578,7 @@ final class HomeViewModel {
578 ], 578 ],
579 responseType: UnassignResponse.self 579 responseType: UnassignResponse.self
580 ) 580 )
581 await invalidateHomeRelatedCaches()
581 assignedTickets.removeAll { $0.id == ticket.id } 582 assignedTickets.removeAll { $0.id == ticket.id }
582 persistNeedsAttentionSnapshot() 583 persistNeedsAttentionSnapshot()
583 } catch { 584 } catch {
@@ -595,6 +596,7 @@ final class HomeViewModel {
595 variables: ["id": build.job.id], 596 variables: ["id": build.job.id],
596 responseType: CancelBuildResponse.self 597 responseType: CancelBuildResponse.self
597 ) 598 )
599 await invalidateHomeRelatedCaches()
598 if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) { 600 if let index = recentBuilds.firstIndex(where: { $0.id == build.id }) {
599 let updatedJob = JobSummary( 601 let updatedJob = JobSummary(
600 id: build.job.id, 602 id: build.job.id,
@@ -684,12 +686,16 @@ final class HomeViewModel {
684 686
685 private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> { 687 private func loadRecentJobs() async -> Result<[HomeJobPayload], Error> {
686 do { 688 do {
687 let response = try await client.execute( 689 let cached = try await client.executeCached(
688 service: .builds, 690 service: .builds,
689 query: Self.jobsQuery, 691 query: Self.jobsQuery,
690 responseType: HomeJobsResponse.self 692 responseType: HomeJobsResponse.self,
693 cacheKey: APICacheKeys.homeJobs(actor: currentUser.canonicalName),
694 resourceType: .buildList,
695 ttl: APICacheTTLs.homeDashboard,
696 policy: .cacheFirstThenRefresh
691 ) 697 )
692 return .success(response.jobs.results) 698 return .success(cached.value.jobs.results)
693 } catch { 699 } catch {
694 return .failure(error) 700 return .failure(error)
695 } 701 }
@@ -780,12 +786,17 @@ final class HomeViewModel {
780 variables["cursor"] = cursor 786 variables["cursor"] = cursor
781 } 787 }
782 788
783 let response = try await client.execute( 789 let cached = try await client.executeCached(
784 service: .lists, 790 service: .lists,
785 query: Self.inboxSubscriptionsQuery, 791 query: Self.inboxSubscriptionsQuery,
786 variables: variables.isEmpty ? nil : variables, 792 variables: variables.isEmpty ? nil : variables,
787 responseType: HomeInboxSubscriptionsResponse.self 793 responseType: HomeInboxSubscriptionsResponse.self,
794 cacheKey: APICacheKeys.inboxSubscriptions(cursor: cursor),
795 resourceType: .ticketList,
796 ttl: APICacheTTLs.inboxSummary,
797 policy: .cacheFirstThenRefresh
788 ) 798 )
799 let response = cached.value
789 800
790 subscriptions.append(contentsOf: response.subscriptions.results) 801 subscriptions.append(contentsOf: response.subscriptions.results)
791 guard let nextCursor = response.subscriptions.cursor else { 802 guard let nextCursor = response.subscriptions.cursor else {
@@ -809,12 +820,17 @@ final class HomeViewModel {
809 variables["cursor"] = cursor 820 variables["cursor"] = cursor
810 } 821 }
811 822
812 let response = try await client.execute( 823 let cached = try await client.executeCached(
813 service: .lists, 824 service: .lists,
814 query: Self.inboxListThreadsQuery, 825 query: Self.inboxListThreadsQuery,
815 variables: variables, 826 variables: variables,
816 responseType: HomeInboxListThreadsResponse.self 827 responseType: HomeInboxListThreadsResponse.self,
828 cacheKey: APICacheKeys.inboxThreads(listRid: mailingList.rid, cursor: cursor),
829 resourceType: .ticketList,
830 ttl: APICacheTTLs.inboxSummary,
831 policy: .cacheFirstThenRefresh
817 ) 832 )
833 let response = cached.value
818 834
819 let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in 835 let unreadThreadSummaries = response.list.threads.results.compactMap { thread -> InboxThreadSummary? in
820 let summary = InboxThreadSummary( 836 let summary = InboxThreadSummary(
@@ -876,12 +892,17 @@ final class HomeViewModel {
876 variables["cursor"] = cursor 892 variables["cursor"] = cursor
877 } 893 }
878 894
879 let response = try await client.execute( 895 let cached = try await client.executeCached(
880 service: .todo, 896 service: .todo,
881 query: Self.trackersQuery, 897 query: Self.trackersQuery,
882 variables: variables.isEmpty ? nil : variables, 898 variables: variables.isEmpty ? nil : variables,
883 responseType: HomeTrackersResponse.self 899 responseType: HomeTrackersResponse.self,
900 cacheKey: APICacheKeys.trackers(cursor: cursor),
901 resourceType: .ticketList,
902 ttl: APICacheTTLs.ticketList,
903 policy: .cacheFirstThenRefresh
884 ) 904 )
905 let response = cached.value
885 906
886 allTrackers.append(contentsOf: response.trackers.results) 907 allTrackers.append(contentsOf: response.trackers.results)
887 guard let nextCursor = response.trackers.cursor else { 908 guard let nextCursor = response.trackers.cursor else {
@@ -925,7 +946,7 @@ final class HomeViewModel {
925 } 946 }
926 947
927 private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] { 948 private func fetchAssignedTickets(for tracker: TrackerSummary) async throws -> [HomeAssignedTicket] {
928 let response = try await client.execute( 949 let cached = try await client.executeCached(
929 service: .todo, 950 service: .todo,
930 query: Self.trackerTicketsQuery, 951 query: Self.trackerTicketsQuery,
931 variables: [ 952 variables: [
@@ -934,8 +955,18 @@ final class HomeViewModel {
934 : tracker.owner.canonicalName, 955 : tracker.owner.canonicalName,
935 "tracker": tracker.name 956 "tracker": tracker.name
936 ], 957 ],
937 responseType: HomeTrackerTicketsResponse.self 958 responseType: HomeTrackerTicketsResponse.self,
959 cacheKey: APICacheKeys.homeTrackerTickets(
960 owner: tracker.owner.canonicalName.hasPrefix("~")
961 ? String(tracker.owner.canonicalName.dropFirst())
962 : tracker.owner.canonicalName,
963 tracker: tracker.name
964 ),
965 resourceType: .ticketList,
966 ttl: APICacheTTLs.ticketList,
967 policy: .cacheFirstThenRefresh
938 ) 968 )
969 let response = cached.value
939 970
940 return response.user.tracker.tickets.results.compactMap { payload in 971 return response.user.tracker.tickets.results.compactMap { payload in
941 guard payload.status.isOpen else { 972 guard payload.status.isOpen else {
@@ -970,6 +1001,7 @@ final class HomeViewModel {
970 ], 1001 ],
971 responseType: StatusEventResponse.self 1002 responseType: StatusEventResponse.self
972 ) 1003 )
1004 await invalidateHomeRelatedCaches()
973 assignedTickets.removeAll { $0.id == ticket.id } 1005 assignedTickets.removeAll { $0.id == ticket.id }
974 persistNeedsAttentionSnapshot() 1006 persistNeedsAttentionSnapshot()
975 } catch { 1007 } catch {
@@ -977,6 +1009,15 @@ final class HomeViewModel {
977 } 1009 }
978 } 1010 }
979 1011
1012 private func invalidateHomeRelatedCaches() async {
1013 await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
1014 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets"))
1015 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
1016 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "trackers"))
1017 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "jobs"))
1018 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "job"))
1019 }
1020
980 private func persistNeedsAttentionSnapshot() { 1021 private func persistNeedsAttentionSnapshot() {
981 let failedBuildCount = recentFailedBuilds().count 1022 let failedBuildCount = recentFailedBuilds().count
982 NeedsAttentionSnapshotStore.save( 1023 NeedsAttentionSnapshotStore.save(
Hutch/Views/Lookup/UserProfileViewModel.swift +14 −6
@@ -49,13 +49,17 @@ final class UserProfileViewModel {
49 defer { isLoadingRepositories = false } 49 defer { isLoadingRepositories = false }
50 50
51 do { 51 do {
52 let result = try await client.execute( 52 let cached = try await client.executeCached(
53 service: .git, 53 service: .git,
54 query: Self.repositoriesQuery, 54 query: Self.repositoriesQuery,
55 variables: ["owner": ownerUsername], 55 variables: ["owner": ownerUsername],
56 responseType: UserRepositoriesResponse.self 56 responseType: UserRepositoriesResponse.self,
57 cacheKey: APICacheKeys.userRepositories(owner: ownerUsername),
58 resourceType: .userProfile,
59 ttl: APICacheTTLs.userProfile,
60 policy: .cacheFirstThenRefresh
57 ) 61 )
58 repositories = result.user.repositories.results.map { $0.repositorySummary(service: .git) } 62 repositories = cached.value.user.repositories.results.map { $0.repositorySummary(service: .git) }
59 } catch { 63 } catch {
60 repositoriesError = error.userFacingMessage 64 repositoriesError = error.userFacingMessage
61 } 65 }
@@ -78,13 +82,17 @@ final class UserProfileViewModel {
78 defer { isLoadingTrackers = false } 82 defer { isLoadingTrackers = false }
79 83
80 do { 84 do {
81 let result = try await client.execute( 85 let cached = try await client.executeCached(
82 service: .todo, 86 service: .todo,
83 query: Self.trackersQuery, 87 query: Self.trackersQuery,
84 variables: ["owner": ownerUsername], 88 variables: ["owner": ownerUsername],
85 responseType: UserTrackersResponse.self 89 responseType: UserTrackersResponse.self,
90 cacheKey: APICacheKeys.userTrackers(owner: ownerUsername),
91 resourceType: .userProfile,
92 ttl: APICacheTTLs.userProfile,
93 policy: .cacheFirstThenRefresh
86 ) 94 )
87 trackers = result.user.trackers.results 95 trackers = cached.value.user.trackers.results
88 } catch { 96 } catch {
89 trackersError = error.userFacingMessage 97 trackersError = error.userFacingMessage
90 } 98 }
Hutch/Views/Pastes/PasteListViewModel.swift +1 −1
@@ -43,7 +43,7 @@ final class PasteListViewModel {
43 } 43 }
44 44
45 func loadPastes() async { 45 func loadPastes() async {
46 if pastes.isEmpty, let cached = service.loadCachedPastes() { 46 if pastes.isEmpty, let cached = await service.loadCachedPastes() {
47 pastes = cached.results 47 pastes = cached.results
48 cursor = cached.cursor 48 cursor = cached.cursor
49 hasMore = cached.cursor != nil 49 hasMore = cached.cursor != nil
Hutch/Views/Repositories/RepositoryListViewModel.swift +61 −35
@@ -155,7 +155,7 @@ final class RepositoryListViewModel {
155 155
156 // Only use cache for non-search, initial loads 156 // Only use cache for non-search, initial loads
157 if !isSearch, repositories.isEmpty { 157 if !isSearch, repositories.isEmpty {
158 loadFromCache() 158 await loadFromCache()
159 } 159 }
160 160
161 // During search, never show the full-screen loading overlay (which 161 // During search, never show the full-screen loading overlay (which
@@ -282,6 +282,8 @@ final class RepositoryListViewModel {
282 ) 282 )
283 repository = result.createRepository.repositorySummary(service: .hg) 283 repository = result.createRepository.repositorySummary(service: .hg)
284 } 284 }
285 await client.invalidateCache(prefix: APICacheKeys.prefix(repository.service.rawValue, "repositories"))
286 await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
285 repositories.insert(repository, at: 0) 287 repositories.insert(repository, at: 0)
286 insertIntoSearchIndex(repository) 288 insertIntoSearchIndex(repository)
287 scheduleBuildStatusRefresh() 289 scheduleBuildStatusRefresh()
@@ -469,13 +471,17 @@ final class RepositoryListViewModel {
469 if useCache && cursor == nil { 471 if useCache && cursor == nil {
470 if service == .hg { 472 if service == .hg {
471 let hgVariables = cursor.map { ["cursor": $0 as any Sendable] } 473 let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
472 let result = try await client.executeAndCache( 474 let cached = try await client.executeCached(
473 service: service, 475 service: service,
474 query: Self.hgQuery, 476 query: Self.hgQuery,
475 variables: hgVariables, 477 variables: hgVariables,
476 responseType: HGRepositoriesResponse.self, 478 responseType: HGRepositoriesResponse.self,
477 cacheKey: cacheKey(for: service) 479 cacheKey: cacheKey(for: service),
480 resourceType: .repositoryList,
481 ttl: APICacheTTLs.repositoryList,
482 policy: .cacheFirstThenRefresh
478 ) 483 )
484 let result = cached.value
479 return Page( 485 return Page(
480 results: result.repositories?.results.map { 486 results: result.repositories?.results.map {
481 RepositoryPayload( 487 RepositoryPayload(
@@ -492,14 +498,17 @@ final class RepositoryListViewModel {
492 cursor: result.repositories?.cursor 498 cursor: result.repositories?.cursor
493 ) 499 )
494 } 500 }
495 let result = try await client.executeAndCache( 501 let cached = try await client.executeCached(
496 service: service, 502 service: service,
497 query: Self.gitQuery, 503 query: Self.gitQuery,
498 variables: variables.isEmpty ? nil : variables, 504 variables: variables.isEmpty ? nil : variables,
499 responseType: RepositoriesResponse.self, 505 responseType: RepositoriesResponse.self,
500 cacheKey: cacheKey(for: service) 506 cacheKey: cacheKey(for: service),
507 resourceType: .repositoryList,
508 ttl: APICacheTTLs.repositoryList,
509 policy: .cacheFirstThenRefresh
501 ) 510 )
502 return result.repositories ?? Self.emptyPage 511 return cached.value.repositories ?? Self.emptyPage
503 } else { 512 } else {
504 if service == .hg { 513 if service == .hg {
505 let hgVariables = cursor.map { ["cursor": $0 as any Sendable] } 514 let hgVariables = cursor.map { ["cursor": $0 as any Sendable] }
@@ -535,31 +544,14 @@ final class RepositoryListViewModel {
535 } 544 }
536 } 545 }
537 546
538 private func loadFromCache() { 547 private func loadFromCache() async {
539 let cachedRepositories = [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in 548 var persistedRepositories: [RepositorySummary] = []
540 guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] } 549 for service in [SRHTService.git, .hg] {
541 let decoder = JSONDecoder() 550 if let data = await client.cachedPayload(forKey: cacheKey(for: service)) {
542 decoder.dateDecodingStrategy = .srhtFlexible 551 persistedRepositories.append(contentsOf: Self.decodeCachedRepositories(data, service: service))
543 switch service {
544 case .git:
545 if let response = try? decoder.decode(
546 GraphQLResponse<RepositoriesResponse>.self,
547 from: data
548 ), let repos = response.data?.repositories {
549 return repos.results.map { $0.repositorySummary(service: service) }
550 }
551 case .hg:
552 if let response = try? decoder.decode(
553 GraphQLResponse<HGRepositoriesResponse>.self,
554 from: data
555 ), let repos = response.data?.repositories {
556 return repos.results.map { $0.repositorySummary(service: service) }
557 }
558 default:
559 break
560 } 552 }
561 return []
562 } 553 }
554 let cachedRepositories = persistedRepositories.isEmpty ? legacyCachedRepositories() : persistedRepositories
563 if !cachedRepositories.isEmpty { 555 if !cachedRepositories.isEmpty {
564 let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder) 556 let sortedRepositories = cachedRepositories.sorted(by: repositorySortOrder)
565 repositories = sortedRepositories 557 repositories = sortedRepositories
@@ -568,6 +560,37 @@ final class RepositoryListViewModel {
568 } 560 }
569 } 561 }
570 562
563 private func legacyCachedRepositories() -> [RepositorySummary] {
564 [SRHTService.git, .hg].flatMap { service -> [RepositorySummary] in
565 guard let data = client.responseCache.get(forKey: cacheKey(for: service)) else { return [] }
566 return Self.decodeCachedRepositories(data, service: service)
567 }
568 }
569
570 private static func decodeCachedRepositories(_ data: Data, service: SRHTService) -> [RepositorySummary] {
571 let decoder = JSONDecoder()
572 decoder.dateDecodingStrategy = .srhtFlexible
573 switch service {
574 case .git:
575 if let response = try? decoder.decode(
576 GraphQLResponse<RepositoriesResponse>.self,
577 from: data
578 ), let repos = response.data?.repositories {
579 return repos.results.map { $0.repositorySummary(service: service) }
580 }
581 case .hg:
582 if let response = try? decoder.decode(
583 GraphQLResponse<HGRepositoriesResponse>.self,
584 from: data
585 ), let repos = response.data?.repositories {
586 return repos.results.map { $0.repositorySummary(service: service) }
587 }
588 default:
589 break
590 }
591 return []
592 }
593
571 private func scheduleBuildStatusRefresh(force: Bool = false) { 594 private func scheduleBuildStatusRefresh(force: Bool = false) {
572 // Skip if we already refreshed recently (120-second TTL). Pull-to-refresh 595 // Skip if we already refreshed recently (120-second TTL). Pull-to-refresh
573 // passes force: true to bypass this check. 596 // passes force: true to bypass this check.
@@ -640,14 +663,17 @@ final class RepositoryListViewModel {
640 } 663 }
641 664
642 if useCache && cursor == nil { 665 if useCache && cursor == nil {
643 let result = try await client.executeAndCache( 666 let cached = try await client.executeCached(
644 service: .builds, 667 service: .builds,
645 query: Self.buildsQuery, 668 query: Self.buildsQuery,
646 variables: variables.isEmpty ? nil : variables, 669 variables: variables.isEmpty ? nil : variables,
647 responseType: BuildJobsResponse.self, 670 responseType: BuildJobsResponse.self,
648 cacheKey: Self.buildsCacheKey 671 cacheKey: APICacheKeys.builds(cursor: cursor, filter: "repository-status"),
672 resourceType: .buildList,
673 ttl: APICacheTTLs.activeBuild,
674 policy: .cacheFirstThenRefresh
649 ) 675 )
650 return result.jobs 676 return cached.value.jobs
651 } 677 }
652 678
653 let result = try await client.execute( 679 let result = try await client.execute(
@@ -681,11 +707,11 @@ final class RepositoryListViewModel {
681 private func cacheKey(for service: SRHTService) -> String { 707 private func cacheKey(for service: SRHTService) -> String {
682 switch service { 708 switch service {
683 case .git: 709 case .git:
684 Self.gitCacheKey 710 APICacheKeys.repositories(service: .git)
685 case .hg: 711 case .hg:
686 Self.hgCacheKey 712 APICacheKeys.repositories(service: .hg)
687 default: 713 default:
688 "\(service.rawValue).repositories" 714 APICacheKeys.repositories(service: service)
689 } 715 }
690 } 716 }
691 717
Hutch/Views/Tickets/TicketListViewModel.swift +36 −7
@@ -299,6 +299,13 @@ final class TicketListViewModel {
299 hasMore = true 299 hasMore = true
300 300
301 do { 301 do {
302 if tickets.isEmpty, let cachedPage = try? await fetchPage(cursor: nil, policy: .cacheOnly) {
303 tickets = cachedPage.results
304 cursor = cachedPage.cursor
305 hasMore = cachedPage.cursor != nil
306 reconcileSelectionWithLoadedTickets()
307 isLoading = false
308 }
302 // todo.sr.ht exposes `tickets(cursor:)` only (see Docs/API/todo.json) — no server-side 309 // todo.sr.ht exposes `tickets(cursor:)` only (see Docs/API/todo.json) — no server-side
303 // status filter. The Open tab filters client-side, so we paginate until the cursor is 310 // status filter. The Open tab filters client-side, so we paginate until the cursor is
304 // exhausted; otherwise older open tickets never appear in the first page (25 items). 311 // exhausted; otherwise older open tickets never appear in the first page (25 items).
@@ -405,6 +412,7 @@ final class TicketListViewModel {
405 responseType: SubmitTicketResponse.self 412 responseType: SubmitTicketResponse.self
406 ) 413 )
407 let ticket = result.submitTicket 414 let ticket = result.submitTicket
415 await invalidateTicketCaches()
408 tickets.insert(ticket, at: 0) 416 tickets.insert(ticket, at: 0)
409 return ticket 417 return ticket
410 } catch { 418 } catch {
@@ -460,6 +468,7 @@ final class TicketListViewModel {
460 ], 468 ],
461 responseType: AssignmentMutationResponse.self 469 responseType: AssignmentMutationResponse.self
462 ) 470 )
471 await invalidateTicketCaches()
463 } catch { 472 } catch {
464 tickets = original 473 tickets = original
465 self.error = error.userFacingMessage 474 self.error = error.userFacingMessage
@@ -502,6 +511,7 @@ final class TicketListViewModel {
502 ], 511 ],
503 responseType: AssignmentMutationResponse.self 512 responseType: AssignmentMutationResponse.self
504 ) 513 )
514 await invalidateTicketCaches()
505 } catch { 515 } catch {
506 tickets = original 516 tickets = original
507 self.error = error.userFacingMessage 517 self.error = error.userFacingMessage
@@ -512,13 +522,17 @@ final class TicketListViewModel {
512 522
513 func loadTrackerLabels() async { 523 func loadTrackerLabels() async {
514 do { 524 do {
515 let result = try await client.execute( 525 let cached = try await client.executeCached(
516 service: .todo, 526 service: .todo,
517 query: Self.trackerLabelsQuery, 527 query: Self.trackerLabelsQuery,
518 variables: ["rid": trackerRid], 528 variables: ["rid": trackerRid],
519 responseType: TrackerLabelsResponse.self 529 responseType: TrackerLabelsResponse.self,
530 cacheKey: APICacheKeys.trackerLabels(trackerRid: trackerRid),
531 resourceType: .ticketList,
532 ttl: APICacheTTLs.ticketList,
533 policy: .cacheFirstThenRefresh
520 ) 534 )
521 syncTrackerLabels(result.tracker.labels.results) 535 syncTrackerLabels(cached.value.tracker.labels.results)
522 } catch { 536 } catch {
523 self.error = error.userFacingMessage 537 self.error = error.userFacingMessage
524 } 538 }
@@ -652,6 +666,7 @@ final class TicketListViewModel {
652 ], 666 ],
653 responseType: LabelMutationResponse.self 667 responseType: LabelMutationResponse.self
654 ) 668 )
669 await invalidateTicketCaches()
655 } catch { 670 } catch {
656 tickets = original 671 tickets = original
657 self.error = error.userFacingMessage 672 self.error = error.userFacingMessage
@@ -692,6 +707,7 @@ final class TicketListViewModel {
692 ], 707 ],
693 responseType: LabelMutationResponse.self 708 responseType: LabelMutationResponse.self
694 ) 709 )
710 await invalidateTicketCaches()
695 } catch { 711 } catch {
696 tickets = original 712 tickets = original
697 self.error = error.userFacingMessage 713 self.error = error.userFacingMessage
@@ -790,6 +806,7 @@ final class TicketListViewModel {
790 responseType: UpdateStatusResponse.self 806 responseType: UpdateStatusResponse.self
791 ) 807 )
792 _ = result.updateTicketStatus 808 _ = result.updateTicketStatus
809 await invalidateTicketCaches()
793 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) { 810 if let index = tickets.firstIndex(where: { $0.id == ticket.id }) {
794 tickets[index] = updatedTicket(from: ticket, input: input) 811 tickets[index] = updatedTicket(from: ticket, input: input)
795 } 812 }
@@ -800,18 +817,22 @@ final class TicketListViewModel {
800 isPerformingAction = false 817 isPerformingAction = false
801 } 818 }
802 819
803 private func fetchPage(cursor: String?) async throws -> TicketsPage { 820 private func fetchPage(cursor: String?, policy: CachePolicy = .cacheFirstThenRefresh) async throws -> TicketsPage {
804 var variables: [String: any Sendable] = ["rid": trackerRid] 821 var variables: [String: any Sendable] = ["rid": trackerRid]
805 if let cursor { 822 if let cursor {
806 variables["cursor"] = cursor 823 variables["cursor"] = cursor
807 } 824 }
808 let result = try await client.execute( 825 let cached = try await client.executeCached(
809 service: .todo, 826 service: .todo,
810 query: Self.query, 827 query: Self.query,
811 variables: variables, 828 variables: variables,
812 responseType: TrackerTicketsResponse.self 829 responseType: TrackerTicketsResponse.self,
830 cacheKey: APICacheKeys.tickets(trackerRid: trackerRid, cursor: cursor),
831 resourceType: .ticketList,
832 ttl: APICacheTTLs.ticketList,
833 policy: policy
813 ) 834 )
814 return result.tracker.tickets 835 return cached.value.tracker.tickets
815 } 836 }
816 837
817 private struct SubmitTicketResponse: Decodable, Sendable { 838 private struct SubmitTicketResponse: Decodable, Sendable {
@@ -919,6 +940,7 @@ final class TicketListViewModel {
919 ) 940 )
920 941
921 if failedTicketIDs.isEmpty { 942 if failedTicketIDs.isEmpty {
943 await invalidateTicketCaches()
922 clearTicketSelection() 944 clearTicketSelection()
923 isSelectionMode = false 945 isSelectionMode = false
924 } else { 946 } else {
@@ -960,6 +982,13 @@ final class TicketListViewModel {
960 ) 982 )
961 } 983 }
962 984
985 private func invalidateTicketCaches() async {
986 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets"))
987 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
988 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
989 await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
990 }
991
963 private static func bulkStatusUpdateInput(resolution: TicketResolution) -> [String: any Sendable] { 992 private static func bulkStatusUpdateInput(resolution: TicketResolution) -> [String: any Sendable] {
964 [ 993 [
965 "status": TicketStatus.resolved.rawValue, 994 "status": TicketStatus.resolved.rawValue,
Hutch/Views/Tickets/TrackerListViewModel.swift +23 −4
@@ -118,6 +118,12 @@ final class TrackerListViewModel {
118 hasMore = true 118 hasMore = true
119 119
120 do { 120 do {
121 if trackers.isEmpty, let cached = try? await fetchPage(cursor: nil, policy: .cacheOnly) {
122 trackers = cached.results
123 cursor = cached.cursor
124 hasMore = cached.cursor != nil
125 isLoading = false
126 }
121 let page = try await fetchPage(cursor: nil) 127 let page = try await fetchPage(cursor: nil)
122 trackers = page.results 128 trackers = page.results
123 cursor = page.cursor 129 cursor = page.cursor
@@ -181,6 +187,7 @@ final class TrackerListViewModel {
181 responseType: CreateTrackerResponse.self 187 responseType: CreateTrackerResponse.self
182 ) 188 )
183 let tracker = result.createTracker 189 let tracker = result.createTracker
190 await invalidateTrackerCaches()
184 trackers.insert(tracker, at: 0) 191 trackers.insert(tracker, at: 0)
185 return tracker 192 return tracker
186 } catch { 193 } catch {
@@ -224,6 +231,7 @@ final class TrackerListViewModel {
224 ], 231 ],
225 responseType: UpdateTrackerResponse.self 232 responseType: UpdateTrackerResponse.self
226 ) 233 )
234 await invalidateTrackerCaches()
227 applyTrackerUpdate(result.updateTracker) 235 applyTrackerUpdate(result.updateTracker)
228 return result.updateTracker 236 return result.updateTracker
229 } catch { 237 } catch {
@@ -246,6 +254,7 @@ final class TrackerListViewModel {
246 variables: ["id": tracker.id], 254 variables: ["id": tracker.id],
247 responseType: DeleteTrackerResponse.self 255 responseType: DeleteTrackerResponse.self
248 ) 256 )
257 await invalidateTrackerCaches()
249 trackers.removeAll { $0.id == tracker.id } 258 trackers.removeAll { $0.id == tracker.id }
250 await loadTrackers() 259 await loadTrackers()
251 return true 260 return true
@@ -269,18 +278,22 @@ final class TrackerListViewModel {
269 278
270 // MARK: - Private 279 // MARK: - Private
271 280
272 private func fetchPage(cursor: String?) async throws -> TrackersPage { 281 private func fetchPage(cursor: String?, policy: CachePolicy = .cacheFirstThenRefresh) async throws -> TrackersPage {
273 var variables: [String: any Sendable] = [:] 282 var variables: [String: any Sendable] = [:]
274 if let cursor { 283 if let cursor {
275 variables["cursor"] = cursor 284 variables["cursor"] = cursor
276 } 285 }
277 let result = try await client.execute( 286 let cached = try await client.executeCached(
278 service: .todo, 287 service: .todo,
279 query: Self.query, 288 query: Self.query,
280 variables: variables.isEmpty ? nil : variables, 289 variables: variables.isEmpty ? nil : variables,
281 responseType: TrackersResponse.self 290 responseType: TrackersResponse.self,
291 cacheKey: APICacheKeys.trackers(cursor: cursor),
292 resourceType: .ticketList,
293 ttl: APICacheTTLs.ticketList,
294 policy: policy
282 ) 295 )
283 return result.trackers 296 return cached.value.trackers
284 } 297 }
285 298
286 private struct CreateTrackerResponse: Decodable, Sendable { 299 private struct CreateTrackerResponse: Decodable, Sendable {
@@ -290,4 +303,10 @@ final class TrackerListViewModel {
290 private func trackerCreationErrorMessage(for error: Error) -> String { 303 private func trackerCreationErrorMessage(for error: Error) -> String {
291 "Couldn’t create the tracker. \(error.userFacingMessage)" 304 "Couldn’t create the tracker. \(error.userFacingMessage)"
292 } 305 }
306
307 private func invalidateTrackerCaches() async {
308 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "trackers"))
309 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
310 await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
311 }
293} 312}