Queue document writes durably and surface sync trouble in the UI
iCloud Drive writes now flow through a persistent WriteBacklog sidecar (drained with backoff, flushed on backgrounding, wiped with the cache on account change), so a save can never be lost to a transient coordinator error. A status banner on the workout list surfaces stuck syncing. Also: the split picker gains a Recent section with day labels, split rows fold SplitItem into SplitListView, and list rows dim the multiply sign in sets-by-reps. Claude-Session: https://claude.ai/code/session_01HJDQQDA9QdP8zByg43H5v3
This commit is contained in:
@@ -18,6 +18,14 @@ enum WorkoutsModelContainer {
|
|||||||
URL.applicationSupportDirectory.appending(path: "Workouts.store")
|
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 {
|
static func make() -> ModelContainer {
|
||||||
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
|
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||||
ensureStoreDirectoryExists()
|
ensureStoreDirectoryExists()
|
||||||
@@ -70,6 +78,7 @@ enum WorkoutsModelContainer {
|
|||||||
let stored = UserDefaults.standard.data(forKey: identityTokenKey)
|
let stored = UserDefaults.standard.data(forKey: identityTokenKey)
|
||||||
guard current != stored else { return }
|
guard current != stored else { return }
|
||||||
wipeStore()
|
wipeStore()
|
||||||
|
try? FileManager.default.removeItem(at: pendingWritesURL)
|
||||||
UserDefaults.standard.set(current, forKey: identityTokenKey)
|
UserDefaults.standard.set(current, forKey: identityTokenKey)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,21 @@ extension Date {
|
|||||||
var abbreviatedWeekday: String { Self.weekdayAbbrev.string(from: self) }
|
var abbreviatedWeekday: String { Self.weekdayAbbrev.string(from: self) }
|
||||||
var dayOfMonth: Int { Calendar.current.component(.day, from: self) }
|
var dayOfMonth: Int { Calendar.current.component(.day, from: self) }
|
||||||
|
|
||||||
|
/// "Today", "Yesterday", or "N days ago" by calendar-day difference.
|
||||||
|
func daysAgoLabel(relativeTo now: Date = Date()) -> String {
|
||||||
|
let calendar = Calendar.current
|
||||||
|
let days = calendar.dateComponents(
|
||||||
|
[.day],
|
||||||
|
from: calendar.startOfDay(for: self),
|
||||||
|
to: calendar.startOfDay(for: now)
|
||||||
|
).day ?? 0
|
||||||
|
switch days {
|
||||||
|
case ..<1: return "Today"
|
||||||
|
case 1: return "Yesterday"
|
||||||
|
default: return "\(days) days ago"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func humanTimeInterval(to other: Date) -> String {
|
func humanTimeInterval(to other: Date) -> String {
|
||||||
let interval = other.timeIntervalSince(self)
|
let interval = other.timeIntervalSince(self)
|
||||||
let hours = Int(interval) / 3600
|
let hours = Int(interval) / 3600
|
||||||
|
|||||||
@@ -21,3 +21,18 @@ list (`WorkoutLogListView`) has UI already waiting on these:
|
|||||||
machine settings, and its own "add exercise to a running workout" path
|
machine settings, and its own "add exercise to a running workout" path
|
||||||
(`Workouts Watch App/Views/WorkoutLogListView.swift`) doesn't snapshot
|
(`Workouts Watch App/Views/WorkoutLogListView.swift`) doesn't snapshot
|
||||||
`machineSettings` into the new log.
|
`machineSettings` into the new log.
|
||||||
|
|
||||||
|
## Wish list
|
||||||
|
|
||||||
|
- **Pinned splits in the split picker**: add a "Pinned" section above "Recent" in
|
||||||
|
`SplitPickerSheet` (`Workouts/Views/WorkoutLogs/WorkoutLogsView.swift`), with a
|
||||||
|
swipe-to-pin action on the rows (and possibly on `SplitListView` for symmetry).
|
||||||
|
Storage decision (2026-07-08): a `pinnedSplitIDs` set in `UserDefaults` — matches
|
||||||
|
the existing preference precedent (`restSeconds`, `weightUnit`) and survives cache
|
||||||
|
rebuilds. Do **not** put an `isPinned` flag in `SplitDocument`: pinning a starter
|
||||||
|
seed would trigger the clone-on-edit fork, and `reconcileSeeds()` would overwrite
|
||||||
|
the flag with bundle bytes anyway. At read time, resolve stored IDs through
|
||||||
|
`sync.currentSplitID(for:)` (as `recentSplits` does) and prune IDs whose split no
|
||||||
|
longer exists; exclude pinned splits from the Recent section. Upgrade path if
|
||||||
|
cross-device pins ever matter: a tiny `Preferences/pins.json` iCloud document.
|
||||||
|
Watch, if wanted later: push the ID list via the `WCPayload` application context.
|
||||||
|
|||||||
@@ -19,10 +19,15 @@ struct WorkoutLogListView: View {
|
|||||||
/// race against the cache, which lags local writes by a beat.
|
/// race against the cache, which lags local writes by a beat.
|
||||||
@State private var doc: WorkoutDocument
|
@State private var doc: WorkoutDocument
|
||||||
|
|
||||||
|
/// The live cache entity, kept only to observe `updatedAt` for the absorb
|
||||||
|
/// below — all rendering and editing goes through the `doc` snapshot.
|
||||||
|
private let workout: Workout
|
||||||
|
|
||||||
@State private var showingExercisePicker = false
|
@State private var showingExercisePicker = false
|
||||||
@State private var selectedLogID: String?
|
@State private var selectedLogID: String?
|
||||||
|
|
||||||
init(workout: Workout) {
|
init(workout: Workout) {
|
||||||
|
self.workout = workout
|
||||||
_doc = State(initialValue: WorkoutDocument(from: workout))
|
_doc = State(initialValue: WorkoutDocument(from: workout))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +99,20 @@ struct WorkoutLogListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle(doc.splitName ?? Split.unnamed)
|
.navigationTitle(doc.splitName ?? Split.unnamed)
|
||||||
|
// Absorb phone edits while this screen is open (H1's watch-side gap):
|
||||||
|
// without this, the snapshot stays frozen at init, and the next watch
|
||||||
|
// tick — made against a state that's missing the phone's edit — would be
|
||||||
|
// stamped fresh and clobber it wholesale on the phone. Strictly-newer
|
||||||
|
// guard only: an equal timestamp is our own edit echoing back through
|
||||||
|
// the phone's push, and a stale push in flight (the phone pushed before
|
||||||
|
// ingesting our latest edit) must not regress what the user is looking
|
||||||
|
// at. Clock skew across devices degrades this to a skipped absorb —
|
||||||
|
// never to a clobber of local state.
|
||||||
|
.onChange(of: workout.updatedAt) { _, incoming in
|
||||||
|
if incoming > doc.updatedAt {
|
||||||
|
doc = WorkoutDocument(from: workout)
|
||||||
|
}
|
||||||
|
}
|
||||||
.navigationDestination(item: $selectedLogID) { logID in
|
.navigationDestination(item: $selectedLogID) { logID in
|
||||||
ExerciseProgressView(
|
ExerciseProgressView(
|
||||||
doc: $doc,
|
doc: $doc,
|
||||||
|
|||||||
+256
-49
@@ -11,12 +11,16 @@ enum ICloudStatus: Equatable {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is
|
/// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is
|
||||||
/// the sole source of truth: every save/delete writes the file first, then
|
/// the sole source of truth. Every save/delete mirrors into the cache
|
||||||
/// mirrors the change into the cache immediately (a same-process write doesn't
|
/// immediately (a same-process write doesn't reliably wake the `NSMetadataQuery`
|
||||||
/// reliably wake the `NSMetadataQuery` observer — and never does in the
|
/// observer — and never does in the simulator — so waiting on it leaves the UI
|
||||||
/// simulator — so waiting on it leaves the UI blind to the user's own action).
|
/// blind to the user's own action) and queues the file write in a per-document
|
||||||
|
/// slot backlog (`WriteBacklog`), drained serially with backoff. A failed write
|
||||||
|
/// therefore never silently evaporates an edit: it stays in its slot, the cache
|
||||||
|
/// keeps showing it, and `writeQueueState` surfaces the stall to the UI.
|
||||||
/// The observer and the connect-time reconcile re-apply idempotently and remain
|
/// The observer and the connect-time reconcile re-apply idempotently and remain
|
||||||
/// the sole channel for *remote* changes.
|
/// the sole channel for *remote* changes; both skip documents superseded by a
|
||||||
|
/// queued-but-unwritten local change.
|
||||||
@Observable
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class SyncEngine {
|
final class SyncEngine {
|
||||||
@@ -42,6 +46,12 @@ final class SyncEngine {
|
|||||||
/// next successful write or full reconcile.
|
/// next successful write or full reconcile.
|
||||||
private(set) var lastSyncError: String?
|
private(set) var lastSyncError: String?
|
||||||
|
|
||||||
|
/// Rollup of the write backlog for the UI banner tiers (see `WriteBacklog`).
|
||||||
|
private(set) var writeQueueState: WriteQueueState = .idle
|
||||||
|
|
||||||
|
/// Number of queued-but-unwritten document writes.
|
||||||
|
var pendingWriteCount: Int { backlog.count }
|
||||||
|
|
||||||
/// Maps a seed's id to its clone's id after a clone-on-edit fork, so a view still
|
/// Maps a seed's id to its clone's id after a clone-on-edit fork, so a view still
|
||||||
/// holding the seed's id resolves to the live clone. In-memory only (@Observable
|
/// holding the seed's id resolves to the live clone. In-memory only (@Observable
|
||||||
/// notifies on change); not persisted — durability comes from `repointWorkouts`,
|
/// notifies on change); not persisted — durability comes from `repointWorkouts`,
|
||||||
@@ -71,6 +81,9 @@ final class SyncEngine {
|
|||||||
private var monitor: MetadataObserver?
|
private var monitor: MetadataObserver?
|
||||||
private var monitorTask: Task<Void, Never>?
|
private var monitorTask: Task<Void, Never>?
|
||||||
private var connectAttempt = 0
|
private var connectAttempt = 0
|
||||||
|
private var backlog: WriteBacklog
|
||||||
|
private let backlogURL: URL
|
||||||
|
private var drainTask: Task<Void, Never>?
|
||||||
|
|
||||||
/// How long `connect()` keeps polling for a still-provisioning iCloud
|
/// How long `connect()` keeps polling for a still-provisioning iCloud
|
||||||
/// container before falling to the end-of-the-line gate. Deliberately long
|
/// container before falling to the end-of-the-line gate. Deliberately long
|
||||||
@@ -81,8 +94,17 @@ final class SyncEngine {
|
|||||||
|
|
||||||
private var context: ModelContext { modelContainer.mainContext }
|
private var context: ModelContext { modelContainer.mainContext }
|
||||||
|
|
||||||
init(container: ModelContainer) {
|
init(container: ModelContainer, backlogURL: URL = WorkoutsModelContainer.pendingWritesURL) {
|
||||||
self.modelContainer = container
|
self.modelContainer = container
|
||||||
|
self.backlogURL = backlogURL
|
||||||
|
// Writes queued in a previous run survive relaunch; anything stuck past
|
||||||
|
// the TTL is dropped rather than drained stale into a container that has
|
||||||
|
// moved on. (An account change wipes the sidecar file before this load —
|
||||||
|
// see `WorkoutsModelContainer.wipeIfAccountChanged`.)
|
||||||
|
var loaded = WriteBacklogFile.load(from: backlogURL)
|
||||||
|
loaded.pruneExpired(now: Date())
|
||||||
|
self.backlog = loaded
|
||||||
|
refreshQueueState()
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Connection (deferred, patient)
|
// MARK: - Connection (deferred, patient)
|
||||||
@@ -165,6 +187,10 @@ final class SyncEngine {
|
|||||||
log.info("connect[\(attempt)]: directories ready → available")
|
log.info("connect[\(attempt)]: directories ready → available")
|
||||||
WorkoutsModelContainer.persistCurrentIdentityToken()
|
WorkoutsModelContainer.persistCurrentIdentityToken()
|
||||||
|
|
||||||
|
// Deliver any backlog surviving from a previous run before reconcile
|
||||||
|
// lists the tree — reconcile skips pending ids anyway, but the sooner
|
||||||
|
// queued edits hit disk the smaller that window is.
|
||||||
|
kickDrain()
|
||||||
await reconcile()
|
await reconcile()
|
||||||
startMonitoring(documentsURL: store.rootURL)
|
startMonitoring(documentsURL: store.rootURL)
|
||||||
cleanupOldStubs()
|
cleanupOldStubs()
|
||||||
@@ -204,7 +230,14 @@ final class SyncEngine {
|
|||||||
monitorTask = nil
|
monitorTask = nil
|
||||||
monitor?.stop()
|
monitor?.stop()
|
||||||
monitor = nil
|
monitor = nil
|
||||||
log.info("restore: began — metadata observer suspended")
|
// Queued writes reference pre-restore state; draining them into the
|
||||||
|
// restored tree would corrupt it with edits the user chose to roll back.
|
||||||
|
drainTask?.cancel()
|
||||||
|
drainTask = nil
|
||||||
|
backlog.removeAll()
|
||||||
|
persistBacklog()
|
||||||
|
refreshQueueState()
|
||||||
|
log.info("restore: began — metadata observer suspended, write backlog cleared")
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Resume live file-watching after a restore. Starts a FRESH metadata observer
|
/// Resume live file-watching after a restore. Starts a FRESH metadata observer
|
||||||
@@ -257,8 +290,8 @@ final class SyncEngine {
|
|||||||
onCacheChanged?()
|
onCacheChanged?()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Apply a workout received from the watch — `save(workout:)` writes the file
|
/// Apply a workout received from the watch — `save(workout:)` mirrors it into
|
||||||
/// and mirrors it into the cache, same as a local edit.
|
/// the cache and queues the file write, same as a local edit.
|
||||||
func ingestFromWatch(_ doc: WorkoutDocument) async {
|
func ingestFromWatch(_ doc: WorkoutDocument) async {
|
||||||
// A workout deleted on the phone leaves a tombstone; a watch that missed the
|
// A workout deleted on the phone leaves a tombstone; a watch that missed the
|
||||||
// delete may still push its stale copy. Honor the veto — never resurrect it —
|
// delete may still push its stale copy. Honor the veto — never resurrect it —
|
||||||
@@ -268,18 +301,50 @@ final class SyncEngine {
|
|||||||
onCacheChanged?()
|
onCacheChanged?()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// A queued-but-unwritten delete is the same veto — its stub just hasn't
|
||||||
|
// landed on disk yet.
|
||||||
|
if case .delete = backlog.pendingWrite(for: doc.id)?.payload {
|
||||||
|
onCacheChanged?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// `updatedAt` intake gate: the watch mixes `sendMessage` with a queued
|
||||||
|
// `transferUserInfo` fallback, which are unordered — a failed-then-queued
|
||||||
|
// older edit can arrive after a newer one. Accept only what's strictly
|
||||||
|
// newer than the cache; the cache mirrors every accepted write
|
||||||
|
// immediately, so it's always at least as new as any pending slot.
|
||||||
|
// Strictly older means the watch is behind — re-push authoritative state
|
||||||
|
// so it corrects. Equal is the duplicate/echo case — ignore silently.
|
||||||
|
//
|
||||||
|
// KNOWN LIMIT (deliberate, revisit if users report vanished sets): this
|
||||||
|
// gate arbitrates *timestamps*, not *content*. A doc edited on a stale
|
||||||
|
// watch snapshot carries a fresh stamp and replaces the whole workout —
|
||||||
|
// concurrent edits inside one push round-trip (or a disconnection) lose
|
||||||
|
// one side wholesale. The watch-side absorb (WorkoutLogListView's
|
||||||
|
// onChange re-seed) narrows the stale window to one push latency; the
|
||||||
|
// durable fix is per-log merge: give WorkoutLogDocument its own optional
|
||||||
|
// `updatedAt` (same additive pattern as startedAt/completedAt), merge
|
||||||
|
// incoming docs log-by-log (newer log wins per id) instead of replacing,
|
||||||
|
// and carry log add/remove as explicit intents so an absent log is never
|
||||||
|
// ambiguous between "deleted" and "not seen yet". That makes edits to
|
||||||
|
// different logs commute, so delivery order and offline gaps stop
|
||||||
|
// mattering entirely.
|
||||||
|
if let cached = CacheMapper.fetchWorkout(id: doc.id, in: context) {
|
||||||
|
if doc.updatedAt < cached.updatedAt {
|
||||||
|
onCacheChanged?()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if doc.updatedAt == cached.updatedAt { return }
|
||||||
|
}
|
||||||
await save(workout: doc)
|
await save(workout: doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - Public CRUD (write path: files only)
|
// MARK: - Public CRUD (mirror-first: cache now, file via the write queue)
|
||||||
|
|
||||||
/// Returns the *effective* id of the split that was written — normally `doc.id`,
|
/// Returns the *effective* id of the split that was written — normally `doc.id`,
|
||||||
/// but the clone's fresh id when an edited seed forks. Open views follow the swap
|
/// but the clone's fresh id when an edited seed forks. Open views follow the swap
|
||||||
/// via `currentSplitID(for:)`.
|
/// via `currentSplitID(for:)`.
|
||||||
@discardableResult
|
@discardableResult
|
||||||
func save(split doc: SplitDocument) async -> String {
|
func save(split doc: SplitDocument) async -> String {
|
||||||
guard let store else { return doc.id }
|
|
||||||
|
|
||||||
// Seeds are immutable: a real edit forks the seed into a user-owned split and
|
// Seeds are immutable: a real edit forks the seed into a user-owned split and
|
||||||
// soft-deletes the original, keeping the curated seed intact and restorable. A
|
// soft-deletes the original, keeping the curated seed intact and restorable. A
|
||||||
// pristine (no-op) save must NOT fork — the edit sheets stamp `updatedAt`
|
// pristine (no-op) save must NOT fork — the edit sheets stamp `updatedAt`
|
||||||
@@ -289,14 +354,9 @@ final class SyncEngine {
|
|||||||
return await cloneSeedOnEdit(doc)
|
return await cloneSeedOnEdit(doc)
|
||||||
}
|
}
|
||||||
|
|
||||||
do {
|
CacheMapper.upsertSplit(doc, relativePath: doc.relativePath, into: context)
|
||||||
try await store.write(doc, to: doc.relativePath)
|
saveCacheAndNotify()
|
||||||
CacheMapper.upsertSplit(doc, relativePath: doc.relativePath, into: context)
|
enqueueWrite(.split(doc), timestamp: doc.updatedAt)
|
||||||
saveCacheAndNotify()
|
|
||||||
lastSyncError = nil
|
|
||||||
} catch {
|
|
||||||
report("Failed to save split", error)
|
|
||||||
}
|
|
||||||
return doc.id
|
return doc.id
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,41 +437,33 @@ final class SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func save(workout doc: WorkoutDocument) async {
|
func save(workout doc: WorkoutDocument) async {
|
||||||
guard let store else { return }
|
|
||||||
// The month bucket in a workout's path derives from `start`, so editing the
|
// The month bucket in a workout's path derives from `start`, so editing the
|
||||||
// start date (or a device in a different time zone) can move the file to a new
|
// start date (or a device in a different time zone) can move the file to a new
|
||||||
// path. Capture the previously-written path before the upsert overwrites it, so
|
// path. Capture the previously-written path before the upsert overwrites it —
|
||||||
// we can remove the stale file below — otherwise the same id would live at two
|
// the queued write removes it after landing, otherwise the same id would live
|
||||||
// paths and the old copy would re-import on the next reconcile.
|
// at two paths and the old copy would re-import on the next reconcile.
|
||||||
let previousPath = CacheMapper.fetchWorkout(id: doc.id, in: context)?.jsonRelativePath
|
let previousPath = CacheMapper.fetchWorkout(id: doc.id, in: context)?.jsonRelativePath
|
||||||
do {
|
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
|
||||||
try await store.write(doc, to: doc.relativePath)
|
saveCacheAndNotify()
|
||||||
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
|
enqueueWrite(
|
||||||
// Same id, new path: drop the orphaned file at the old bucket. The id lives
|
.workout(doc),
|
||||||
// on at the new path, so this is a plain removal — no tombstone (a tombstone
|
timestamp: doc.updatedAt,
|
||||||
// would veto the record that just moved). Phone stays the sole writer.
|
stalePath: previousPath != doc.relativePath ? previousPath : nil
|
||||||
if let previousPath, previousPath != doc.relativePath {
|
)
|
||||||
try? await store.remove(at: previousPath)
|
|
||||||
}
|
|
||||||
saveCacheAndNotify()
|
|
||||||
lastSyncError = nil
|
|
||||||
} catch {
|
|
||||||
report("Failed to save workout", error)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(split: Split) async {
|
func delete(split: Split) async {
|
||||||
let id = split.id, livePath = split.jsonRelativePath
|
let id = split.id, livePath = split.jsonRelativePath
|
||||||
await softDelete(id: id, kind: "split", livePath: livePath)
|
|
||||||
deleteCachedEntity(id: id)
|
deleteCachedEntity(id: id)
|
||||||
saveCacheAndNotify()
|
saveCacheAndNotify()
|
||||||
|
enqueueWrite(.delete(id: id, kind: "split", livePath: livePath), timestamp: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
func delete(workout: Workout) async {
|
func delete(workout: Workout) async {
|
||||||
let id = workout.id, livePath = workout.jsonRelativePath
|
let id = workout.id, livePath = workout.jsonRelativePath
|
||||||
await softDelete(id: id, kind: "workout", livePath: livePath)
|
|
||||||
deleteCachedEntity(id: id)
|
deleteCachedEntity(id: id)
|
||||||
saveCacheAndNotify()
|
saveCacheAndNotify()
|
||||||
|
enqueueWrite(.delete(id: id, kind: "workout", livePath: livePath), timestamp: Date())
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Persist pending cache mutations and fan out the change notification — the
|
/// Persist pending cache mutations and fan out the change notification — the
|
||||||
@@ -421,8 +473,10 @@ final class SyncEngine {
|
|||||||
onCacheChanged?()
|
onCacheChanged?()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Writes a tombstone stub then removes the live file. Other devices learn of
|
/// Writes a tombstone stub then removes the live file, immediately and outside
|
||||||
/// the delete via the stub even if they were offline for the file removal.
|
/// the write queue. Used only by the multi-step flows that need their file ops
|
||||||
|
/// ordered within a larger transaction (`cloneSeedOnEdit`, `performCleanup`);
|
||||||
|
/// plain user deletes go through the queue as `.delete` payloads.
|
||||||
private func softDelete(id: String, kind: String, livePath: String) async {
|
private func softDelete(id: String, kind: String, livePath: String) async {
|
||||||
guard let store, let tombstones else { return }
|
guard let store, let tombstones else { return }
|
||||||
do {
|
do {
|
||||||
@@ -434,6 +488,145 @@ final class SyncEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MARK: - Write queue
|
||||||
|
|
||||||
|
/// Queue a file write whose cache mirror has already been applied, persist the
|
||||||
|
/// backlog, and make sure the drain loop is running. The per-id slot keeps only
|
||||||
|
/// the newest version (`WriteBacklog.enqueue` is newer-wins on `timestamp`).
|
||||||
|
private func enqueueWrite(_ payload: PendingWrite.Payload, timestamp: Date, stalePath: String? = nil) {
|
||||||
|
var write = PendingWrite(payload: payload, timestamp: timestamp, enqueuedAt: Date())
|
||||||
|
if let stalePath { write.stalePaths.insert(stalePath) }
|
||||||
|
backlog.enqueue(write)
|
||||||
|
persistBacklog()
|
||||||
|
refreshQueueState()
|
||||||
|
kickDrain()
|
||||||
|
}
|
||||||
|
|
||||||
|
private func kickDrain() {
|
||||||
|
guard drainTask == nil, store != nil, !isRestoring else { return }
|
||||||
|
drainTask = Task { [weak self] in
|
||||||
|
await self?.drainBacklog()
|
||||||
|
guard let self else { return }
|
||||||
|
self.drainTask = nil
|
||||||
|
// A write enqueued in the same beat the loop was exiting finds
|
||||||
|
// `drainTask` still set and skips its kick — re-check. No spin: the
|
||||||
|
// loop only exits non-empty when the kick guard blocks anyway.
|
||||||
|
if !self.backlog.isEmpty { self.kickDrain() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serial delivery of the backlog: one write at a time, oldest due slot first,
|
||||||
|
/// exponential backoff per entry. Sleeps in short quanta while nothing is due
|
||||||
|
/// so a fresh enqueue never waits out a long backoff.
|
||||||
|
private func drainBacklog() async {
|
||||||
|
while store != nil, !isRestoring, !backlog.isEmpty {
|
||||||
|
guard let entry = backlog.nextDue(at: Date()) else {
|
||||||
|
try? await Task.sleep(for: .seconds(1))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
await attempt(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Try every pending write immediately, ignoring backoff — the app is about to
|
||||||
|
/// background (last chance before suspension), or a test needs determinism.
|
||||||
|
func flushPendingWrites() async {
|
||||||
|
guard store != nil, !isRestoring else { return }
|
||||||
|
for entry in backlog.entries {
|
||||||
|
await attempt(entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func attempt(_ entry: PendingWrite) async {
|
||||||
|
switch await perform(entry) {
|
||||||
|
case .success:
|
||||||
|
backlog.resolve(id: entry.documentID, ifTimestampAtMost: entry.timestamp)
|
||||||
|
case .retry:
|
||||||
|
backlog.markFailed(id: entry.documentID, at: Date())
|
||||||
|
case .fault(let message):
|
||||||
|
backlog.markFailed(id: entry.documentID, at: Date(), fault: message)
|
||||||
|
}
|
||||||
|
persistBacklog()
|
||||||
|
refreshQueueState()
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum WriteOutcome {
|
||||||
|
case success
|
||||||
|
case retry
|
||||||
|
case fault(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func perform(_ entry: PendingWrite) async -> WriteOutcome {
|
||||||
|
guard let store, let tombstones else { return .retry }
|
||||||
|
do {
|
||||||
|
switch entry.payload {
|
||||||
|
case .split(let doc):
|
||||||
|
try await store.write(doc, to: doc.relativePath)
|
||||||
|
case .workout(let doc):
|
||||||
|
try await store.write(doc, to: doc.relativePath)
|
||||||
|
case .delete(let id, let kind, let livePath):
|
||||||
|
// The stub is the authoritative delete record (it vetoes
|
||||||
|
// resurrection everywhere); the live-file removal is best-effort —
|
||||||
|
// the observer's stub handling and reconcile both reap a straggler.
|
||||||
|
try await tombstones.writeTombstone(Tombstone(id: id, deletedAt: entry.timestamp, kind: kind))
|
||||||
|
try? await store.remove(at: livePath)
|
||||||
|
}
|
||||||
|
for stale in entry.stalePaths where stale != entry.targetPath {
|
||||||
|
try? await store.remove(at: stale)
|
||||||
|
}
|
||||||
|
return .success
|
||||||
|
} catch {
|
||||||
|
log.error("write queue: \(entry.documentID, privacy: .public) failed (attempt \(entry.attempts + 1)): \(error)")
|
||||||
|
if let message = Self.unrecoverableDescription(for: error) { return .fault(message) }
|
||||||
|
return .retry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Errors no amount of retrying fixes — escalate the banner immediately.
|
||||||
|
/// (The drain still retries them on the long cadence; conditions can change.)
|
||||||
|
private static func unrecoverableDescription(for error: Error) -> String? {
|
||||||
|
let ns = error as NSError
|
||||||
|
guard ns.domain == NSCocoaErrorDomain else { return nil }
|
||||||
|
switch ns.code {
|
||||||
|
case NSFileWriteOutOfSpaceError:
|
||||||
|
return "iPhone storage is full — changes can't be saved to iCloud."
|
||||||
|
case NSFileWriteVolumeReadOnlyError:
|
||||||
|
return "iCloud storage is read-only — changes can't be saved."
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func persistBacklog() {
|
||||||
|
WriteBacklogFile.save(backlog, to: backlogURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshQueueState() {
|
||||||
|
let state: WriteQueueState
|
||||||
|
if backlog.isEmpty {
|
||||||
|
state = .idle
|
||||||
|
} else if let fault = backlog.firstFaultMessage {
|
||||||
|
state = .fault(fault)
|
||||||
|
} else if backlog.maxAttempts >= WriteBacklog.tier2AttemptThreshold {
|
||||||
|
state = .fault("Changes aren't reaching iCloud.")
|
||||||
|
} else if backlog.maxAttempts >= WriteBacklog.tier1AttemptThreshold {
|
||||||
|
state = .retrying
|
||||||
|
} else {
|
||||||
|
state = .pending
|
||||||
|
}
|
||||||
|
if state != writeQueueState { writeQueueState = state }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// True when a queued-but-unwritten local change for `id` is at least as new
|
||||||
|
/// as the file content just read — the cache already reflects the newer
|
||||||
|
/// pending version, so importing the older file would regress it until the
|
||||||
|
/// drain rewrites. (A pending `.delete` compares via its deletion stamp and
|
||||||
|
/// keeps the import from resurrecting the entity.)
|
||||||
|
private func supersededByPendingWrite(id: String, fileTimestamp: Date) -> Bool {
|
||||||
|
guard let pending = backlog.pendingWrite(for: id) else { return false }
|
||||||
|
return pending.timestamp >= fileTimestamp
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Import / reconcile
|
// MARK: - Import / reconcile
|
||||||
|
|
||||||
private func importFile(relativePath: String) async {
|
private func importFile(relativePath: String) async {
|
||||||
@@ -453,10 +646,12 @@ final class SyncEngine {
|
|||||||
if relativePath.hasPrefix("Splits/") {
|
if relativePath.hasPrefix("Splits/") {
|
||||||
guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
|
guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
|
||||||
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
|
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
|
||||||
|
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return }
|
||||||
CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
|
CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
|
||||||
} else if relativePath.hasPrefix("Workouts/") {
|
} else if relativePath.hasPrefix("Workouts/") {
|
||||||
guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
|
guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
|
||||||
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
|
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
|
||||||
|
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { return }
|
||||||
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
|
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -496,26 +691,34 @@ final class SyncEngine {
|
|||||||
if path.hasPrefix("Splits/") {
|
if path.hasPrefix("Splits/") {
|
||||||
guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
|
guard let doc = try? DocumentCoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
|
||||||
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
|
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
|
||||||
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
|
|
||||||
liveSplitIDs.insert(doc.id)
|
liveSplitIDs.insert(doc.id)
|
||||||
|
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue }
|
||||||
|
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
|
||||||
} else if path.hasPrefix("Workouts/") {
|
} else if path.hasPrefix("Workouts/") {
|
||||||
guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue }
|
guard let doc = try? DocumentCoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue }
|
||||||
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
|
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
|
||||||
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
|
|
||||||
liveWorkoutIDs.insert(doc.id)
|
liveWorkoutIDs.insert(doc.id)
|
||||||
|
if supersededByPendingWrite(id: doc.id, fileTimestamp: doc.updatedAt) { continue }
|
||||||
|
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prune cache entities no longer backed by a live file — but never for
|
// Prune cache entities no longer backed by a live file — but never for
|
||||||
// a path that failed to read this pass (it may just be un-downloadable
|
// a path that failed to read this pass (it may just be un-downloadable
|
||||||
// right now; pruning would make an eviction look like a deletion).
|
// right now; pruning would make an eviction look like a deletion), and
|
||||||
|
// never for an id with a queued-but-unwritten write (its file simply
|
||||||
|
// hasn't landed yet; pruning would evaporate the pending edit's mirror).
|
||||||
if let splits = try? context.fetch(FetchDescriptor<Split>()) {
|
if let splits = try? context.fetch(FetchDescriptor<Split>()) {
|
||||||
for s in splits where !liveSplitIDs.contains(s.id) && !unreadablePaths.contains(s.jsonRelativePath) {
|
for s in splits where !liveSplitIDs.contains(s.id)
|
||||||
|
&& !unreadablePaths.contains(s.jsonRelativePath)
|
||||||
|
&& backlog.pendingWrite(for: s.id) == nil {
|
||||||
context.delete(s)
|
context.delete(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if let workouts = try? context.fetch(FetchDescriptor<Workout>()) {
|
if let workouts = try? context.fetch(FetchDescriptor<Workout>()) {
|
||||||
for w in workouts where !liveWorkoutIDs.contains(w.id) && !unreadablePaths.contains(w.jsonRelativePath) {
|
for w in workouts where !liveWorkoutIDs.contains(w.id)
|
||||||
|
&& !unreadablePaths.contains(w.jsonRelativePath)
|
||||||
|
&& backlog.pendingWrite(for: w.id) == nil {
|
||||||
context.delete(w)
|
context.delete(w)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -537,12 +740,16 @@ final class SyncEngine {
|
|||||||
if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) }
|
if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Observer `.removed` handling. Skips entities with a queued-but-unwritten
|
||||||
|
/// write: their file may simply not have landed yet (or an old-path removal
|
||||||
|
/// raced the rewrite), and the pending edit's mirror must survive until the
|
||||||
|
/// drain delivers it.
|
||||||
private func deleteCachedEntity(jsonRelativePath path: String) {
|
private func deleteCachedEntity(jsonRelativePath path: String) {
|
||||||
if let splits = try? context.fetch(FetchDescriptor<Split>(predicate: #Predicate { $0.jsonRelativePath == path })) {
|
if let splits = try? context.fetch(FetchDescriptor<Split>(predicate: #Predicate { $0.jsonRelativePath == path })) {
|
||||||
splits.forEach(context.delete)
|
splits.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
|
||||||
}
|
}
|
||||||
if let workouts = try? context.fetch(FetchDescriptor<Workout>(predicate: #Predicate { $0.jsonRelativePath == path })) {
|
if let workouts = try? context.fetch(FetchDescriptor<Workout>(predicate: #Predicate { $0.jsonRelativePath == path })) {
|
||||||
workouts.forEach(context.delete)
|
workouts.filter { backlog.pendingWrite(for: $0.id) == nil }.forEach(context.delete)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,203 @@
|
|||||||
|
//
|
||||||
|
// WriteBacklog.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
|
||||||
|
/// One queued write: the latest pending version of a single document (or its
|
||||||
|
/// deletion), plus retry bookkeeping. `timestamp` is the document's `updatedAt`
|
||||||
|
/// (a delete's deletion stamp) — slot replacement and the import/reconcile
|
||||||
|
/// supersede checks key on it, newer wins.
|
||||||
|
struct PendingWrite: Codable, Sendable {
|
||||||
|
enum Payload: Codable, Sendable {
|
||||||
|
case split(SplitDocument)
|
||||||
|
case workout(WorkoutDocument)
|
||||||
|
case delete(id: String, kind: String, livePath: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload: Payload
|
||||||
|
var timestamp: Date
|
||||||
|
/// Paths this document previously lived at (month re-bucketing, replaced
|
||||||
|
/// pending versions, a delete that superseded a queued save) — best-effort
|
||||||
|
/// removed after the write lands so the id never survives at two paths.
|
||||||
|
var stalePaths: Set<String> = []
|
||||||
|
/// When the slot was first occupied — replacement keeps it, so the TTL
|
||||||
|
/// measures how long the user's intent has been stuck, not the last edit.
|
||||||
|
var enqueuedAt: Date
|
||||||
|
var attempts: Int = 0
|
||||||
|
var lastAttemptAt: Date?
|
||||||
|
/// Set when an attempt failed with an error retrying can't fix (disk full).
|
||||||
|
/// The drain keeps retrying regardless — conditions can change — but the
|
||||||
|
/// banner escalates immediately.
|
||||||
|
var faultMessage: String?
|
||||||
|
|
||||||
|
var documentID: String {
|
||||||
|
switch payload {
|
||||||
|
case .split(let doc): doc.id
|
||||||
|
case .workout(let doc): doc.id
|
||||||
|
case .delete(let id, _, _): id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where a successful write lands; nil for deletes (they only remove).
|
||||||
|
var targetPath: String? {
|
||||||
|
switch payload {
|
||||||
|
case .split(let doc): doc.relativePath
|
||||||
|
case .workout(let doc): doc.relativePath
|
||||||
|
case .delete: nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var nextAttemptAt: Date {
|
||||||
|
guard let lastAttemptAt else { return enqueuedAt }
|
||||||
|
return lastAttemptAt.addingTimeInterval(WriteBacklog.retryDelay(afterAttempts: attempts))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Depth-1 per-document write queue: at most one pending write per document id,
|
||||||
|
/// newest `updatedAt` wins the slot. Pure state + policy — no I/O, no clock of
|
||||||
|
/// its own — so the whole replacement/retry/TTL table is unit-testable.
|
||||||
|
/// `SyncEngine` owns the drain loop and persistence.
|
||||||
|
struct WriteBacklog: Codable, Sendable {
|
||||||
|
private(set) var slots: [String: PendingWrite] = [:]
|
||||||
|
|
||||||
|
var isEmpty: Bool { slots.isEmpty }
|
||||||
|
var count: Int { slots.count }
|
||||||
|
|
||||||
|
/// Highest attempt count across pending writes — drives the banner tiers.
|
||||||
|
var maxAttempts: Int { slots.values.map(\.attempts).max() ?? 0 }
|
||||||
|
|
||||||
|
/// The oldest entry's fault, if any entry has hit an unrecoverable error.
|
||||||
|
var firstFaultMessage: String? {
|
||||||
|
entries.compactMap(\.faultMessage).first
|
||||||
|
}
|
||||||
|
|
||||||
|
/// All pending writes, oldest slot first.
|
||||||
|
var entries: [PendingWrite] {
|
||||||
|
slots.values.sorted { $0.enqueuedAt < $1.enqueuedAt }
|
||||||
|
}
|
||||||
|
|
||||||
|
func pendingWrite(for id: String) -> PendingWrite? { slots[id] }
|
||||||
|
|
||||||
|
/// Newer-wins slot replacement. An incoming write at least as new as the
|
||||||
|
/// occupant takes the slot (equal = the latest local re-save of the same
|
||||||
|
/// state; content-identical, so last-in is right). It inherits the
|
||||||
|
/// occupant's retry bookkeeping — the backlog is failing for environmental
|
||||||
|
/// reasons, and resetting attempts on every edit would flap the banner —
|
||||||
|
/// plus its stale paths and its target path when the two differ (the
|
||||||
|
/// occupant was never written, but an earlier attempt may have landed).
|
||||||
|
/// An incoming write strictly older than the occupant is dropped.
|
||||||
|
mutating func enqueue(_ write: PendingWrite) {
|
||||||
|
let id = write.documentID
|
||||||
|
guard let existing = slots[id] else {
|
||||||
|
slots[id] = write
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard write.timestamp >= existing.timestamp else { return }
|
||||||
|
var merged = write
|
||||||
|
merged.stalePaths.formUnion(existing.stalePaths)
|
||||||
|
if let oldPath = existing.targetPath, oldPath != merged.targetPath {
|
||||||
|
merged.stalePaths.insert(oldPath)
|
||||||
|
}
|
||||||
|
merged.enqueuedAt = existing.enqueuedAt
|
||||||
|
merged.attempts = existing.attempts
|
||||||
|
merged.lastAttemptAt = existing.lastAttemptAt
|
||||||
|
merged.faultMessage = existing.faultMessage
|
||||||
|
slots[id] = merged
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func markFailed(id: String, at now: Date, fault: String? = nil) {
|
||||||
|
guard var entry = slots[id] else { return }
|
||||||
|
entry.attempts += 1
|
||||||
|
entry.lastAttemptAt = now
|
||||||
|
if let fault { entry.faultMessage = fault }
|
||||||
|
slots[id] = entry
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Remove after a successful write — replacement-safe: a newer write that
|
||||||
|
/// slotted in while this one was in flight stays queued.
|
||||||
|
mutating func resolve(id: String, ifTimestampAtMost timestamp: Date) {
|
||||||
|
guard let entry = slots[id], entry.timestamp <= timestamp else { return }
|
||||||
|
slots[id] = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
mutating func removeAll() { slots.removeAll() }
|
||||||
|
|
||||||
|
/// Drop entries stuck longer than `ttl` — applied when a persisted backlog
|
||||||
|
/// is loaded, so a slot from a long-dead run can't drain stale state into a
|
||||||
|
/// container that has long since moved on.
|
||||||
|
mutating func pruneExpired(now: Date, ttl: TimeInterval = 24 * 60 * 60) {
|
||||||
|
slots = slots.filter { now.timeIntervalSince($0.value.enqueuedAt) < ttl }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The next entry whose backoff has elapsed, oldest slot first.
|
||||||
|
func nextDue(at now: Date) -> PendingWrite? {
|
||||||
|
entries.first { $0.nextAttemptAt <= now }
|
||||||
|
}
|
||||||
|
|
||||||
|
func earliestNextAttempt() -> Date? {
|
||||||
|
slots.values.map(\.nextAttemptAt).min()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Backoff schedule: three quick silent retries (tier 0, ~14 s total), then
|
||||||
|
/// calm long retries capped at 5 minutes.
|
||||||
|
static func retryDelay(afterAttempts attempts: Int) -> TimeInterval {
|
||||||
|
switch attempts {
|
||||||
|
case ..<1: 0
|
||||||
|
case 1: 2
|
||||||
|
case 2: 4
|
||||||
|
case 3: 8
|
||||||
|
case 4: 30
|
||||||
|
case 5: 60
|
||||||
|
case 6: 120
|
||||||
|
default: 300
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Attempts after which the calm "changes pending" banner shows (~14 s of
|
||||||
|
/// silent retries have failed).
|
||||||
|
static let tier1AttemptThreshold = 3
|
||||||
|
/// Attempts after which the backlog is treated as a fault even without an
|
||||||
|
/// unrecoverable error (~10 minutes of failures).
|
||||||
|
static let tier2AttemptThreshold = 8
|
||||||
|
}
|
||||||
|
|
||||||
|
/// UI-facing rollup of the backlog — the banner tiers.
|
||||||
|
enum WriteQueueState: Equatable {
|
||||||
|
/// Nothing queued.
|
||||||
|
case idle
|
||||||
|
/// Writes queued inside the silent tier-0 window — no UI.
|
||||||
|
case pending
|
||||||
|
/// Tier 1: writes keep failing; calm "changes pending" banner.
|
||||||
|
case retrying
|
||||||
|
/// Tier 2: unrecoverable error or backoff exhaustion; prominent banner
|
||||||
|
/// pointing at Diagnostics.
|
||||||
|
case fault(String)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load/persist the backlog sidecar file. Failures are non-fatal by design: an
|
||||||
|
/// unreadable file loads as an empty backlog (worst case, queued-but-unwritten
|
||||||
|
/// edits from a previous run are lost — the same loss the queue exists to
|
||||||
|
/// narrow, never corruption), and a failed persist leaves the previous file in
|
||||||
|
/// place for the in-memory backlog to overwrite on the next change.
|
||||||
|
enum WriteBacklogFile {
|
||||||
|
static func load(from url: URL) -> WriteBacklog {
|
||||||
|
guard let data = try? Data(contentsOf: url),
|
||||||
|
let backlog = try? JSONDecoder().decode(WriteBacklog.self, from: data) else {
|
||||||
|
return WriteBacklog()
|
||||||
|
}
|
||||||
|
return backlog
|
||||||
|
}
|
||||||
|
|
||||||
|
static func save(_ backlog: WriteBacklog, to url: URL) {
|
||||||
|
if backlog.isEmpty {
|
||||||
|
try? FileManager.default.removeItem(at: url)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
guard let data = try? JSONEncoder().encode(backlog) else { return }
|
||||||
|
try? data.write(to: url, options: .atomic)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
//
|
||||||
|
// SyncStatusBanner.swift
|
||||||
|
// Workouts
|
||||||
|
//
|
||||||
|
// Copyright 2026 Rouslan Zenetl. All Rights Reserved.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
/// Surfaces the write queue's banner tiers at the bottom of the main screen.
|
||||||
|
/// Tier 0 (fresh writes, silent retries) shows nothing; tier 1 is a calm
|
||||||
|
/// "changes pending" capsule that clears itself on success; tier 2 is a
|
||||||
|
/// prominent fault that opens Diagnostics.
|
||||||
|
struct SyncStatusBanner: View {
|
||||||
|
@Environment(SyncEngine.self) private var sync
|
||||||
|
@State private var showingDiagnostics = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Group {
|
||||||
|
switch sync.writeQueueState {
|
||||||
|
case .idle, .pending:
|
||||||
|
EmptyView()
|
||||||
|
case .retrying:
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
ProgressView()
|
||||||
|
.controlSize(.small)
|
||||||
|
Text("Changes pending — retrying…")
|
||||||
|
.font(.footnote)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.regularMaterial, in: Capsule())
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
case .fault(let message):
|
||||||
|
Button {
|
||||||
|
showingDiagnostics = true
|
||||||
|
} label: {
|
||||||
|
HStack(alignment: .firstTextBaseline, spacing: 10) {
|
||||||
|
Image(systemName: "exclamationmark.icloud.fill")
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(message)
|
||||||
|
.font(.footnote.weight(.semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.leading)
|
||||||
|
Text("Your changes are safe on this iPhone. Tap for diagnostics.")
|
||||||
|
.font(.caption2)
|
||||||
|
.foregroundStyle(.white.opacity(0.85))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.red.gradient, in: RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.bottom, 6)
|
||||||
|
.sheet(isPresented: $showingDiagnostics) {
|
||||||
|
NavigationStack { DiagnosticsView() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.animation(.default, value: sync.writeQueueState)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
//
|
|
||||||
// SplitItem.swift
|
|
||||||
// Workouts
|
|
||||||
//
|
|
||||||
// Created by rzen on 7/18/25 at 2:45 PM.
|
|
||||||
//
|
|
||||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
|
||||||
//
|
|
||||||
|
|
||||||
import SwiftUI
|
|
||||||
|
|
||||||
struct SplitItem: View {
|
|
||||||
var split: Split
|
|
||||||
|
|
||||||
var body: some View {
|
|
||||||
VStack {
|
|
||||||
ZStack(alignment: .bottom) {
|
|
||||||
// Golden ratio rectangle (1:1.618)
|
|
||||||
RoundedRectangle(cornerRadius: 12)
|
|
||||||
.fill(
|
|
||||||
LinearGradient(
|
|
||||||
gradient: Gradient(colors: [splitColor, splitColor.darker(by: 0.2)]),
|
|
||||||
startPoint: .topLeading,
|
|
||||||
endPoint: .bottomTrailing
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.aspectRatio(1.618, contentMode: .fit)
|
|
||||||
.shadow(radius: 2)
|
|
||||||
|
|
||||||
GeometryReader { geo in
|
|
||||||
VStack(spacing: 4) {
|
|
||||||
Spacer()
|
|
||||||
|
|
||||||
// Icon in the center - using dynamic sizing
|
|
||||||
Image(systemName: split.systemImage)
|
|
||||||
.font(.system(size: min(geo.size.width * 0.3, 40), weight: .bold))
|
|
||||||
.scaledToFit()
|
|
||||||
.frame(maxWidth: geo.size.width * 0.6, maxHeight: geo.size.height * 0.4)
|
|
||||||
.padding(.bottom, 4)
|
|
||||||
|
|
||||||
// Name at the bottom inside the rectangle
|
|
||||||
Text(split.name)
|
|
||||||
.font(.headline)
|
|
||||||
.lineLimit(1)
|
|
||||||
.padding(.horizontal, 8)
|
|
||||||
|
|
||||||
Text("\(split.exercisesArray.count) exercises")
|
|
||||||
.font(.caption)
|
|
||||||
.padding(.bottom, 8)
|
|
||||||
}
|
|
||||||
.foregroundColor(.white)
|
|
||||||
.frame(width: geo.size.width, height: geo.size.height)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private var splitColor: Color {
|
|
||||||
Color.color(from: split.color)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,39 +10,37 @@
|
|||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
/// The Splits library screen, pushed from Settings → Library: a two-column grid of
|
/// The Splits library screen, pushed from Settings → Library: a plain list of
|
||||||
/// split tiles. Tapping a tile opens `SplitDetailView`; long-pressing offers Delete
|
/// splits, one row each. Tapping a row opens `SplitDetailView`; long-pressing
|
||||||
/// (the sole split-delete affordance); the toolbar's + adds a new split. Starter
|
/// offers Delete (the sole split-delete affordance); the toolbar's + adds a new
|
||||||
/// splits are seeded automatically on the true first run (`SyncEngine.autoSeedIfEmpty`),
|
/// split. Starter splits are seeded automatically on the true first run
|
||||||
/// so an empty library just invites creating one.
|
/// (`SyncEngine.autoSeedIfEmpty`), so an empty library just invites creating one.
|
||||||
struct SplitListView: View {
|
struct SplitListView: View {
|
||||||
@Environment(SyncEngine.self) private var sync
|
@Environment(SyncEngine.self) private var sync
|
||||||
|
|
||||||
@Query(sort: \Split.order) private var splits: [Split]
|
@Query(sort: \Split.name) private var splits: [Split]
|
||||||
|
|
||||||
@State private var showingAddSheet = false
|
@State private var showingAddSheet = false
|
||||||
@State private var splitToDelete: Split?
|
@State private var splitToDelete: Split?
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
List {
|
||||||
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 16) {
|
ForEach(splits) { split in
|
||||||
ForEach(splits) { split in
|
NavigationLink {
|
||||||
NavigationLink {
|
SplitDetailView(split: split)
|
||||||
SplitDetailView(split: split)
|
} label: {
|
||||||
|
SplitRow(split: split)
|
||||||
|
}
|
||||||
|
.contextMenu {
|
||||||
|
Button(role: .destructive) {
|
||||||
|
splitToDelete = split
|
||||||
} label: {
|
} label: {
|
||||||
SplitItem(split: split)
|
Label("Delete", systemImage: "trash")
|
||||||
}
|
|
||||||
.contextMenu {
|
|
||||||
Button(role: .destructive) {
|
|
||||||
splitToDelete = split
|
|
||||||
} label: {
|
|
||||||
Label("Delete", systemImage: "trash")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.padding()
|
|
||||||
}
|
}
|
||||||
|
.listStyle(.insetGrouped)
|
||||||
.overlay {
|
.overlay {
|
||||||
if splits.isEmpty {
|
if splits.isEmpty {
|
||||||
ContentUnavailableView(
|
ContentUnavailableView(
|
||||||
@@ -87,3 +85,34 @@ struct SplitListView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A single split row: a small rounded-square badge (the split's color at low
|
||||||
|
/// opacity, its symbol tinted full-strength) beside the split name and its
|
||||||
|
/// exercise count.
|
||||||
|
private struct SplitRow: View {
|
||||||
|
var split: Split
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Image(systemName: split.systemImage)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(splitColor)
|
||||||
|
.frame(width: 36, height: 36)
|
||||||
|
.background(splitColor.opacity(0.12), in: RoundedRectangle(cornerRadius: 8))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(split.name)
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
|
||||||
|
Text("\(split.exercisesArray.count) exercises")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var splitColor: Color {
|
||||||
|
Color.color(from: split.color)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -461,7 +461,6 @@ private struct WorkoutLogRow: View {
|
|||||||
/// "3 × 12" (or "3 × 5 min" for timed exercises) with the numbers in full
|
/// "3 × 12" (or "3 × 5 min" for timed exercises) with the numbers in full
|
||||||
/// strength and the × dimmed, so the counts read at a glance.
|
/// strength and the × dimmed, so the counts read at a glance.
|
||||||
private var setsAndReps: Text {
|
private var setsAndReps: Text {
|
||||||
let times = Text(" × ").foregroundStyle(.secondary)
|
|
||||||
let count: String
|
let count: String
|
||||||
if loadType == .duration {
|
if loadType == .duration {
|
||||||
let mins = log.durationSeconds / 60
|
let mins = log.durationSeconds / 60
|
||||||
@@ -476,7 +475,7 @@ private struct WorkoutLogRow: View {
|
|||||||
} else {
|
} else {
|
||||||
count = "\(log.reps)"
|
count = "\(log.reps)"
|
||||||
}
|
}
|
||||||
return Text("\(log.sets)") + times + Text(count)
|
return Text("\(log.sets)\(Text(" × ").foregroundStyle(.secondary))\(count)")
|
||||||
}
|
}
|
||||||
|
|
||||||
private var showsWeight: Bool { loadType == .weight }
|
private var showsWeight: Bool { loadType == .weight }
|
||||||
|
|||||||
@@ -61,6 +61,11 @@ struct WorkoutLogsView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Workout Logs")
|
.navigationTitle("Workout Logs")
|
||||||
|
// Write-queue banner tiers (calm "pending" capsule / prominent fault).
|
||||||
|
// On the root screen so a stalled save is visible where editing happens.
|
||||||
|
.safeAreaInset(edge: .bottom) {
|
||||||
|
SyncStatusBanner()
|
||||||
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Button {
|
Button {
|
||||||
@@ -152,24 +157,70 @@ struct SplitPickerSheet: View {
|
|||||||
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
workouts.filter { $0.status == .inProgress || $0.status == .notStarted }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The splits most recently trained, newest first, each with the start date
|
||||||
|
/// of its latest workout — derived from the workout log (`workouts` is
|
||||||
|
/// already sorted by start descending). Each workout's `splitID` follows the
|
||||||
|
/// seed clone-on-edit redirect so a workout started from a since-forked seed
|
||||||
|
/// still credits the live clone. Deduped, capped, and limited to splits that
|
||||||
|
/// still exist.
|
||||||
|
private var recentSplits: [(split: Split, lastStart: Date)] {
|
||||||
|
var seen = Set<String>()
|
||||||
|
var recents: [(split: Split, lastStart: Date)] = []
|
||||||
|
for workout in workouts {
|
||||||
|
guard let splitID = workout.splitID else { continue }
|
||||||
|
let liveID = sync.currentSplitID(for: splitID)
|
||||||
|
guard !seen.contains(liveID) else { continue }
|
||||||
|
seen.insert(liveID)
|
||||||
|
if let split = splits.first(where: { $0.id == liveID }) {
|
||||||
|
recents.append((split, workout.start))
|
||||||
|
if recents.count == 3 { break }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return recents
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One selectable split row, shared by the Recent and All Splits sections.
|
||||||
|
/// Recent rows pass `lastTrained` to show how long ago the split was run.
|
||||||
|
private func splitRow(_ split: Split, lastTrained: Date? = nil) -> some View {
|
||||||
|
Button {
|
||||||
|
confirmAndStart(with: split)
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Image(systemName: split.systemImage)
|
||||||
|
.foregroundColor(Color.color(from: split.color))
|
||||||
|
.frame(width: 28)
|
||||||
|
VStack(alignment: .leading, spacing: 2) {
|
||||||
|
Text(split.name)
|
||||||
|
if let lastTrained {
|
||||||
|
Text(lastTrained.daysAgoLabel())
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
Text("\(split.exercisesArray.count) exercises")
|
||||||
|
.font(.caption)
|
||||||
|
.foregroundColor(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
List {
|
List {
|
||||||
ForEach(splits) { split in
|
if !recentSplits.isEmpty {
|
||||||
Button {
|
Section("Recent") {
|
||||||
confirmAndStart(with: split)
|
ForEach(recentSplits, id: \.split.id) { recent in
|
||||||
} label: {
|
splitRow(recent.split, lastTrained: recent.lastStart)
|
||||||
HStack {
|
|
||||||
Image(systemName: split.systemImage)
|
|
||||||
.foregroundColor(Color.color(from: split.color))
|
|
||||||
Text(split.name)
|
|
||||||
Spacer()
|
|
||||||
Text("\(split.exercisesArray.count) exercises")
|
|
||||||
.font(.caption)
|
|
||||||
.foregroundColor(.secondary)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Section(recentSplits.isEmpty ? "" : "All Splits") {
|
||||||
|
ForEach(splits) { split in
|
||||||
|
splitRow(split)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.navigationTitle("Select a Split")
|
.navigationTitle("Select a Split")
|
||||||
.toolbar {
|
.toolbar {
|
||||||
|
|||||||
@@ -31,6 +31,11 @@ struct WorkoutsApp: App {
|
|||||||
// the app is backgrounded, so re-assert it each time the scene becomes active.
|
// the app is backgrounded, so re-assert it each time the scene becomes active.
|
||||||
.onChange(of: scenePhase, initial: true) { _, phase in
|
.onChange(of: scenePhase, initial: true) { _, phase in
|
||||||
UIApplication.shared.isIdleTimerDisabled = (phase == .active)
|
UIApplication.shared.isIdleTimerDisabled = (phase == .active)
|
||||||
|
// Last chance before suspension: push any queued document writes out
|
||||||
|
// now rather than waiting out a retry backoff we may not live to see.
|
||||||
|
if phase == .background {
|
||||||
|
Task { await services.syncEngine.flushPendingWrites() }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,474 @@
|
|||||||
|
import Foundation
|
||||||
|
import SwiftData
|
||||||
|
import Testing
|
||||||
|
@testable import Workouts
|
||||||
|
|
||||||
|
/// Pins the pure decision table of `WriteBacklog` — the depth-1 per-document write
|
||||||
|
/// queue `SyncEngine` drains to disk. Every operation here is a `struct`-level
|
||||||
|
/// mutation with no I/O and no clock of its own, so replacement, retry bookkeeping,
|
||||||
|
/// backoff, and TTL pruning are all deterministic and directly testable.
|
||||||
|
/// `SyncEngineWriteQueueTests` below covers how the engine wires this into the cache
|
||||||
|
/// and the `save`/`delete`/`ingestFromWatch` call sites.
|
||||||
|
struct WriteBacklogTests {
|
||||||
|
|
||||||
|
private static let t0 = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
|
||||||
|
/// Mid-month, noon dates in the current time zone — matches
|
||||||
|
/// `WorkoutPathBucketingTests`'s pattern so a derived `relativePath` month bucket
|
||||||
|
/// can never be nudged across a boundary by a real-world UTC offset.
|
||||||
|
private func date(year: Int, month: Int, day: Int = 15, hour: Int = 12) -> Date {
|
||||||
|
var comps = DateComponents()
|
||||||
|
comps.year = year; comps.month = month; comps.day = day; comps.hour = hour
|
||||||
|
return Calendar(identifier: .gregorian).date(from: comps)!
|
||||||
|
}
|
||||||
|
|
||||||
|
private func workoutDoc(id: String, start: Date, updatedAt: Date, splitName: String? = nil) -> WorkoutDocument {
|
||||||
|
WorkoutDocument(
|
||||||
|
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, splitID: nil,
|
||||||
|
splitName: splitName, start: start, end: nil,
|
||||||
|
status: WorkoutStatus.notStarted.rawValue, createdAt: updatedAt, updatedAt: updatedAt,
|
||||||
|
logs: [], metrics: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds a `PendingWrite` with a workout payload and full control over every
|
||||||
|
/// bookkeeping field, so replacement/retry/backoff tests can set up exact
|
||||||
|
/// preconditions directly instead of driving them indirectly through `SyncEngine`.
|
||||||
|
private func pendingWorkout(
|
||||||
|
id: String, start: Date, timestamp: Date, enqueuedAt: Date, splitName: String? = nil,
|
||||||
|
attempts: Int = 0, lastAttemptAt: Date? = nil, faultMessage: String? = nil,
|
||||||
|
stalePaths: Set<String> = []
|
||||||
|
) -> PendingWrite {
|
||||||
|
PendingWrite(
|
||||||
|
payload: .workout(workoutDoc(id: id, start: start, updatedAt: timestamp, splitName: splitName)),
|
||||||
|
timestamp: timestamp, stalePaths: stalePaths, enqueuedAt: enqueuedAt,
|
||||||
|
attempts: attempts, lastAttemptAt: lastAttemptAt, faultMessage: faultMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - enqueue
|
||||||
|
|
||||||
|
/// Enqueuing into an empty slot stores the write verbatim, and `count`/`isEmpty`
|
||||||
|
/// reflect the newly-occupied slot — the base case every replacement test below
|
||||||
|
/// builds on.
|
||||||
|
@Test func enqueueIntoEmptySlotStoresWrite() {
|
||||||
|
var backlog = WriteBacklog()
|
||||||
|
#expect(backlog.isEmpty)
|
||||||
|
#expect(backlog.count == 0)
|
||||||
|
|
||||||
|
let write = pendingWorkout(id: "01ENQUEUEEMPTY0000000001", start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0)
|
||||||
|
backlog.enqueue(write)
|
||||||
|
|
||||||
|
#expect(!backlog.isEmpty)
|
||||||
|
#expect(backlog.count == 1)
|
||||||
|
#expect(backlog.pendingWrite(for: write.documentID)?.timestamp == Self.t0)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Newer-wins replacement: the incoming document's content takes the slot, but it
|
||||||
|
/// inherits the FIRST entry's retry bookkeeping (`enqueuedAt`, `attempts`,
|
||||||
|
/// `lastAttemptAt`, `faultMessage`) — resetting those on every edit would flap the
|
||||||
|
/// retry banner even though the backlog is failing for environmental reasons, not
|
||||||
|
/// because of what's in the slot. Stale paths union both sides' preexisting sets,
|
||||||
|
/// plus the occupant's now-abandoned target path (a month-rebucketing edit moves a
|
||||||
|
/// workout's file; an earlier attempt may already have landed at the old path).
|
||||||
|
@Test func newerWinsReplacementInheritsBookkeepingAndUnionsStalePaths() throws {
|
||||||
|
var backlog = WriteBacklog()
|
||||||
|
let id = "01NEWERWINS0000000000001"
|
||||||
|
let marchStart = date(year: 2024, month: 3)
|
||||||
|
let aprilStart = date(year: 2024, month: 4)
|
||||||
|
let oldTargetPath = "Workouts/2024/03/\(id).json"
|
||||||
|
|
||||||
|
let existing = pendingWorkout(
|
||||||
|
id: id, start: marchStart, timestamp: Self.t0,
|
||||||
|
enqueuedAt: Self.t0.addingTimeInterval(-100),
|
||||||
|
attempts: 2, lastAttemptAt: Self.t0.addingTimeInterval(-10), faultMessage: "disk full",
|
||||||
|
stalePaths: ["Workouts/2024/02/\(id).json"]
|
||||||
|
)
|
||||||
|
#expect(existing.targetPath == oldTargetPath)
|
||||||
|
backlog.enqueue(existing)
|
||||||
|
|
||||||
|
let incoming = pendingWorkout(
|
||||||
|
id: id, start: aprilStart, timestamp: Self.t0.addingTimeInterval(10),
|
||||||
|
enqueuedAt: Self.t0, // would be its own enqueuedAt if it were the first — must be overridden
|
||||||
|
stalePaths: ["Workouts/2024/05/\(id).json"]
|
||||||
|
)
|
||||||
|
backlog.enqueue(incoming)
|
||||||
|
|
||||||
|
let slot = try #require(backlog.pendingWrite(for: id))
|
||||||
|
#expect(slot.timestamp == incoming.timestamp)
|
||||||
|
guard case .workout(let doc) = slot.payload else {
|
||||||
|
Issue.record("expected a workout payload"); return
|
||||||
|
}
|
||||||
|
#expect(doc.start == aprilStart) // the newer document content wins the slot
|
||||||
|
|
||||||
|
#expect(slot.enqueuedAt == existing.enqueuedAt)
|
||||||
|
#expect(slot.attempts == existing.attempts)
|
||||||
|
#expect(slot.lastAttemptAt == existing.lastAttemptAt)
|
||||||
|
#expect(slot.faultMessage == existing.faultMessage)
|
||||||
|
|
||||||
|
#expect(slot.stalePaths.isSuperset(of: existing.stalePaths))
|
||||||
|
#expect(slot.stalePaths.isSuperset(of: incoming.stalePaths))
|
||||||
|
#expect(slot.stalePaths.contains(oldTargetPath))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An incoming write at exactly the occupant's timestamp still wins the slot —
|
||||||
|
/// equal timestamps are treated as the latest local re-save of the same logical
|
||||||
|
/// state, so last-in is correct.
|
||||||
|
@Test func equalTimestampIncomingWinsSlot() throws {
|
||||||
|
var backlog = WriteBacklog()
|
||||||
|
let id = "01EQUALTIMESTAMP000000001"
|
||||||
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, splitName: "Original"))
|
||||||
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, splitName: "Incoming"))
|
||||||
|
|
||||||
|
let slot = try #require(backlog.pendingWrite(for: id))
|
||||||
|
guard case .workout(let doc) = slot.payload else {
|
||||||
|
Issue.record("expected a workout payload"); return
|
||||||
|
}
|
||||||
|
#expect(doc.splitName == "Incoming")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An incoming write strictly older than the occupant is dropped outright — the
|
||||||
|
/// slot (and its bookkeeping) is left completely unchanged.
|
||||||
|
@Test func strictlyOlderIncomingIsDropped() throws {
|
||||||
|
var backlog = WriteBacklog()
|
||||||
|
let id = "01OLDERDROPPED0000000001"
|
||||||
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0, splitName: "Kept"))
|
||||||
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0.addingTimeInterval(-5), enqueuedAt: Self.t0, splitName: "Ignored"))
|
||||||
|
|
||||||
|
let slot = try #require(backlog.pendingWrite(for: id))
|
||||||
|
guard case .workout(let doc) = slot.payload else {
|
||||||
|
Issue.record("expected a workout payload"); return
|
||||||
|
}
|
||||||
|
#expect(doc.splitName == "Kept")
|
||||||
|
#expect(slot.timestamp == Self.t0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - resolve
|
||||||
|
|
||||||
|
/// `resolve(id:ifTimestampAtMost:)` is the replacement-safety property the drain
|
||||||
|
/// relies on: it removes the slot when the just-written version is still current
|
||||||
|
/// (`entry.timestamp <= given`), but a write that slotted in WHILE the attempt was
|
||||||
|
/// in flight (`entry.timestamp > given`) survives — the drain only just wrote the
|
||||||
|
/// OLD content, so the newer queued edit must not be discarded as if it had landed.
|
||||||
|
@Test func resolveRemovesOnlyWhenNoNewerWriteSlottedIn() throws {
|
||||||
|
var backlog = WriteBacklog()
|
||||||
|
let id = "01RESOLVESAFETY0000000001"
|
||||||
|
let firstAttemptTimestamp = Self.t0
|
||||||
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: firstAttemptTimestamp, enqueuedAt: Self.t0))
|
||||||
|
|
||||||
|
let newerTimestamp = Self.t0.addingTimeInterval(30)
|
||||||
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: newerTimestamp, enqueuedAt: Self.t0))
|
||||||
|
|
||||||
|
backlog.resolve(id: id, ifTimestampAtMost: firstAttemptTimestamp)
|
||||||
|
let survivor = try #require(backlog.pendingWrite(for: id))
|
||||||
|
#expect(survivor.timestamp == newerTimestamp)
|
||||||
|
|
||||||
|
backlog.resolve(id: id, ifTimestampAtMost: newerTimestamp)
|
||||||
|
#expect(backlog.pendingWrite(for: id) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - markFailed
|
||||||
|
|
||||||
|
/// `markFailed` increments attempts and stamps `lastAttemptAt` on every call, only
|
||||||
|
/// overwrites `faultMessage` when a new one is supplied (an unrecoverable error
|
||||||
|
/// shouldn't be forgotten by a subsequent plain retry failure), `maxAttempts` is
|
||||||
|
/// the max across every slot, and `firstFaultMessage` walks slots oldest-`enqueuedAt`
|
||||||
|
/// first — so once the OLDEST slot also faults, its message takes priority even
|
||||||
|
/// though a newer slot faulted first in wall-clock time.
|
||||||
|
@Test func markFailedTracksAttemptsFaultPriorityAndMax() throws {
|
||||||
|
var backlog = WriteBacklog()
|
||||||
|
let older = "01MARKFAILEDOLDER00000001" // enqueued first
|
||||||
|
let newer = "01MARKFAILEDNEWER00000001" // enqueued second
|
||||||
|
backlog.enqueue(pendingWorkout(id: older, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0))
|
||||||
|
backlog.enqueue(pendingWorkout(id: newer, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0.addingTimeInterval(5)))
|
||||||
|
#expect(backlog.maxAttempts == 0)
|
||||||
|
#expect(backlog.firstFaultMessage == nil)
|
||||||
|
|
||||||
|
let failAt1 = Self.t0.addingTimeInterval(1)
|
||||||
|
backlog.markFailed(id: older, at: failAt1)
|
||||||
|
var entry = try #require(backlog.pendingWrite(for: older))
|
||||||
|
#expect(entry.attempts == 1)
|
||||||
|
#expect(entry.lastAttemptAt == failAt1)
|
||||||
|
#expect(entry.faultMessage == nil)
|
||||||
|
|
||||||
|
// `newer` is the only faulting slot so far — it surfaces even though `older`
|
||||||
|
// was enqueued earlier and hasn't faulted.
|
||||||
|
backlog.markFailed(id: newer, at: Self.t0.addingTimeInterval(2), fault: "disk full")
|
||||||
|
#expect(backlog.firstFaultMessage == "disk full")
|
||||||
|
|
||||||
|
// Once `older` (the earlier-enqueued slot) also faults, its message takes
|
||||||
|
// priority — `firstFaultMessage` walks entries oldest-first.
|
||||||
|
backlog.markFailed(id: older, at: Self.t0.addingTimeInterval(3), fault: "older fault")
|
||||||
|
#expect(backlog.firstFaultMessage == "older fault")
|
||||||
|
|
||||||
|
// A later failure with no new fault keeps the existing message rather than
|
||||||
|
// clearing it.
|
||||||
|
backlog.markFailed(id: older, at: Self.t0.addingTimeInterval(4))
|
||||||
|
entry = try #require(backlog.pendingWrite(for: older))
|
||||||
|
#expect(entry.attempts == 3)
|
||||||
|
#expect(entry.faultMessage == "older fault")
|
||||||
|
|
||||||
|
#expect(backlog.maxAttempts == 3) // older: 3 attempts, newer: 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Backoff scheduling
|
||||||
|
|
||||||
|
/// `nextDue`/`earliestNextAttempt` are the drain loop's scheduling surface: a
|
||||||
|
/// fresh entry (never attempted) is due immediately at its `enqueuedAt`, and each
|
||||||
|
/// failure re-anchors the backoff at `retryDelay(afterAttempts:)` from the FAILURE
|
||||||
|
/// time — not cumulative, not from `enqueuedAt`.
|
||||||
|
@Test func nextDueAndEarliestNextAttemptHonorBackoff() throws {
|
||||||
|
var backlog = WriteBacklog()
|
||||||
|
let id = "01BACKOFFDUE0000000000001"
|
||||||
|
let enqueuedAt = Self.t0
|
||||||
|
backlog.enqueue(pendingWorkout(id: id, start: Self.t0, timestamp: Self.t0, enqueuedAt: enqueuedAt))
|
||||||
|
// A second, never-failing slot enqueued much later — present throughout so
|
||||||
|
// `earliestNextAttempt` is genuinely picking a minimum across slots, not just
|
||||||
|
// echoing the only one that exists.
|
||||||
|
let freshID = "01BACKOFFFRESH00000000001"
|
||||||
|
backlog.enqueue(pendingWorkout(id: freshID, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0.addingTimeInterval(10_000)))
|
||||||
|
|
||||||
|
var entry = try #require(backlog.pendingWrite(for: id))
|
||||||
|
#expect(entry.nextAttemptAt == enqueuedAt)
|
||||||
|
#expect(backlog.nextDue(at: enqueuedAt.addingTimeInterval(-1)) == nil)
|
||||||
|
#expect(backlog.nextDue(at: enqueuedAt)?.documentID == id)
|
||||||
|
#expect(backlog.earliestNextAttempt() == enqueuedAt)
|
||||||
|
|
||||||
|
let firstFailure = Self.t0.addingTimeInterval(100)
|
||||||
|
backlog.markFailed(id: id, at: firstFailure)
|
||||||
|
entry = try #require(backlog.pendingWrite(for: id))
|
||||||
|
let expectedFirstRetry = firstFailure.addingTimeInterval(WriteBacklog.retryDelay(afterAttempts: 1))
|
||||||
|
#expect(entry.nextAttemptAt == expectedFirstRetry)
|
||||||
|
#expect(backlog.nextDue(at: expectedFirstRetry.addingTimeInterval(-0.5)) == nil)
|
||||||
|
#expect(backlog.nextDue(at: expectedFirstRetry)?.documentID == id)
|
||||||
|
|
||||||
|
let secondFailure = expectedFirstRetry.addingTimeInterval(50)
|
||||||
|
backlog.markFailed(id: id, at: secondFailure)
|
||||||
|
entry = try #require(backlog.pendingWrite(for: id))
|
||||||
|
let expectedSecondRetry = secondFailure.addingTimeInterval(WriteBacklog.retryDelay(afterAttempts: 2))
|
||||||
|
#expect(entry.nextAttemptAt == expectedSecondRetry)
|
||||||
|
#expect(backlog.earliestNextAttempt() == expectedSecondRetry)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Locks the exact backoff table: three quick silent retries (2s/4s/8s), then calm
|
||||||
|
/// long retries, capped at 5 minutes from attempt 7 onward.
|
||||||
|
@Test func retryDelayScheduleMatchesBackoffTable() {
|
||||||
|
#expect(WriteBacklog.retryDelay(afterAttempts: 0) == 0)
|
||||||
|
#expect(WriteBacklog.retryDelay(afterAttempts: 1) == 2)
|
||||||
|
#expect(WriteBacklog.retryDelay(afterAttempts: 2) == 4)
|
||||||
|
#expect(WriteBacklog.retryDelay(afterAttempts: 3) == 8)
|
||||||
|
#expect(WriteBacklog.retryDelay(afterAttempts: 4) == 30)
|
||||||
|
#expect(WriteBacklog.retryDelay(afterAttempts: 5) == 60)
|
||||||
|
#expect(WriteBacklog.retryDelay(afterAttempts: 6) == 120)
|
||||||
|
#expect(WriteBacklog.retryDelay(afterAttempts: 7) == 300)
|
||||||
|
#expect(WriteBacklog.retryDelay(afterAttempts: 50) == 300) // capped, not just "7"
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - pruneExpired
|
||||||
|
|
||||||
|
/// A persisted backlog loaded on relaunch drops any slot stuck longer than the
|
||||||
|
/// TTL — a run that died with a queued write shouldn't drain stale state into a
|
||||||
|
/// container that's long since moved on — while a younger slot survives untouched.
|
||||||
|
@Test func pruneExpiredDropsOnlyEntriesPastTheTTL() throws {
|
||||||
|
var backlog = WriteBacklog()
|
||||||
|
let now = Self.t0
|
||||||
|
let ttl: TimeInterval = 60
|
||||||
|
let youngID = "01PRUNEYOUNG0000000000001"
|
||||||
|
let oldID = "01PRUNEOLD0000000000000001"
|
||||||
|
backlog.enqueue(pendingWorkout(id: youngID, start: Self.t0, timestamp: Self.t0, enqueuedAt: now.addingTimeInterval(-30)))
|
||||||
|
backlog.enqueue(pendingWorkout(id: oldID, start: Self.t0, timestamp: Self.t0, enqueuedAt: now.addingTimeInterval(-90)))
|
||||||
|
|
||||||
|
backlog.pruneExpired(now: now, ttl: ttl)
|
||||||
|
|
||||||
|
#expect(backlog.pendingWrite(for: youngID) != nil)
|
||||||
|
#expect(backlog.pendingWrite(for: oldID) == nil)
|
||||||
|
#expect(backlog.count == 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WriteBacklogFile
|
||||||
|
|
||||||
|
/// The sidecar file round-trips a non-empty backlog's slots (ids and timestamps
|
||||||
|
/// intact), and saving an EMPTY backlog removes any existing file instead of
|
||||||
|
/// writing `{}` — the sidecar's absence IS "no pending writes," matching what a
|
||||||
|
/// fresh install/relaunch with nothing queued looks like on disk.
|
||||||
|
@Test func fileRoundTripPreservesSlotsAndEmptySaveRemovesFile() throws {
|
||||||
|
let url = FileManager.default.temporaryDirectory.appending(path: UUID().uuidString + ".json")
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
var backlog = WriteBacklog()
|
||||||
|
let idA = "01FILEROUNDTRIPA00000001"
|
||||||
|
let idB = "01FILEROUNDTRIPB00000001"
|
||||||
|
backlog.enqueue(pendingWorkout(id: idA, start: Self.t0, timestamp: Self.t0, enqueuedAt: Self.t0))
|
||||||
|
backlog.enqueue(pendingWorkout(id: idB, start: Self.t0, timestamp: Self.t0.addingTimeInterval(5), enqueuedAt: Self.t0.addingTimeInterval(5)))
|
||||||
|
|
||||||
|
WriteBacklogFile.save(backlog, to: url)
|
||||||
|
#expect(FileManager.default.fileExists(atPath: url.path))
|
||||||
|
|
||||||
|
let loaded = WriteBacklogFile.load(from: url)
|
||||||
|
#expect(loaded.count == 2)
|
||||||
|
#expect(loaded.pendingWrite(for: idA)?.timestamp == Self.t0)
|
||||||
|
#expect(loaded.pendingWrite(for: idA)?.documentID == idA)
|
||||||
|
#expect(loaded.pendingWrite(for: idB)?.timestamp == Self.t0.addingTimeInterval(5))
|
||||||
|
|
||||||
|
// Saving an EMPTY backlog over an existing file removes it rather than
|
||||||
|
// writing `{}`.
|
||||||
|
WriteBacklogFile.save(WriteBacklog(), to: url)
|
||||||
|
#expect(!FileManager.default.fileExists(atPath: url.path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - SyncEngine integration
|
||||||
|
|
||||||
|
/// Coverage for `SyncEngine`'s write-queue integration: every `save`/`delete` mirrors
|
||||||
|
/// into the SwiftData cache immediately and enqueues the file write (see
|
||||||
|
/// `WriteBacklogTests` above for the queue's own pure mechanics). None of these tests
|
||||||
|
/// call `connect()` — matching `SyncEngineTests`'s scope note, the engine's `store`
|
||||||
|
/// stays nil, so `kickDrain()` never launches a background drain that could race these
|
||||||
|
/// assertions, and no real iCloud container is ever touched. Each test builds its own
|
||||||
|
/// `SyncEngine` against a fresh in-memory `ModelContainer` and a unique temp
|
||||||
|
/// `backlogURL`, so tests can't contaminate each other or the developer's real
|
||||||
|
/// Application Support directory.
|
||||||
|
struct SyncEngineWriteQueueTests {
|
||||||
|
|
||||||
|
private static let t0 = Date(timeIntervalSince1970: 1_700_000_000)
|
||||||
|
|
||||||
|
private func tempBacklogURL() -> URL {
|
||||||
|
FileManager.default.temporaryDirectory.appending(path: "WriteBacklogTests-\(UUID().uuidString).json")
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private func makeEngine(backlogURL: URL) throws -> (engine: SyncEngine, container: ModelContainer) {
|
||||||
|
let schema = Schema([Split.self, Exercise.self, Workout.self, WorkoutLog.self])
|
||||||
|
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true, cloudKitDatabase: .none)
|
||||||
|
let container = try ModelContainer(for: schema, configurations: [config])
|
||||||
|
return (SyncEngine(container: container, backlogURL: backlogURL), container)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeWorkoutDoc(id: String, updatedAt: Date, splitName: String? = "Push Day") -> WorkoutDocument {
|
||||||
|
WorkoutDocument(
|
||||||
|
schemaVersion: WorkoutDocument.currentSchemaVersion, id: id, splitID: "SPLIT-1",
|
||||||
|
splitName: splitName, start: Self.t0, end: nil,
|
||||||
|
status: WorkoutStatus.notStarted.rawValue, createdAt: Self.t0, updatedAt: updatedAt,
|
||||||
|
logs: [], metrics: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - save(workout:)
|
||||||
|
|
||||||
|
/// A local save mirrors into the cache immediately (a same-process write doesn't
|
||||||
|
/// reliably wake the metadata observer, and never does in the simulator) and
|
||||||
|
/// queues exactly one write.
|
||||||
|
@MainActor
|
||||||
|
@Test func saveWorkoutMirrorsToCacheAndQueuesWrite() async throws {
|
||||||
|
let (engine, container) = try makeEngine(backlogURL: tempBacklogURL())
|
||||||
|
let id = "01SAVEQUEUE0000000000001"
|
||||||
|
await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0))
|
||||||
|
|
||||||
|
let cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
||||||
|
#expect(cached.updatedAt == Self.t0)
|
||||||
|
#expect(engine.pendingWriteCount == 1)
|
||||||
|
#expect(engine.writeQueueState == .pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Depth-1 per-document slot: saving the same id twice keeps exactly one queued
|
||||||
|
/// write, and the cache — which every save mirrors into unconditionally — shows
|
||||||
|
/// the LATEST version, matching what the eventually-drained file will contain.
|
||||||
|
@MainActor
|
||||||
|
@Test func repeatedSaveSameIDStaysDepthOneWithNewestCached() async throws {
|
||||||
|
let (engine, container) = try makeEngine(backlogURL: tempBacklogURL())
|
||||||
|
let id = "01DEPTHONE00000000000001"
|
||||||
|
await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0))
|
||||||
|
await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(60)))
|
||||||
|
|
||||||
|
#expect(engine.pendingWriteCount == 1)
|
||||||
|
let cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
||||||
|
#expect(cached.updatedAt == Self.t0.addingTimeInterval(60))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - ingestFromWatch
|
||||||
|
|
||||||
|
/// The watch bridge's `updatedAt` intake gate: a strictly-older push is dropped
|
||||||
|
/// (the cache is already ahead of the watch), an equal timestamp is the
|
||||||
|
/// echo/duplicate case and is ignored wholesale even when some other field
|
||||||
|
/// differs, and a strictly-newer push is accepted. `sendMessage` plus a queued
|
||||||
|
/// `transferUserInfo` fallback are unordered, so this gate is what keeps a stale
|
||||||
|
/// racing push from clobbering a newer local save.
|
||||||
|
@MainActor
|
||||||
|
@Test func ingestFromWatchAppliesUpdatedAtGate() async throws {
|
||||||
|
let (engine, container) = try makeEngine(backlogURL: tempBacklogURL())
|
||||||
|
let id = "01INGESTGATE000000000001"
|
||||||
|
await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0, splitName: "Push Day"))
|
||||||
|
|
||||||
|
// (a) strictly older — dropped, cache unchanged.
|
||||||
|
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(-60), splitName: "Push Day"))
|
||||||
|
var cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
||||||
|
#expect(cached.updatedAt == Self.t0)
|
||||||
|
|
||||||
|
// (b) same updatedAt but a changed field — the echo case, ignored entirely
|
||||||
|
// (equal timestamp means duplicate, not a genuine edit).
|
||||||
|
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0, splitName: "Changed Name"))
|
||||||
|
cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
||||||
|
#expect(cached.updatedAt == Self.t0)
|
||||||
|
#expect(cached.splitName == "Push Day")
|
||||||
|
|
||||||
|
// (c) strictly newer — accepted.
|
||||||
|
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(60), splitName: "Changed Name"))
|
||||||
|
cached = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
||||||
|
#expect(cached.updatedAt == Self.t0.addingTimeInterval(60))
|
||||||
|
#expect(cached.splitName == "Changed Name")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A queued-but-unwritten delete vetoes resurrection: a watch push racing the
|
||||||
|
/// delete — even carrying an `updatedAt` newer than the deleted version — must
|
||||||
|
/// not bring the entity back, because the delete's stub simply hasn't landed on
|
||||||
|
/// disk yet (the same veto an already-written tombstone would apply).
|
||||||
|
@MainActor
|
||||||
|
@Test func pendingDeleteVetoesWatchIngestResurrection() async throws {
|
||||||
|
let (engine, container) = try makeEngine(backlogURL: tempBacklogURL())
|
||||||
|
let id = "01DELETEVETO0000000000001"
|
||||||
|
await engine.save(workout: makeWorkoutDoc(id: id, updatedAt: Self.t0))
|
||||||
|
let entity = try #require(CacheMapper.fetchWorkout(id: id, in: container.mainContext))
|
||||||
|
await engine.delete(workout: entity)
|
||||||
|
|
||||||
|
await engine.ingestFromWatch(makeWorkoutDoc(id: id, updatedAt: Self.t0.addingTimeInterval(120)))
|
||||||
|
|
||||||
|
#expect(CacheMapper.fetchWorkout(id: id, in: container.mainContext) == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Restore lifecycle
|
||||||
|
|
||||||
|
/// `beginRestore()` clears the backlog outright — queued writes reference
|
||||||
|
/// pre-restore state, and draining them into the just-restored tree would
|
||||||
|
/// reintroduce edits the user chose to roll back by restoring a backup.
|
||||||
|
@MainActor
|
||||||
|
@Test func beginRestoreClearsBacklogAndResetsQueueState() async throws {
|
||||||
|
let (engine, _) = try makeEngine(backlogURL: tempBacklogURL())
|
||||||
|
await engine.save(workout: makeWorkoutDoc(id: "01RESTORECLEAR00000001", updatedAt: Self.t0))
|
||||||
|
#expect(engine.pendingWriteCount == 1)
|
||||||
|
|
||||||
|
engine.beginRestore()
|
||||||
|
#expect(engine.pendingWriteCount == 0)
|
||||||
|
#expect(engine.writeQueueState == .idle)
|
||||||
|
|
||||||
|
engine.endRestore() // balance the begin/end pair, matching every production call site
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Persistence
|
||||||
|
|
||||||
|
/// The backlog sidecar survives across process launches: a second engine pointed
|
||||||
|
/// at the SAME `backlogURL` picks up the first engine's still-queued write on
|
||||||
|
/// `init`, before ever connecting — this is what lets a killed app resume
|
||||||
|
/// draining on next launch instead of silently losing the edit.
|
||||||
|
@MainActor
|
||||||
|
@Test func backlogPersistsAcrossEngineInstancesSharingBacklogURL() async throws {
|
||||||
|
let url = tempBacklogURL()
|
||||||
|
defer { try? FileManager.default.removeItem(at: url) }
|
||||||
|
|
||||||
|
let (engineA, _) = try makeEngine(backlogURL: url)
|
||||||
|
await engineA.save(workout: makeWorkoutDoc(id: "01PERSISTBACKLOG000001", updatedAt: Self.t0))
|
||||||
|
#expect(engineA.pendingWriteCount == 1)
|
||||||
|
|
||||||
|
let (engineB, _) = try makeEngine(backlogURL: url)
|
||||||
|
#expect(engineB.pendingWriteCount == 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user