// // 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 routine(RoutineDocument) case workout(WorkoutDocument) case schedule(ScheduleDocument) case delete(id: String, kind: String, livePath: String) // PINNED KEY: the case was renamed `.split` → `.routine`, but its persisted // JSON key stays `"split"` so a `PendingWrites.json` sidecar written by a // previous run still decodes. `workout` / `schedule` / `delete` keep their // default keys. enum CodingKeys: String, CodingKey { case routine = "split" case workout case schedule case delete } } 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 = [] /// 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 .routine(let doc): doc.id case .workout(let doc): doc.id case .schedule(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 .routine(let doc): doc.relativePath case .workout(let doc): doc.relativePath case .schedule(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) } }