1 contributor
import Foundation
import SwiftData
import os.log
private let logger = Logger(subsystem: "ro.xdev.healthprobe", category: "SnapshotLifecycleService")
enum SnapshotLifecycleService {
struct DeletionPreview {
let target: HealthSnapshot
let affectedDeltas: [SnapshotDelta]
let mergedDelta: SnapshotDelta?
let willBreakChain: Bool
let description: String
}
static func previewDeletion(of snapshot: HealthSnapshot, context: ModelContext) throws -> DeletionPreview {
let allDeltas = try fetchDeltas(context: context)
let incomingDelta = allDeltas.first { $0.toSnapshotID == snapshot.id }
let outgoingDelta = allDeltas.first { $0.fromSnapshotID == snapshot.id }
var willBreakChain = false
var description = ""
let integrityResult = IntegrityService.validate(snapshot: snapshot, delta: incomingDelta)
switch integrityResult {
case .valid, .pendingSync:
break
case .checksumMismatch(_, let expected, let actual):
willBreakChain = true
description = "Checksum mismatch: expected \(expected.prefix(8))…, got \(actual.prefix(8))…"
case .missingDelta(let fromID, _):
willBreakChain = true
description = "Missing delta from \(fromID)"
case .corrupted(_, let reason):
willBreakChain = true
description = reason
}
var affectedDeltas: [SnapshotDelta] = []
if let d = incomingDelta { affectedDeltas.append(d) }
if let d = outgoingDelta { affectedDeltas.append(d) }
// For intermediate deletion, compute the merged delta preview
var mergedDelta: SnapshotDelta? = nil
if let d1 = incomingDelta, let d2 = outgoingDelta,
let prevSnap = try fetchSnapshot(id: d1.fromSnapshotID, context: context),
let nextSnap = try fetchSnapshot(id: d2.toSnapshotID, context: context) {
mergedDelta = DeltaService.mergeDeltas(
d1: d1, d2: d2,
snapshotBefore: prevSnap,
snapshotAfter: nextSnap
)
}
return DeletionPreview(
target: snapshot,
affectedDeltas: affectedDeltas,
mergedDelta: mergedDelta,
willBreakChain: willBreakChain,
description: description
)
}
static func delete(_ snapshot: HealthSnapshot, context: ModelContext) throws {
let allDeltas = try fetchDeltas(context: context)
let incomingDelta = allDeltas.first { $0.toSnapshotID == snapshot.id }
let outgoingDelta = allDeltas.first { $0.fromSnapshotID == snapshot.id }
let deviceID = snapshot.deviceID
let version = Bundle.main.appBuildVersion
// Build operation log before making changes
let log = OperationLog(
operationType: "delete",
summary: buildSummary(snapshot: snapshot, incoming: incomingDelta, outgoing: outgoingDelta),
deviceID: deviceID,
appBuildVersion: version
)
log.affectedSnapshotIDs = [snapshot.id.uuidString]
let logID = log.id
context.insert(log)
if incomingDelta == nil && outgoingDelta == nil {
// Standalone snapshot — just delete
context.delete(snapshot)
} else if incomingDelta == nil, let outgoing = outgoingDelta {
// Oldest snapshot: delete it and outgoing delta, set next as chain start
if let nextSnap = try fetchSnapshot(id: outgoing.toSnapshotID, context: context) {
nextSnap.previousSnapshotID = nil
nextSnap.isChainStart = true
refreshDetailCaches(for: nextSnap, baseline: nil)
}
context.delete(outgoing)
context.delete(snapshot)
} else if outgoingDelta == nil, let incoming = incomingDelta {
// Latest snapshot: delete it and incoming delta
context.delete(incoming)
context.delete(snapshot)
} else if let d1 = incomingDelta, let d2 = outgoingDelta {
// Intermediate snapshot: merge deltas and delete
guard let prevSnap = try fetchSnapshot(id: d1.fromSnapshotID, context: context),
let nextSnap = try fetchSnapshot(id: d2.toSnapshotID, context: context) else {
logger.error("SnapshotLifecycleService: failed to find surrounding snapshots for merge")
throw LifecycleError.missingNeighbor
}
let merged = DeltaService.mergeDeltas(
d1: d1, d2: d2,
snapshotBefore: prevSnap,
snapshotAfter: nextSnap
)
merged.deviceID = deviceID
context.insert(merged)
for td in merged.typeDeltas ?? [] { context.insert(td) }
nextSnap.previousSnapshotID = prevSnap.id
refreshDetailCaches(for: nextSnap, baseline: prevSnap)
context.delete(d1)
context.delete(d2)
context.delete(snapshot)
}
// Atomic save: log + destructive changes in same save call
try context.save()
// Post-save OperationLog verification
let verifyDescriptor = FetchDescriptor<OperationLog>(
predicate: #Predicate<OperationLog> { $0.id == logID }
)
if (try? context.fetch(verifyDescriptor).first) == nil {
logger.critical("OperationLog not found after save — attempting recovery re-insert")
let recovery = OperationLog(
operationType: log.operationType,
summary: log.summary,
deviceID: log.operationDeviceID,
appBuildVersion: log.operationAppBuildVersion
)
recovery.affectedSnapshotIDsJSON = log.affectedSnapshotIDsJSON
context.insert(recovery)
try? context.save()
}
}
static func rebuildMissingDetailCaches(
context: ModelContext,
maxTypeCounts: Int
) throws -> Bool {
guard maxTypeCounts > 0 else { return false }
let descriptor = FetchDescriptor<HealthSnapshot>(
sortBy: [SortDescriptor(\.timestamp, order: .forward)]
)
let snapshots = try context.fetch(descriptor)
var rebuiltCount = 0
for snapshot in snapshots {
guard let baselineID = snapshot.previousSnapshotID,
let baseline = try fetchSnapshot(id: baselineID, context: context) else {
continue
}
let baselineByType = Dictionary(
uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
)
for typeCount in snapshot.typeCounts ?? [] {
guard shouldBackfillDetailCache(
typeCount: typeCount,
baseline: baselineByType[typeCount.typeIdentifier],
baselineID: baseline.id
) else {
continue
}
typeCount.setDetailCache(
TypeCountDetailCacheBuilder.build(
current: typeCount,
previous: baselineByType[typeCount.typeIdentifier],
baselineSnapshotID: baseline.id
)
)
rebuiltCount += 1
if rebuiltCount >= maxTypeCounts {
try context.save()
return false
}
}
}
if rebuiltCount > 0 {
try context.save()
}
return true
}
// MARK: - Fetch helpers
private static func fetchDeltas(context: ModelContext) throws -> [SnapshotDelta] {
try context.fetch(FetchDescriptor<SnapshotDelta>())
}
private static func fetchSnapshot(id: UUID, context: ModelContext) throws -> HealthSnapshot? {
let descriptor = FetchDescriptor<HealthSnapshot>(
predicate: #Predicate<HealthSnapshot> { $0.id == id }
)
return try context.fetch(descriptor).first
}
private static func refreshDetailCaches(for snapshot: HealthSnapshot, baseline: HealthSnapshot?) {
guard let baseline else {
for typeCount in snapshot.typeCounts ?? [] {
typeCount.setDetailCache(nil)
}
return
}
let baselineByType = Dictionary(
uniqueKeysWithValues: (baseline.typeCounts ?? []).map { ($0.typeIdentifier, $0) }
)
for typeCount in snapshot.typeCounts ?? [] {
typeCount.setDetailCache(
TypeCountDetailCacheBuilder.build(
current: typeCount,
previous: baselineByType[typeCount.typeIdentifier],
baselineSnapshotID: baseline.id
)
)
}
}
@MainActor private static func shouldBackfillDetailCache(
typeCount: TypeCount,
baseline: TypeCount?,
baselineID: UUID
) -> Bool {
if typeCount.detailCache?.matchesBaseline(baselineID) == true {
return false
}
guard canBuildDetailCache(typeCount),
baseline.map(canBuildDetailCache(_:)) ?? true else {
return false
}
return true
}
@MainActor private static func canBuildDetailCache(_ typeCount: TypeCount) -> Bool {
typeCount.count <= 0 || typeCount.recordArchiveData != nil
}
private static func buildSummary(snapshot: HealthSnapshot, incoming: SnapshotDelta?, outgoing: SnapshotDelta?) -> String {
let position: String
if incoming == nil && outgoing == nil { position = "standalone" }
else if incoming == nil { position = "oldest" }
else if outgoing == nil { position = "latest" }
else { position = "intermediate" }
return "Deleted \(position) snapshot \(snapshot.id) at \(snapshot.timestamp)"
}
enum LifecycleError: Error {
case missingNeighbor
}
}
private extension Bundle {
var appBuildVersion: String {
let version = infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
let build = infoDictionary?["CFBundleVersion"] as? String ?? ""
return "\(version) (\(build))"
}
}