summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--Docs/API_CACHE.md11
-rw-r--r--Hutch.xcodeproj/project.pbxproj16
-rw-r--r--Hutch/App/AppState.swift7
-rw-r--r--Hutch/Networking/APICache.swift337
-rw-r--r--Hutch/Networking/APICacheKeys.swift105
-rw-r--r--Hutch/Networking/SRHTClient.swift299
-rw-r--r--Hutch/Views/Builds/BuildDetailView.swift6
-rw-r--r--Hutch/Views/Builds/BuildDetailViewModel.swift114
-rw-r--r--Hutch/Views/Repositories/FileTreeViewModel.swift61
-rw-r--r--Hutch/Views/Repositories/RepositoryDetailViewModel.swift22
-rw-r--r--Hutch/Views/Settings/SettingsView.swift7
-rw-r--r--Hutch/Views/Shared/StaleCacheStatusRow.swift32
-rw-r--r--Hutch/Views/Tickets/TicketDetailView.swift6
-rw-r--r--Hutch/Views/Tickets/TicketDetailViewModel.swift120
-rw-r--r--HutchTests/APICacheTests.swift309
-rw-r--r--HutchTests/AppStateTests.swift1
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