Commit d67a51cd1e
Unsigned
Layout: unified · split
Docs/API_CACHE.md added +11
| @@ -0,0 +1,11 @@ | ||
| 1 | # SourceHut API Cache | |
| 2 | ||
| 3 | Hutch 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 | ||
| 5 | Cache 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 | ||
| 7 | TTLs 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 | ||
| 9 | Invalidation 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 | ||
| 11 | Known 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 | 517 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 518 | 518 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 519 | 519 | CODE_SIGN_STYLE = Automatic; |
| 520 | CURRENT_PROJECT_VERSION = 82; | |
| 520 | CURRENT_PROJECT_VERSION = 83; | |
| 521 | 521 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 522 | 522 | ENABLE_PREVIEWS = YES; |
| 523 | 523 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -534,7 +534,7 @@ | ||
| 534 | 534 | "$(inherited)", |
| 535 | 535 | "@executable_path/Frameworks", |
| 536 | 536 | ); |
| 537 | MARKETING_VERSION = 3.2.2; | |
| 537 | MARKETING_VERSION = 3.3.0; | |
| 538 | 538 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 539 | 539 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 540 | 540 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -554,7 +554,7 @@ | ||
| 554 | 554 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; |
| 555 | 555 | CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; |
| 556 | 556 | CODE_SIGN_STYLE = Automatic; |
| 557 | CURRENT_PROJECT_VERSION = 82; | |
| 557 | CURRENT_PROJECT_VERSION = 83; | |
| 558 | 558 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 559 | 559 | ENABLE_PREVIEWS = YES; |
| 560 | 560 | GENERATE_INFOPLIST_FILE = YES; |
| @@ -571,7 +571,7 @@ | ||
| 571 | 571 | "$(inherited)", |
| 572 | 572 | "@executable_path/Frameworks", |
| 573 | 573 | ); |
| 574 | MARKETING_VERSION = 3.2.2; | |
| 574 | MARKETING_VERSION = 3.3.0; | |
| 575 | 575 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; |
| 576 | 576 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 577 | 577 | STRING_CATALOG_GENERATE_SYMBOLS = YES; |
| @@ -634,7 +634,7 @@ | ||
| 634 | 634 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 635 | 635 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 636 | 636 | CODE_SIGN_STYLE = Automatic; |
| 637 | CURRENT_PROJECT_VERSION = 81; | |
| 637 | CURRENT_PROJECT_VERSION = 83; | |
| 638 | 638 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 639 | 639 | GENERATE_INFOPLIST_FILE = NO; |
| 640 | 640 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -644,7 +644,7 @@ | ||
| 644 | 644 | "@executable_path/Frameworks", |
| 645 | 645 | "@executable_path/../../Frameworks", |
| 646 | 646 | ); |
| 647 | MARKETING_VERSION = 3.2.1; | |
| 647 | MARKETING_VERSION = 3.3.0; | |
| 648 | 648 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 649 | 649 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 650 | 650 | SKIP_INSTALL = YES; |
| @@ -663,7 +663,7 @@ | ||
| 663 | 663 | APPLICATION_EXTENSION_API_ONLY = YES; |
| 664 | 664 | CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; |
| 665 | 665 | CODE_SIGN_STYLE = Automatic; |
| 666 | CURRENT_PROJECT_VERSION = 81; | |
| 666 | CURRENT_PROJECT_VERSION = 83; | |
| 667 | 667 | DEVELOPMENT_TEAM = ZCNAX3VL9D; |
| 668 | 668 | GENERATE_INFOPLIST_FILE = NO; |
| 669 | 669 | INFOPLIST_FILE = HutchWidgetExtension/Info.plist; |
| @@ -673,7 +673,7 @@ | ||
| 673 | 673 | "@executable_path/Frameworks", |
| 674 | 674 | "@executable_path/../../Frameworks", |
| 675 | 675 | ); |
| 676 | MARKETING_VERSION = 3.2.1; | |
| 676 | MARKETING_VERSION = 3.3.0; | |
| 677 | 677 | PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; |
| 678 | 678 | PRODUCT_NAME = "$(TARGET_NAME)"; |
| 679 | 679 | SKIP_INSTALL = YES; |
Hutch/App/AppState.swift +6 −1
| @@ -212,6 +212,7 @@ final class AppState { | ||
| 212 | 212 | clearActiveSessionState() |
| 213 | 213 | try? KeychainHelper.deleteAll() |
| 214 | 214 | URLCache.shared.removeAllCachedResponses() |
| 215 | await client.clearPersistentCache() | |
| 215 | 216 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } |
| 216 | 217 | await clearWebData() |
| 217 | 218 | clearWebContentRenderCaches() |
| @@ -232,6 +233,7 @@ final class AppState { | ||
| 232 | 233 | } |
| 233 | 234 | try? KeychainHelper.deleteAll() |
| 234 | 235 | URLCache.shared.removeAllCachedResponses() |
| 236 | await client.clearPersistentCache() | |
| 235 | 237 | HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } |
| 236 | 238 | await clearWebData() |
| 237 | 239 | clearWebContentRenderCaches() |
| @@ -450,7 +452,10 @@ final class AppState { | ||
| 450 | 452 | } |
| 451 | 453 | |
| 452 | 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 | 459 | let user: User |
| 455 | 460 | if let knownUser { |
| 456 | 461 | user = knownUser |
Hutch/Networking/APICache.swift added +337
| @@ -0,0 +1,337 @@ | ||
| 1 | import CryptoKit | |
| 2 | import Foundation | |
| 3 | ||
| 4 | enum CachePolicy: Sendable, Equatable { | |
| 5 | case networkOnly | |
| 6 | case cacheOnly | |
| 7 | case cacheFirstThenRefresh | |
| 8 | case refreshIgnoringCache | |
| 9 | } | |
| 10 | ||
| 11 | enum 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 | ||
| 28 | struct 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 | ||
| 43 | struct APICacheEntry: Sendable { | |
| 44 | var metadata: CacheEntryMetadata | |
| 45 | let payload: Data | |
| 46 | } | |
| 47 | ||
| 48 | struct 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 | ||
| 57 | enum CacheValueSource: Sendable, Equatable { | |
| 58 | case cache | |
| 59 | case network | |
| 60 | } | |
| 61 | ||
| 62 | enum 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 | ||
| 79 | struct 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 | ||
| 112 | protocol 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 | ||
| 122 | actor 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 | ||
| 323 | extension JSONEncoder { | |
| 324 | static var srhtCache: JSONEncoder { | |
| 325 | let encoder = JSONEncoder() | |
| 326 | encoder.dateEncodingStrategy = .iso8601 | |
| 327 | return encoder | |
| 328 | } | |
| 329 | } | |
| 330 | ||
| 331 | extension 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 @@ | ||
| 1 | import Foundation | |
| 2 | ||
| 3 | enum 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 | ||
| 92 | enum 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 @@ | ||
| 1 | import CryptoKit | |
| 1 | 2 | import Foundation |
| 2 | 3 | import os |
| 3 | 4 | |
| @@ -20,6 +21,8 @@ final class SRHTClient: Sendable { | ||
| 20 | 21 | private let session: URLSession |
| 21 | 22 | private let decoder: JSONDecoder |
| 22 | 23 | private let encoder: JSONEncoder |
| 24 | private let cache: any APICache | |
| 25 | private let requestCoalescer = RequestCoalescer() | |
| 23 | 26 | |
| 24 | 27 | /// The personal access token used for `Authorization: Bearer` headers. |
| 25 | 28 | /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``. |
| @@ -32,12 +35,19 @@ final class SRHTClient: Sendable { | ||
| 32 | 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 | 43 | self.session = session |
| 37 | 44 | self.decoder = JSONDecoder() |
| 38 | 45 | self.decoder.dateDecodingStrategy = .srhtFlexible |
| 39 | 46 | self.encoder = JSONEncoder() |
| 40 | 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 | 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 | 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 | 293 | // MARK: - Multipart Upload |
| 160 | 294 | |
| 161 | 295 | /// Execute a GraphQL mutation with a file upload using the |
| @@ -614,6 +748,152 @@ final class SRHTClient: Sendable { | ||
| 614 | 748 | // MARK: - Data Helper |
| 615 | 749 | |
| 616 | 750 | private 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 | 897 | func throwGraphQLErrorsIfPresent(in data: Data) throws { |
| 618 | 898 | if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), |
| 619 | 899 | let errors = envelope.errors, |
| @@ -632,6 +912,23 @@ private extension SRHTClient { | ||
| 632 | 912 | } |
| 633 | 913 | } |
| 634 | 914 | |
| 915 | private 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 | ||
| 635 | 932 | private extension Data { |
| 636 | 933 | mutating func append(_ string: String) { |
| 637 | 934 | if let data = string.data(using: .utf8) { |
Hutch/Views/Builds/BuildDetailView.swift +6
| @@ -166,6 +166,12 @@ struct BuildDetailView: View { | ||
| 166 | 166 | ) |
| 167 | 167 | } else if let job = viewModel.job { |
| 168 | 168 | List { |
| 169 | if let cacheMetadata = viewModel.cacheMetadata { | |
| 170 | Section { | |
| 171 | StaleCacheStatusRow(metadata: cacheMetadata, isRefreshing: viewModel.isRefreshingCachedData) | |
| 172 | } | |
| 173 | } | |
| 174 | ||
| 169 | 175 | Section("Details") { |
| 170 | 176 | HStack { |
| 171 | 177 | Text("Status") |
Hutch/Views/Builds/BuildDetailViewModel.swift +85 −29
| @@ -28,7 +28,7 @@ private struct SubmittedJob: Decodable, Sendable { | ||
| 28 | 28 | @MainActor |
| 29 | 29 | final class BuildDetailViewModel { |
| 30 | 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 | 33 | let jobId: Int |
| 34 | 34 | private let client: SRHTClient |
| @@ -46,6 +46,8 @@ final class BuildDetailViewModel { | ||
| 46 | 46 | private(set) var isRebuilding = false |
| 47 | 47 | private(set) var isSubmittingEditedBuild = false |
| 48 | 48 | private(set) var rawJobResponse: String? |
| 49 | private(set) var cacheMetadata: CacheEntryMetadata? | |
| 50 | private(set) var isRefreshingCachedData = false | |
| 49 | 51 | var error: String? |
| 50 | 52 | /// Transient error shown for action failures (cancel, rebuild, submit). |
| 51 | 53 | /// Separate from `error` so auto-refresh doesn't immediately clear it. |
| @@ -128,22 +130,21 @@ final class BuildDetailViewModel { | ||
| 128 | 130 | rawJobResponse = nil |
| 129 | 131 | |
| 130 | 132 | do { |
| 131 | let result = try await client.execute( | |
| 133 | let result = try await client.executeCached( | |
| 132 | 134 | service: .builds, |
| 133 | 135 | query: Self.detailQuery, |
| 134 | 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 | |
| 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 | |
| 147 | 148 | } |
| 148 | 149 | } catch { |
| 149 | 150 | self.error = error.userFacingMessage |
| @@ -159,26 +160,19 @@ final class BuildDetailViewModel { | ||
| 159 | 160 | |
| 160 | 161 | do { |
| 161 | 162 | let cacheKey = Self.cacheKey(for: jobId) |
| 162 | let result = try await client.executeAndCache( | |
| 163 | let result = try await client.executeCached( | |
| 163 | 164 | service: .builds, |
| 164 | 165 | query: Self.detailQuery, |
| 165 | 166 | variables: ["id": jobId], |
| 166 | 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 | 174 | .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) | |
| 182 | 176 | } catch { |
| 183 | 177 | self.error = error.userFacingMessage |
| 184 | 178 | } |
| @@ -201,7 +195,14 @@ final class BuildDetailViewModel { | ||
| 201 | 195 | loadingTaskLogs.insert(cacheKey) |
| 202 | 196 | |
| 203 | 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 | 206 | failedTaskLogs.remove(cacheKey) |
| 206 | 207 | } catch { |
| 207 | 208 | failedTaskLogs.insert(cacheKey) |
| @@ -222,7 +223,13 @@ final class BuildDetailViewModel { | ||
| 222 | 223 | isLoadingBuildLog = true |
| 223 | 224 | |
| 224 | 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 | 233 | } catch { |
| 227 | 234 | self.error = error.userFacingMessage |
| 228 | 235 | } |
| @@ -287,6 +294,7 @@ final class BuildDetailViewModel { | ||
| 287 | 294 | variables: ["id": jobId], |
| 288 | 295 | responseType: CancelResponse.self |
| 289 | 296 | ) |
| 297 | await invalidateAfterMutation() | |
| 290 | 298 | await reloadJobPreservingDebugState() |
| 291 | 299 | } catch { |
| 292 | 300 | // Revert optimistic update on failure. |
| @@ -329,6 +337,7 @@ final class BuildDetailViewModel { | ||
| 329 | 337 | variables: variables, |
| 330 | 338 | responseType: SubmitJobResponse.self |
| 331 | 339 | ) |
| 340 | await invalidateAfterMutation() | |
| 332 | 341 | return result.submit.id |
| 333 | 342 | } catch { |
| 334 | 343 | setActionError("Couldn't rebuild. \(error.userFacingMessage)") |
| @@ -377,6 +386,7 @@ final class BuildDetailViewModel { | ||
| 377 | 386 | variables: variables, |
| 378 | 387 | responseType: SubmitJobResponse.self |
| 379 | 388 | ) |
| 389 | await invalidateAfterMutation() | |
| 380 | 390 | return result.submit.id |
| 381 | 391 | } catch { |
| 382 | 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 | 476 | private var shouldAutoRefresh: Bool { |
| 421 | 477 | guard let job else { return true } |
| 422 | 478 | return !job.status.isTerminal |
Hutch/Views/Repositories/FileTreeViewModel.swift +47 −14
| @@ -90,6 +90,7 @@ final class FileTreeViewModel { | ||
| 90 | 90 | private(set) var viewingObject: GitObject? |
| 91 | 91 | |
| 92 | 92 | private(set) var isLoading = false |
| 93 | private(set) var cacheMetadata: CacheEntryMetadata? | |
| 93 | 94 | var error: String? |
| 94 | 95 | |
| 95 | 96 | // Available references for the branch/tag picker |
| @@ -285,12 +286,18 @@ final class FileTreeViewModel { | ||
| 285 | 286 | do { |
| 286 | 287 | let result: RevparseResponse |
| 287 | 288 | do { |
| 288 | result = try await client.execute( | |
| 289 | let cached = try await client.executeCached( | |
| 289 | 290 | service: service, |
| 290 | 291 | query: Self.rootTreeQuery, |
| 291 | 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 | 301 | } catch { |
| 295 | 302 | if isMissingGitReferenceError(error) { |
| 296 | 303 | navStack = [FileNavEntry(name: "root", treeId: "")] |
| @@ -375,12 +382,18 @@ final class FileTreeViewModel { | ||
| 375 | 382 | ] |
| 376 | 383 | |
| 377 | 384 | do { |
| 378 | let result = try await client.execute( | |
| 385 | let cached = try await client.executeCached( | |
| 379 | 386 | service: service, |
| 380 | 387 | query: Self.subtreeQuery, |
| 381 | 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 | 397 | navStack.append(FileNavEntry(name: name, treeId: treeId)) |
| 385 | 398 | var allEntries = result.repository?.object?.entries?.results ?? [] |
| 386 | 399 | var cursor = result.repository?.object?.entries?.cursor |
| @@ -407,12 +420,18 @@ final class FileTreeViewModel { | ||
| 407 | 420 | ] |
| 408 | 421 | |
| 409 | 422 | do { |
| 410 | let result = try await client.execute( | |
| 423 | let cached = try await client.executeCached( | |
| 411 | 424 | service: service, |
| 412 | 425 | query: Self.blobQuery, |
| 413 | 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 | 435 | viewingEntry = entry |
| 417 | 436 | viewingObject = result.repository?.object ?? .unknown |
| 418 | 437 | } catch { |
| @@ -453,12 +472,18 @@ final class FileTreeViewModel { | ||
| 453 | 472 | ] |
| 454 | 473 | |
| 455 | 474 | do { |
| 456 | let result = try await client.execute( | |
| 475 | let cached = try await client.executeCached( | |
| 457 | 476 | service: service, |
| 458 | 477 | query: Self.subtreeQuery, |
| 459 | 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 | 487 | var allEntries = result.repository?.object?.entries?.results ?? [] |
| 463 | 488 | var cursor = result.repository?.object?.entries?.cursor |
| 464 | 489 | while let nextCursor = cursor { |
| @@ -480,13 +505,17 @@ final class FileTreeViewModel { | ||
| 480 | 505 | "treeId": treeId, |
| 481 | 506 | "cursor": cursor |
| 482 | 507 | ] |
| 483 | let result = try await client.execute( | |
| 508 | let cached = try await client.executeCached( | |
| 484 | 509 | service: service, |
| 485 | 510 | query: Self.treeEntriesPageQuery, |
| 486 | 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 | 521 | // MARK: - Linked File (used by README link interception) |
| @@ -517,13 +546,17 @@ final class FileTreeViewModel { | ||
| 517 | 546 | "revspec": revspec, |
| 518 | 547 | "path": path |
| 519 | 548 | ] |
| 520 | let result = try await client.execute( | |
| 549 | let cached = try await client.executeCached( | |
| 521 | 550 | service: service, |
| 522 | 551 | query: Self.linkedFileQuery, |
| 523 | 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 | 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 | 138 | private(set) var readmePath: String? |
| 139 | 139 | private(set) var isLoadingReadme = false |
| 140 | 140 | private(set) var readmeLoaded = false |
| 141 | private(set) var readmeCacheMetadata: CacheEntryMetadata? | |
| 141 | 142 | |
| 142 | 143 | // MARK: - Artifacts state |
| 143 | 144 | |
| @@ -379,13 +380,18 @@ final class RepositoryDetailViewModel { | ||
| 379 | 380 | |
| 380 | 381 | do { |
| 381 | 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 | 384 | service: service, |
| 384 | 385 | query: Self.readmeQuery, |
| 385 | 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 | 395 | readmePath = nil |
| 390 | 396 | readmeContent = .html(html) |
| 391 | 397 | readmeLoaded = true |
| @@ -396,12 +402,18 @@ final class RepositoryDetailViewModel { | ||
| 396 | 402 | for filename in Self.readmeFilenames { |
| 397 | 403 | let pathResult: PathResponse |
| 398 | 404 | do { |
| 399 | pathResult = try await client.execute( | |
| 405 | let cached = try await client.executeCached( | |
| 400 | 406 | service: service, |
| 401 | 407 | query: Self.readmeFileQuery(filename: filename), |
| 402 | 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 | 417 | } catch { |
| 406 | 418 | if isEmptyRepositoryError(error) { |
| 407 | 419 | readmeContent = nil |
Hutch/Views/Settings/SettingsView.swift +7
| @@ -133,6 +133,13 @@ struct SettingsView: View { | ||
| 133 | 133 | .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } |
| 134 | 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 | 143 | Button("Reset App Data", role: .destructive) { |
| 137 | 144 | pendingDestructiveAction = .resetAppData |
| 138 | 145 | } |
Hutch/Views/Shared/StaleCacheStatusRow.swift added +32
| @@ -0,0 +1,32 @@ | ||
| 1 | import SwiftUI | |
| 2 | ||
| 3 | struct 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 | 169 | } else if let ticket = viewModel.ticket { |
| 170 | 170 | ScrollView { |
| 171 | 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 | 178 | // Header |
| 173 | 179 | ticketHeader(ticket, viewModel: viewModel) |
| 174 | 180 | |
Hutch/Views/Tickets/TicketDetailViewModel.swift +84 −36
| @@ -98,7 +98,7 @@ private struct LabelsPage: Decodable, Sendable { | ||
| 98 | 98 | @MainActor |
| 99 | 99 | final class TicketDetailViewModel { |
| 100 | 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 | 104 | let ownerUsername: String |
| @@ -114,6 +114,8 @@ final class TicketDetailViewModel { | ||
| 114 | 114 | private(set) var isPerformingAction = false |
| 115 | 115 | private(set) var trackerLabels: [TicketLabel] = [] |
| 116 | 116 | private(set) var rawTicketResponse: String? |
| 117 | private(set) var cacheMetadata: CacheEntryMetadata? | |
| 118 | private(set) var isRefreshingCachedData = false | |
| 117 | 119 | var commentText = "" |
| 118 | 120 | var error: String? |
| 119 | 121 | |
| @@ -291,30 +293,25 @@ final class TicketDetailViewModel { | ||
| 291 | 293 | rawTicketResponse = nil |
| 292 | 294 | |
| 293 | 295 | do { |
| 294 | let result = try await client.execute( | |
| 296 | let result = try await client.executeCached( | |
| 295 | 297 | service: .todo, |
| 296 | 298 | query: Self.detailQuery, |
| 297 | 299 | variables: [ |
| 298 | 300 | "rid": trackerRid, |
| 299 | 301 | "ticketId": ticketId |
| 300 | 302 | ], |
| 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 | |
| 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 | 315 | } catch { |
| 319 | 316 | self.error = error.userFacingMessage |
| 320 | 317 | } |
| @@ -329,7 +326,7 @@ final class TicketDetailViewModel { | ||
| 329 | 326 | |
| 330 | 327 | do { |
| 331 | 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 | 330 | service: .todo, |
| 334 | 331 | query: Self.detailQuery, |
| 335 | 332 | variables: [ |
| @@ -337,25 +334,14 @@ final class TicketDetailViewModel { | ||
| 337 | 334 | "ticketId": ticketId |
| 338 | 335 | ], |
| 339 | 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 | 343 | .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) | |
| 359 | 345 | } catch { |
| 360 | 346 | self.error = error.userFacingMessage |
| 361 | 347 | } |
| @@ -391,6 +377,7 @@ final class TicketDetailViewModel { | ||
| 391 | 377 | events.append(event) |
| 392 | 378 | events.sort(by: Self.timelineOrder) |
| 393 | 379 | commentText = "" |
| 380 | await invalidateAfterMutation() | |
| 394 | 381 | } catch { |
| 395 | 382 | self.error = error.userFacingMessage |
| 396 | 383 | } |
| @@ -423,6 +410,7 @@ final class TicketDetailViewModel { | ||
| 423 | 410 | ], |
| 424 | 411 | responseType: UpdateStatusResponse.self |
| 425 | 412 | ) |
| 413 | await invalidateAfterMutation() | |
| 426 | 414 | // Re-fetch the ticket to get updated status/resolution |
| 427 | 415 | await reloadTicketPreservingDebugState() |
| 428 | 416 | } catch { |
| @@ -457,6 +445,7 @@ final class TicketDetailViewModel { | ||
| 457 | 445 | ], |
| 458 | 446 | responseType: AssignUserResponse.self |
| 459 | 447 | ) |
| 448 | await invalidateAfterMutation() | |
| 460 | 449 | // Reload to reflect the change |
| 461 | 450 | await reloadTicketPreservingDebugState() |
| 462 | 451 | } catch { |
| @@ -503,6 +492,7 @@ final class TicketDetailViewModel { | ||
| 503 | 492 | ], |
| 504 | 493 | responseType: AssignUserResponse.self |
| 505 | 494 | ) |
| 495 | await invalidateAfterMutation() | |
| 506 | 496 | await reloadTicketPreservingDebugState() |
| 507 | 497 | } catch { |
| 508 | 498 | ticket = TicketDetail( |
| @@ -550,6 +540,7 @@ final class TicketDetailViewModel { | ||
| 550 | 540 | ], |
| 551 | 541 | responseType: UnassignUserResponse.self |
| 552 | 542 | ) |
| 543 | await invalidateAfterMutation() | |
| 553 | 544 | // Reload to reflect the change |
| 554 | 545 | await reloadTicketPreservingDebugState() |
| 555 | 546 | } catch { |
| @@ -575,6 +566,7 @@ final class TicketDetailViewModel { | ||
| 575 | 566 | ], |
| 576 | 567 | responseType: LabelTicketResponse.self |
| 577 | 568 | ) |
| 569 | await invalidateAfterMutation() | |
| 578 | 570 | await reloadTicketPreservingDebugState() |
| 579 | 571 | } catch { |
| 580 | 572 | self.error = error.userFacingMessage |
| @@ -599,6 +591,7 @@ final class TicketDetailViewModel { | ||
| 599 | 591 | ], |
| 600 | 592 | responseType: UnlabelTicketResponse.self |
| 601 | 593 | ) |
| 594 | await invalidateAfterMutation() | |
| 602 | 595 | await reloadTicketPreservingDebugState() |
| 603 | 596 | } catch { |
| 604 | 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 | 705 | static func matchesAssignee(_ entity: Entity, user: User) -> Bool { |
| 658 | 706 | let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) |
| 659 | 707 | let userCanonical = normalizedCanonicalName(user.canonicalName) |
HutchTests/APICacheTests.swift added +309
| @@ -0,0 +1,309 @@ | ||
| 1 | import Foundation | |
| 2 | import Testing | |
| 3 | @testable import Hutch | |
| 4 | ||
| 5 | @Suite(.serialized) | |
| 6 | struct 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 | ||
| 265 | private enum CachedURLProtocolResponse: Sendable { | |
| 266 | case success(String) | |
| 267 | case failure | |
| 268 | } | |
| 269 | ||
| 270 | private 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 @@ | ||
| 1 | import Foundation | |
| 1 | 2 | import Testing |
| 2 | 3 | @testable import Hutch |
| 3 | 4 | |