Commit d67a51cd1e

d67a51cd1e4b4eed4ebe1100b0d5a093a9ec4e40

parent: f131ec5d6a

Unsigned

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

feat: add persistent stale-while-revalidate API cache

Introduce an actor-backed persistent cache layer at the SRHTClient boundary
for read-only SourceHut data. Cache entries now store stable metadata
including key, resource type, fetched/expires/access timestamps, payload
hash, schema version, and payload size, with bounded memory and disk usage.

Add centralized cache key builders and TTL defaults for repository, file,
ticket, build, log, profile, status, and list-style resources. Support
networkOnly, cacheOnly, cacheFirstThenRefresh, and refreshIgnoringCache
policies, plus request coalescing for duplicate in-flight cache keys.

Integrate first-pass caching into high-value low-risk read paths:
- build detail and completed/active build logs
- ticket detail
- README lookup
- repository tree, blob, and linked file reads

Keep mutation paths network-only and add simple prefix invalidation after
ticket and build mutations. Add compact cached/stale UI status rows and a
Settings action to clear the persistent cache.

Add focused cache tests covering round trips, expiration, stale fallback,
policy behavior, request coalescing, prefix invalidation, size limits, LRU
pruning, expired pruning, and mutation bypass behavior. Document storage,
key, TTL, invalidation, limitations, and next recommended targets.

Layout: unified · split

