// // SchemaSkipScanner.swift // Reference code copied from the indie-diag skill (2026-07-06 addition), // adapted for Workouts. // // Answers a question `NSMetadataQuery` can't: of the documents that are fully // synced and locally present, how many is this build currently *skipping* // because their `schemaVersion` is newer than it understands (IndieSync's // `VersionedDocument.isReadable` forward gate)? A skipped document is a // healthy, expected state — the data is safe and fully synced, just written by // a newer app version on another device — and updating the app recovers it. // // ADAPTATION FROM THE REFERENCE: the reference scanner takes a single // `currentSchemaVersion: Int`. Workouts has TWO document types with // INDEPENDENT version counters (`RoutineDocument.currentSchemaVersion` and // `WorkoutDocument.currentSchemaVersion`), so one global ceiling can't classify // both correctly — a Routine written at a version the workout counter has already // reached would be misjudged. The forward-gate ceiling therefore has to be // resolved per file (its location under `Documents/` tells us its type), so the // scanner takes a `CeilingResolver` closure. A single-Int convenience init is // kept so the file stays drop-in for single-document-type apps. Deliberately // never triggers a download: callers filter to locally-present documents first // (see `DiagnosticsCoordinator.generate()`). // import Foundation /// Outcome of probing one document's schema version. public enum SchemaProbeState: Sendable, Equatable { /// `schemaVersion` is at or below what this build understands. case readable(version: Int) /// `schemaVersion` is newer than this build understands — the app's /// `isReadable` forward gate skips this document. An app update /// recovers it; the data is intact. case skipped(version: Int) /// Decoded as JSON but no schema version field was found (e.g. a /// tombstone stub or other non-versioned file in the container). case noVersionField /// The file couldn't be read or parsed at all. case unreadable(description: String) } /// Probe result for one document. public struct SchemaProbeResult: Sendable, Identifiable { public var id: URL { url } public let url: URL public let fileName: String public let state: SchemaProbeState } /// Point-in-time result of scanning locally-available documents. /// /// Only documents that were actually probed appear here — evicted /// documents are excluded upstream (never downloaded just to probe), so /// `skippedCount` is a floor, not a guarantee, whenever /// `DocumentSyncSnapshot.evictedCount` is nonzero. public struct SchemaSkipSnapshot: Sendable { public let results: [SchemaProbeResult] public let currentSchemaVersion: Int public let generatedAt: Date public var probedCount: Int { results.count } public var skippedCount: Int { results.count { if case .skipped = $0.state { return true } else { return false } } } public var skipped: [SchemaProbeResult] { results.filter { if case .skipped = $0.state { return true } else { return false } } } /// Highest schema version seen among skipped documents — i.e. the /// newest app version that has written into this container. Useful in /// support copy ("another device is running a newer version"). public var newestSkippedVersion: Int? { skipped.compactMap { if case .skipped(let v) = $0.state { return v } else { return nil } }.max() } } /// Probes documents for their `schemaVersion` and counts the ones this /// build would skip. A plain `actor` so the coordinated file reads block off /// the caller's thread and a wedged iCloud account can't freeze the UI. public actor SchemaSkipScanner { /// Pulls the schema version out of a document's raw bytes. Return nil /// if the data has no version field. public typealias VersionExtractor = @Sendable (Data) -> Int? /// Resolves the newest schema version THIS build can read for the document at /// `url` — its forward-gate ceiling. Workouts resolves this from the file's /// location under `Documents/` (Splits/ vs Workouts/); see the file header. public typealias CeilingResolver = @Sendable (URL) -> Int private let ceilingForURL: CeilingResolver private let extractVersion: VersionExtractor /// Representative "newest schema this build understands" (the max ceiling), /// kept only for `SchemaSkipSnapshot.currentSchemaVersion`'s informational use. private let representativeCeiling: Int /// Per-URL ceiling — the form Workouts uses (two document types, two counters). /// - Parameters: /// - ceilingForURL: newest readable version for the document at a given URL. /// - representativeCeiling: a single number for the snapshot's informational /// field — pass the max of the app's document-type ceilings. /// - extractVersion: override when documents don't match the default layouts. public init( ceilingForURL: @escaping CeilingResolver, representativeCeiling: Int, extractVersion: VersionExtractor? = nil ) { self.ceilingForURL = ceilingForURL self.representativeCeiling = representativeCeiling self.extractVersion = extractVersion ?? Self.defaultExtractor } /// Single global ceiling — the reference package's original signature, kept so /// this file stays drop-in for single-document-type apps. public init(currentSchemaVersion: Int, extractVersion: VersionExtractor? = nil) { self.init( ceilingForURL: { _ in currentSchemaVersion }, representativeCeiling: currentSchemaVersion, extractVersion: extractVersion ) } /// Probe the given document URLs. Pass only locally-present documents /// (filter a `DocumentSyncSnapshot` down to states that imply a full /// local copy) — this scanner reads whatever it's handed, and reading /// an evicted document would trigger a full download. public func scan(urls: [URL]) -> SchemaSkipSnapshot { SchemaSkipSnapshot( results: urls.map(probe), currentSchemaVersion: representativeCeiling, generatedAt: Date() ) } private func probe(_ url: URL) -> SchemaProbeResult { let fileName = url.lastPathComponent // Coordinated read, same as the sync engine's document reads — a // plain Data(contentsOf:) can race an in-flight iCloud update. var coordinationError: NSError? var data: Data? var readErrorDescription: String? NSFileCoordinator(filePresenter: nil).coordinate( readingItemAt: url, options: .withoutChanges, error: &coordinationError ) { actualURL in do { data = try Data(contentsOf: actualURL) } catch { readErrorDescription = error.localizedDescription } } guard let data else { let description = coordinationError?.localizedDescription ?? readErrorDescription ?? "Unknown read error" return SchemaProbeResult(url: url, fileName: fileName, state: .unreadable(description: description)) } guard let version = extractVersion(data) else { return SchemaProbeResult(url: url, fileName: fileName, state: .noVersionField) } let ceiling = ceilingForURL(url) let state: SchemaProbeState = version > ceiling ? .skipped(version: version) : .readable(version: version) return SchemaProbeResult(url: url, fileName: fileName, state: state) } /// Covers both canonical portfolio layouts: top-level `schemaVersion` /// (IndieSync's `VersionedDocument`) and `meta.schemaVersion` /// (time-series-data-model's Meta+Payload). Decodes only the version /// field — never the full document, so a future schema can't trip it. private static let defaultExtractor: VersionExtractor = { data in struct TopLevel: Decodable { let schemaVersion: Int } struct MetaWrapped: Decodable { struct Meta: Decodable { let schemaVersion: Int } let meta: Meta } let decoder = JSONDecoder() if let doc = try? decoder.decode(TopLevel.self, from: data) { return doc.schemaVersion } if let doc = try? decoder.decode(MetaWrapped.self, from: data) { return doc.meta.schemaVersion } return nil } }