2026-06-20 14:25:39 +03:00
|
|
|
// CacheMaintenance.swift
|
|
|
|
|
// Jarvis — bounds the on-device SwiftData caches so they don't grow forever.
|
|
|
|
|
// Saved (bookmarked) stories are always protected.
|
|
|
|
|
|
|
|
|
|
import Foundation
|
|
|
|
|
import SwiftData
|
|
|
|
|
|
|
|
|
|
@MainActor
|
|
|
|
|
enum CacheMaintenance {
|
2026-06-20 14:31:52 +03:00
|
|
|
private static let hour: TimeInterval = 3_600
|
2026-06-20 14:25:39 +03:00
|
|
|
private static let day: TimeInterval = 86_400
|
|
|
|
|
|
2026-06-22 02:33:33 +03:00
|
|
|
private static let recentHours = 84.0 // 3.5 days: survives weekends
|
|
|
|
|
private static let markerHours = 96.0 // 4 days: active window + buffer
|
2026-06-20 14:31:52 +03:00
|
|
|
private static let articleDays = 14.0 // cached article bodies (backend article retention)
|
2026-06-20 14:25:39 +03:00
|
|
|
|
|
|
|
|
/// Age-based eviction. Run on launch. Cheap; protects saved stories.
|
|
|
|
|
static func prune(_ context: ModelContext) {
|
|
|
|
|
let now = Date()
|
2026-06-20 14:31:52 +03:00
|
|
|
let seenCutoff = now.addingTimeInterval(-markerHours * hour)
|
|
|
|
|
let readCutoff = now.addingTimeInterval(-markerHours * hour)
|
2026-06-20 14:25:39 +03:00
|
|
|
let articleCutoff = now.addingTimeInterval(-articleDays * day)
|
2026-06-20 14:31:52 +03:00
|
|
|
let storyCutoff = now.addingTimeInterval(-recentHours * hour)
|
2026-06-20 14:25:39 +03:00
|
|
|
|
|
|
|
|
// Markers: simple age batch deletes.
|
|
|
|
|
try? context.delete(model: SeenStory.self, where: #Predicate { $0.seenAt < seenCutoff })
|
|
|
|
|
try? context.delete(model: ReadStory.self, where: #Predicate { $0.readAt < readCutoff })
|
|
|
|
|
|
|
|
|
|
// Offline content: drop old entries that aren't saved.
|
|
|
|
|
let saved = savedIDs(context)
|
|
|
|
|
if let arts = try? context.fetch(
|
|
|
|
|
FetchDescriptor<CachedArticle>(predicate: #Predicate { $0.cachedAt < articleCutoff })) {
|
|
|
|
|
for a in arts where !saved.contains(a.storyId) { context.delete(a) }
|
|
|
|
|
}
|
|
|
|
|
if let stories = try? context.fetch(
|
|
|
|
|
FetchDescriptor<CachedStory>(predicate: #Predicate { $0.cachedAt < storyCutoff })) {
|
|
|
|
|
for s in stories where !saved.contains(s.id) { context.delete(s) }
|
|
|
|
|
}
|
|
|
|
|
try? context.save()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// Manual "Clear cache" — wipes offline stories/articles + seen/read state,
|
|
|
|
|
/// keeps your saved bookmarks (and their articles).
|
|
|
|
|
static func clear(_ context: ModelContext) {
|
|
|
|
|
try? context.delete(model: SeenStory.self)
|
|
|
|
|
try? context.delete(model: ReadStory.self)
|
|
|
|
|
let saved = savedIDs(context)
|
|
|
|
|
if let arts = try? context.fetch(FetchDescriptor<CachedArticle>()) {
|
|
|
|
|
for a in arts where !saved.contains(a.storyId) { context.delete(a) }
|
|
|
|
|
}
|
|
|
|
|
if let stories = try? context.fetch(FetchDescriptor<CachedStory>()) {
|
|
|
|
|
for s in stories where !saved.contains(s.id) { context.delete(s) }
|
|
|
|
|
}
|
|
|
|
|
try? context.save()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private static func savedIDs(_ context: ModelContext) -> Set<String> {
|
|
|
|
|
Set((try? context.fetch(FetchDescriptor<SavedStory>()))?.map(\.id) ?? [])
|
|
|
|
|
}
|
|
|
|
|
}
|