Docs/API_CACHE.md added +11
@@ -0,0 +1,11 @@
1# SourceHut API Cache
2
3Hutch caches read-only SourceHut API responses at the `SRHTClient` boundary. The cache stores raw response bytes plus metadata on disk, with a small bounded memory layer for hot entries. Disk files are account-scoped under the app caches directory, and `PersistentAPICache` is an actor so disk I/O, pruning, and metadata updates stay off the main actor.
4
5Cache keys are built in `APICacheKeys`. Keys are explicit and include the service plus request-shaping inputs such as repository IDs, refs, tree/blob IDs, paths, owners, ticket IDs, job IDs, log URLs, cursors, and filters. Views and view models should not invent ad hoc cache strings.
6
7TTLs live in `APICacheTTLs`. Active build data uses a very short TTL, mutable ticket and list data use medium-short TTLs, repository metadata and profile data live longer, completed build logs are long-lived, and content-addressed git objects are treated as mostly immutable. Moving refs such as `HEAD` use shorter file/content TTLs.
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.
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.
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 = 82; 520 CURRENT_PROJECT_VERSION = 83;
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.2.2; 537 MARKETING_VERSION = 3.3.0;
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 = 82; 557 CURRENT_PROJECT_VERSION = 83;
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.2.2; 574 MARKETING_VERSION = 3.3.0;
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 = 81; 637 CURRENT_PROJECT_VERSION = 83;
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.2.1; 647 MARKETING_VERSION = 3.3.0;
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 = 81; 666 CURRENT_PROJECT_VERSION = 83;
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.2.1; 676 MARKETING_VERSION = 3.3.0;
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/App/AppState.swift +6 −1
@@ -212,6 +212,7 @@ final class AppState {
212 clearActiveSessionState() 212 clearActiveSessionState()
213 try? KeychainHelper.deleteAll() 213 try? KeychainHelper.deleteAll()
214 URLCache.shared.removeAllCachedResponses() 214 URLCache.shared.removeAllCachedResponses()
215 await client.clearPersistentCache()
215 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } 216 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
216 await clearWebData() 217 await clearWebData()
217 clearWebContentRenderCaches() 218 clearWebContentRenderCaches()
@@ -232,6 +233,7 @@ final class AppState {
232 } 233 }
233 try? KeychainHelper.deleteAll() 234 try? KeychainHelper.deleteAll()
234 URLCache.shared.removeAllCachedResponses() 235 URLCache.shared.removeAllCachedResponses()
236 await client.clearPersistentCache()
235 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } 237 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
236 await clearWebData() 238 await clearWebData()
237 clearWebContentRenderCaches() 239 clearWebContentRenderCaches()
@@ -450,7 +452,10 @@ final class AppState {
450 } 452 }
451 453
452 private func makeSession(for account: AccountEntry, knownUser: User? = nil) async throws -> AccountSession { 454 private func makeSession(for account: AccountEntry, knownUser: User? = nil) async throws -> AccountSession {
453 let sessionClient = SRHTClient(token: account.token) 455 let sessionClient = SRHTClient(
456 token: account.token,
457 cache: PersistentAPICache(configuration: .accountScoped(accountID: account.id))
458 )
454 let user: User 459 let user: User
455 if let knownUser { 460 if let knownUser {
456 user = knownUser 461 user = knownUser
Hutch/Networking/APICache.swift added +337
@@ -0,0 +1,337 @@
1import CryptoKit
2import Foundation
3
4enum CachePolicy: Sendable, Equatable {
5 case networkOnly
6 case cacheOnly
7 case cacheFirstThenRefresh
8 case refreshIgnoringCache
9}
10
11enum CacheResourceType: String, Codable, Sendable {
12 case repositoryDetail
13 case repositoryList
14 case repositoryTree
15 case repositoryFile
16 case repositoryReadme
17 case ticketDetail
18 case ticketList
19 case buildDetail
20 case buildList
21 case buildLog
22 case userProfile
23 case status
24 case pasteList
25 case debug
26}
27
28struct CacheEntryMetadata: Codable, Sendable, Equatable {
29 let cacheKey: String
30 let resourceType: CacheResourceType
31 let fetchedAt: Date
32 let expiresAt: Date
33 var lastAccessedAt: Date
34 let payloadHash: String
35 let schemaVersion: Int
36 let payloadSize: Int
37
38 func isExpired(now: Date = Date()) -> Bool {
39 expiresAt <= now
40 }
41}
42
43struct APICacheEntry: Sendable {
44 var metadata: CacheEntryMetadata
45 let payload: Data
46}
47
48struct CachedValue<Value> {
49 let value: Value
50 let metadata: CacheEntryMetadata?
51 let source: CacheValueSource
52
53 var isFromCache: Bool { source == .cache }
54 var isStale: Bool { metadata?.isExpired() ?? false }
55}
56
57enum CacheValueSource: Sendable, Equatable {
58 case cache
59 case network
60}
61
62enum APICacheError: LocalizedError, Sendable {
63 case miss
64 case entryTooLarge(Int)
65 case cacheTooLarge
66
67 var errorDescription: String? {
68 switch self {
69 case .miss:
70 "No cached data is available."
71 case .entryTooLarge(let bytes):
72 "The response is too large to cache (\(bytes) bytes)."
73 case .cacheTooLarge:
74 "The cache size limit was exceeded."
75 }
76 }
77}
78
79struct APICacheConfiguration: Sendable {
80 var directory: URL
81 var maxCacheSizeBytes: Int
82 var maxEntrySizeBytes: Int
83 var memoryEntryLimit: Int
84 var schemaVersion: Int
85
86 static func accountScoped(accountID: String) -> APICacheConfiguration {
87 let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first
88 ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true)
89 return APICacheConfiguration(
90 directory: base
91 .appendingPathComponent("Hutch", isDirectory: true)
92 .appendingPathComponent("APICache", isDirectory: true)
93 .appendingPathComponent(accountID, isDirectory: true),
94 maxCacheSizeBytes: 50 * 1024 * 1024,
95 maxEntrySizeBytes: 2 * 1024 * 1024,
96 memoryEntryLimit: 64,
97 schemaVersion: 1
98 )
99 }
100
101 static func temporary(directory: URL) -> APICacheConfiguration {
102 APICacheConfiguration(
103 directory: directory,
104 maxCacheSizeBytes: 4 * 1024 * 1024,
105 maxEntrySizeBytes: 512 * 1024,
106 memoryEntryLimit: 16,
107 schemaVersion: 1
108 )
109 }
110}
111
112protocol APICache: Sendable {
113 func read(cacheKey: String) async throws -> APICacheEntry
114 func write(payload: Data, cacheKey: String, resourceType: CacheResourceType, ttl: TimeInterval) async throws -> CacheEntryMetadata
115 func remove(cacheKey: String) async
116 func removeByPrefix(_ prefix: String) async
117 func clearAll() async
118 func pruneExpired(now: Date) async
119 func pruneToSizeLimit() async
120}
121
122actor PersistentAPICache: APICache {
123 private struct StoredEntry: Codable, Sendable {
124 var metadata: CacheEntryMetadata
125 let payload: Data
126 }
127
128 private let configuration: APICacheConfiguration
129 private let fileManager: FileManager
130 private var memoryEntries: [String: APICacheEntry] = [:]
131 private var memoryOrder: [String] = []
132 private var knownMetadata: [String: CacheEntryMetadata] = [:]
133 private var writeCountSincePrune = 0
134
135 init(configuration: APICacheConfiguration, fileManager: FileManager = .default) {
136 self.configuration = configuration
137 self.fileManager = fileManager
138 }
139
140 func read(cacheKey: String) async throws -> APICacheEntry {
141 if var entry = memoryEntries[cacheKey] {
142 entry.metadata.lastAccessedAt = Date()
143 memoryEntries[cacheKey] = entry
144 markMemoryUse(cacheKey)
145 try? persist(entry)
146 return entry
147 }
148
149 let url = fileURL(for: cacheKey)
150 guard fileManager.fileExists(atPath: url.path) else {
151 throw APICacheError.miss
152 }
153
154 var stored = try decodeEntry(from: url)
155 guard stored.metadata.schemaVersion == configuration.schemaVersion else {
156 try? fileManager.removeItem(at: url)
157 throw APICacheError.miss
158 }
159
160 stored.metadata.lastAccessedAt = Date()
161 let entry = APICacheEntry(metadata: stored.metadata, payload: stored.payload)
162 knownMetadata[cacheKey] = stored.metadata
163 remember(entry)
164 try? persist(entry)
165 return entry
166 }
167
168 func write(
169 payload: Data,
170 cacheKey: String,
171 resourceType: CacheResourceType,
172 ttl: TimeInterval
173 ) async throws -> CacheEntryMetadata {
174 guard payload.count <= configuration.maxEntrySizeBytes else {
175 throw APICacheError.entryTooLarge(payload.count)
176 }
177
178 try ensureDirectoryExists()
179 let now = Date()
180 let payloadHash = Self.payloadHash(payload)
181 if let existing = try? await read(cacheKey: cacheKey),
182 existing.metadata.payloadHash == payloadHash {
183 let metadata = CacheEntryMetadata(
184 cacheKey: cacheKey,
185 resourceType: resourceType,
186 fetchedAt: now,
187 expiresAt: now.addingTimeInterval(ttl),
188 lastAccessedAt: now,
189 payloadHash: payloadHash,
190 schemaVersion: configuration.schemaVersion,
191 payloadSize: payload.count
192 )
193 let entry = APICacheEntry(metadata: metadata, payload: payload)
194 remember(entry)
195 try persist(entry)
196 return metadata
197 }
198
199 let metadata = CacheEntryMetadata(
200 cacheKey: cacheKey,
201 resourceType: resourceType,
202 fetchedAt: now,
203 expiresAt: now.addingTimeInterval(ttl),
204 lastAccessedAt: now,
205 payloadHash: payloadHash,
206 schemaVersion: configuration.schemaVersion,
207 payloadSize: payload.count
208 )
209 let entry = APICacheEntry(metadata: metadata, payload: payload)
210 remember(entry)
211 try persist(entry)
212
213 writeCountSincePrune += 1
214 if writeCountSincePrune >= 12 {
215 writeCountSincePrune = 0
216 await pruneExpired(now: now)
217 await pruneToSizeLimit()
218 }
219 return metadata
220 }
221
222 func remove(cacheKey: String) async {
223 memoryEntries.removeValue(forKey: cacheKey)
224 memoryOrder.removeAll { $0 == cacheKey }
225 knownMetadata.removeValue(forKey: cacheKey)
226 try? fileManager.removeItem(at: fileURL(for: cacheKey))
227 }
228
229 func removeByPrefix(_ prefix: String) async {
230 await loadKnownMetadataIfNeeded()
231 for key in knownMetadata.keys where key.hasPrefix(prefix) {
232 await remove(cacheKey: key)
233 }
234 }
235
236 func clearAll() async {
237 memoryEntries.removeAll()
238 memoryOrder.removeAll()
239 knownMetadata.removeAll()
240 try? fileManager.removeItem(at: configuration.directory)
241 }
242
243 func pruneExpired(now: Date = Date()) async {
244 await loadKnownMetadataIfNeeded()
245 for metadata in knownMetadata.values where metadata.isExpired(now: now) {
246 await remove(cacheKey: metadata.cacheKey)
247 }
248 }
249
250 func pruneToSizeLimit() async {
251 await loadKnownMetadataIfNeeded()
252 var totalSize = knownMetadata.values.reduce(0) { $0 + $1.payloadSize }
253 guard totalSize > configuration.maxCacheSizeBytes else { return }
254
255 let victims = knownMetadata.values.sorted { $0.lastAccessedAt < $1.lastAccessedAt }
256 for metadata in victims {
257 await remove(cacheKey: metadata.cacheKey)
258 totalSize -= metadata.payloadSize
259 if totalSize <= configuration.maxCacheSizeBytes { break }
260 }
261 }
262
263 private func remember(_ entry: APICacheEntry) {
264 memoryEntries[entry.metadata.cacheKey] = entry
265 knownMetadata[entry.metadata.cacheKey] = entry.metadata
266 markMemoryUse(entry.metadata.cacheKey)
267 while memoryOrder.count > configuration.memoryEntryLimit, let evicted = memoryOrder.first {
268 memoryOrder.removeFirst()
269 memoryEntries.removeValue(forKey: evicted)
270 }
271 }
272
273 private func markMemoryUse(_ cacheKey: String) {
274 memoryOrder.removeAll { $0 == cacheKey }
275 memoryOrder.append(cacheKey)
276 }
277
278 private func persist(_ entry: APICacheEntry) throws {
279 try ensureDirectoryExists()
280 let stored = StoredEntry(metadata: entry.metadata, payload: entry.payload)
281 let data = try JSONEncoder.srhtCache.encode(stored)
282 try data.write(to: fileURL(for: entry.metadata.cacheKey), options: [.atomic])
283 }
284
285 private func decodeEntry(from url: URL) throws -> StoredEntry {
286 let data = try Data(contentsOf: url)
287 return try JSONDecoder.srhtCache.decode(StoredEntry.self, from: data)
288 }
289
290 private func loadKnownMetadataIfNeeded() async {
291 guard knownMetadata.isEmpty else { return }
292 guard let urls = try? fileManager.contentsOfDirectory(
293 at: configuration.directory,
294 includingPropertiesForKeys: nil
295 ) else { return }
296
297 for url in urls where url.pathExtension == "json" {
298 guard let stored = try? decodeEntry(from: url) else { continue }
299 knownMetadata[stored.metadata.cacheKey] = stored.metadata
300 }
301 }
302
303 private func ensureDirectoryExists() throws {
304 if !fileManager.fileExists(atPath: configuration.directory.path) {
305 try fileManager.createDirectory(
306 at: configuration.directory,
307 withIntermediateDirectories: true
308 )
309 }
310 }
311
312 private func fileURL(for cacheKey: String) -> URL {
313 configuration.directory
314 .appendingPathComponent(Self.payloadHash(Data(cacheKey.utf8)))
315 .appendingPathExtension("json")
316 }
317
318 private static func payloadHash(_ data: Data) -> String {
319 SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined()
320 }
321}
322
323extension JSONEncoder {
324 static var srhtCache: JSONEncoder {
325 let encoder = JSONEncoder()
326 encoder.dateEncodingStrategy = .iso8601
327 return encoder
328 }
329}
330
331extension JSONDecoder {
332 static var srhtCache: JSONDecoder {
333 let decoder = JSONDecoder()
334 decoder.dateDecodingStrategy = .iso8601
335 return decoder
336 }
337}
Hutch/Networking/APICacheKeys.swift added +105
@@ -0,0 +1,105 @@
1import Foundation
2
3enum APICacheKeys {
4 static func repositories(service: SRHTService, owner: String? = nil, cursor: String? = nil, filter: String? = nil) -> String {
5 make([
6 service.rawValue,
7 "repositories",
8 owner.map { "owner:\(normalize($0))" },
9 cursor.map { "cursor:\($0)" },
10 filter.map { "filter:\(normalize($0))" }
11 ])
12 }
13
14 static func repository(service: SRHTService, owner: String, name: String) -> String {
15 make([service.rawValue, "repository", normalize(owner), normalize(name)])
16 }
17
18 static func repositoryRID(service: SRHTService, rid: String) -> String {
19 make([service.rawValue, "repository", "rid:\(rid)"])
20 }
21
22 static func refs(service: SRHTService, rid: String, cursor: String? = nil) -> String {
23 make([service.rawValue, "refs", "rid:\(rid)", cursor.map { "cursor:\($0)" }])
24 }
25
26 static func readme(service: SRHTService, rid: String, path: String? = nil, ref: String = "HEAD") -> String {
27 make([service.rawValue, "readme", "rid:\(rid)", "ref:\(ref)", path.map { "path:\($0)" }])
28 }
29
30 static func treeRoot(service: SRHTService, rid: String, ref: String) -> String {
31 make([service.rawValue, "tree", "rid:\(rid)", "ref:\(ref)", "root"])
32 }
33
34 static func treeEntries(service: SRHTService, rid: String, treeId: String, cursor: String? = nil) -> String {
35 make([service.rawValue, "tree", "rid:\(rid)", "tree:\(treeId)", cursor.map { "cursor:\($0)" }])
36 }
37
38 static func blob(service: SRHTService, rid: String, blobId: String) -> String {
39 make([service.rawValue, "blob", "rid:\(rid)", "blob:\(blobId)"])
40 }
41
42 static func path(service: SRHTService, rid: String, ref: String, path: String) -> String {
43 make([service.rawValue, "path", "rid:\(rid)", "ref:\(ref)", "path:\(path)"])
44 }
45
46 static func ticketDetail(owner: String, trackerRid: String, ticketId: Int) -> String {
47 make([SRHTService.todo.rawValue, "ticket", normalize(owner), "tracker:\(trackerRid)", "ticket:\(ticketId)"])
48 }
49
50 static func trackerLabels(trackerRid: String) -> String {
51 make([SRHTService.todo.rawValue, "tracker-labels", "tracker:\(trackerRid)"])
52 }
53
54 static func builds(cursor: String? = nil, filter: String? = nil) -> String {
55 make([SRHTService.builds.rawValue, "jobs", cursor.map { "cursor:\($0)" }, filter.map { "filter:\($0)" }])
56 }
57
58 static func buildDetail(jobId: Int) -> String {
59 make([SRHTService.builds.rawValue, "job", "id:\(jobId)"])
60 }
61
62 static func buildLog(url: URL, jobId: Int? = nil, task: String? = nil) -> String {
63 make([SRHTService.builds.rawValue, "log", jobId.map { "job:\($0)" }, task.map { "task:\($0)" }, url.absoluteString])
64 }
65
66 static func userRepositories(owner: String, cursor: String? = nil) -> String {
67 make([SRHTService.git.rawValue, "user-repositories", normalize(owner), cursor.map { "cursor:\($0)" }])
68 }
69
70 static func userTrackers(owner: String, cursor: String? = nil) -> String {
71 make([SRHTService.todo.rawValue, "user-trackers", normalize(owner), cursor.map { "cursor:\($0)" }])
72 }
73
74 static func pasteList(cursor: String? = nil) -> String {
75 make([SRHTService.paste.rawValue, "pastes", cursor.map { "cursor:\($0)" }])
76 }
77
78 static func prefix(_ components: String...) -> String {
79 make(components)
80 }
81
82 private static func make(_ parts: [String?]) -> String {
83 parts.compactMap { $0?.replacingOccurrences(of: "|", with: "%7C") }
84 .joined(separator: "|")
85 }
86
87 private static func normalize(_ value: String) -> String {
88 value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
89 }
90}
91
92enum APICacheTTLs {
93 // Active build data changes quickly; completed logs and content-addressed git data are effectively immutable.
94 static let activeBuild: TimeInterval = 15
95 static let completedBuildDetail: TimeInterval = 60 * 60
96 static let completedBuildLog: TimeInterval = 30 * 24 * 60 * 60
97 static let ticketDetail: TimeInterval = 5 * 60
98 static let ticketList: TimeInterval = 2 * 60
99 static let repositoryMetadata: TimeInterval = 30 * 60
100 static let repositoryList: TimeInterval = 5 * 60
101 static let immutableFileContent: TimeInterval = 14 * 24 * 60 * 60
102 static let movingRefFileContent: TimeInterval = 10 * 60
103 static let userProfile: TimeInterval = 30 * 60
104 static let status: TimeInterval = 5 * 60
105}
Hutch/Networking/SRHTClient.swift +298 −1
@@ -1,3 +1,4 @@
1import CryptoKit
1import Foundation 2import Foundation
2import os 3import os
3 4
@@ -20,6 +21,8 @@ final class SRHTClient: Sendable {
20 private let session: URLSession 21 private let session: URLSession
21 private let decoder: JSONDecoder 22 private let decoder: JSONDecoder
22 private let encoder: JSONEncoder 23 private let encoder: JSONEncoder
24 private let cache: any APICache
25 private let requestCoalescer = RequestCoalescer()
23 26
24 /// The personal access token used for `Authorization: Bearer` headers. 27 /// The personal access token used for `Authorization: Bearer` headers.
25 /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``. 28 /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``.
@@ -32,12 +35,19 @@ final class SRHTClient: Sendable {
32 tokenLock.withLock { $0 != nil } 35 tokenLock.withLock { $0 != nil }
33 } 36 }
34 37
35 init(session: URLSession = .shared, token: String? = nil) { 38 init(
39 session: URLSession = .shared,
40 token: String? = nil,
41 cache: (any APICache)? = nil
42 ) {
36 self.session = session 43 self.session = session
37 self.decoder = JSONDecoder() 44 self.decoder = JSONDecoder()
38 self.decoder.dateDecodingStrategy = .srhtFlexible 45 self.decoder.dateDecodingStrategy = .srhtFlexible
39 self.encoder = JSONEncoder() 46 self.encoder = JSONEncoder()
40 self.tokenLock = OSAllocatedUnfairLock(initialState: token) 47 self.tokenLock = OSAllocatedUnfairLock(initialState: token)
48 self.cache = cache ?? PersistentAPICache(
49 configuration: .accountScoped(accountID: token.map { Self.tokenCacheScope($0) } ?? "anonymous")
50 )
41 } 51 }
42 52
43 /// Update the stored token (e.g. after the user saves a new one in Keychain). 53 /// Update the stored token (e.g. after the user saves a new one in Keychain).
@@ -156,6 +166,130 @@ final class SRHTClient: Sendable {
156 return result 166 return result
157 } 167 }
158 168
169 func executeCached<T: Decodable>(
170 service: SRHTService,
171 query: String,
172 variables: [String: any Sendable]? = nil,
173 responseType _: T.Type,
174 cacheKey: String,
175 resourceType: CacheResourceType,
176 ttl: TimeInterval,
177 policy: CachePolicy = .cacheFirstThenRefresh
178 ) async throws -> CachedValue<T> {
179 switch policy {
180 case .networkOnly:
181 let data = try await performGraphQLRequest(
182 service: service,
183 query: query,
184 variables: variables
185 )
186 let value: T = try decodeGraphQLData(data, service: service, query: query, variables: variables)
187 return CachedValue(value: value, metadata: nil, source: .network)
188
189 case .cacheOnly:
190 let entry = try await cache.read(cacheKey: cacheKey)
191 let value: T = try decodeGraphQLData(entry.payload, service: service, query: query, variables: variables)
192 return CachedValue(value: value, metadata: entry.metadata, source: .cache)
193
194 case .cacheFirstThenRefresh:
195 if let entry = try? await cache.read(cacheKey: cacheKey) {
196 let value: T = try decodeGraphQLData(entry.payload, service: service, query: query, variables: variables)
197 if entry.metadata.isExpired() {
198 Task.detached { [self] in
199 _ = try? await self.fetchAndCacheGraphQLData(
200 service: service,
201 query: query,
202 variables: variables,
203 cacheKey: cacheKey,
204 resourceType: resourceType,
205 ttl: ttl
206 )
207 }
208 }
209 return CachedValue(value: value, metadata: entry.metadata, source: .cache)
210 }
211
212 let (value, metadata): (T, CacheEntryMetadata?) = try await fetchAndCacheGraphQL(
213 service: service,
214 query: query,
215 variables: variables,
216 cacheKey: cacheKey,
217 resourceType: resourceType,
218 ttl: ttl
219 )
220 return CachedValue(value: value, metadata: metadata, source: .network)
221
222 case .refreshIgnoringCache:
223 let (value, metadata): (T, CacheEntryMetadata?) = try await fetchAndCacheGraphQL(
224 service: service,
225 query: query,
226 variables: variables,
227 cacheKey: cacheKey,
228 resourceType: resourceType,
229 ttl: ttl
230 )
231 return CachedValue(value: value, metadata: metadata, source: .network)
232 }
233 }
234
235 func fetchCachedText(
236 url: URL,
237 cacheKey: String,
238 resourceType: CacheResourceType = .buildLog,
239 ttl: TimeInterval,
240 policy: CachePolicy = .cacheFirstThenRefresh
241 ) async throws -> CachedValue<String> {
242 switch policy {
243 case .networkOnly:
244 let text = try await fetchText(url: url)
245 return CachedValue(value: text, metadata: nil, source: .network)
246 case .cacheOnly:
247 let entry = try await cache.read(cacheKey: cacheKey)
248 guard let text = String(data: entry.payload, encoding: .utf8) else {
249 throw SRHTError.decodingError(
250 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Cached text is not UTF-8"))
251 )
252 }
253 return CachedValue(value: text, metadata: entry.metadata, source: .cache)
254 case .cacheFirstThenRefresh:
255 if let entry = try? await cache.read(cacheKey: cacheKey),
256 let text = String(data: entry.payload, encoding: .utf8) {
257 if entry.metadata.isExpired() {
258 Task.detached { [self] in
259 _ = try? await self.fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
260 }
261 }
262 return CachedValue(value: text, metadata: entry.metadata, source: .cache)
263 }
264 let (text, metadata) = try await fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
265 return CachedValue(value: text, metadata: metadata, source: .network)
266 case .refreshIgnoringCache:
267 let (text, metadata) = try await fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
268 return CachedValue(value: text, metadata: metadata, source: .network)
269 }
270 }
271
272 func cachedPayload(forKey cacheKey: String) async -> Data? {
273 if let entry = try? await cache.read(cacheKey: cacheKey) {
274 return entry.payload
275 }
276 return responseCache.get(forKey: cacheKey)
277 }
278
279 func invalidateCache(prefix: String) async {
280 await cache.removeByPrefix(prefix)
281 }
282
283 func removeCachedValue(forKey cacheKey: String) async {
284 await cache.remove(cacheKey: cacheKey)
285 responseCache.remove(forKey: cacheKey)
286 }
287
288 func clearPersistentCache() async {
289 await cache.clearAll()
290 responseCache.clear()
291 }
292
159 // MARK: - Multipart Upload 293 // MARK: - Multipart Upload
160 294
161 /// Execute a GraphQL mutation with a file upload using the 295 /// Execute a GraphQL mutation with a file upload using the
@@ -614,6 +748,152 @@ final class SRHTClient: Sendable {
614// MARK: - Data Helper 748// MARK: - Data Helper
615 749
616private extension SRHTClient { 750private extension SRHTClient {
751 func performGraphQLRequest(
752 service: SRHTService,
753 query: String,
754 variables: [String: any Sendable]?
755 ) async throws -> Data {
756 guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else {
757 throw SRHTError.unauthorized
758 }
759
760 var request = URLRequest(url: service.url)
761 request.httpMethod = "POST"
762 request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent")
763 request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
764 request.setValue("application/json", forHTTPHeaderField: "Content-Type")
765
766 let body = GraphQLRequestBody(
767 query: query,
768 variables: variables?.mapValues { AnyCodable($0) }
769 )
770 request.httpBody = try encoder.encode(body)
771
772 let (data, response): (Data, URLResponse)
773 do {
774 (data, response) = try await session.data(for: request)
775 } catch {
776 throw SRHTError.networkError(error)
777 }
778
779 if let http = response as? HTTPURLResponse {
780 if http.statusCode == 401 {
781 throw SRHTError.unauthorized
782 }
783 if !(200...299).contains(http.statusCode) {
784 try throwGraphQLErrorsIfPresent(in: data)
785 throw SRHTError.httpError(http.statusCode)
786 }
787 }
788
789 try throwGraphQLErrorsIfPresent(in: data)
790 return data
791 }
792
793 func decodeGraphQLData<T: Decodable>(
794 _ data: Data,
795 service: SRHTService,
796 query: String,
797 variables: [String: any Sendable]?
798 ) throws -> T {
799 let graphQLResponse: GraphQLResponse<T>
800 do {
801 graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data)
802 } catch {
803 #if DEBUG
804 let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>"
805 logger.error(
806 """
807 Decoding failed for \(String(describing: T.self), privacy: .public)
808 service: \(service.rawValue, privacy: .public)
809 query:
810 \(query, privacy: .public)
811 variables:
812 \(String(describing: variables), privacy: .public)
813 error:
814 \(String(describing: error), privacy: .public)
815 response:
816 \(responseBody, privacy: .public)
817 """
818 )
819 #endif
820 throw SRHTError.decodingError(error)
821 }
822
823 if let errors = graphQLResponse.errors, !errors.isEmpty {
824 throw SRHTError.graphQLErrors(errors)
825 }
826
827 guard let result = graphQLResponse.data else {
828 throw SRHTError.decodingError(
829 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response"))
830 )
831 }
832 return result
833 }
834
835 func fetchAndCacheGraphQL<T: Decodable>(
836 service: SRHTService,
837 query: String,
838 variables: [String: any Sendable]?,
839 cacheKey: String,
840 resourceType: CacheResourceType,
841 ttl: TimeInterval
842 ) async throws -> (T, CacheEntryMetadata?) {
843 let data = try await requestCoalescer.value(for: cacheKey) {
844 try await self.performGraphQLRequest(service: service, query: query, variables: variables)
845 }
846 let value: T = try decodeGraphQLData(data, service: service, query: query, variables: variables)
847 responseCache.set(data, forKey: cacheKey)
848 let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
849 return (value, metadata)
850 }
851
852 func fetchAndCacheGraphQLData(
853 service: SRHTService,
854 query: String,
855 variables: [String: any Sendable]?,
856 cacheKey: String,
857 resourceType: CacheResourceType,
858 ttl: TimeInterval
859 ) async throws -> CacheEntryMetadata? {
860 let data = try await requestCoalescer.value(for: cacheKey) {
861 try await self.performGraphQLRequest(service: service, query: query, variables: variables)
862 }
863 responseCache.set(data, forKey: cacheKey)
864 return try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
865 }
866
867 func fetchAndCacheText(
868 url: URL,
869 cacheKey: String,
870 resourceType: CacheResourceType,
871 ttl: TimeInterval
872 ) async throws -> (String, CacheEntryMetadata?) {
873 let data = try await requestCoalescer.value(for: cacheKey) {
874 let text = try await self.fetchText(url: url)
875 guard let data = text.data(using: .utf8) else {
876 throw SRHTError.decodingError(
877 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Text could not be encoded as UTF-8"))
878 )
879 }
880 return data
881 }
882 guard let text = String(data: data, encoding: .utf8) else {
883 throw SRHTError.decodingError(
884 DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text"))
885 )
886 }
887 responseCache.set(data, forKey: cacheKey)
888 let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl)
889 return (text, metadata)
890 }
891
892 static func tokenCacheScope(_ token: String) -> String {
893 let digest = SHA256.hash(data: Data(token.utf8))
894 return digest.prefix(8).map { String(format: "%02x", $0) }.joined()
895 }
896
617 func throwGraphQLErrorsIfPresent(in data: Data) throws { 897 func throwGraphQLErrorsIfPresent(in data: Data) throws {
618 if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), 898 if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
619 let errors = envelope.errors, 899 let errors = envelope.errors,
@@ -632,6 +912,23 @@ private extension SRHTClient {
632 } 912 }
633} 913}
634 914
915private actor RequestCoalescer {
916 private var tasks: [String: Task<Data, Error>] = [:]
917
918 func value(for key: String, operation: @Sendable @escaping () async throws -> Data) async throws -> Data {
919 if let task = tasks[key] {
920 return try await task.value
921 }
922
923 let task = Task {
924 try await operation()
925 }
926 tasks[key] = task
927 defer { tasks.removeValue(forKey: key) }
928 return try await task.value
929 }
930}
931
635private extension Data { 932private extension Data {
636 mutating func append(_ string: String) { 933 mutating func append(_ string: String) {
637 if let data = string.data(using: .utf8) { 934 if let data = string.data(using: .utf8) {
Hutch/Views/Builds/BuildDetailView.swift +6
@@ -166,6 +166,12 @@ struct BuildDetailView: View {
166 ) 166 )
167 } else if let job = viewModel.job { 167 } else if let job = viewModel.job {
168 List { 168 List {
169 if let cacheMetadata = viewModel.cacheMetadata {
170 Section {
171 StaleCacheStatusRow(metadata: cacheMetadata, isRefreshing: viewModel.isRefreshingCachedData)
172 }
173 }
174
169 Section("Details") { 175 Section("Details") {
170 HStack { 176 HStack {
171 Text("Status") 177 Text("Status")
Hutch/Views/Builds/BuildDetailViewModel.swift +85 −29
@@ -28,7 +28,7 @@ private struct SubmittedJob: Decodable, Sendable {
28@MainActor 28@MainActor
29final class BuildDetailViewModel { 29final class BuildDetailViewModel {
30 private static let autoRefreshInterval: Duration = .seconds(5) 30 private static let autoRefreshInterval: Duration = .seconds(5)
31 private static func cacheKey(for jobId: Int) -> String { "build.detail.\(jobId)" } 31 private static func cacheKey(for jobId: Int) -> String { APICacheKeys.buildDetail(jobId: jobId) }
32 32
33 let jobId: Int 33 let jobId: Int
34 private let client: SRHTClient 34 private let client: SRHTClient
@@ -46,6 +46,8 @@ final class BuildDetailViewModel {
46 private(set) var isRebuilding = false 46 private(set) var isRebuilding = false
47 private(set) var isSubmittingEditedBuild = false 47 private(set) var isSubmittingEditedBuild = false
48 private(set) var rawJobResponse: String? 48 private(set) var rawJobResponse: String?
49 private(set) var cacheMetadata: CacheEntryMetadata?
50 private(set) var isRefreshingCachedData = false
49 var error: String? 51 var error: String?
50 /// Transient error shown for action failures (cancel, rebuild, submit). 52 /// Transient error shown for action failures (cancel, rebuild, submit).
51 /// Separate from `error` so auto-refresh doesn't immediately clear it. 53 /// Separate from `error` so auto-refresh doesn't immediately clear it.
@@ -128,22 +130,21 @@ final class BuildDetailViewModel {
128 rawJobResponse = nil 130 rawJobResponse = nil
129 131
130 do { 132 do {
131 let result = try await client.execute( 133 let result = try await client.executeCached(
132 service: .builds, 134 service: .builds,
133 query: Self.detailQuery, 135 query: Self.detailQuery,
134 variables: ["id": jobId], 136 variables: ["id": jobId],
135 responseType: JobDetailResponse.self 137 responseType: JobDetailResponse.self,
138 cacheKey: Self.cacheKey(for: jobId),
139 resourceType: .buildDetail,
140 ttl: job?.status.isTerminal == true ? APICacheTTLs.completedBuildDetail : APICacheTTLs.activeBuild,
141 policy: .cacheFirstThenRefresh
136 ) 142 )
137 var loadedJob = result.job 143 apply(result.value, metadata: result.metadata)
138 loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in 144 if result.isFromCache {
139 task.withOrdinal(index) 145 isLoading = false
140 } 146 await refreshJobInBackground()
141 if job != loadedJob { 147 return
142 job = loadedJob
143 }
144
145 if loadedJob.status.isTerminal {
146 stopAutoRefresh()
147 } 148 }
148 } catch { 149 } catch {
149 self.error = error.userFacingMessage 150 self.error = error.userFacingMessage
@@ -159,26 +160,19 @@ final class BuildDetailViewModel {
159 160
160 do { 161 do {
161 let cacheKey = Self.cacheKey(for: jobId) 162 let cacheKey = Self.cacheKey(for: jobId)
162 let result = try await client.executeAndCache( 163 let result = try await client.executeCached(
163 service: .builds, 164 service: .builds,
164 query: Self.detailQuery, 165 query: Self.detailQuery,
165 variables: ["id": jobId], 166 variables: ["id": jobId],
166 responseType: JobDetailResponse.self, 167 responseType: JobDetailResponse.self,
167 cacheKey: cacheKey 168 cacheKey: cacheKey,
169 resourceType: .buildDetail,
170 ttl: job?.status.isTerminal == true ? APICacheTTLs.completedBuildDetail : APICacheTTLs.activeBuild,
171 policy: .refreshIgnoringCache
168 ) 172 )
169 rawJobResponse = client.responseCache.get(forKey: cacheKey) 173 rawJobResponse = await client.cachedPayload(forKey: cacheKey)
170 .flatMap { String(data: $0, encoding: .utf8) } 174 .flatMap { String(data: $0, encoding: .utf8) }
171 var loadedJob = result.job 175 apply(result.value, metadata: result.metadata)
172 loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
173 task.withOrdinal(index)
174 }
175 if job != loadedJob {
176 job = loadedJob
177 }
178
179 if loadedJob.status.isTerminal {
180 stopAutoRefresh()
181 }
182 } catch { 176 } catch {
183 self.error = error.userFacingMessage 177 self.error = error.userFacingMessage
184 } 178 }
@@ -201,7 +195,14 @@ final class BuildDetailViewModel {
201 loadingTaskLogs.insert(cacheKey) 195 loadingTaskLogs.insert(cacheKey)
202 196
203 do { 197 do {
204 taskLogs[cacheKey] = try await client.fetchText(url: logURL) 198 let logCacheKey = APICacheKeys.buildLog(url: logURL, jobId: jobId, task: cacheKey)
199 let result = try await client.fetchCachedText(
200 url: logURL,
201 cacheKey: logCacheKey,
202 ttl: APICacheTTLs.completedBuildLog,
203 policy: .cacheFirstThenRefresh
204 )
205 taskLogs[cacheKey] = result.value
205 failedTaskLogs.remove(cacheKey) 206 failedTaskLogs.remove(cacheKey)
206 } catch { 207 } catch {
207 failedTaskLogs.insert(cacheKey) 208 failedTaskLogs.insert(cacheKey)
@@ -222,7 +223,13 @@ final class BuildDetailViewModel {
222 isLoadingBuildLog = true 223 isLoadingBuildLog = true
223 224
224 do { 225 do {
225 buildLogText = try await client.fetchText(url: logURL) 226 let result = try await client.fetchCachedText(
227 url: logURL,
228 cacheKey: APICacheKeys.buildLog(url: logURL, jobId: jobId),
229 ttl: jobIsTerminal ? APICacheTTLs.completedBuildLog : APICacheTTLs.activeBuild,
230 policy: jobIsTerminal ? .cacheFirstThenRefresh : .refreshIgnoringCache
231 )
232 buildLogText = result.value
226 } catch { 233 } catch {
227 self.error = error.userFacingMessage 234 self.error = error.userFacingMessage
228 } 235 }
@@ -287,6 +294,7 @@ final class BuildDetailViewModel {
287 variables: ["id": jobId], 294 variables: ["id": jobId],
288 responseType: CancelResponse.self 295 responseType: CancelResponse.self
289 ) 296 )
297 await invalidateAfterMutation()
290 await reloadJobPreservingDebugState() 298 await reloadJobPreservingDebugState()
291 } catch { 299 } catch {
292 // Revert optimistic update on failure. 300 // Revert optimistic update on failure.
@@ -329,6 +337,7 @@ final class BuildDetailViewModel {
329 variables: variables, 337 variables: variables,
330 responseType: SubmitJobResponse.self 338 responseType: SubmitJobResponse.self
331 ) 339 )
340 await invalidateAfterMutation()
332 return result.submit.id 341 return result.submit.id
333 } catch { 342 } catch {
334 setActionError("Couldn't rebuild. \(error.userFacingMessage)") 343 setActionError("Couldn't rebuild. \(error.userFacingMessage)")
@@ -377,6 +386,7 @@ final class BuildDetailViewModel {
377 variables: variables, 386 variables: variables,
378 responseType: SubmitJobResponse.self 387 responseType: SubmitJobResponse.self
379 ) 388 )
389 await invalidateAfterMutation()
380 return result.submit.id 390 return result.submit.id
381 } catch { 391 } catch {
382 setActionError("Couldn’t submit the build. \(error.userFacingMessage)") 392 setActionError("Couldn’t submit the build. \(error.userFacingMessage)")
@@ -417,6 +427,52 @@ final class BuildDetailViewModel {
417 } 427 }
418 } 428 }
419 429
430 private func refreshJobInBackground() async {
431 guard !isRefreshingCachedData else { return }
432 isRefreshingCachedData = true
433 defer { isRefreshingCachedData = false }
434
435 do {
436 let result = try await client.executeCached(
437 service: .builds,
438 query: Self.detailQuery,
439 variables: ["id": jobId],
440 responseType: JobDetailResponse.self,
441 cacheKey: Self.cacheKey(for: jobId),
442 resourceType: .buildDetail,
443 ttl: job?.status.isTerminal == true ? APICacheTTLs.completedBuildDetail : APICacheTTLs.activeBuild,
444 policy: .refreshIgnoringCache
445 )
446 apply(result.value, metadata: result.metadata)
447 } catch {
448 if job == nil {
449 self.error = error.userFacingMessage
450 }
451 }
452 }
453
454 private func apply(_ response: JobDetailResponse, metadata: CacheEntryMetadata?) {
455 cacheMetadata = metadata
456 var loadedJob = response.job
457 loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
458 task.withOrdinal(index)
459 }
460 if job != loadedJob {
461 job = loadedJob
462 }
463
464 if loadedJob.status.isTerminal {
465 stopAutoRefresh()
466 }
467 }
468
469 private func invalidateAfterMutation() async {
470 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "job"))
471 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "jobs"))
472 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "log"))
473 await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
474 }
475
420 private var shouldAutoRefresh: Bool { 476 private var shouldAutoRefresh: Bool {
421 guard let job else { return true } 477 guard let job else { return true }
422 return !job.status.isTerminal 478 return !job.status.isTerminal
Hutch/Views/Repositories/FileTreeViewModel.swift +47 −14
@@ -90,6 +90,7 @@ final class FileTreeViewModel {
90 private(set) var viewingObject: GitObject? 90 private(set) var viewingObject: GitObject?
91 91
92 private(set) var isLoading = false 92 private(set) var isLoading = false
93 private(set) var cacheMetadata: CacheEntryMetadata?
93 var error: String? 94 var error: String?
94 95
95 // Available references for the branch/tag picker 96 // Available references for the branch/tag picker
@@ -285,12 +286,18 @@ final class FileTreeViewModel {
285 do { 286 do {
286 let result: RevparseResponse 287 let result: RevparseResponse
287 do { 288 do {
288 result = try await client.execute( 289 let cached = try await client.executeCached(
289 service: service, 290 service: service,
290 query: Self.rootTreeQuery, 291 query: Self.rootTreeQuery,
291 variables: variables, 292 variables: variables,
292 responseType: RevparseResponse.self 293 responseType: RevparseResponse.self,
294 cacheKey: APICacheKeys.treeRoot(service: service, rid: repositoryRid, ref: revspec),
295 resourceType: .repositoryTree,
296 ttl: APICacheTTLs.movingRefFileContent,
297 policy: .cacheFirstThenRefresh
293 ) 298 )
299 result = cached.value
300 cacheMetadata = cached.metadata
294 } catch { 301 } catch {
295 if isMissingGitReferenceError(error) { 302 if isMissingGitReferenceError(error) {
296 navStack = [FileNavEntry(name: "root", treeId: "")] 303 navStack = [FileNavEntry(name: "root", treeId: "")]
@@ -375,12 +382,18 @@ final class FileTreeViewModel {
375 ] 382 ]
376 383
377 do { 384 do {
378 let result = try await client.execute( 385 let cached = try await client.executeCached(
379 service: service, 386 service: service,
380 query: Self.subtreeQuery, 387 query: Self.subtreeQuery,
381 variables: variables, 388 variables: variables,
382 responseType: SubtreeResponse.self 389 responseType: SubtreeResponse.self,
390 cacheKey: APICacheKeys.treeEntries(service: service, rid: repositoryRid, treeId: treeId),
391 resourceType: .repositoryTree,
392 ttl: APICacheTTLs.immutableFileContent,
393 policy: .cacheFirstThenRefresh
383 ) 394 )
395 let result = cached.value
396 cacheMetadata = cached.metadata
384 navStack.append(FileNavEntry(name: name, treeId: treeId)) 397 navStack.append(FileNavEntry(name: name, treeId: treeId))
385 var allEntries = result.repository?.object?.entries?.results ?? [] 398 var allEntries = result.repository?.object?.entries?.results ?? []
386 var cursor = result.repository?.object?.entries?.cursor 399 var cursor = result.repository?.object?.entries?.cursor
@@ -407,12 +420,18 @@ final class FileTreeViewModel {
407 ] 420 ]
408 421
409 do { 422 do {
410 let result = try await client.execute( 423 let cached = try await client.executeCached(
411 service: service, 424 service: service,
412 query: Self.blobQuery, 425 query: Self.blobQuery,
413 variables: variables, 426 variables: variables,
414 responseType: BlobResponse.self 427 responseType: BlobResponse.self,
428 cacheKey: APICacheKeys.blob(service: service, rid: repositoryRid, blobId: blobId),
429 resourceType: .repositoryFile,
430 ttl: APICacheTTLs.immutableFileContent,
431 policy: .cacheFirstThenRefresh
415 ) 432 )
433 let result = cached.value
434 cacheMetadata = cached.metadata
416 viewingEntry = entry 435 viewingEntry = entry
417 viewingObject = result.repository?.object ?? .unknown 436 viewingObject = result.repository?.object ?? .unknown
418 } catch { 437 } catch {
@@ -453,12 +472,18 @@ final class FileTreeViewModel {
453 ] 472 ]
454 473
455 do { 474 do {
456 let result = try await client.execute( 475 let cached = try await client.executeCached(
457 service: service, 476 service: service,
458 query: Self.subtreeQuery, 477 query: Self.subtreeQuery,
459 variables: variables, 478 variables: variables,
460 responseType: SubtreeResponse.self 479 responseType: SubtreeResponse.self,
480 cacheKey: APICacheKeys.treeEntries(service: service, rid: repositoryRid, treeId: targetEntry.treeId),
481 resourceType: .repositoryTree,
482 ttl: APICacheTTLs.immutableFileContent,
483 policy: .cacheFirstThenRefresh
461 ) 484 )
485 let result = cached.value
486 cacheMetadata = cached.metadata
462 var allEntries = result.repository?.object?.entries?.results ?? [] 487 var allEntries = result.repository?.object?.entries?.results ?? []
463 var cursor = result.repository?.object?.entries?.cursor 488 var cursor = result.repository?.object?.entries?.cursor
464 while let nextCursor = cursor { 489 while let nextCursor = cursor {
@@ -480,13 +505,17 @@ final class FileTreeViewModel {
480 "treeId": treeId, 505 "treeId": treeId,
481 "cursor": cursor 506 "cursor": cursor
482 ] 507 ]
483 let result = try await client.execute( 508 let cached = try await client.executeCached(
484 service: service, 509 service: service,
485 query: Self.treeEntriesPageQuery, 510 query: Self.treeEntriesPageQuery,
486 variables: variables, 511 variables: variables,
487 responseType: SubtreeResponse.self 512 responseType: SubtreeResponse.self,
513 cacheKey: APICacheKeys.treeEntries(service: service, rid: repositoryRid, treeId: treeId, cursor: cursor),
514 resourceType: .repositoryTree,
515 ttl: APICacheTTLs.immutableFileContent,
516 policy: .cacheFirstThenRefresh
488 ) 517 )
489 return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil) 518 return cached.value.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
490 } 519 }
491 520
492 // MARK: - Linked File (used by README link interception) 521 // MARK: - Linked File (used by README link interception)
@@ -517,13 +546,17 @@ final class FileTreeViewModel {
517 "revspec": revspec, 546 "revspec": revspec,
518 "path": path 547 "path": path
519 ] 548 ]
520 let result = try await client.execute( 549 let cached = try await client.executeCached(
521 service: service, 550 service: service,
522 query: Self.linkedFileQuery, 551 query: Self.linkedFileQuery,
523 variables: variables, 552 variables: variables,
524 responseType: LinkedFileResponse.self 553 responseType: LinkedFileResponse.self,
554 cacheKey: APICacheKeys.path(service: service, rid: repositoryRid, ref: revspec, path: path),
555 resourceType: .repositoryFile,
556 ttl: revspec == "HEAD" ? APICacheTTLs.movingRefFileContent : APICacheTTLs.immutableFileContent,
557 policy: .cacheFirstThenRefresh
525 ) 558 )
526 return result.repository?.path 559 return cached.value.repository?.path
527 } 560 }
528 561
529 /// Dismiss the file view and go back to the directory listing. 562 /// Dismiss the file view and go back to the directory listing.
Hutch/Views/Repositories/RepositoryDetailViewModel.swift +17 −5
@@ -138,6 +138,7 @@ final class RepositoryDetailViewModel {
138 private(set) var readmePath: String? 138 private(set) var readmePath: String?
139 private(set) var isLoadingReadme = false 139 private(set) var isLoadingReadme = false
140 private(set) var readmeLoaded = false 140 private(set) var readmeLoaded = false
141 private(set) var readmeCacheMetadata: CacheEntryMetadata?
141 142
142 // MARK: - Artifacts state 143 // MARK: - Artifacts state
143 144
@@ -379,13 +380,18 @@ final class RepositoryDetailViewModel {
379 380
380 do { 381 do {
381 // Step 1: Check the custom HTML readme set via the web UI 382 // Step 1: Check the custom HTML readme set via the web UI
382 let result = try await client.execute( 383 let result = try await client.executeCached(
383 service: service, 384 service: service,
384 query: Self.readmeQuery, 385 query: Self.readmeQuery,
385 variables: ["rid": repository.rid], 386 variables: ["rid": repository.rid],
386 responseType: ReadmeResponse.self 387 responseType: ReadmeResponse.self,
388 cacheKey: APICacheKeys.readme(service: service, rid: repository.rid),
389 resourceType: .repositoryReadme,
390 ttl: APICacheTTLs.movingRefFileContent,
391 policy: .cacheFirstThenRefresh
387 ) 392 )
388 if let html = result.repository?.readme, !html.isEmpty { 393 readmeCacheMetadata = result.metadata
394 if let html = result.value.repository?.readme, !html.isEmpty {
389 readmePath = nil 395 readmePath = nil
390 readmeContent = .html(html) 396 readmeContent = .html(html)
391 readmeLoaded = true 397 readmeLoaded = true
@@ -396,12 +402,18 @@ final class RepositoryDetailViewModel {
396 for filename in Self.readmeFilenames { 402 for filename in Self.readmeFilenames {
397 let pathResult: PathResponse 403 let pathResult: PathResponse
398 do { 404 do {
399 pathResult = try await client.execute( 405 let cached = try await client.executeCached(
400 service: service, 406 service: service,
401 query: Self.readmeFileQuery(filename: filename), 407 query: Self.readmeFileQuery(filename: filename),
402 variables: ["rid": repository.rid], 408 variables: ["rid": repository.rid],
403 responseType: PathResponse.self 409 responseType: PathResponse.self,
410 cacheKey: APICacheKeys.readme(service: service, rid: repository.rid, path: filename),
411 resourceType: .repositoryReadme,
412 ttl: APICacheTTLs.movingRefFileContent,
413 policy: .cacheFirstThenRefresh
404 ) 414 )
415 pathResult = cached.value
416 readmeCacheMetadata = cached.metadata
405 } catch { 417 } catch {
406 if isEmptyRepositoryError(error) { 418 if isEmptyRepositoryError(error) {
407 readmeContent = nil 419 readmeContent = nil
Hutch/Views/Settings/SettingsView.swift +7
@@ -133,6 +133,13 @@ struct SettingsView: View {
133 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } 133 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
134 .themedRow() 134 .themedRow()
135 135
136 Button {
137 Task { await appState.client.clearPersistentCache() }
138 } label: {
139 Label("Clear Cache", systemImage: "externaldrive.badge.xmark")
140 }
141 .themedRow()
142
136 Button("Reset App Data", role: .destructive) { 143 Button("Reset App Data", role: .destructive) {
137 pendingDestructiveAction = .resetAppData 144 pendingDestructiveAction = .resetAppData
138 } 145 }
Hutch/Views/Shared/StaleCacheStatusRow.swift added +32
@@ -0,0 +1,32 @@
1import SwiftUI
2
3struct StaleCacheStatusRow: View {
4 let metadata: CacheEntryMetadata
5 let isRefreshing: Bool
6
7 var body: some View {
8 HStack(spacing: 8) {
9 Image(systemName: metadata.isExpired() ? "clock.badge.exclamationmark" : "clock")
10 .foregroundStyle(.secondary)
11 Text(statusText)
12 .font(.caption)
13 .foregroundStyle(.secondary)
14 Spacer(minLength: 0)
15 if isRefreshing {
16 ProgressView()
17 .controlSize(.mini)
18 }
19 }
20 .themedRow()
21 }
22
23 private var statusText: String {
24 if isRefreshing {
25 return "Showing cached data. Refreshing…"
26 }
27 if metadata.isExpired() {
28 return "Showing cached data. Last updated \(metadata.fetchedAt.relativeDescription)."
29 }
30 return "Last updated \(metadata.fetchedAt.relativeDescription)"
31 }
32}
Hutch/Views/Tickets/TicketDetailView.swift +6
@@ -169,6 +169,12 @@ struct TicketDetailView: View {
169 } else if let ticket = viewModel.ticket { 169 } else if let ticket = viewModel.ticket {
170 ScrollView { 170 ScrollView {
171 VStack(alignment: .leading, spacing: 0) { 171 VStack(alignment: .leading, spacing: 0) {
172 if let cacheMetadata = viewModel.cacheMetadata {
173 StaleCacheStatusRow(metadata: cacheMetadata, isRefreshing: viewModel.isRefreshingCachedData)
174 .padding(.horizontal)
175 .padding(.vertical, 8)
176 }
177
172 // Header 178 // Header
173 ticketHeader(ticket, viewModel: viewModel) 179 ticketHeader(ticket, viewModel: viewModel)
174 180
Hutch/Views/Tickets/TicketDetailViewModel.swift +84 −36
@@ -98,7 +98,7 @@ private struct LabelsPage: Decodable, Sendable {
98@MainActor 98@MainActor
99final class TicketDetailViewModel { 99final class TicketDetailViewModel {
100 private static func cacheKey(ownerUsername: String, trackerRid: String, ticketId: Int) -> String { 100 private static func cacheKey(ownerUsername: String, trackerRid: String, ticketId: Int) -> String {
101 "ticket.detail.\(ownerUsername).\(trackerRid).\(ticketId)" 101 APICacheKeys.ticketDetail(owner: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
102 } 102 }
103 103
104 let ownerUsername: String 104 let ownerUsername: String
@@ -114,6 +114,8 @@ final class TicketDetailViewModel {
114 private(set) var isPerformingAction = false 114 private(set) var isPerformingAction = false
115 private(set) var trackerLabels: [TicketLabel] = [] 115 private(set) var trackerLabels: [TicketLabel] = []
116 private(set) var rawTicketResponse: String? 116 private(set) var rawTicketResponse: String?
117 private(set) var cacheMetadata: CacheEntryMetadata?
118 private(set) var isRefreshingCachedData = false
117 var commentText = "" 119 var commentText = ""
118 var error: String? 120 var error: String?
119 121
@@ -291,30 +293,25 @@ final class TicketDetailViewModel {
291 rawTicketResponse = nil 293 rawTicketResponse = nil
292 294
293 do { 295 do {
294 let result = try await client.execute( 296 let result = try await client.executeCached(
295 service: .todo, 297 service: .todo,
296 query: Self.detailQuery, 298 query: Self.detailQuery,
297 variables: [ 299 variables: [
298 "rid": trackerRid, 300 "rid": trackerRid,
299 "ticketId": ticketId 301 "ticketId": ticketId
300 ], 302 ],
301 responseType: TicketDetailResponse.self 303 responseType: TicketDetailResponse.self,
302 ) 304 cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId),
303 let payload = result.tracker.ticket 305 resourceType: .ticketDetail,
304 ticket = TicketDetail( 306 ttl: APICacheTTLs.ticketDetail,
305 id: payload.id, 307 policy: .cacheFirstThenRefresh
306 created: payload.created,
307 updated: payload.updated,
308 title: payload.title,
309 description: payload.description,
310 status: payload.status,
311 resolution: payload.resolution,
312 authenticity: payload.authenticity,
313 submitter: payload.submitter,
314 assignees: payload.assignees,
315 labels: payload.labels
316 ) 308 )
317 events = payload.events.results.sorted(by: Self.timelineOrder) 309 apply(result.value, metadata: result.metadata)
310 if result.isFromCache {
311 isLoading = false
312 await refreshTicketInBackground()
313 return
314 }
318 } catch { 315 } catch {
319 self.error = error.userFacingMessage 316 self.error = error.userFacingMessage
320 } 317 }
@@ -329,7 +326,7 @@ final class TicketDetailViewModel {
329 326
330 do { 327 do {
331 let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId) 328 let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
332 let result = try await client.executeAndCache( 329 let result = try await client.executeCached(
333 service: .todo, 330 service: .todo,
334 query: Self.detailQuery, 331 query: Self.detailQuery,
335 variables: [ 332 variables: [
@@ -337,25 +334,14 @@ final class TicketDetailViewModel {
337 "ticketId": ticketId 334 "ticketId": ticketId
338 ], 335 ],
339 responseType: TicketDetailResponse.self, 336 responseType: TicketDetailResponse.self,
340 cacheKey: cacheKey 337 cacheKey: cacheKey,
338 resourceType: .ticketDetail,
339 ttl: APICacheTTLs.ticketDetail,
340 policy: .refreshIgnoringCache
341 ) 341 )
342 rawTicketResponse = client.responseCache.get(forKey: cacheKey) 342 rawTicketResponse = await client.cachedPayload(forKey: cacheKey)
343 .flatMap { String(data: $0, encoding: .utf8) } 343 .flatMap { String(data: $0, encoding: .utf8) }
344 let payload = result.tracker.ticket 344 apply(result.value, metadata: result.metadata)
345 ticket = TicketDetail(
346 id: payload.id,
347 created: payload.created,
348 updated: payload.updated,
349 title: payload.title,
350 description: payload.description,
351 status: payload.status,
352 resolution: payload.resolution,
353 authenticity: payload.authenticity,
354 submitter: payload.submitter,
355 assignees: payload.assignees,
356 labels: payload.labels
357 )
358 events = payload.events.results.sorted(by: Self.timelineOrder)
359 } catch { 345 } catch {
360 self.error = error.userFacingMessage 346 self.error = error.userFacingMessage
361 } 347 }
@@ -391,6 +377,7 @@ final class TicketDetailViewModel {
391 events.append(event) 377 events.append(event)
392 events.sort(by: Self.timelineOrder) 378 events.sort(by: Self.timelineOrder)
393 commentText = "" 379 commentText = ""
380 await invalidateAfterMutation()
394 } catch { 381 } catch {
395 self.error = error.userFacingMessage 382 self.error = error.userFacingMessage
396 } 383 }
@@ -423,6 +410,7 @@ final class TicketDetailViewModel {
423 ], 410 ],
424 responseType: UpdateStatusResponse.self 411 responseType: UpdateStatusResponse.self
425 ) 412 )
413 await invalidateAfterMutation()
426 // Re-fetch the ticket to get updated status/resolution 414 // Re-fetch the ticket to get updated status/resolution
427 await reloadTicketPreservingDebugState() 415 await reloadTicketPreservingDebugState()
428 } catch { 416 } catch {
@@ -457,6 +445,7 @@ final class TicketDetailViewModel {
457 ], 445 ],
458 responseType: AssignUserResponse.self 446 responseType: AssignUserResponse.self
459 ) 447 )
448 await invalidateAfterMutation()
460 // Reload to reflect the change 449 // Reload to reflect the change
461 await reloadTicketPreservingDebugState() 450 await reloadTicketPreservingDebugState()
462 } catch { 451 } catch {
@@ -503,6 +492,7 @@ final class TicketDetailViewModel {
503 ], 492 ],
504 responseType: AssignUserResponse.self 493 responseType: AssignUserResponse.self
505 ) 494 )
495 await invalidateAfterMutation()
506 await reloadTicketPreservingDebugState() 496 await reloadTicketPreservingDebugState()
507 } catch { 497 } catch {
508 ticket = TicketDetail( 498 ticket = TicketDetail(
@@ -550,6 +540,7 @@ final class TicketDetailViewModel {
550 ], 540 ],
551 responseType: UnassignUserResponse.self 541 responseType: UnassignUserResponse.self
552 ) 542 )
543 await invalidateAfterMutation()
553 // Reload to reflect the change 544 // Reload to reflect the change
554 await reloadTicketPreservingDebugState() 545 await reloadTicketPreservingDebugState()
555 } catch { 546 } catch {
@@ -575,6 +566,7 @@ final class TicketDetailViewModel {
575 ], 566 ],
576 responseType: LabelTicketResponse.self 567 responseType: LabelTicketResponse.self
577 ) 568 )
569 await invalidateAfterMutation()
578 await reloadTicketPreservingDebugState() 570 await reloadTicketPreservingDebugState()
579 } catch { 571 } catch {
580 self.error = error.userFacingMessage 572 self.error = error.userFacingMessage
@@ -599,6 +591,7 @@ final class TicketDetailViewModel {
599 ], 591 ],
600 responseType: UnlabelTicketResponse.self 592 responseType: UnlabelTicketResponse.self
601 ) 593 )
594 await invalidateAfterMutation()
602 await reloadTicketPreservingDebugState() 595 await reloadTicketPreservingDebugState()
603 } catch { 596 } catch {
604 self.error = error.userFacingMessage 597 self.error = error.userFacingMessage
@@ -654,6 +647,61 @@ final class TicketDetailViewModel {
654 } 647 }
655 } 648 }
656 649
650 private func refreshTicketInBackground() async {
651 guard !isRefreshingCachedData else { return }
652 isRefreshingCachedData = true
653 defer { isRefreshingCachedData = false }
654
655 do {
656 let result = try await client.executeCached(
657 service: .todo,
658 query: Self.detailQuery,
659 variables: [
660 "rid": trackerRid,
661 "ticketId": ticketId
662 ],
663 responseType: TicketDetailResponse.self,
664 cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId),
665 resourceType: .ticketDetail,
666 ttl: APICacheTTLs.ticketDetail,
667 policy: .refreshIgnoringCache
668 )
669 apply(result.value, metadata: result.metadata)
670 } catch {
671 if ticket == nil {
672 self.error = error.userFacingMessage
673 }
674 }
675 }
676
677 private func apply(_ response: TicketDetailResponse, metadata: CacheEntryMetadata?) {
678 cacheMetadata = metadata
679 let payload = response.tracker.ticket
680 let updatedTicket = TicketDetail(
681 id: payload.id,
682 created: payload.created,
683 updated: payload.updated,
684 title: payload.title,
685 description: payload.description,
686 status: payload.status,
687 resolution: payload.resolution,
688 authenticity: payload.authenticity,
689 submitter: payload.submitter,
690 assignees: payload.assignees,
691 labels: payload.labels
692 )
693 ticket = updatedTicket
694 let updatedEvents = payload.events.results.sorted(by: Self.timelineOrder)
695 events = updatedEvents
696 }
697
698 private func invalidateAfterMutation() async {
699 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket"))
700 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets"))
701 await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker"))
702 await client.invalidateCache(prefix: APICacheKeys.prefix("home"))
703 }
704
657 static func matchesAssignee(_ entity: Entity, user: User) -> Bool { 705 static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
658 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) 706 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
659 let userCanonical = normalizedCanonicalName(user.canonicalName) 707 let userCanonical = normalizedCanonicalName(user.canonicalName)
HutchTests/APICacheTests.swift added +309
@@ -0,0 +1,309 @@
1import Foundation
2import Testing
3@testable import Hutch
4
5@Suite(.serialized)
6struct APICacheTests {
7 private struct Payload: Codable, Sendable, Equatable {
8 let value: String
9 }
10
11 private struct GraphPayload: Decodable, Sendable, Equatable {
12 let item: Payload
13 }
14
15 @Test
16 func cacheReadWriteRoundTrip() async throws {
17 let cache = makeCache()
18 let data = try JSONEncoder().encode(Payload(value: "cached"))
19
20 _ = try await cache.write(payload: data, cacheKey: "repo|one", resourceType: .repositoryDetail, ttl: 60)
21 let entry = try await cache.read(cacheKey: "repo|one")
22 let decoded = try JSONDecoder().decode(Payload.self, from: entry.payload)
23
24 #expect(decoded == Payload(value: "cached"))
25 #expect(entry.metadata.cacheKey == "repo|one")
26 #expect(entry.metadata.resourceType == .repositoryDetail)
27 }
28
29 @Test
30 func expiredEntryBehaviorAndPruneExpired() async throws {
31 let cache = makeCache()
32 let data = Data("expired".utf8)
33
34 let metadata = try await cache.write(payload: data, cacheKey: "ticket|old", resourceType: .ticketDetail, ttl: -1)
35 #expect(metadata.isExpired())
36 let entry = try await cache.read(cacheKey: "ticket|old")
37 #expect(entry.payload == data)
38
39 await cache.pruneExpired(now: Date())
40
41 await expectCacheMiss(cache, key: "ticket|old")
42 }
43
44 @Test
45 func invalidationByPrefixRemovesMatchingEntriesOnly() async throws {
46 let cache = makeCache()
47 _ = try await cache.write(payload: Data("a".utf8), cacheKey: "todo|ticket|1", resourceType: .ticketDetail, ttl: 60)
48 _ = try await cache.write(payload: Data("b".utf8), cacheKey: "todo|tickets", resourceType: .ticketList, ttl: 60)
49 _ = try await cache.write(payload: Data("c".utf8), cacheKey: "builds|job|1", resourceType: .buildDetail, ttl: 60)
50
51 await cache.removeByPrefix("todo|ticket")
52
53 await expectCacheMiss(cache, key: "todo|ticket|1")
54 await expectCacheMiss(cache, key: "todo|tickets")
55 _ = try await cache.read(cacheKey: "builds|job|1")
56 }
57
58 @Test
59 func maxEntrySizeEnforced() async throws {
60 let directory = temporaryDirectory()
61 let cache = PersistentAPICache(configuration: APICacheConfiguration(
62 directory: directory,
63 maxCacheSizeBytes: 1024,
64 maxEntrySizeBytes: 3,
65 memoryEntryLimit: 4,
66 schemaVersion: 1
67 ))
68
69 do {
70 _ = try await cache.write(payload: Data("toolarge".utf8), cacheKey: "large", resourceType: .buildLog, ttl: 60)
71 Issue.record("Expected max-entry enforcement.")
72 } catch APICacheError.entryTooLarge(let bytes) {
73 #expect(bytes == 8)
74 } catch {
75 Issue.record("Unexpected error: \(error)")
76 }
77 }
78
79 @Test
80 func pruneToSizeLimitUsesLRU() async throws {
81 let directory = temporaryDirectory()
82 let cache = PersistentAPICache(configuration: APICacheConfiguration(
83 directory: directory,
84 maxCacheSizeBytes: 9,
85 maxEntrySizeBytes: 20,
86 memoryEntryLimit: 4,
87 schemaVersion: 1
88 ))
89
90 _ = try await cache.write(payload: Data("1111".utf8), cacheKey: "old", resourceType: .repositoryFile, ttl: 60)
91 try await Task.sleep(for: .milliseconds(5))
92 _ = try await cache.write(payload: Data("2222".utf8), cacheKey: "middle", resourceType: .repositoryFile, ttl: 60)
93 try await Task.sleep(for: .milliseconds(5))
94 _ = try await cache.write(payload: Data("3333".utf8), cacheKey: "new", resourceType: .repositoryFile, ttl: 60)
95
96 await cache.pruneToSizeLimit()
97
98 await expectCacheMiss(cache, key: "old")
99 _ = try await cache.read(cacheKey: "middle")
100 _ = try await cache.read(cacheKey: "new")
101 }
102
103 @Test
104 func cacheFirstThenRefreshReturnsUsableStaleCacheWhenRefreshFails() async throws {
105 let cache = makeCache()
106 let staleEnvelope = #"{"data":{"item":{"value":"stale"}}}"#.data(using: .utf8)!
107 _ = try await cache.write(payload: staleEnvelope, cacheKey: "resource", resourceType: .repositoryDetail, ttl: -1)
108 CachedURLProtocol.reset(responses: [.failure])
109 let client = makeClient(cache: cache)
110
111 let result = try await client.executeCached(
112 service: .git,
113 query: "{ item { value } }",
114 responseType: GraphPayload.self,
115 cacheKey: "resource",
116 resourceType: .repositoryDetail,
117 ttl: 60,
118 policy: .cacheFirstThenRefresh
119 )
120
121 #expect(result.value.item.value == "stale")
122 #expect(result.isFromCache)
123 }
124
125 @Test
126 func refreshIgnoringCacheUpdatesCache() async throws {
127 let cache = makeCache()
128 CachedURLProtocol.reset(responses: [.success("fresh")])
129 let client = makeClient(cache: cache)
130
131 let result = try await client.executeCached(
132 service: .git,
133 query: "{ item { value } }",
134 responseType: GraphPayload.self,
135 cacheKey: "resource",
136 resourceType: .repositoryDetail,
137 ttl: 60,
138 policy: .refreshIgnoringCache
139 )
140 let cached = try await client.executeCached(
141 service: .git,
142 query: "{ item { value } }",
143 responseType: GraphPayload.self,
144 cacheKey: "resource",
145 resourceType: .repositoryDetail,
146 ttl: 60,
147 policy: .cacheOnly
148 )
149
150 #expect(result.value.item.value == "fresh")
151 #expect(cached.value.item.value == "fresh")
152 }
153
154 @Test
155 func networkOnlyBypassesCacheAndDoesNotWrite() async throws {
156 let cache = makeCache()
157 _ = try await cache.write(
158 payload: #"{"data":{"item":{"value":"cached"}}}"#.data(using: .utf8)!,
159 cacheKey: "resource",
160 resourceType: .repositoryDetail,
161 ttl: 60
162 )
163 CachedURLProtocol.reset(responses: [.success("network")])
164 let client = makeClient(cache: cache)
165
166 let result = try await client.executeCached(
167 service: .git,
168 query: "{ item { value } }",
169 responseType: GraphPayload.self,
170 cacheKey: "resource",
171 resourceType: .repositoryDetail,
172 ttl: 60,
173 policy: .networkOnly
174 )
175 let cached = try await client.executeCached(
176 service: .git,
177 query: "{ item { value } }",
178 responseType: GraphPayload.self,
179 cacheKey: "resource",
180 resourceType: .repositoryDetail,
181 ttl: 60,
182 policy: .cacheOnly
183 )
184
185 #expect(result.value.item.value == "network")
186 #expect(cached.value.item.value == "cached")
187 }
188
189 @Test
190 func plainMutationPathDoesNotReadFromCache() async throws {
191 let cache = makeCache()
192 _ = try await cache.write(
193 payload: #"{"data":{"item":{"value":"cached"}}}"#.data(using: .utf8)!,
194 cacheKey: "mutation-resource",
195 resourceType: .debug,
196 ttl: 60
197 )
198 CachedURLProtocol.reset(responses: [.success("network")])
199 let client = makeClient(cache: cache)
200
201 let result = try await client.execute(
202 service: .git,
203 query: "mutation update { item { value } }",
204 responseType: GraphPayload.self
205 )
206
207 #expect(result.item.value == "network")
208 #expect(CachedURLProtocol.requestCount == 1)
209 }
210
211 @Test
212 func duplicateConcurrentRequestsAreCoalesced() async throws {
213 let cache = makeCache()
214 CachedURLProtocol.reset(responses: [.success("fresh")], responseDelay: 0.05)
215 let client = makeClient(cache: cache)
216
217 async let first: CachedValue<GraphPayload> = client.executeCached(
218 service: .git,
219 query: "{ item { value } }",
220 responseType: GraphPayload.self,
221 cacheKey: "same-resource",
222 resourceType: .repositoryDetail,
223 ttl: 60,
224 policy: .refreshIgnoringCache
225 )
226 async let second: CachedValue<GraphPayload> = client.executeCached(
227 service: .git,
228 query: "{ item { value } }",
229 responseType: GraphPayload.self,
230 cacheKey: "same-resource",
231 resourceType: .repositoryDetail,
232 ttl: 60,
233 policy: .refreshIgnoringCache
234 )
235
236 let values = try await [first.value.item.value, second.value.item.value]
237 #expect(values == ["fresh", "fresh"])
238 #expect(CachedURLProtocol.requestCount == 1)
239 }
240
241 private func makeCache() -> PersistentAPICache {
242 PersistentAPICache(configuration: .temporary(directory: temporaryDirectory()))
243 }
244
245 private func makeClient(cache: any APICache) -> SRHTClient {
246 SRHTClient(session: CachedURLProtocol.makeSession(), token: "token", cache: cache)
247 }
248
249 private func temporaryDirectory() -> URL {
250 FileManager.default.temporaryDirectory
251 .appendingPathComponent("HutchAPICacheTests-\(UUID().uuidString)", isDirectory: true)
252 }
253
254 private func expectCacheMiss(_ cache: any APICache, key: String) async {
255 do {
256 _ = try await cache.read(cacheKey: key)
257 Issue.record("Expected cache miss for \(key).")
258 } catch APICacheError.miss {
259 } catch {
260 Issue.record("Unexpected error for \(key): \(error).")
261 }
262 }
263}
264
265private enum CachedURLProtocolResponse: Sendable {
266 case success(String)
267 case failure
268}
269
270private final class CachedURLProtocol: URLProtocol, @unchecked Sendable {
271 nonisolated(unsafe) private static var responses: [CachedURLProtocolResponse] = []
272 nonisolated(unsafe) private static var delay: TimeInterval = 0
273 nonisolated(unsafe) static var requestCount = 0
274
275 override class func canInit(with _: URLRequest) -> Bool { true }
276 override class func canonicalRequest(for request: URLRequest) -> URLRequest { request }
277
278 override func startLoading() {
279 Self.requestCount += 1
280 if Self.delay > 0 {
281 Thread.sleep(forTimeInterval: Self.delay)
282 }
283 let next = Self.responses.isEmpty ? .success("fresh") : Self.responses.removeFirst()
284 switch next {
285 case .success(let value):
286 let data = #"{"data":{"item":{"value":"\#(value)"}}}"#.data(using: .utf8)!
287 let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!
288 client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed)
289 client?.urlProtocol(self, didLoad: data)
290 client?.urlProtocolDidFinishLoading(self)
291 case .failure:
292 client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet))
293 }
294 }
295
296 override func stopLoading() {}
297
298 static func reset(responses: [CachedURLProtocolResponse], responseDelay: TimeInterval = 0) {
299 Self.responses = responses
300 Self.delay = responseDelay
301 Self.requestCount = 0
302 }
303
304 static func makeSession() -> URLSession {
305 let config = URLSessionConfiguration.ephemeral
306 config.protocolClasses = [CachedURLProtocol.self]
307 return URLSession(configuration: config)
308 }
309}
HutchTests/AppStateTests.swift +1
@@ -1,3 +1,4 @@
1import Foundation
1import Testing 2import Testing
2@testable import Hutch 3@testable import Hutch
3 4