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 @@
517517 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
518518 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
519519 CODE_SIGN_STYLE = Automatic;
520 CURRENT_PROJECT_VERSION = 82;
520 CURRENT_PROJECT_VERSION = 83;
521521 DEVELOPMENT_TEAM = ZCNAX3VL9D;
522522 ENABLE_PREVIEWS = YES;
523523 GENERATE_INFOPLIST_FILE = YES;
@@ -534,7 +534,7 @@
534534 "$(inherited)",
535535 "@executable_path/Frameworks",
536536 );
537 MARKETING_VERSION = 3.2.2;
537 MARKETING_VERSION = 3.3.0;
538538 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
539539 PRODUCT_NAME = "$(TARGET_NAME)";
540540 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -554,7 +554,7 @@
554554 ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
555555 CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements;
556556 CODE_SIGN_STYLE = Automatic;
557 CURRENT_PROJECT_VERSION = 82;
557 CURRENT_PROJECT_VERSION = 83;
558558 DEVELOPMENT_TEAM = ZCNAX3VL9D;
559559 ENABLE_PREVIEWS = YES;
560560 GENERATE_INFOPLIST_FILE = YES;
@@ -571,7 +571,7 @@
571571 "$(inherited)",
572572 "@executable_path/Frameworks",
573573 );
574 MARKETING_VERSION = 3.2.2;
574 MARKETING_VERSION = 3.3.0;
575575 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch;
576576 PRODUCT_NAME = "$(TARGET_NAME)";
577577 STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -634,7 +634,7 @@
634634 APPLICATION_EXTENSION_API_ONLY = YES;
635635 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
636636 CODE_SIGN_STYLE = Automatic;
637 CURRENT_PROJECT_VERSION = 81;
637 CURRENT_PROJECT_VERSION = 83;
638638 DEVELOPMENT_TEAM = ZCNAX3VL9D;
639639 GENERATE_INFOPLIST_FILE = NO;
640640 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -644,7 +644,7 @@
644644 "@executable_path/Frameworks",
645645 "@executable_path/../../Frameworks",
646646 );
647 MARKETING_VERSION = 3.2.1;
647 MARKETING_VERSION = 3.3.0;
648648 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
649649 PRODUCT_NAME = "$(TARGET_NAME)";
650650 SKIP_INSTALL = YES;
@@ -663,7 +663,7 @@
663663 APPLICATION_EXTENSION_API_ONLY = YES;
664664 CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements;
665665 CODE_SIGN_STYLE = Automatic;
666 CURRENT_PROJECT_VERSION = 81;
666 CURRENT_PROJECT_VERSION = 83;
667667 DEVELOPMENT_TEAM = ZCNAX3VL9D;
668668 GENERATE_INFOPLIST_FILE = NO;
669669 INFOPLIST_FILE = HutchWidgetExtension/Info.plist;
@@ -673,7 +673,7 @@
673673 "@executable_path/Frameworks",
674674 "@executable_path/../../Frameworks",
675675 );
676 MARKETING_VERSION = 3.2.1;
676 MARKETING_VERSION = 3.3.0;
677677 PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension;
678678 PRODUCT_NAME = "$(TARGET_NAME)";
679679 SKIP_INSTALL = YES;
Hutch/App/AppState.swift +6 −1
@@ -212,6 +212,7 @@ final class AppState {
212212 clearActiveSessionState()
213213 try? KeychainHelper.deleteAll()
214214 URLCache.shared.removeAllCachedResponses()
215 await client.clearPersistentCache()
215216 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
216217 await clearWebData()
217218 clearWebContentRenderCaches()
@@ -232,6 +233,7 @@ final class AppState {
232233 }
233234 try? KeychainHelper.deleteAll()
234235 URLCache.shared.removeAllCachedResponses()
236 await client.clearPersistentCache()
235237 HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) }
236238 await clearWebData()
237239 clearWebContentRenderCaches()
@@ -450,7 +452,10 @@ final class AppState {
450452 }
451453
452454 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 )
454459 let user: User
455460 if let knownUser {
456461 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
12import Foundation
23import os
34
@@ -20,6 +21,8 @@ final class SRHTClient: Sendable {
2021 private let session: URLSession
2122 private let decoder: JSONDecoder
2223 private let encoder: JSONEncoder
24 private let cache: any APICache
25 private let requestCoalescer = RequestCoalescer()
2326
2427 /// The personal access token used for `Authorization: Bearer` headers.
2528 /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``.
@@ -32,12 +35,19 @@ final class SRHTClient: Sendable {
3235 tokenLock.withLock { $0 != nil }
3336 }
3437
35 init(session: URLSession = .shared, token: String? = nil) {
38 init(
39 session: URLSession = .shared,
40 token: String? = nil,
41 cache: (any APICache)? = nil
42 ) {
3643 self.session = session
3744 self.decoder = JSONDecoder()
3845 self.decoder.dateDecodingStrategy = .srhtFlexible
3946 self.encoder = JSONEncoder()
4047 self.tokenLock = OSAllocatedUnfairLock(initialState: token)
48 self.cache = cache ?? PersistentAPICache(
49 configuration: .accountScoped(accountID: token.map { Self.tokenCacheScope($0) } ?? "anonymous")
50 )
4151 }
4252
4353 /// Update the stored token (e.g. after the user saves a new one in Keychain).
@@ -156,6 +166,130 @@ final class SRHTClient: Sendable {
156166 return result
157167 }
158168
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
159293 // MARK: - Multipart Upload
160294
161295 /// Execute a GraphQL mutation with a file upload using the
@@ -614,6 +748,152 @@ final class SRHTClient: Sendable {
614748// MARK: - Data Helper
615749
616750private 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
617897 func throwGraphQLErrorsIfPresent(in data: Data) throws {
618898 if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data),
619899 let errors = envelope.errors,
@@ -632,6 +912,23 @@ private extension SRHTClient {
632912 }
633913}
634914
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
635932private extension Data {
636933 mutating func append(_ string: String) {
637934 if let data = string.data(using: .utf8) {
Hutch/Views/Builds/BuildDetailView.swift +6
@@ -166,6 +166,12 @@ struct BuildDetailView: View {
166166 )
167167 } else if let job = viewModel.job {
168168 List {
169 if let cacheMetadata = viewModel.cacheMetadata {
170 Section {
171 StaleCacheStatusRow(metadata: cacheMetadata, isRefreshing: viewModel.isRefreshingCachedData)
172 }
173 }
174
169175 Section("Details") {
170176 HStack {
171177 Text("Status")
Hutch/Views/Builds/BuildDetailViewModel.swift +85 −29
@@ -28,7 +28,7 @@ private struct SubmittedJob: Decodable, Sendable {
2828@MainActor
2929final class BuildDetailViewModel {
3030 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) }
3232
3333 let jobId: Int
3434 private let client: SRHTClient
@@ -46,6 +46,8 @@ final class BuildDetailViewModel {
4646 private(set) var isRebuilding = false
4747 private(set) var isSubmittingEditedBuild = false
4848 private(set) var rawJobResponse: String?
49 private(set) var cacheMetadata: CacheEntryMetadata?
50 private(set) var isRefreshingCachedData = false
4951 var error: String?
5052 /// Transient error shown for action failures (cancel, rebuild, submit).
5153 /// Separate from `error` so auto-refresh doesn't immediately clear it.
@@ -128,22 +130,21 @@ final class BuildDetailViewModel {
128130 rawJobResponse = nil
129131
130132 do {
131 let result = try await client.execute(
133 let result = try await client.executeCached(
132134 service: .builds,
133135 query: Self.detailQuery,
134136 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
136142 )
137 var loadedJob = result.job
138 loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in
139 task.withOrdinal(index)
140 }
141 if job != loadedJob {
142 job = loadedJob
143 }
144
145 if loadedJob.status.isTerminal {
146 stopAutoRefresh()
143 apply(result.value, metadata: result.metadata)
144 if result.isFromCache {
145 isLoading = false
146 await refreshJobInBackground()
147 return
147148 }
148149 } catch {
149150 self.error = error.userFacingMessage
@@ -159,26 +160,19 @@ final class BuildDetailViewModel {
159160
160161 do {
161162 let cacheKey = Self.cacheKey(for: jobId)
162 let result = try await client.executeAndCache(
163 let result = try await client.executeCached(
163164 service: .builds,
164165 query: Self.detailQuery,
165166 variables: ["id": jobId],
166167 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
168172 )
169 rawJobResponse = client.responseCache.get(forKey: cacheKey)
173 rawJobResponse = await client.cachedPayload(forKey: cacheKey)
170174 .flatMap { String(data: $0, encoding: .utf8) }
171 var loadedJob = result.job
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 }
175 apply(result.value, metadata: result.metadata)
182176 } catch {
183177 self.error = error.userFacingMessage
184178 }
@@ -201,7 +195,14 @@ final class BuildDetailViewModel {
201195 loadingTaskLogs.insert(cacheKey)
202196
203197 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
205206 failedTaskLogs.remove(cacheKey)
206207 } catch {
207208 failedTaskLogs.insert(cacheKey)
@@ -222,7 +223,13 @@ final class BuildDetailViewModel {
222223 isLoadingBuildLog = true
223224
224225 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
226233 } catch {
227234 self.error = error.userFacingMessage
228235 }
@@ -287,6 +294,7 @@ final class BuildDetailViewModel {
287294 variables: ["id": jobId],
288295 responseType: CancelResponse.self
289296 )
297 await invalidateAfterMutation()
290298 await reloadJobPreservingDebugState()
291299 } catch {
292300 // Revert optimistic update on failure.
@@ -329,6 +337,7 @@ final class BuildDetailViewModel {
329337 variables: variables,
330338 responseType: SubmitJobResponse.self
331339 )
340 await invalidateAfterMutation()
332341 return result.submit.id
333342 } catch {
334343 setActionError("Couldn't rebuild. \(error.userFacingMessage)")
@@ -377,6 +386,7 @@ final class BuildDetailViewModel {
377386 variables: variables,
378387 responseType: SubmitJobResponse.self
379388 )
389 await invalidateAfterMutation()
380390 return result.submit.id
381391 } catch {
382392 setActionError("Couldn’t submit the build. \(error.userFacingMessage)")
@@ -417,6 +427,52 @@ final class BuildDetailViewModel {
417427 }
418428 }
419429
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
420476 private var shouldAutoRefresh: Bool {
421477 guard let job else { return true }
422478 return !job.status.isTerminal
Hutch/Views/Repositories/FileTreeViewModel.swift +47 −14
@@ -90,6 +90,7 @@ final class FileTreeViewModel {
9090 private(set) var viewingObject: GitObject?
9191
9292 private(set) var isLoading = false
93 private(set) var cacheMetadata: CacheEntryMetadata?
9394 var error: String?
9495
9596 // Available references for the branch/tag picker
@@ -285,12 +286,18 @@ final class FileTreeViewModel {
285286 do {
286287 let result: RevparseResponse
287288 do {
288 result = try await client.execute(
289 let cached = try await client.executeCached(
289290 service: service,
290291 query: Self.rootTreeQuery,
291292 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
293298 )
299 result = cached.value
300 cacheMetadata = cached.metadata
294301 } catch {
295302 if isMissingGitReferenceError(error) {
296303 navStack = [FileNavEntry(name: "root", treeId: "")]
@@ -375,12 +382,18 @@ final class FileTreeViewModel {
375382 ]
376383
377384 do {
378 let result = try await client.execute(
385 let cached = try await client.executeCached(
379386 service: service,
380387 query: Self.subtreeQuery,
381388 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
383394 )
395 let result = cached.value
396 cacheMetadata = cached.metadata
384397 navStack.append(FileNavEntry(name: name, treeId: treeId))
385398 var allEntries = result.repository?.object?.entries?.results ?? []
386399 var cursor = result.repository?.object?.entries?.cursor
@@ -407,12 +420,18 @@ final class FileTreeViewModel {
407420 ]
408421
409422 do {
410 let result = try await client.execute(
423 let cached = try await client.executeCached(
411424 service: service,
412425 query: Self.blobQuery,
413426 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
415432 )
433 let result = cached.value
434 cacheMetadata = cached.metadata
416435 viewingEntry = entry
417436 viewingObject = result.repository?.object ?? .unknown
418437 } catch {
@@ -453,12 +472,18 @@ final class FileTreeViewModel {
453472 ]
454473
455474 do {
456 let result = try await client.execute(
475 let cached = try await client.executeCached(
457476 service: service,
458477 query: Self.subtreeQuery,
459478 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
461484 )
485 let result = cached.value
486 cacheMetadata = cached.metadata
462487 var allEntries = result.repository?.object?.entries?.results ?? []
463488 var cursor = result.repository?.object?.entries?.cursor
464489 while let nextCursor = cursor {
@@ -480,13 +505,17 @@ final class FileTreeViewModel {
480505 "treeId": treeId,
481506 "cursor": cursor
482507 ]
483 let result = try await client.execute(
508 let cached = try await client.executeCached(
484509 service: service,
485510 query: Self.treeEntriesPageQuery,
486511 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
488517 )
489 return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
518 return cached.value.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil)
490519 }
491520
492521 // MARK: - Linked File (used by README link interception)
@@ -517,13 +546,17 @@ final class FileTreeViewModel {
517546 "revspec": revspec,
518547 "path": path
519548 ]
520 let result = try await client.execute(
549 let cached = try await client.executeCached(
521550 service: service,
522551 query: Self.linkedFileQuery,
523552 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
525558 )
526 return result.repository?.path
559 return cached.value.repository?.path
527560 }
528561
529562 /// 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 {
138138 private(set) var readmePath: String?
139139 private(set) var isLoadingReadme = false
140140 private(set) var readmeLoaded = false
141 private(set) var readmeCacheMetadata: CacheEntryMetadata?
141142
142143 // MARK: - Artifacts state
143144
@@ -379,13 +380,18 @@ final class RepositoryDetailViewModel {
379380
380381 do {
381382 // 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(
383384 service: service,
384385 query: Self.readmeQuery,
385386 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
387392 )
388 if let html = result.repository?.readme, !html.isEmpty {
393 readmeCacheMetadata = result.metadata
394 if let html = result.value.repository?.readme, !html.isEmpty {
389395 readmePath = nil
390396 readmeContent = .html(html)
391397 readmeLoaded = true
@@ -396,12 +402,18 @@ final class RepositoryDetailViewModel {
396402 for filename in Self.readmeFilenames {
397403 let pathResult: PathResponse
398404 do {
399 pathResult = try await client.execute(
405 let cached = try await client.executeCached(
400406 service: service,
401407 query: Self.readmeFileQuery(filename: filename),
402408 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
404414 )
415 pathResult = cached.value
416 readmeCacheMetadata = cached.metadata
405417 } catch {
406418 if isEmptyRepositoryError(error) {
407419 readmeContent = nil
Hutch/Views/Settings/SettingsView.swift +7
@@ -133,6 +133,13 @@ struct SettingsView: View {
133133 .alignmentGuide(.listRowSeparatorLeading) { _ in 0 }
134134 .themedRow()
135135
136 Button {
137 Task { await appState.client.clearPersistentCache() }
138 } label: {
139 Label("Clear Cache", systemImage: "externaldrive.badge.xmark")
140 }
141 .themedRow()
142
136143 Button("Reset App Data", role: .destructive) {
137144 pendingDestructiveAction = .resetAppData
138145 }
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 {
169169 } else if let ticket = viewModel.ticket {
170170 ScrollView {
171171 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
172178 // Header
173179 ticketHeader(ticket, viewModel: viewModel)
174180
Hutch/Views/Tickets/TicketDetailViewModel.swift +84 −36
@@ -98,7 +98,7 @@ private struct LabelsPage: Decodable, Sendable {
9898@MainActor
9999final class TicketDetailViewModel {
100100 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)
102102 }
103103
104104 let ownerUsername: String
@@ -114,6 +114,8 @@ final class TicketDetailViewModel {
114114 private(set) var isPerformingAction = false
115115 private(set) var trackerLabels: [TicketLabel] = []
116116 private(set) var rawTicketResponse: String?
117 private(set) var cacheMetadata: CacheEntryMetadata?
118 private(set) var isRefreshingCachedData = false
117119 var commentText = ""
118120 var error: String?
119121
@@ -291,30 +293,25 @@ final class TicketDetailViewModel {
291293 rawTicketResponse = nil
292294
293295 do {
294 let result = try await client.execute(
296 let result = try await client.executeCached(
295297 service: .todo,
296298 query: Self.detailQuery,
297299 variables: [
298300 "rid": trackerRid,
299301 "ticketId": ticketId
300302 ],
301 responseType: TicketDetailResponse.self
302 )
303 let payload = result.tracker.ticket
304 ticket = TicketDetail(
305 id: payload.id,
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
303 responseType: TicketDetailResponse.self,
304 cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId),
305 resourceType: .ticketDetail,
306 ttl: APICacheTTLs.ticketDetail,
307 policy: .cacheFirstThenRefresh
316308 )
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 }
318315 } catch {
319316 self.error = error.userFacingMessage
320317 }
@@ -329,7 +326,7 @@ final class TicketDetailViewModel {
329326
330327 do {
331328 let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId)
332 let result = try await client.executeAndCache(
329 let result = try await client.executeCached(
333330 service: .todo,
334331 query: Self.detailQuery,
335332 variables: [
@@ -337,25 +334,14 @@ final class TicketDetailViewModel {
337334 "ticketId": ticketId
338335 ],
339336 responseType: TicketDetailResponse.self,
340 cacheKey: cacheKey
337 cacheKey: cacheKey,
338 resourceType: .ticketDetail,
339 ttl: APICacheTTLs.ticketDetail,
340 policy: .refreshIgnoringCache
341341 )
342 rawTicketResponse = client.responseCache.get(forKey: cacheKey)
342 rawTicketResponse = await client.cachedPayload(forKey: cacheKey)
343343 .flatMap { String(data: $0, encoding: .utf8) }
344 let payload = result.tracker.ticket
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)
344 apply(result.value, metadata: result.metadata)
359345 } catch {
360346 self.error = error.userFacingMessage
361347 }
@@ -391,6 +377,7 @@ final class TicketDetailViewModel {
391377 events.append(event)
392378 events.sort(by: Self.timelineOrder)
393379 commentText = ""
380 await invalidateAfterMutation()
394381 } catch {
395382 self.error = error.userFacingMessage
396383 }
@@ -423,6 +410,7 @@ final class TicketDetailViewModel {
423410 ],
424411 responseType: UpdateStatusResponse.self
425412 )
413 await invalidateAfterMutation()
426414 // Re-fetch the ticket to get updated status/resolution
427415 await reloadTicketPreservingDebugState()
428416 } catch {
@@ -457,6 +445,7 @@ final class TicketDetailViewModel {
457445 ],
458446 responseType: AssignUserResponse.self
459447 )
448 await invalidateAfterMutation()
460449 // Reload to reflect the change
461450 await reloadTicketPreservingDebugState()
462451 } catch {
@@ -503,6 +492,7 @@ final class TicketDetailViewModel {
503492 ],
504493 responseType: AssignUserResponse.self
505494 )
495 await invalidateAfterMutation()
506496 await reloadTicketPreservingDebugState()
507497 } catch {
508498 ticket = TicketDetail(
@@ -550,6 +540,7 @@ final class TicketDetailViewModel {
550540 ],
551541 responseType: UnassignUserResponse.self
552542 )
543 await invalidateAfterMutation()
553544 // Reload to reflect the change
554545 await reloadTicketPreservingDebugState()
555546 } catch {
@@ -575,6 +566,7 @@ final class TicketDetailViewModel {
575566 ],
576567 responseType: LabelTicketResponse.self
577568 )
569 await invalidateAfterMutation()
578570 await reloadTicketPreservingDebugState()
579571 } catch {
580572 self.error = error.userFacingMessage
@@ -599,6 +591,7 @@ final class TicketDetailViewModel {
599591 ],
600592 responseType: UnlabelTicketResponse.self
601593 )
594 await invalidateAfterMutation()
602595 await reloadTicketPreservingDebugState()
603596 } catch {
604597 self.error = error.userFacingMessage
@@ -654,6 +647,61 @@ final class TicketDetailViewModel {
654647 }
655648 }
656649
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
657705 static func matchesAssignee(_ entity: Entity, user: User) -> Bool {
658706 let assigneeCanonical = normalizedCanonicalName(entity.canonicalName)
659707 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
12import Testing
23@testable import Hutch
34