diff options
| -rw-r--r-- | Docs/API_CACHE.md | 11 | ||||
| -rw-r--r-- | Hutch.xcodeproj/project.pbxproj | 16 | ||||
| -rw-r--r-- | Hutch/App/AppState.swift | 7 | ||||
| -rw-r--r-- | Hutch/Networking/APICache.swift | 337 | ||||
| -rw-r--r-- | Hutch/Networking/APICacheKeys.swift | 105 | ||||
| -rw-r--r-- | Hutch/Networking/SRHTClient.swift | 299 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildDetailView.swift | 6 | ||||
| -rw-r--r-- | Hutch/Views/Builds/BuildDetailViewModel.swift | 114 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/FileTreeViewModel.swift | 61 | ||||
| -rw-r--r-- | Hutch/Views/Repositories/RepositoryDetailViewModel.swift | 22 | ||||
| -rw-r--r-- | Hutch/Views/Settings/SettingsView.swift | 7 | ||||
| -rw-r--r-- | Hutch/Views/Shared/StaleCacheStatusRow.swift | 32 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailView.swift | 6 | ||||
| -rw-r--r-- | Hutch/Views/Tickets/TicketDetailViewModel.swift | 120 | ||||
| -rw-r--r-- | HutchTests/APICacheTests.swift | 309 | ||||
| -rw-r--r-- | HutchTests/AppStateTests.swift | 1 |
16 files changed, 1359 insertions, 94 deletions
diff --git a/Docs/API_CACHE.md b/Docs/API_CACHE.md new file mode 100644 index 0000000..d15b75d --- /dev/null +++ b/Docs/API_CACHE.md @@ -0,0 +1,11 @@ +# SourceHut API Cache + +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. + +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. + +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. + +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. + +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. diff --git a/Hutch.xcodeproj/project.pbxproj b/Hutch.xcodeproj/project.pbxproj index 030479b..e32ea73 100644 --- a/Hutch.xcodeproj/project.pbxproj +++ b/Hutch.xcodeproj/project.pbxproj @@ -517,7 +517,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -534,7 +534,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.2.2; + MARKETING_VERSION = 3.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -554,7 +554,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = Hutch/Hutch.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 82; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_TEAM = ZCNAX3VL9D; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -571,7 +571,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 3.2.2; + MARKETING_VERSION = 3.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -634,7 +634,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 81; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -644,7 +644,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.2.1; + MARKETING_VERSION = 3.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -663,7 +663,7 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = HutchWidgetExtension/HutchWidgetExtension.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 81; + CURRENT_PROJECT_VERSION = 83; DEVELOPMENT_TEAM = ZCNAX3VL9D; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = HutchWidgetExtension/Info.plist; @@ -673,7 +673,7 @@ "@executable_path/Frameworks", "@executable_path/../../Frameworks", ); - MARKETING_VERSION = 3.2.1; + MARKETING_VERSION = 3.3.0; PRODUCT_BUNDLE_IDENTIFIER = net.cleberg.Hutch.HutchWidgetExtension; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; diff --git a/Hutch/App/AppState.swift b/Hutch/App/AppState.swift index ebd25d1..f91f1a4 100644 --- a/Hutch/App/AppState.swift +++ b/Hutch/App/AppState.swift @@ -212,6 +212,7 @@ final class AppState { clearActiveSessionState() try? KeychainHelper.deleteAll() URLCache.shared.removeAllCachedResponses() + await client.clearPersistentCache() HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } await clearWebData() clearWebContentRenderCaches() @@ -232,6 +233,7 @@ final class AppState { } try? KeychainHelper.deleteAll() URLCache.shared.removeAllCachedResponses() + await client.clearPersistentCache() HTTPCookieStorage.shared.cookies?.forEach { HTTPCookieStorage.shared.deleteCookie($0) } await clearWebData() clearWebContentRenderCaches() @@ -450,7 +452,10 @@ final class AppState { } private func makeSession(for account: AccountEntry, knownUser: User? = nil) async throws -> AccountSession { - let sessionClient = SRHTClient(token: account.token) + let sessionClient = SRHTClient( + token: account.token, + cache: PersistentAPICache(configuration: .accountScoped(accountID: account.id)) + ) let user: User if let knownUser { user = knownUser diff --git a/Hutch/Networking/APICache.swift b/Hutch/Networking/APICache.swift new file mode 100644 index 0000000..9c1a262 --- /dev/null +++ b/Hutch/Networking/APICache.swift @@ -0,0 +1,337 @@ +import CryptoKit +import Foundation + +enum CachePolicy: Sendable, Equatable { + case networkOnly + case cacheOnly + case cacheFirstThenRefresh + case refreshIgnoringCache +} + +enum CacheResourceType: String, Codable, Sendable { + case repositoryDetail + case repositoryList + case repositoryTree + case repositoryFile + case repositoryReadme + case ticketDetail + case ticketList + case buildDetail + case buildList + case buildLog + case userProfile + case status + case pasteList + case debug +} + +struct CacheEntryMetadata: Codable, Sendable, Equatable { + let cacheKey: String + let resourceType: CacheResourceType + let fetchedAt: Date + let expiresAt: Date + var lastAccessedAt: Date + let payloadHash: String + let schemaVersion: Int + let payloadSize: Int + + func isExpired(now: Date = Date()) -> Bool { + expiresAt <= now + } +} + +struct APICacheEntry: Sendable { + var metadata: CacheEntryMetadata + let payload: Data +} + +struct CachedValue<Value> { + let value: Value + let metadata: CacheEntryMetadata? + let source: CacheValueSource + + var isFromCache: Bool { source == .cache } + var isStale: Bool { metadata?.isExpired() ?? false } +} + +enum CacheValueSource: Sendable, Equatable { + case cache + case network +} + +enum APICacheError: LocalizedError, Sendable { + case miss + case entryTooLarge(Int) + case cacheTooLarge + + var errorDescription: String? { + switch self { + case .miss: + "No cached data is available." + case .entryTooLarge(let bytes): + "The response is too large to cache (\(bytes) bytes)." + case .cacheTooLarge: + "The cache size limit was exceeded." + } + } +} + +struct APICacheConfiguration: Sendable { + var directory: URL + var maxCacheSizeBytes: Int + var maxEntrySizeBytes: Int + var memoryEntryLimit: Int + var schemaVersion: Int + + static func accountScoped(accountID: String) -> APICacheConfiguration { + let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + return APICacheConfiguration( + directory: base + .appendingPathComponent("Hutch", isDirectory: true) + .appendingPathComponent("APICache", isDirectory: true) + .appendingPathComponent(accountID, isDirectory: true), + maxCacheSizeBytes: 50 * 1024 * 1024, + maxEntrySizeBytes: 2 * 1024 * 1024, + memoryEntryLimit: 64, + schemaVersion: 1 + ) + } + + static func temporary(directory: URL) -> APICacheConfiguration { + APICacheConfiguration( + directory: directory, + maxCacheSizeBytes: 4 * 1024 * 1024, + maxEntrySizeBytes: 512 * 1024, + memoryEntryLimit: 16, + schemaVersion: 1 + ) + } +} + +protocol APICache: Sendable { + func read(cacheKey: String) async throws -> APICacheEntry + func write(payload: Data, cacheKey: String, resourceType: CacheResourceType, ttl: TimeInterval) async throws -> CacheEntryMetadata + func remove(cacheKey: String) async + func removeByPrefix(_ prefix: String) async + func clearAll() async + func pruneExpired(now: Date) async + func pruneToSizeLimit() async +} + +actor PersistentAPICache: APICache { + private struct StoredEntry: Codable, Sendable { + var metadata: CacheEntryMetadata + let payload: Data + } + + private let configuration: APICacheConfiguration + private let fileManager: FileManager + private var memoryEntries: [String: APICacheEntry] = [:] + private var memoryOrder: [String] = [] + private var knownMetadata: [String: CacheEntryMetadata] = [:] + private var writeCountSincePrune = 0 + + init(configuration: APICacheConfiguration, fileManager: FileManager = .default) { + self.configuration = configuration + self.fileManager = fileManager + } + + func read(cacheKey: String) async throws -> APICacheEntry { + if var entry = memoryEntries[cacheKey] { + entry.metadata.lastAccessedAt = Date() + memoryEntries[cacheKey] = entry + markMemoryUse(cacheKey) + try? persist(entry) + return entry + } + + let url = fileURL(for: cacheKey) + guard fileManager.fileExists(atPath: url.path) else { + throw APICacheError.miss + } + + var stored = try decodeEntry(from: url) + guard stored.metadata.schemaVersion == configuration.schemaVersion else { + try? fileManager.removeItem(at: url) + throw APICacheError.miss + } + + stored.metadata.lastAccessedAt = Date() + let entry = APICacheEntry(metadata: stored.metadata, payload: stored.payload) + knownMetadata[cacheKey] = stored.metadata + remember(entry) + try? persist(entry) + return entry + } + + func write( + payload: Data, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval + ) async throws -> CacheEntryMetadata { + guard payload.count <= configuration.maxEntrySizeBytes else { + throw APICacheError.entryTooLarge(payload.count) + } + + try ensureDirectoryExists() + let now = Date() + let payloadHash = Self.payloadHash(payload) + if let existing = try? await read(cacheKey: cacheKey), + existing.metadata.payloadHash == payloadHash { + let metadata = CacheEntryMetadata( + cacheKey: cacheKey, + resourceType: resourceType, + fetchedAt: now, + expiresAt: now.addingTimeInterval(ttl), + lastAccessedAt: now, + payloadHash: payloadHash, + schemaVersion: configuration.schemaVersion, + payloadSize: payload.count + ) + let entry = APICacheEntry(metadata: metadata, payload: payload) + remember(entry) + try persist(entry) + return metadata + } + + let metadata = CacheEntryMetadata( + cacheKey: cacheKey, + resourceType: resourceType, + fetchedAt: now, + expiresAt: now.addingTimeInterval(ttl), + lastAccessedAt: now, + payloadHash: payloadHash, + schemaVersion: configuration.schemaVersion, + payloadSize: payload.count + ) + let entry = APICacheEntry(metadata: metadata, payload: payload) + remember(entry) + try persist(entry) + + writeCountSincePrune += 1 + if writeCountSincePrune >= 12 { + writeCountSincePrune = 0 + await pruneExpired(now: now) + await pruneToSizeLimit() + } + return metadata + } + + func remove(cacheKey: String) async { + memoryEntries.removeValue(forKey: cacheKey) + memoryOrder.removeAll { $0 == cacheKey } + knownMetadata.removeValue(forKey: cacheKey) + try? fileManager.removeItem(at: fileURL(for: cacheKey)) + } + + func removeByPrefix(_ prefix: String) async { + await loadKnownMetadataIfNeeded() + for key in knownMetadata.keys where key.hasPrefix(prefix) { + await remove(cacheKey: key) + } + } + + func clearAll() async { + memoryEntries.removeAll() + memoryOrder.removeAll() + knownMetadata.removeAll() + try? fileManager.removeItem(at: configuration.directory) + } + + func pruneExpired(now: Date = Date()) async { + await loadKnownMetadataIfNeeded() + for metadata in knownMetadata.values where metadata.isExpired(now: now) { + await remove(cacheKey: metadata.cacheKey) + } + } + + func pruneToSizeLimit() async { + await loadKnownMetadataIfNeeded() + var totalSize = knownMetadata.values.reduce(0) { $0 + $1.payloadSize } + guard totalSize > configuration.maxCacheSizeBytes else { return } + + let victims = knownMetadata.values.sorted { $0.lastAccessedAt < $1.lastAccessedAt } + for metadata in victims { + await remove(cacheKey: metadata.cacheKey) + totalSize -= metadata.payloadSize + if totalSize <= configuration.maxCacheSizeBytes { break } + } + } + + private func remember(_ entry: APICacheEntry) { + memoryEntries[entry.metadata.cacheKey] = entry + knownMetadata[entry.metadata.cacheKey] = entry.metadata + markMemoryUse(entry.metadata.cacheKey) + while memoryOrder.count > configuration.memoryEntryLimit, let evicted = memoryOrder.first { + memoryOrder.removeFirst() + memoryEntries.removeValue(forKey: evicted) + } + } + + private func markMemoryUse(_ cacheKey: String) { + memoryOrder.removeAll { $0 == cacheKey } + memoryOrder.append(cacheKey) + } + + private func persist(_ entry: APICacheEntry) throws { + try ensureDirectoryExists() + let stored = StoredEntry(metadata: entry.metadata, payload: entry.payload) + let data = try JSONEncoder.srhtCache.encode(stored) + try data.write(to: fileURL(for: entry.metadata.cacheKey), options: [.atomic]) + } + + private func decodeEntry(from url: URL) throws -> StoredEntry { + let data = try Data(contentsOf: url) + return try JSONDecoder.srhtCache.decode(StoredEntry.self, from: data) + } + + private func loadKnownMetadataIfNeeded() async { + guard knownMetadata.isEmpty else { return } + guard let urls = try? fileManager.contentsOfDirectory( + at: configuration.directory, + includingPropertiesForKeys: nil + ) else { return } + + for url in urls where url.pathExtension == "json" { + guard let stored = try? decodeEntry(from: url) else { continue } + knownMetadata[stored.metadata.cacheKey] = stored.metadata + } + } + + private func ensureDirectoryExists() throws { + if !fileManager.fileExists(atPath: configuration.directory.path) { + try fileManager.createDirectory( + at: configuration.directory, + withIntermediateDirectories: true + ) + } + } + + private func fileURL(for cacheKey: String) -> URL { + configuration.directory + .appendingPathComponent(Self.payloadHash(Data(cacheKey.utf8))) + .appendingPathExtension("json") + } + + private static func payloadHash(_ data: Data) -> String { + SHA256.hash(data: data).map { String(format: "%02x", $0) }.joined() + } +} + +extension JSONEncoder { + static var srhtCache: JSONEncoder { + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .iso8601 + return encoder + } +} + +extension JSONDecoder { + static var srhtCache: JSONDecoder { + let decoder = JSONDecoder() + decoder.dateDecodingStrategy = .iso8601 + return decoder + } +} diff --git a/Hutch/Networking/APICacheKeys.swift b/Hutch/Networking/APICacheKeys.swift new file mode 100644 index 0000000..5e57a51 --- /dev/null +++ b/Hutch/Networking/APICacheKeys.swift @@ -0,0 +1,105 @@ +import Foundation + +enum APICacheKeys { + static func repositories(service: SRHTService, owner: String? = nil, cursor: String? = nil, filter: String? = nil) -> String { + make([ + service.rawValue, + "repositories", + owner.map { "owner:\(normalize($0))" }, + cursor.map { "cursor:\($0)" }, + filter.map { "filter:\(normalize($0))" } + ]) + } + + static func repository(service: SRHTService, owner: String, name: String) -> String { + make([service.rawValue, "repository", normalize(owner), normalize(name)]) + } + + static func repositoryRID(service: SRHTService, rid: String) -> String { + make([service.rawValue, "repository", "rid:\(rid)"]) + } + + static func refs(service: SRHTService, rid: String, cursor: String? = nil) -> String { + make([service.rawValue, "refs", "rid:\(rid)", cursor.map { "cursor:\($0)" }]) + } + + static func readme(service: SRHTService, rid: String, path: String? = nil, ref: String = "HEAD") -> String { + make([service.rawValue, "readme", "rid:\(rid)", "ref:\(ref)", path.map { "path:\($0)" }]) + } + + static func treeRoot(service: SRHTService, rid: String, ref: String) -> String { + make([service.rawValue, "tree", "rid:\(rid)", "ref:\(ref)", "root"]) + } + + static func treeEntries(service: SRHTService, rid: String, treeId: String, cursor: String? = nil) -> String { + make([service.rawValue, "tree", "rid:\(rid)", "tree:\(treeId)", cursor.map { "cursor:\($0)" }]) + } + + static func blob(service: SRHTService, rid: String, blobId: String) -> String { + make([service.rawValue, "blob", "rid:\(rid)", "blob:\(blobId)"]) + } + + static func path(service: SRHTService, rid: String, ref: String, path: String) -> String { + make([service.rawValue, "path", "rid:\(rid)", "ref:\(ref)", "path:\(path)"]) + } + + static func ticketDetail(owner: String, trackerRid: String, ticketId: Int) -> String { + make([SRHTService.todo.rawValue, "ticket", normalize(owner), "tracker:\(trackerRid)", "ticket:\(ticketId)"]) + } + + static func trackerLabels(trackerRid: String) -> String { + make([SRHTService.todo.rawValue, "tracker-labels", "tracker:\(trackerRid)"]) + } + + static func builds(cursor: String? = nil, filter: String? = nil) -> String { + make([SRHTService.builds.rawValue, "jobs", cursor.map { "cursor:\($0)" }, filter.map { "filter:\($0)" }]) + } + + static func buildDetail(jobId: Int) -> String { + make([SRHTService.builds.rawValue, "job", "id:\(jobId)"]) + } + + static func buildLog(url: URL, jobId: Int? = nil, task: String? = nil) -> String { + make([SRHTService.builds.rawValue, "log", jobId.map { "job:\($0)" }, task.map { "task:\($0)" }, url.absoluteString]) + } + + static func userRepositories(owner: String, cursor: String? = nil) -> String { + make([SRHTService.git.rawValue, "user-repositories", normalize(owner), cursor.map { "cursor:\($0)" }]) + } + + static func userTrackers(owner: String, cursor: String? = nil) -> String { + make([SRHTService.todo.rawValue, "user-trackers", normalize(owner), cursor.map { "cursor:\($0)" }]) + } + + static func pasteList(cursor: String? = nil) -> String { + make([SRHTService.paste.rawValue, "pastes", cursor.map { "cursor:\($0)" }]) + } + + static func prefix(_ components: String...) -> String { + make(components) + } + + private static func make(_ parts: [String?]) -> String { + parts.compactMap { $0?.replacingOccurrences(of: "|", with: "%7C") } + .joined(separator: "|") + } + + private static func normalize(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() + } +} + +enum APICacheTTLs { + // Active build data changes quickly; completed logs and content-addressed git data are effectively immutable. + static let activeBuild: TimeInterval = 15 + static let completedBuildDetail: TimeInterval = 60 * 60 + static let completedBuildLog: TimeInterval = 30 * 24 * 60 * 60 + static let ticketDetail: TimeInterval = 5 * 60 + static let ticketList: TimeInterval = 2 * 60 + static let repositoryMetadata: TimeInterval = 30 * 60 + static let repositoryList: TimeInterval = 5 * 60 + static let immutableFileContent: TimeInterval = 14 * 24 * 60 * 60 + static let movingRefFileContent: TimeInterval = 10 * 60 + static let userProfile: TimeInterval = 30 * 60 + static let status: TimeInterval = 5 * 60 +} diff --git a/Hutch/Networking/SRHTClient.swift b/Hutch/Networking/SRHTClient.swift index b4f8ee0..37fd6b8 100644 --- a/Hutch/Networking/SRHTClient.swift +++ b/Hutch/Networking/SRHTClient.swift @@ -1,3 +1,4 @@ +import CryptoKit import Foundation import os @@ -20,6 +21,8 @@ final class SRHTClient: Sendable { private let session: URLSession private let decoder: JSONDecoder private let encoder: JSONEncoder + private let cache: any APICache + private let requestCoalescer = RequestCoalescer() /// The personal access token used for `Authorization: Bearer` headers. /// Loaded from Keychain on init; can be refreshed via ``reloadToken()``. @@ -32,12 +35,19 @@ final class SRHTClient: Sendable { tokenLock.withLock { $0 != nil } } - init(session: URLSession = .shared, token: String? = nil) { + init( + session: URLSession = .shared, + token: String? = nil, + cache: (any APICache)? = nil + ) { self.session = session self.decoder = JSONDecoder() self.decoder.dateDecodingStrategy = .srhtFlexible self.encoder = JSONEncoder() self.tokenLock = OSAllocatedUnfairLock(initialState: token) + self.cache = cache ?? PersistentAPICache( + configuration: .accountScoped(accountID: token.map { Self.tokenCacheScope($0) } ?? "anonymous") + ) } /// Update the stored token (e.g. after the user saves a new one in Keychain). @@ -156,6 +166,130 @@ final class SRHTClient: Sendable { return result } + func executeCached<T: Decodable>( + service: SRHTService, + query: String, + variables: [String: any Sendable]? = nil, + responseType _: T.Type, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval, + policy: CachePolicy = .cacheFirstThenRefresh + ) async throws -> CachedValue<T> { + switch policy { + case .networkOnly: + let data = try await performGraphQLRequest( + service: service, + query: query, + variables: variables + ) + let value: T = try decodeGraphQLData(data, service: service, query: query, variables: variables) + return CachedValue(value: value, metadata: nil, source: .network) + + case .cacheOnly: + let entry = try await cache.read(cacheKey: cacheKey) + let value: T = try decodeGraphQLData(entry.payload, service: service, query: query, variables: variables) + return CachedValue(value: value, metadata: entry.metadata, source: .cache) + + case .cacheFirstThenRefresh: + if let entry = try? await cache.read(cacheKey: cacheKey) { + let value: T = try decodeGraphQLData(entry.payload, service: service, query: query, variables: variables) + if entry.metadata.isExpired() { + Task.detached { [self] in + _ = try? await self.fetchAndCacheGraphQLData( + service: service, + query: query, + variables: variables, + cacheKey: cacheKey, + resourceType: resourceType, + ttl: ttl + ) + } + } + return CachedValue(value: value, metadata: entry.metadata, source: .cache) + } + + let (value, metadata): (T, CacheEntryMetadata?) = try await fetchAndCacheGraphQL( + service: service, + query: query, + variables: variables, + cacheKey: cacheKey, + resourceType: resourceType, + ttl: ttl + ) + return CachedValue(value: value, metadata: metadata, source: .network) + + case .refreshIgnoringCache: + let (value, metadata): (T, CacheEntryMetadata?) = try await fetchAndCacheGraphQL( + service: service, + query: query, + variables: variables, + cacheKey: cacheKey, + resourceType: resourceType, + ttl: ttl + ) + return CachedValue(value: value, metadata: metadata, source: .network) + } + } + + func fetchCachedText( + url: URL, + cacheKey: String, + resourceType: CacheResourceType = .buildLog, + ttl: TimeInterval, + policy: CachePolicy = .cacheFirstThenRefresh + ) async throws -> CachedValue<String> { + switch policy { + case .networkOnly: + let text = try await fetchText(url: url) + return CachedValue(value: text, metadata: nil, source: .network) + case .cacheOnly: + let entry = try await cache.read(cacheKey: cacheKey) + guard let text = String(data: entry.payload, encoding: .utf8) else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Cached text is not UTF-8")) + ) + } + return CachedValue(value: text, metadata: entry.metadata, source: .cache) + case .cacheFirstThenRefresh: + if let entry = try? await cache.read(cacheKey: cacheKey), + let text = String(data: entry.payload, encoding: .utf8) { + if entry.metadata.isExpired() { + Task.detached { [self] in + _ = try? await self.fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + } + } + return CachedValue(value: text, metadata: entry.metadata, source: .cache) + } + let (text, metadata) = try await fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + return CachedValue(value: text, metadata: metadata, source: .network) + case .refreshIgnoringCache: + let (text, metadata) = try await fetchAndCacheText(url: url, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + return CachedValue(value: text, metadata: metadata, source: .network) + } + } + + func cachedPayload(forKey cacheKey: String) async -> Data? { + if let entry = try? await cache.read(cacheKey: cacheKey) { + return entry.payload + } + return responseCache.get(forKey: cacheKey) + } + + func invalidateCache(prefix: String) async { + await cache.removeByPrefix(prefix) + } + + func removeCachedValue(forKey cacheKey: String) async { + await cache.remove(cacheKey: cacheKey) + responseCache.remove(forKey: cacheKey) + } + + func clearPersistentCache() async { + await cache.clearAll() + responseCache.clear() + } + // MARK: - Multipart Upload /// Execute a GraphQL mutation with a file upload using the @@ -614,6 +748,152 @@ final class SRHTClient: Sendable { // MARK: - Data Helper private extension SRHTClient { + func performGraphQLRequest( + service: SRHTService, + query: String, + variables: [String: any Sendable]? + ) async throws -> Data { + guard let token = tokenLock.withLock({ $0 }), !token.isEmpty else { + throw SRHTError.unauthorized + } + + var request = URLRequest(url: service.url) + request.httpMethod = "POST" + request.setValue(Bundle.main.hutchUserAgent, forHTTPHeaderField: "User-Agent") + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + + let body = GraphQLRequestBody( + query: query, + variables: variables?.mapValues { AnyCodable($0) } + ) + request.httpBody = try encoder.encode(body) + + let (data, response): (Data, URLResponse) + do { + (data, response) = try await session.data(for: request) + } catch { + throw SRHTError.networkError(error) + } + + if let http = response as? HTTPURLResponse { + if http.statusCode == 401 { + throw SRHTError.unauthorized + } + if !(200...299).contains(http.statusCode) { + try throwGraphQLErrorsIfPresent(in: data) + throw SRHTError.httpError(http.statusCode) + } + } + + try throwGraphQLErrorsIfPresent(in: data) + return data + } + + func decodeGraphQLData<T: Decodable>( + _ data: Data, + service: SRHTService, + query: String, + variables: [String: any Sendable]? + ) throws -> T { + let graphQLResponse: GraphQLResponse<T> + do { + graphQLResponse = try decoder.decode(GraphQLResponse<T>.self, from: data) + } catch { + #if DEBUG + let responseBody = String(data: data, encoding: .utf8) ?? "<non-utf8 response>" + logger.error( + """ + Decoding failed for \(String(describing: T.self), privacy: .public) + service: \(service.rawValue, privacy: .public) + query: + \(query, privacy: .public) + variables: + \(String(describing: variables), privacy: .public) + error: + \(String(describing: error), privacy: .public) + response: + \(responseBody, privacy: .public) + """ + ) + #endif + throw SRHTError.decodingError(error) + } + + if let errors = graphQLResponse.errors, !errors.isEmpty { + throw SRHTError.graphQLErrors(errors) + } + + guard let result = graphQLResponse.data else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "No data in response")) + ) + } + return result + } + + func fetchAndCacheGraphQL<T: Decodable>( + service: SRHTService, + query: String, + variables: [String: any Sendable]?, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval + ) async throws -> (T, CacheEntryMetadata?) { + let data = try await requestCoalescer.value(for: cacheKey) { + try await self.performGraphQLRequest(service: service, query: query, variables: variables) + } + let value: T = try decodeGraphQLData(data, service: service, query: query, variables: variables) + responseCache.set(data, forKey: cacheKey) + let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + return (value, metadata) + } + + func fetchAndCacheGraphQLData( + service: SRHTService, + query: String, + variables: [String: any Sendable]?, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval + ) async throws -> CacheEntryMetadata? { + let data = try await requestCoalescer.value(for: cacheKey) { + try await self.performGraphQLRequest(service: service, query: query, variables: variables) + } + responseCache.set(data, forKey: cacheKey) + return try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + } + + func fetchAndCacheText( + url: URL, + cacheKey: String, + resourceType: CacheResourceType, + ttl: TimeInterval + ) async throws -> (String, CacheEntryMetadata?) { + let data = try await requestCoalescer.value(for: cacheKey) { + let text = try await self.fetchText(url: url) + guard let data = text.data(using: .utf8) else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Text could not be encoded as UTF-8")) + ) + } + return data + } + guard let text = String(data: data, encoding: .utf8) else { + throw SRHTError.decodingError( + DecodingError.dataCorrupted(.init(codingPath: [], debugDescription: "Response is not UTF-8 text")) + ) + } + responseCache.set(data, forKey: cacheKey) + let metadata = try? await cache.write(payload: data, cacheKey: cacheKey, resourceType: resourceType, ttl: ttl) + return (text, metadata) + } + + static func tokenCacheScope(_ token: String) -> String { + let digest = SHA256.hash(data: Data(token.utf8)) + return digest.prefix(8).map { String(format: "%02x", $0) }.joined() + } + func throwGraphQLErrorsIfPresent(in data: Data) throws { if let envelope = try? decoder.decode(GraphQLResponse<EmptyData>.self, from: data), let errors = envelope.errors, @@ -632,6 +912,23 @@ private extension SRHTClient { } } +private actor RequestCoalescer { + private var tasks: [String: Task<Data, Error>] = [:] + + func value(for key: String, operation: @Sendable @escaping () async throws -> Data) async throws -> Data { + if let task = tasks[key] { + return try await task.value + } + + let task = Task { + try await operation() + } + tasks[key] = task + defer { tasks.removeValue(forKey: key) } + return try await task.value + } +} + private extension Data { mutating func append(_ string: String) { if let data = string.data(using: .utf8) { diff --git a/Hutch/Views/Builds/BuildDetailView.swift b/Hutch/Views/Builds/BuildDetailView.swift index 334dff7..51d0c88 100644 --- a/Hutch/Views/Builds/BuildDetailView.swift +++ b/Hutch/Views/Builds/BuildDetailView.swift @@ -166,6 +166,12 @@ struct BuildDetailView: View { ) } else if let job = viewModel.job { List { + if let cacheMetadata = viewModel.cacheMetadata { + Section { + StaleCacheStatusRow(metadata: cacheMetadata, isRefreshing: viewModel.isRefreshingCachedData) + } + } + Section("Details") { HStack { Text("Status") diff --git a/Hutch/Views/Builds/BuildDetailViewModel.swift b/Hutch/Views/Builds/BuildDetailViewModel.swift index dcf70a0..74e8701 100644 --- a/Hutch/Views/Builds/BuildDetailViewModel.swift +++ b/Hutch/Views/Builds/BuildDetailViewModel.swift @@ -28,7 +28,7 @@ private struct SubmittedJob: Decodable, Sendable { @MainActor final class BuildDetailViewModel { private static let autoRefreshInterval: Duration = .seconds(5) - private static func cacheKey(for jobId: Int) -> String { "build.detail.\(jobId)" } + private static func cacheKey(for jobId: Int) -> String { APICacheKeys.buildDetail(jobId: jobId) } let jobId: Int private let client: SRHTClient @@ -46,6 +46,8 @@ final class BuildDetailViewModel { private(set) var isRebuilding = false private(set) var isSubmittingEditedBuild = false private(set) var rawJobResponse: String? + private(set) var cacheMetadata: CacheEntryMetadata? + private(set) var isRefreshingCachedData = false var error: String? /// Transient error shown for action failures (cancel, rebuild, submit). /// Separate from `error` so auto-refresh doesn't immediately clear it. @@ -128,22 +130,21 @@ final class BuildDetailViewModel { rawJobResponse = nil do { - let result = try await client.execute( + let result = try await client.executeCached( service: .builds, query: Self.detailQuery, variables: ["id": jobId], - responseType: JobDetailResponse.self + responseType: JobDetailResponse.self, + cacheKey: Self.cacheKey(for: jobId), + resourceType: .buildDetail, + ttl: job?.status.isTerminal == true ? APICacheTTLs.completedBuildDetail : APICacheTTLs.activeBuild, + policy: .cacheFirstThenRefresh ) - var loadedJob = result.job - loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in - task.withOrdinal(index) - } - if job != loadedJob { - job = loadedJob - } - - if loadedJob.status.isTerminal { - stopAutoRefresh() + apply(result.value, metadata: result.metadata) + if result.isFromCache { + isLoading = false + await refreshJobInBackground() + return } } catch { self.error = error.userFacingMessage @@ -159,26 +160,19 @@ final class BuildDetailViewModel { do { let cacheKey = Self.cacheKey(for: jobId) - let result = try await client.executeAndCache( + let result = try await client.executeCached( service: .builds, query: Self.detailQuery, variables: ["id": jobId], responseType: JobDetailResponse.self, - cacheKey: cacheKey + cacheKey: cacheKey, + resourceType: .buildDetail, + ttl: job?.status.isTerminal == true ? APICacheTTLs.completedBuildDetail : APICacheTTLs.activeBuild, + policy: .refreshIgnoringCache ) - rawJobResponse = client.responseCache.get(forKey: cacheKey) + rawJobResponse = await client.cachedPayload(forKey: cacheKey) .flatMap { String(data: $0, encoding: .utf8) } - var loadedJob = result.job - loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in - task.withOrdinal(index) - } - if job != loadedJob { - job = loadedJob - } - - if loadedJob.status.isTerminal { - stopAutoRefresh() - } + apply(result.value, metadata: result.metadata) } catch { self.error = error.userFacingMessage } @@ -201,7 +195,14 @@ final class BuildDetailViewModel { loadingTaskLogs.insert(cacheKey) do { - taskLogs[cacheKey] = try await client.fetchText(url: logURL) + let logCacheKey = APICacheKeys.buildLog(url: logURL, jobId: jobId, task: cacheKey) + let result = try await client.fetchCachedText( + url: logURL, + cacheKey: logCacheKey, + ttl: APICacheTTLs.completedBuildLog, + policy: .cacheFirstThenRefresh + ) + taskLogs[cacheKey] = result.value failedTaskLogs.remove(cacheKey) } catch { failedTaskLogs.insert(cacheKey) @@ -222,7 +223,13 @@ final class BuildDetailViewModel { isLoadingBuildLog = true do { - buildLogText = try await client.fetchText(url: logURL) + let result = try await client.fetchCachedText( + url: logURL, + cacheKey: APICacheKeys.buildLog(url: logURL, jobId: jobId), + ttl: jobIsTerminal ? APICacheTTLs.completedBuildLog : APICacheTTLs.activeBuild, + policy: jobIsTerminal ? .cacheFirstThenRefresh : .refreshIgnoringCache + ) + buildLogText = result.value } catch { self.error = error.userFacingMessage } @@ -287,6 +294,7 @@ final class BuildDetailViewModel { variables: ["id": jobId], responseType: CancelResponse.self ) + await invalidateAfterMutation() await reloadJobPreservingDebugState() } catch { // Revert optimistic update on failure. @@ -329,6 +337,7 @@ final class BuildDetailViewModel { variables: variables, responseType: SubmitJobResponse.self ) + await invalidateAfterMutation() return result.submit.id } catch { setActionError("Couldn't rebuild. \(error.userFacingMessage)") @@ -377,6 +386,7 @@ final class BuildDetailViewModel { variables: variables, responseType: SubmitJobResponse.self ) + await invalidateAfterMutation() return result.submit.id } catch { setActionError("Couldn’t submit the build. \(error.userFacingMessage)") @@ -417,6 +427,52 @@ final class BuildDetailViewModel { } } + private func refreshJobInBackground() async { + guard !isRefreshingCachedData else { return } + isRefreshingCachedData = true + defer { isRefreshingCachedData = false } + + do { + let result = try await client.executeCached( + service: .builds, + query: Self.detailQuery, + variables: ["id": jobId], + responseType: JobDetailResponse.self, + cacheKey: Self.cacheKey(for: jobId), + resourceType: .buildDetail, + ttl: job?.status.isTerminal == true ? APICacheTTLs.completedBuildDetail : APICacheTTLs.activeBuild, + policy: .refreshIgnoringCache + ) + apply(result.value, metadata: result.metadata) + } catch { + if job == nil { + self.error = error.userFacingMessage + } + } + } + + private func apply(_ response: JobDetailResponse, metadata: CacheEntryMetadata?) { + cacheMetadata = metadata + var loadedJob = response.job + loadedJob.tasks = loadedJob.tasks.enumerated().map { index, task in + task.withOrdinal(index) + } + if job != loadedJob { + job = loadedJob + } + + if loadedJob.status.isTerminal { + stopAutoRefresh() + } + } + + private func invalidateAfterMutation() async { + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "job")) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "jobs")) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.builds.rawValue, "log")) + await client.invalidateCache(prefix: APICacheKeys.prefix("home")) + } + private var shouldAutoRefresh: Bool { guard let job else { return true } return !job.status.isTerminal diff --git a/Hutch/Views/Repositories/FileTreeViewModel.swift b/Hutch/Views/Repositories/FileTreeViewModel.swift index 9ccd74b..11f9e7f 100644 --- a/Hutch/Views/Repositories/FileTreeViewModel.swift +++ b/Hutch/Views/Repositories/FileTreeViewModel.swift @@ -90,6 +90,7 @@ final class FileTreeViewModel { private(set) var viewingObject: GitObject? private(set) var isLoading = false + private(set) var cacheMetadata: CacheEntryMetadata? var error: String? // Available references for the branch/tag picker @@ -285,12 +286,18 @@ final class FileTreeViewModel { do { let result: RevparseResponse do { - result = try await client.execute( + let cached = try await client.executeCached( service: service, query: Self.rootTreeQuery, variables: variables, - responseType: RevparseResponse.self + responseType: RevparseResponse.self, + cacheKey: APICacheKeys.treeRoot(service: service, rid: repositoryRid, ref: revspec), + resourceType: .repositoryTree, + ttl: APICacheTTLs.movingRefFileContent, + policy: .cacheFirstThenRefresh ) + result = cached.value + cacheMetadata = cached.metadata } catch { if isMissingGitReferenceError(error) { navStack = [FileNavEntry(name: "root", treeId: "")] @@ -375,12 +382,18 @@ final class FileTreeViewModel { ] do { - let result = try await client.execute( + let cached = try await client.executeCached( service: service, query: Self.subtreeQuery, variables: variables, - responseType: SubtreeResponse.self + responseType: SubtreeResponse.self, + cacheKey: APICacheKeys.treeEntries(service: service, rid: repositoryRid, treeId: treeId), + resourceType: .repositoryTree, + ttl: APICacheTTLs.immutableFileContent, + policy: .cacheFirstThenRefresh ) + let result = cached.value + cacheMetadata = cached.metadata navStack.append(FileNavEntry(name: name, treeId: treeId)) var allEntries = result.repository?.object?.entries?.results ?? [] var cursor = result.repository?.object?.entries?.cursor @@ -407,12 +420,18 @@ final class FileTreeViewModel { ] do { - let result = try await client.execute( + let cached = try await client.executeCached( service: service, query: Self.blobQuery, variables: variables, - responseType: BlobResponse.self + responseType: BlobResponse.self, + cacheKey: APICacheKeys.blob(service: service, rid: repositoryRid, blobId: blobId), + resourceType: .repositoryFile, + ttl: APICacheTTLs.immutableFileContent, + policy: .cacheFirstThenRefresh ) + let result = cached.value + cacheMetadata = cached.metadata viewingEntry = entry viewingObject = result.repository?.object ?? .unknown } catch { @@ -453,12 +472,18 @@ final class FileTreeViewModel { ] do { - let result = try await client.execute( + let cached = try await client.executeCached( service: service, query: Self.subtreeQuery, variables: variables, - responseType: SubtreeResponse.self + responseType: SubtreeResponse.self, + cacheKey: APICacheKeys.treeEntries(service: service, rid: repositoryRid, treeId: targetEntry.treeId), + resourceType: .repositoryTree, + ttl: APICacheTTLs.immutableFileContent, + policy: .cacheFirstThenRefresh ) + let result = cached.value + cacheMetadata = cached.metadata var allEntries = result.repository?.object?.entries?.results ?? [] var cursor = result.repository?.object?.entries?.cursor while let nextCursor = cursor { @@ -480,13 +505,17 @@ final class FileTreeViewModel { "treeId": treeId, "cursor": cursor ] - let result = try await client.execute( + let cached = try await client.executeCached( service: service, query: Self.treeEntriesPageQuery, variables: variables, - responseType: SubtreeResponse.self + responseType: SubtreeResponse.self, + cacheKey: APICacheKeys.treeEntries(service: service, rid: repositoryRid, treeId: treeId, cursor: cursor), + resourceType: .repositoryTree, + ttl: APICacheTTLs.immutableFileContent, + policy: .cacheFirstThenRefresh ) - return result.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil) + return cached.value.repository?.object?.entries ?? GitTreeEntryPage(results: [], cursor: nil) } // MARK: - Linked File (used by README link interception) @@ -517,13 +546,17 @@ final class FileTreeViewModel { "revspec": revspec, "path": path ] - let result = try await client.execute( + let cached = try await client.executeCached( service: service, query: Self.linkedFileQuery, variables: variables, - responseType: LinkedFileResponse.self + responseType: LinkedFileResponse.self, + cacheKey: APICacheKeys.path(service: service, rid: repositoryRid, ref: revspec, path: path), + resourceType: .repositoryFile, + ttl: revspec == "HEAD" ? APICacheTTLs.movingRefFileContent : APICacheTTLs.immutableFileContent, + policy: .cacheFirstThenRefresh ) - return result.repository?.path + return cached.value.repository?.path } /// Dismiss the file view and go back to the directory listing. diff --git a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift index 9aff1a4..4839437 100644 --- a/Hutch/Views/Repositories/RepositoryDetailViewModel.swift +++ b/Hutch/Views/Repositories/RepositoryDetailViewModel.swift @@ -138,6 +138,7 @@ final class RepositoryDetailViewModel { private(set) var readmePath: String? private(set) var isLoadingReadme = false private(set) var readmeLoaded = false + private(set) var readmeCacheMetadata: CacheEntryMetadata? // MARK: - Artifacts state @@ -379,13 +380,18 @@ final class RepositoryDetailViewModel { do { // Step 1: Check the custom HTML readme set via the web UI - let result = try await client.execute( + let result = try await client.executeCached( service: service, query: Self.readmeQuery, variables: ["rid": repository.rid], - responseType: ReadmeResponse.self + responseType: ReadmeResponse.self, + cacheKey: APICacheKeys.readme(service: service, rid: repository.rid), + resourceType: .repositoryReadme, + ttl: APICacheTTLs.movingRefFileContent, + policy: .cacheFirstThenRefresh ) - if let html = result.repository?.readme, !html.isEmpty { + readmeCacheMetadata = result.metadata + if let html = result.value.repository?.readme, !html.isEmpty { readmePath = nil readmeContent = .html(html) readmeLoaded = true @@ -396,12 +402,18 @@ final class RepositoryDetailViewModel { for filename in Self.readmeFilenames { let pathResult: PathResponse do { - pathResult = try await client.execute( + let cached = try await client.executeCached( service: service, query: Self.readmeFileQuery(filename: filename), variables: ["rid": repository.rid], - responseType: PathResponse.self + responseType: PathResponse.self, + cacheKey: APICacheKeys.readme(service: service, rid: repository.rid, path: filename), + resourceType: .repositoryReadme, + ttl: APICacheTTLs.movingRefFileContent, + policy: .cacheFirstThenRefresh ) + pathResult = cached.value + readmeCacheMetadata = cached.metadata } catch { if isEmptyRepositoryError(error) { readmeContent = nil diff --git a/Hutch/Views/Settings/SettingsView.swift b/Hutch/Views/Settings/SettingsView.swift index ce980e2..f307429 100644 --- a/Hutch/Views/Settings/SettingsView.swift +++ b/Hutch/Views/Settings/SettingsView.swift @@ -133,6 +133,13 @@ struct SettingsView: View { .alignmentGuide(.listRowSeparatorLeading) { _ in 0 } .themedRow() + Button { + Task { await appState.client.clearPersistentCache() } + } label: { + Label("Clear Cache", systemImage: "externaldrive.badge.xmark") + } + .themedRow() + Button("Reset App Data", role: .destructive) { pendingDestructiveAction = .resetAppData } diff --git a/Hutch/Views/Shared/StaleCacheStatusRow.swift b/Hutch/Views/Shared/StaleCacheStatusRow.swift new file mode 100644 index 0000000..166fec4 --- /dev/null +++ b/Hutch/Views/Shared/StaleCacheStatusRow.swift @@ -0,0 +1,32 @@ +import SwiftUI + +struct StaleCacheStatusRow: View { + let metadata: CacheEntryMetadata + let isRefreshing: Bool + + var body: some View { + HStack(spacing: 8) { + Image(systemName: metadata.isExpired() ? "clock.badge.exclamationmark" : "clock") + .foregroundStyle(.secondary) + Text(statusText) + .font(.caption) + .foregroundStyle(.secondary) + Spacer(minLength: 0) + if isRefreshing { + ProgressView() + .controlSize(.mini) + } + } + .themedRow() + } + + private var statusText: String { + if isRefreshing { + return "Showing cached data. Refreshing…" + } + if metadata.isExpired() { + return "Showing cached data. Last updated \(metadata.fetchedAt.relativeDescription)." + } + return "Last updated \(metadata.fetchedAt.relativeDescription)" + } +} diff --git a/Hutch/Views/Tickets/TicketDetailView.swift b/Hutch/Views/Tickets/TicketDetailView.swift index 0028083..92263f6 100644 --- a/Hutch/Views/Tickets/TicketDetailView.swift +++ b/Hutch/Views/Tickets/TicketDetailView.swift @@ -169,6 +169,12 @@ struct TicketDetailView: View { } else if let ticket = viewModel.ticket { ScrollView { VStack(alignment: .leading, spacing: 0) { + if let cacheMetadata = viewModel.cacheMetadata { + StaleCacheStatusRow(metadata: cacheMetadata, isRefreshing: viewModel.isRefreshingCachedData) + .padding(.horizontal) + .padding(.vertical, 8) + } + // Header ticketHeader(ticket, viewModel: viewModel) diff --git a/Hutch/Views/Tickets/TicketDetailViewModel.swift b/Hutch/Views/Tickets/TicketDetailViewModel.swift index 483f749..df2fd46 100644 --- a/Hutch/Views/Tickets/TicketDetailViewModel.swift +++ b/Hutch/Views/Tickets/TicketDetailViewModel.swift @@ -98,7 +98,7 @@ private struct LabelsPage: Decodable, Sendable { @MainActor final class TicketDetailViewModel { private static func cacheKey(ownerUsername: String, trackerRid: String, ticketId: Int) -> String { - "ticket.detail.\(ownerUsername).\(trackerRid).\(ticketId)" + APICacheKeys.ticketDetail(owner: ownerUsername, trackerRid: trackerRid, ticketId: ticketId) } let ownerUsername: String @@ -114,6 +114,8 @@ final class TicketDetailViewModel { private(set) var isPerformingAction = false private(set) var trackerLabels: [TicketLabel] = [] private(set) var rawTicketResponse: String? + private(set) var cacheMetadata: CacheEntryMetadata? + private(set) var isRefreshingCachedData = false var commentText = "" var error: String? @@ -291,30 +293,25 @@ final class TicketDetailViewModel { rawTicketResponse = nil do { - let result = try await client.execute( + let result = try await client.executeCached( service: .todo, query: Self.detailQuery, variables: [ "rid": trackerRid, "ticketId": ticketId ], - responseType: TicketDetailResponse.self - ) - let payload = result.tracker.ticket - ticket = TicketDetail( - id: payload.id, - created: payload.created, - updated: payload.updated, - title: payload.title, - description: payload.description, - status: payload.status, - resolution: payload.resolution, - authenticity: payload.authenticity, - submitter: payload.submitter, - assignees: payload.assignees, - labels: payload.labels + responseType: TicketDetailResponse.self, + cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId), + resourceType: .ticketDetail, + ttl: APICacheTTLs.ticketDetail, + policy: .cacheFirstThenRefresh ) - events = payload.events.results.sorted(by: Self.timelineOrder) + apply(result.value, metadata: result.metadata) + if result.isFromCache { + isLoading = false + await refreshTicketInBackground() + return + } } catch { self.error = error.userFacingMessage } @@ -329,7 +326,7 @@ final class TicketDetailViewModel { do { let cacheKey = Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId) - let result = try await client.executeAndCache( + let result = try await client.executeCached( service: .todo, query: Self.detailQuery, variables: [ @@ -337,25 +334,14 @@ final class TicketDetailViewModel { "ticketId": ticketId ], responseType: TicketDetailResponse.self, - cacheKey: cacheKey + cacheKey: cacheKey, + resourceType: .ticketDetail, + ttl: APICacheTTLs.ticketDetail, + policy: .refreshIgnoringCache ) - rawTicketResponse = client.responseCache.get(forKey: cacheKey) + rawTicketResponse = await client.cachedPayload(forKey: cacheKey) .flatMap { String(data: $0, encoding: .utf8) } - let payload = result.tracker.ticket - ticket = TicketDetail( - id: payload.id, - created: payload.created, - updated: payload.updated, - title: payload.title, - description: payload.description, - status: payload.status, - resolution: payload.resolution, - authenticity: payload.authenticity, - submitter: payload.submitter, - assignees: payload.assignees, - labels: payload.labels - ) - events = payload.events.results.sorted(by: Self.timelineOrder) + apply(result.value, metadata: result.metadata) } catch { self.error = error.userFacingMessage } @@ -391,6 +377,7 @@ final class TicketDetailViewModel { events.append(event) events.sort(by: Self.timelineOrder) commentText = "" + await invalidateAfterMutation() } catch { self.error = error.userFacingMessage } @@ -423,6 +410,7 @@ final class TicketDetailViewModel { ], responseType: UpdateStatusResponse.self ) + await invalidateAfterMutation() // Re-fetch the ticket to get updated status/resolution await reloadTicketPreservingDebugState() } catch { @@ -457,6 +445,7 @@ final class TicketDetailViewModel { ], responseType: AssignUserResponse.self ) + await invalidateAfterMutation() // Reload to reflect the change await reloadTicketPreservingDebugState() } catch { @@ -503,6 +492,7 @@ final class TicketDetailViewModel { ], responseType: AssignUserResponse.self ) + await invalidateAfterMutation() await reloadTicketPreservingDebugState() } catch { ticket = TicketDetail( @@ -550,6 +540,7 @@ final class TicketDetailViewModel { ], responseType: UnassignUserResponse.self ) + await invalidateAfterMutation() // Reload to reflect the change await reloadTicketPreservingDebugState() } catch { @@ -575,6 +566,7 @@ final class TicketDetailViewModel { ], responseType: LabelTicketResponse.self ) + await invalidateAfterMutation() await reloadTicketPreservingDebugState() } catch { self.error = error.userFacingMessage @@ -599,6 +591,7 @@ final class TicketDetailViewModel { ], responseType: UnlabelTicketResponse.self ) + await invalidateAfterMutation() await reloadTicketPreservingDebugState() } catch { self.error = error.userFacingMessage @@ -654,6 +647,61 @@ final class TicketDetailViewModel { } } + private func refreshTicketInBackground() async { + guard !isRefreshingCachedData else { return } + isRefreshingCachedData = true + defer { isRefreshingCachedData = false } + + do { + let result = try await client.executeCached( + service: .todo, + query: Self.detailQuery, + variables: [ + "rid": trackerRid, + "ticketId": ticketId + ], + responseType: TicketDetailResponse.self, + cacheKey: Self.cacheKey(ownerUsername: ownerUsername, trackerRid: trackerRid, ticketId: ticketId), + resourceType: .ticketDetail, + ttl: APICacheTTLs.ticketDetail, + policy: .refreshIgnoringCache + ) + apply(result.value, metadata: result.metadata) + } catch { + if ticket == nil { + self.error = error.userFacingMessage + } + } + } + + private func apply(_ response: TicketDetailResponse, metadata: CacheEntryMetadata?) { + cacheMetadata = metadata + let payload = response.tracker.ticket + let updatedTicket = TicketDetail( + id: payload.id, + created: payload.created, + updated: payload.updated, + title: payload.title, + description: payload.description, + status: payload.status, + resolution: payload.resolution, + authenticity: payload.authenticity, + submitter: payload.submitter, + assignees: payload.assignees, + labels: payload.labels + ) + ticket = updatedTicket + let updatedEvents = payload.events.results.sorted(by: Self.timelineOrder) + events = updatedEvents + } + + private func invalidateAfterMutation() async { + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "ticket")) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tickets")) + await client.invalidateCache(prefix: APICacheKeys.prefix(SRHTService.todo.rawValue, "tracker")) + await client.invalidateCache(prefix: APICacheKeys.prefix("home")) + } + static func matchesAssignee(_ entity: Entity, user: User) -> Bool { let assigneeCanonical = normalizedCanonicalName(entity.canonicalName) let userCanonical = normalizedCanonicalName(user.canonicalName) diff --git a/HutchTests/APICacheTests.swift b/HutchTests/APICacheTests.swift new file mode 100644 index 0000000..89c6c53 --- /dev/null +++ b/HutchTests/APICacheTests.swift @@ -0,0 +1,309 @@ +import Foundation +import Testing +@testable import Hutch + +@Suite(.serialized) +struct APICacheTests { + private struct Payload: Codable, Sendable, Equatable { + let value: String + } + + private struct GraphPayload: Decodable, Sendable, Equatable { + let item: Payload + } + + @Test + func cacheReadWriteRoundTrip() async throws { + let cache = makeCache() + let data = try JSONEncoder().encode(Payload(value: "cached")) + + _ = try await cache.write(payload: data, cacheKey: "repo|one", resourceType: .repositoryDetail, ttl: 60) + let entry = try await cache.read(cacheKey: "repo|one") + let decoded = try JSONDecoder().decode(Payload.self, from: entry.payload) + + #expect(decoded == Payload(value: "cached")) + #expect(entry.metadata.cacheKey == "repo|one") + #expect(entry.metadata.resourceType == .repositoryDetail) + } + + @Test + func expiredEntryBehaviorAndPruneExpired() async throws { + let cache = makeCache() + let data = Data("expired".utf8) + + let metadata = try await cache.write(payload: data, cacheKey: "ticket|old", resourceType: .ticketDetail, ttl: -1) + #expect(metadata.isExpired()) + let entry = try await cache.read(cacheKey: "ticket|old") + #expect(entry.payload == data) + + await cache.pruneExpired(now: Date()) + + await expectCacheMiss(cache, key: "ticket|old") + } + + @Test + func invalidationByPrefixRemovesMatchingEntriesOnly() async throws { + let cache = makeCache() + _ = try await cache.write(payload: Data("a".utf8), cacheKey: "todo|ticket|1", resourceType: .ticketDetail, ttl: 60) + _ = try await cache.write(payload: Data("b".utf8), cacheKey: "todo|tickets", resourceType: .ticketList, ttl: 60) + _ = try await cache.write(payload: Data("c".utf8), cacheKey: "builds|job|1", resourceType: .buildDetail, ttl: 60) + + await cache.removeByPrefix("todo|ticket") + + await expectCacheMiss(cache, key: "todo|ticket|1") + await expectCacheMiss(cache, key: "todo|tickets") + _ = try await cache.read(cacheKey: "builds|job|1") + } + + @Test + func maxEntrySizeEnforced() async throws { + let directory = temporaryDirectory() + let cache = PersistentAPICache(configuration: APICacheConfiguration( + directory: directory, + maxCacheSizeBytes: 1024, + maxEntrySizeBytes: 3, + memoryEntryLimit: 4, + schemaVersion: 1 + )) + + do { + _ = try await cache.write(payload: Data("toolarge".utf8), cacheKey: "large", resourceType: .buildLog, ttl: 60) + Issue.record("Expected max-entry enforcement.") + } catch APICacheError.entryTooLarge(let bytes) { + #expect(bytes == 8) + } catch { + Issue.record("Unexpected error: \(error)") + } + } + + @Test + func pruneToSizeLimitUsesLRU() async throws { + let directory = temporaryDirectory() + let cache = PersistentAPICache(configuration: APICacheConfiguration( + directory: directory, + maxCacheSizeBytes: 9, + maxEntrySizeBytes: 20, + memoryEntryLimit: 4, + schemaVersion: 1 + )) + + _ = try await cache.write(payload: Data("1111".utf8), cacheKey: "old", resourceType: .repositoryFile, ttl: 60) + try await Task.sleep(for: .milliseconds(5)) + _ = try await cache.write(payload: Data("2222".utf8), cacheKey: "middle", resourceType: .repositoryFile, ttl: 60) + try await Task.sleep(for: .milliseconds(5)) + _ = try await cache.write(payload: Data("3333".utf8), cacheKey: "new", resourceType: .repositoryFile, ttl: 60) + + await cache.pruneToSizeLimit() + + await expectCacheMiss(cache, key: "old") + _ = try await cache.read(cacheKey: "middle") + _ = try await cache.read(cacheKey: "new") + } + + @Test + func cacheFirstThenRefreshReturnsUsableStaleCacheWhenRefreshFails() async throws { + let cache = makeCache() + let staleEnvelope = #"{"data":{"item":{"value":"stale"}}}"#.data(using: .utf8)! + _ = try await cache.write(payload: staleEnvelope, cacheKey: "resource", resourceType: .repositoryDetail, ttl: -1) + CachedURLProtocol.reset(responses: [.failure]) + let client = makeClient(cache: cache) + + let result = try await client.executeCached( + service: .git, + query: "{ item { value } }", + responseType: GraphPayload.self, + cacheKey: "resource", + resourceType: .repositoryDetail, + ttl: 60, + policy: .cacheFirstThenRefresh + ) + + #expect(result.value.item.value == "stale") + #expect(result.isFromCache) + } + + @Test + func refreshIgnoringCacheUpdatesCache() async throws { + let cache = makeCache() + CachedURLProtocol.reset(responses: [.success("fresh")]) + let client = makeClient(cache: cache) + + let result = try await client.executeCached( + service: .git, + query: "{ item { value } }", + responseType: GraphPayload.self, + cacheKey: "resource", + resourceType: .repositoryDetail, + ttl: 60, + policy: .refreshIgnoringCache + ) + let cached = try await client.executeCached( + service: .git, + query: "{ item { value } }", + responseType: GraphPayload.self, + cacheKey: "resource", + resourceType: .repositoryDetail, + ttl: 60, + policy: .cacheOnly + ) + + #expect(result.value.item.value == "fresh") + #expect(cached.value.item.value == "fresh") + } + + @Test + func networkOnlyBypassesCacheAndDoesNotWrite() async throws { + let cache = makeCache() + _ = try await cache.write( + payload: #"{"data":{"item":{"value":"cached"}}}"#.data(using: .utf8)!, + cacheKey: "resource", + resourceType: .repositoryDetail, + ttl: 60 + ) + CachedURLProtocol.reset(responses: [.success("network")]) + let client = makeClient(cache: cache) + + let result = try await client.executeCached( + service: .git, + query: "{ item { value } }", + responseType: GraphPayload.self, + cacheKey: "resource", + resourceType: .repositoryDetail, + ttl: 60, + policy: .networkOnly + ) + let cached = try await client.executeCached( + service: .git, + query: "{ item { value } }", + responseType: GraphPayload.self, + cacheKey: "resource", + resourceType: .repositoryDetail, + ttl: 60, + policy: .cacheOnly + ) + + #expect(result.value.item.value == "network") + #expect(cached.value.item.value == "cached") + } + + @Test + func plainMutationPathDoesNotReadFromCache() async throws { + let cache = makeCache() + _ = try await cache.write( + payload: #"{"data":{"item":{"value":"cached"}}}"#.data(using: .utf8)!, + cacheKey: "mutation-resource", + resourceType: .debug, + ttl: 60 + ) + CachedURLProtocol.reset(responses: [.success("network")]) + let client = makeClient(cache: cache) + + let result = try await client.execute( + service: .git, + query: "mutation update { item { value } }", + responseType: GraphPayload.self + ) + + #expect(result.item.value == "network") + #expect(CachedURLProtocol.requestCount == 1) + } + + @Test + func duplicateConcurrentRequestsAreCoalesced() async throws { + let cache = makeCache() + CachedURLProtocol.reset(responses: [.success("fresh")], responseDelay: 0.05) + let client = makeClient(cache: cache) + + async let first: CachedValue<GraphPayload> = client.executeCached( + service: .git, + query: "{ item { value } }", + responseType: GraphPayload.self, + cacheKey: "same-resource", + resourceType: .repositoryDetail, + ttl: 60, + policy: .refreshIgnoringCache + ) + async let second: CachedValue<GraphPayload> = client.executeCached( + service: .git, + query: "{ item { value } }", + responseType: GraphPayload.self, + cacheKey: "same-resource", + resourceType: .repositoryDetail, + ttl: 60, + policy: .refreshIgnoringCache + ) + + let values = try await [first.value.item.value, second.value.item.value] + #expect(values == ["fresh", "fresh"]) + #expect(CachedURLProtocol.requestCount == 1) + } + + private func makeCache() -> PersistentAPICache { + PersistentAPICache(configuration: .temporary(directory: temporaryDirectory())) + } + + private func makeClient(cache: any APICache) -> SRHTClient { + SRHTClient(session: CachedURLProtocol.makeSession(), token: "token", cache: cache) + } + + private func temporaryDirectory() -> URL { + FileManager.default.temporaryDirectory + .appendingPathComponent("HutchAPICacheTests-\(UUID().uuidString)", isDirectory: true) + } + + private func expectCacheMiss(_ cache: any APICache, key: String) async { + do { + _ = try await cache.read(cacheKey: key) + Issue.record("Expected cache miss for \(key).") + } catch APICacheError.miss { + } catch { + Issue.record("Unexpected error for \(key): \(error).") + } + } +} + +private enum CachedURLProtocolResponse: Sendable { + case success(String) + case failure +} + +private final class CachedURLProtocol: URLProtocol, @unchecked Sendable { + nonisolated(unsafe) private static var responses: [CachedURLProtocolResponse] = [] + nonisolated(unsafe) private static var delay: TimeInterval = 0 + nonisolated(unsafe) static var requestCount = 0 + + override class func canInit(with _: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.requestCount += 1 + if Self.delay > 0 { + Thread.sleep(forTimeInterval: Self.delay) + } + let next = Self.responses.isEmpty ? .success("fresh") : Self.responses.removeFirst() + switch next { + case .success(let value): + let data = #"{"data":{"item":{"value":"\#(value)"}}}"#.data(using: .utf8)! + let response = HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + case .failure: + client?.urlProtocol(self, didFailWithError: URLError(.notConnectedToInternet)) + } + } + + override func stopLoading() {} + + static func reset(responses: [CachedURLProtocolResponse], responseDelay: TimeInterval = 0) { + Self.responses = responses + Self.delay = responseDelay + Self.requestCount = 0 + } + + static func makeSession() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [CachedURLProtocol.self] + return URLSession(configuration: config) + } +} diff --git a/HutchTests/AppStateTests.swift b/HutchTests/AppStateTests.swift index 45812ed..88a8272 100644 --- a/HutchTests/AppStateTests.swift +++ b/HutchTests/AppStateTests.swift @@ -1,3 +1,4 @@ +import Foundation import Testing @testable import Hutch |
