Both CachedPayload and PendingOperation only ever stored their payload as plain JSON on disk (SwiftData/SQLite, no encryption of its own) - readable by anyone with access to the logged-in session, per the earlier discussion on where this cache lives. Adds AES-GCM encryption at the one place raw bytes cross into/out of OfflineCacheStore (CachingOutlineAPIClient, which already owns encode/decode) - OfflineCacheStore itself stays a dumb opaque-blob store, since its key/id/kind columns can't be encrypted without breaking the #Predicate queries built against them. Key management (KeychainCacheEncryptionKeyStore, mirrors KeychainTokenStore exactly): a random 256-bit key, generated once and Keychain-stored, not derived from anything guessable. It doesn't need deriving to survive an uninstall/reinstall either - Keychain items are scoped to the app's code signature, not its on-disk presence, so a reinstall of the same app regains access to the same key automatically (same reason a saved API token already survives a reinstall today). If the on-disk cache also happens to survive (dragging the .app to the Trash doesn't clean ~/Library/Containers), a reinstall can still read it. A row written before this shipped (still plaintext) or encrypted under a since-cleared key just fails to decrypt and is treated as a cache miss - same as any other decode failure, so it silently refetches and re-caches encrypted rather than crashing. No explicit migration needed. Also: OfflineCacheStore.clearEverything() wipes both the cache AND the pending write queue (clearAll(), used by Settings' "Clear All Cache", still only touches the cache - it shouldn't silently discard someone's unsynced edits). Exposed as CachingOutlineAPIClient.clearEverythingForSignOut(), for sign-out to use alongside clearing the key. CacheEncryptionKeyStoring is a protocol (like TokenStoring) so tests never touch the real Keychain - existing CachingOutlineAPIClientTests now inject an in-memory StaticCacheEncryptionKeyStore. 8 new tests (retry/failure-log tests from the previous commit plus 3 new ones here: ciphertext isn't plaintext JSON, a cleared key makes old rows unreadable, clearEverythingForSignOut wipes both tables). 95/95 passing.
130 lines
6.0 KiB
Swift
130 lines
6.0 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
/// Read-through cache backing `CachingOutlineAPIClient` — stores whatever it
|
|
/// last fetched successfully, keyed by request shape, so browsing keeps
|
|
/// working (stale) once the network stops. Also owns the offline write
|
|
/// queue (`PendingOperation`) — same store, same actor, since both need
|
|
/// synchronized access to the same on-disk SwiftData container.
|
|
@ModelActor
|
|
public actor OfflineCacheStore {
|
|
public static func makeContainer(inMemory: Bool = false) throws -> ModelContainer {
|
|
let configuration = ModelConfiguration(isStoredInMemoryOnly: inMemory)
|
|
return try ModelContainer(for: CachedPayload.self, PendingOperation.self, configurations: configuration)
|
|
}
|
|
|
|
// MARK: - Read-through cache
|
|
|
|
public func save(_ data: Data, forKey key: String) {
|
|
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
|
if let existing = try? modelContext.fetch(descriptor).first {
|
|
existing.payload = data
|
|
existing.cachedAt = Date()
|
|
} else {
|
|
modelContext.insert(CachedPayload(key: key, payload: data, cachedAt: Date()))
|
|
}
|
|
try? modelContext.save()
|
|
}
|
|
|
|
public func load(forKey key: String) -> Data? {
|
|
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
|
return try? modelContext.fetch(descriptor).first?.payload
|
|
}
|
|
|
|
/// Everything cached under a key prefix — e.g. every individually
|
|
/// cached document (`"document:<id>"`) or collection
|
|
/// (`"collection:<id>"`) after a Full Local Sync, for building a local
|
|
/// search index without a per-item exact-key lookup.
|
|
public func loadAll(keyPrefix: String) -> [Data] {
|
|
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key.starts(with: keyPrefix) })
|
|
return ((try? modelContext.fetch(descriptor)) ?? []).map(\.payload)
|
|
}
|
|
|
|
/// Used to drop a temporary `pending-*` document's cache entry once a
|
|
/// queued create syncs and the server hands back the real id — the
|
|
/// placeholder key would otherwise sit around as a dead orphan forever.
|
|
public func removeCacheEntry(forKey key: String) {
|
|
let descriptor = FetchDescriptor<CachedPayload>(predicate: #Predicate { $0.key == key })
|
|
guard let existing = try? modelContext.fetch(descriptor).first else { return }
|
|
modelContext.delete(existing)
|
|
try? modelContext.save()
|
|
}
|
|
|
|
public func itemCount() -> Int {
|
|
(try? modelContext.fetchCount(FetchDescriptor<CachedPayload>())) ?? 0
|
|
}
|
|
|
|
public func totalBytes() -> Int {
|
|
let descriptor = FetchDescriptor<CachedPayload>()
|
|
let rows = (try? modelContext.fetch(descriptor)) ?? []
|
|
return rows.reduce(0) { $0 + $1.payload.count }
|
|
}
|
|
|
|
public func clearAll() {
|
|
let descriptor = FetchDescriptor<CachedPayload>()
|
|
guard let rows = try? modelContext.fetch(descriptor) else { return }
|
|
rows.forEach { modelContext.delete($0) }
|
|
try? modelContext.save()
|
|
}
|
|
|
|
/// Wipes both the read-through cache AND the pending write queue —
|
|
/// used only on sign-out (see `SessionStore.signOut()`), since the
|
|
/// encryption key backing every row here is about to be cleared too.
|
|
/// Anything left un-wiped would just become permanently undecryptable
|
|
/// garbage instead of readable by the next account signed in on this
|
|
/// machine — deliberately more thorough than `clearAll()` (Settings'
|
|
/// "Clear All Cache", which never touches pending writes; that button
|
|
/// shouldn't silently discard someone's unsynced edits).
|
|
public func clearEverything() {
|
|
let cachedDescriptor = FetchDescriptor<CachedPayload>()
|
|
(try? modelContext.fetch(cachedDescriptor))?.forEach { modelContext.delete($0) }
|
|
let pendingDescriptor = FetchDescriptor<PendingOperation>()
|
|
(try? modelContext.fetch(pendingDescriptor))?.forEach { modelContext.delete($0) }
|
|
try? modelContext.save()
|
|
}
|
|
|
|
// MARK: - Offline write queue
|
|
|
|
/// Upserts by `id` — a second call with the same id (an edit coalescing
|
|
/// onto a still-unsynced edit, or a create being cancelled by its own
|
|
/// synthesized id) replaces the row in place rather than piling up.
|
|
public func enqueueOperation(id: String, kind: String, payload: Data) {
|
|
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
|
if let existing = try? modelContext.fetch(descriptor).first {
|
|
existing.kind = kind
|
|
existing.payload = payload
|
|
existing.lastError = nil
|
|
existing.attemptCount = 0
|
|
} else {
|
|
modelContext.insert(PendingOperation(id: id, kind: kind, payload: payload, createdAt: Date()))
|
|
}
|
|
try? modelContext.save()
|
|
}
|
|
|
|
public func pendingOperations() -> [PendingOperation] {
|
|
let descriptor = FetchDescriptor<PendingOperation>(sortBy: [SortDescriptor(\.createdAt)])
|
|
return (try? modelContext.fetch(descriptor)) ?? []
|
|
}
|
|
|
|
/// Returns whether a matching operation was actually found and removed —
|
|
/// callers use this to detect "this targeted something that never made
|
|
/// it to the server in the first place" and skip queuing a delete.
|
|
@discardableResult
|
|
public func removeOperation(id: String) -> Bool {
|
|
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
|
guard let existing = try? modelContext.fetch(descriptor).first else { return false }
|
|
modelContext.delete(existing)
|
|
try? modelContext.save()
|
|
return true
|
|
}
|
|
|
|
public func recordFailure(id: String, error: String) {
|
|
let descriptor = FetchDescriptor<PendingOperation>(predicate: #Predicate { $0.id == id })
|
|
guard let existing = try? modelContext.fetch(descriptor).first else { return }
|
|
existing.lastAttemptAt = Date()
|
|
existing.lastError = error
|
|
existing.attemptCount += 1
|
|
try? modelContext.save()
|
|
}
|
|
}
|