ingestFromWatch arbitrated by whole-document updatedAt, so concurrent edits to the same workout on both devices (phone edits exercise A while the watch completes exercise B) lost one side wholesale — the newer snapshot replaced the other (H1). Reconcile per log instead. WorkoutMergePlanner (pure, deterministic) unions logs by id, resolves each by newest per-log updatedAt, and applies phone-authored deletion tombstones so an absent log is never ambiguous between "deleted on the phone" and "just added on the watch". Edits to different exercises now commute — delivery order and offline gaps stop mattering. A stale/duplicate push merges back to exactly the cached doc, so ingest re-pushes authoritative state rather than writing. The per-log updatedAt scaffolding shipped (unused) in schema v4; it's now stamped by transition(to:) on status flips and a new touch() at the content-only edit sites (order, notes, machine settings, adjusted entries, new logs) on both phone and watch. deletedLogIDs is new: additive on the wire and cache, phone-authored (deleteLog), pruned after a 30-day grace. Because an older build rewriting a file would strip the tombstones and resurrect a deleted exercise, WorkoutDocument schema bumps 4->5 (forward gate quarantines old builds) and the cache bumps 5->6. recomputeStatusFromLogs takes an injectable now: so the merge recomputes status/end deterministically. WorkoutMergePlannerTests pins the decision table (commute, per-log newer-wins, legacy-nil, watch-add, tombstone honored/resurrect/union/prune, status recompute, no-op re-push); WorkoutDocumentMapperTests gains the deletedLogIDs round-trip.
100 lines
4.6 KiB
Swift
100 lines
4.6 KiB
Swift
import Foundation
|
|
import SwiftData
|
|
|
|
/// Builds the SwiftData cache container. The iCloud Drive JSON documents are the
|
|
/// source of truth, so this store is a rebuildable cache — wiping it is always
|
|
/// safe; `SyncEngine` repopulates it from the container on next launch.
|
|
enum WorkoutsModelContainer {
|
|
/// Bump whenever the cache schema changes — wipes and rebuilds from files.
|
|
/// 2: added health-metric columns to `Workout` and `activityTypeRaw` to `Split`.
|
|
/// 3: added `categoryRaw` to `Exercise`.
|
|
/// 4: removed the weight-reminder columns and `categoryRaw` from `Exercise` and
|
|
/// the `completed` column from `WorkoutLog`; added `machineSettings` to both.
|
|
/// 5: `weight` went Int → Double on `Exercise` and `WorkoutLog`; added
|
|
/// `setEntries` and `logUpdatedAt` to `WorkoutLog`.
|
|
/// 6: added `deletedLogIDs` to `Workout` (per-log merge deletion tombstones).
|
|
static let currentSchemaVersion = 6
|
|
private static let schemaVersionKey = "Workouts.persistenceSchemaVersion"
|
|
private static let identityTokenKey = "Workouts.iCloudIdentityToken"
|
|
|
|
private static var storeURL: URL {
|
|
URL.applicationSupportDirectory.appending(path: "Workouts.store")
|
|
}
|
|
|
|
/// Sidecar file holding queued-but-unwritten document writes (`WriteBacklog`,
|
|
/// iOS only). Lives beside the cache store — outside the iCloud container —
|
|
/// and is wiped with it on an account change: a backlog from one account must
|
|
/// never drain into another account's container.
|
|
static var pendingWritesURL: URL {
|
|
URL.applicationSupportDirectory.appending(path: "PendingWrites.json")
|
|
}
|
|
|
|
static func make() -> ModelContainer {
|
|
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
|
|
ensureStoreDirectoryExists()
|
|
wipeIfNeeded()
|
|
wipeIfAccountChanged()
|
|
|
|
do {
|
|
// `.none` is load-bearing: the default `.automatic` would silently
|
|
// enable CloudKit mirroring on top of our iCloud Drive file sync.
|
|
let config = ModelConfiguration(schema: schema, url: storeURL, cloudKitDatabase: .none)
|
|
let container = try ModelContainer(for: schema, configurations: [config])
|
|
UserDefaults.standard.set(currentSchemaVersion, forKey: schemaVersionKey)
|
|
return container
|
|
} catch {
|
|
print("Workouts: ModelContainer creation failed at \(storeURL.path): \(error). Falling back to in-memory.")
|
|
}
|
|
|
|
do {
|
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true)
|
|
return try ModelContainer(for: schema, configurations: [config])
|
|
} catch {
|
|
fatalError("Workouts: could not create even an in-memory ModelContainer: \(error)")
|
|
}
|
|
}
|
|
|
|
/// Records the current iCloud identity token as the cache's owner. Call after
|
|
/// rebuilding the cache for a newly-signed-in account.
|
|
static func persistCurrentIdentityToken() {
|
|
UserDefaults.standard.set(currentIdentityTokenData(), forKey: identityTokenKey)
|
|
}
|
|
|
|
// MARK: - Wipe helpers
|
|
|
|
private static func ensureStoreDirectoryExists() {
|
|
let parent = storeURL.deletingLastPathComponent()
|
|
try? FileManager.default.createDirectory(at: parent, withIntermediateDirectories: true)
|
|
}
|
|
|
|
private static func wipeIfNeeded() {
|
|
let stored = UserDefaults.standard.integer(forKey: schemaVersionKey)
|
|
guard stored < currentSchemaVersion else { return }
|
|
wipeStore()
|
|
}
|
|
|
|
/// Wipes the cache when the signed-in iCloud account differs from the one the
|
|
/// cache was built for. A nil token is "not ready yet", not "no account" — so
|
|
/// only compare once a token resolves; mid-session changes are handled live.
|
|
private static func wipeIfAccountChanged() {
|
|
guard let current = currentIdentityTokenData() else { return }
|
|
let stored = UserDefaults.standard.data(forKey: identityTokenKey)
|
|
guard current != stored else { return }
|
|
wipeStore()
|
|
try? FileManager.default.removeItem(at: pendingWritesURL)
|
|
UserDefaults.standard.set(current, forKey: identityTokenKey)
|
|
}
|
|
|
|
private static func currentIdentityTokenData() -> Data? {
|
|
guard let token = FileManager.default.ubiquityIdentityToken else { return nil }
|
|
return try? NSKeyedArchiver.archivedData(withRootObject: token, requiringSecureCoding: false)
|
|
}
|
|
|
|
private static func wipeStore() {
|
|
let base = storeURL
|
|
for url in [base, URL(fileURLWithPath: base.path + "-wal"), URL(fileURLWithPath: base.path + "-shm")] {
|
|
try? FileManager.default.removeItem(at: url)
|
|
}
|
|
}
|
|
}
|