Starter splits ship as byte-canonical SplitDocument JSON with fixed ULIDs (Workouts/Resources/StarterSplits, regenerated by Scripts/generate_starter_splits.swift) and auto-seed after connect into a verifiably empty container, re-checked after a settle delay — wrong guesses are harmless because identical bytes make same-path conflicts empty and tombstones reap resurrected seeds. Seeds are immutable: SyncEngine.save(split:) forks an edited seed to a fresh ULID and soft-deletes the original, whose stub is exempt from pruning (IndieSync 0.3.0 prune(exempting:)) and vetoes resurrection forever; split views resolve by id through a redirect map to follow the swap. Add Starter Splits in Settings restores deleted seeds by lifting the veto stub and rewriting the bundle bytes. Also fixes ingestFromWatch bypassing the tombstone veto (a phone-deleted workout resurrected when a stale watch resent it) and reaps a live file immediately when its tombstone arrives. SplitDetailView also picks up the category-grouped exercise sections from the exercise-category work.
558 lines
25 KiB
Swift
558 lines
25 KiB
Swift
import Foundation
|
|
import IndieSync
|
|
import SwiftData
|
|
import Observation
|
|
import os
|
|
|
|
enum ICloudStatus: Equatable {
|
|
case checking
|
|
case available
|
|
case unavailable
|
|
}
|
|
|
|
/// Orchestrates the iCloud Drive file layer and the SwiftData cache. iCloud is
|
|
/// the sole source of truth: every save/delete writes files only; the metadata
|
|
/// observer (and the connect-time reconcile) is the sole mutator of the cache.
|
|
@Observable
|
|
@MainActor
|
|
final class SyncEngine {
|
|
nonisolated static let containerIdentifier = "iCloud.dev.rzen.indie.Workouts"
|
|
|
|
private(set) var iCloudStatus: ICloudStatus = .checking
|
|
private(set) var isSyncing = false
|
|
|
|
/// Last non-fatal sync failure, surfaced for the UI to render. Cleared on the
|
|
/// next successful write or full reconcile.
|
|
private(set) var lastSyncError: String?
|
|
|
|
/// 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
|
|
/// notifies on change); not persisted — a seed is edited at most once before it's
|
|
/// gone, so nothing needs to survive a relaunch.
|
|
private(set) var cloneRedirects: [String: String] = [:]
|
|
|
|
/// Follow the redirect chain from `id` to the current live split id. A seed
|
|
/// redirects at most once, but the loop tolerates (and breaks) any chain or cycle.
|
|
func currentSplitID(for id: String) -> String {
|
|
var current = id
|
|
var seen: Set<String> = [current]
|
|
while let next = cloneRedirects[current], seen.insert(next).inserted {
|
|
current = next
|
|
}
|
|
return current
|
|
}
|
|
|
|
/// Called after the cache changes (local or remote). The watch bridge uses
|
|
/// this to push fresh state to the watch.
|
|
var onCacheChanged: (() -> Void)?
|
|
|
|
/// Called when a completed workout is persisted. The HealthKit writer uses this to
|
|
/// schedule an estimated Health workout if nothing recorded it.
|
|
var onWorkoutCompleted: ((WorkoutDocument) -> Void)?
|
|
|
|
/// Called when a workout carrying watch-recorded metrics is persisted, so the
|
|
/// writer can cancel any pending phone estimate for it.
|
|
var onWatchMetricsArrived: ((String) -> Void)?
|
|
|
|
private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync")
|
|
private let modelContainer: ModelContainer
|
|
private var store: DocumentFileStore?
|
|
private var tombstones: TombstoneStore?
|
|
private var monitor: MetadataObserver?
|
|
private var monitorTask: Task<Void, Never>?
|
|
private var connectAttempt = 0
|
|
|
|
/// How long `connect()` keeps polling for a still-provisioning iCloud
|
|
/// container before falling to the end-of-the-line gate. Deliberately long
|
|
/// (~10 min): as long as the user is signed into iCloud, a container that's
|
|
/// slow to come online should never be misreported as unavailable. Impatient
|
|
/// users bail sooner via the connecting screen's escape hatch (28s).
|
|
private static let connectTimeoutSeconds: TimeInterval = 600
|
|
|
|
private var context: ModelContext { modelContainer.mainContext }
|
|
|
|
init(container: ModelContainer) {
|
|
self.modelContainer = container
|
|
}
|
|
|
|
// MARK: - Connection (deferred, patient)
|
|
|
|
func connect() async {
|
|
guard iCloudStatus != .available else { return }
|
|
connectAttempt += 1
|
|
let attempt = connectAttempt
|
|
iCloudStatus = .checking
|
|
log.info("connect[\(attempt)]: resolving container \(Self.containerIdentifier, privacy: .public)")
|
|
|
|
// Definitive failure first: if the user isn't signed into iCloud at all,
|
|
// no container is ever coming — go straight to the end-of-the-line gate
|
|
// rather than spinning. (Signed-in-but-Drive-off still reports a token;
|
|
// that case falls through to the patient poll below and the escape hatch.)
|
|
let signedIn = await Task.detached {
|
|
FileManager.default.ubiquityIdentityToken != nil
|
|
}.value
|
|
guard attempt == connectAttempt else { return }
|
|
guard signedIn else {
|
|
log.error("connect[\(attempt)]: not signed into iCloud → unavailable")
|
|
iCloudStatus = .unavailable
|
|
return
|
|
}
|
|
|
|
// Signed in, but the container may still be provisioning — common right
|
|
// after enabling iCloud Drive. Keep polling patiently: we'd rather hold the
|
|
// spinner than misreport a working account as unavailable. We only give up
|
|
// after a considerable timeout; the user can bail sooner via the connecting
|
|
// screen's escape hatch (which bumps connectAttempt and stops this loop).
|
|
var resolved: URL?
|
|
let deadline = Date().addingTimeInterval(Self.connectTimeoutSeconds)
|
|
while resolved == nil {
|
|
let url = await Task.detached {
|
|
FileManager.default.url(forUbiquityContainerIdentifier: Self.containerIdentifier)
|
|
}.value
|
|
guard attempt == connectAttempt else { return }
|
|
if let url {
|
|
resolved = url
|
|
break
|
|
}
|
|
if Date() >= deadline {
|
|
log.error("connect[\(attempt)]: container still nil after \(Int(Self.connectTimeoutSeconds))s → unavailable")
|
|
iCloudStatus = .unavailable
|
|
return
|
|
}
|
|
log.info("connect[\(attempt)]: container nil — still provisioning, retrying")
|
|
try? await Task.sleep(for: .seconds(2))
|
|
guard attempt == connectAttempt else { return }
|
|
}
|
|
guard let containerURL = resolved else { return }
|
|
log.info("connect[\(attempt)]: container URL = \(containerURL.path, privacy: .public)")
|
|
|
|
let store = DocumentFileStore(root: containerURL.appendingPathComponent("Documents", isDirectory: true))
|
|
|
|
// Safety net only: prepareDirectories is a local op that effectively never
|
|
// blocks, but if the first container file op ever wedges we don't want an
|
|
// eternal spinner. This is generous — it isn't the connect path's clock.
|
|
let safety = Task { [weak self] in
|
|
try? await Task.sleep(for: .seconds(30))
|
|
guard let self, !Task.isCancelled else { return }
|
|
if self.iCloudStatus == .checking, attempt == self.connectAttempt {
|
|
self.log.error("connect[\(attempt)]: prepareDirectories wedged 30s → unavailable")
|
|
self.iCloudStatus = .unavailable
|
|
}
|
|
}
|
|
log.info("connect[\(attempt)]: preparing directories…")
|
|
await store.prepareDirectories(["Splits", "Workouts", "Stubs"])
|
|
safety.cancel()
|
|
guard attempt == connectAttempt else { return }
|
|
|
|
self.store = store
|
|
self.tombstones = TombstoneStore(store: store)
|
|
iCloudStatus = .available
|
|
log.info("connect[\(attempt)]: directories ready → available")
|
|
WorkoutsModelContainer.persistCurrentIdentityToken()
|
|
|
|
await reconcile()
|
|
startMonitoring(documentsURL: store.rootURL)
|
|
cleanupOldStubs()
|
|
// Off the connect path so opening the gate isn't delayed by the settle wait.
|
|
Task { await self.autoSeedIfEmpty() }
|
|
}
|
|
|
|
/// Invoked from the connecting screen when the user chooses not to keep
|
|
/// waiting. Bumps `connectAttempt` to stop the in-flight poll loop, then drops
|
|
/// to the end-of-the-line gate (with its Try Again).
|
|
func abandonWaiting() {
|
|
guard iCloudStatus == .checking else { return }
|
|
connectAttempt += 1
|
|
iCloudStatus = .unavailable
|
|
log.info("connect: abandoned by user → unavailable")
|
|
}
|
|
|
|
// MARK: - Monitoring
|
|
|
|
private func startMonitoring(documentsURL: URL) {
|
|
monitorTask?.cancel()
|
|
let monitor = MetadataObserver(documentsURL: documentsURL)
|
|
self.monitor = monitor
|
|
monitor.start()
|
|
monitorTask = Task { [weak self] in
|
|
for await batch in monitor.events() {
|
|
await self?.handle(batch)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func handle(_ batch: [FileChangeEvent]) async {
|
|
for event in batch {
|
|
switch event {
|
|
case .added(let path), .modified(let path):
|
|
if path.hasPrefix("Stubs/") {
|
|
let id = idFromStubPath(path)
|
|
deleteCachedEntity(id: id)
|
|
// A stub can arrive before (or instead of) the deleting device's
|
|
// live-file removal; drop any live file for this id so the delete
|
|
// takes effect and reconcile can't re-import it.
|
|
await removeLiveFile(forID: id)
|
|
} else {
|
|
await importFile(relativePath: path)
|
|
}
|
|
case .removed(let path):
|
|
if !path.hasPrefix("Stubs/") {
|
|
deleteCachedEntity(jsonRelativePath: path)
|
|
}
|
|
}
|
|
}
|
|
do { try context.save() } catch { report("Cache save failed", error) }
|
|
onCacheChanged?()
|
|
}
|
|
|
|
/// Apply a workout received from the watch. iCloud Drive stays the source of
|
|
/// truth (we write the file), but we also upsert the cache directly here.
|
|
///
|
|
/// The phone's own edits drive a local view copy, so they don't need this — but a
|
|
/// watch-originated change has nothing else refreshing the phone UI, and a
|
|
/// same-process file overwrite doesn't reliably wake the `NSMetadataQuery`
|
|
/// observer. Upserting the doc we just wrote keeps cache and file consistent (the
|
|
/// observer re-applies idempotently if it does fire) and lets open phone screens
|
|
/// reflect watch progress live.
|
|
func ingestFromWatch(_ doc: WorkoutDocument) async {
|
|
// 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 —
|
|
// and re-push authoritative state (via onCacheChanged → pushAll) so the watch
|
|
// replaces its stale set and drops the deleted workout.
|
|
if let tombstones, await tombstones.stubExists(id: doc.id) {
|
|
onCacheChanged?()
|
|
return
|
|
}
|
|
await save(workout: doc)
|
|
CacheMapper.upsertWorkout(doc, relativePath: doc.relativePath, into: context)
|
|
do { try context.save() } catch { report("Cache save failed", error) }
|
|
onCacheChanged?()
|
|
}
|
|
|
|
// MARK: - Public CRUD (write path: files only)
|
|
|
|
/// 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
|
|
/// via `currentSplitID(for:)`.
|
|
@discardableResult
|
|
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
|
|
// soft-deletes the original, keeping the curated seed intact and restorable. A
|
|
// pristine (no-op) save must NOT fork — the edit sheets stamp `updatedAt`
|
|
// unconditionally, so merely opening and saving a seed's editor without a change
|
|
// would otherwise clone it.
|
|
if SeedLibrary.isSeed(id: doc.id), !SeedLibrary.isPristine(doc) {
|
|
return await cloneSeedOnEdit(doc)
|
|
}
|
|
|
|
do {
|
|
try await store.write(doc, to: doc.relativePath)
|
|
lastSyncError = nil
|
|
} catch {
|
|
report("Failed to save split", error)
|
|
}
|
|
// Cache updates reactively via the monitor.
|
|
return doc.id
|
|
}
|
|
|
|
/// Fork an edited seed into a user-owned split. Writes the clone under a fresh
|
|
/// ULID and upserts it into the cache immediately (same rationale as
|
|
/// `ingestFromWatch`: a same-process write doesn't reliably wake the metadata
|
|
/// observer, and open screens must see the clone the moment `save` returns), then
|
|
/// soft-deletes the seed and drops its cache entity. Records the id redirect so a
|
|
/// view still holding the seed's id follows the identity swap.
|
|
private func cloneSeedOnEdit(_ doc: SplitDocument) async -> String {
|
|
guard let store else { return doc.id }
|
|
var clone = doc
|
|
clone.id = ULID.make()
|
|
clone.createdAt = Date()
|
|
// Exercise ids are kept as-is — they only need uniqueness within the document.
|
|
do {
|
|
try await store.write(clone, to: clone.relativePath)
|
|
CacheMapper.upsertSplit(clone, relativePath: clone.relativePath, into: context)
|
|
// Soft-delete the seed (writes its veto stub, then removes the live file),
|
|
// then evict its now-orphaned cache entity.
|
|
await softDelete(id: doc.id, kind: "split", livePath: doc.relativePath)
|
|
deleteCachedEntity(id: doc.id)
|
|
try context.save()
|
|
cloneRedirects[doc.id] = clone.id
|
|
lastSyncError = nil
|
|
onCacheChanged?()
|
|
return clone.id
|
|
} catch {
|
|
report("Failed to fork edited starter split", error)
|
|
return doc.id
|
|
}
|
|
}
|
|
|
|
func save(workout doc: WorkoutDocument) async {
|
|
guard let store else { return }
|
|
do {
|
|
try await store.write(doc, to: doc.relativePath)
|
|
lastSyncError = nil
|
|
} catch {
|
|
report("Failed to save workout", error)
|
|
}
|
|
// Drive the HealthKit writer: a watch-recorded doc cancels any pending estimate;
|
|
// a completed doc (re)considers an estimate (the writer dedupes on metrics).
|
|
if doc.metrics?.source == .watch { onWatchMetricsArrived?(doc.id) }
|
|
if doc.status == WorkoutStatus.completed.rawValue { onWorkoutCompleted?(doc) }
|
|
}
|
|
|
|
func delete(split: Split) async {
|
|
await softDelete(id: split.id, kind: "split", livePath: split.jsonRelativePath)
|
|
}
|
|
|
|
func delete(workout: Workout) async {
|
|
await softDelete(id: workout.id, kind: "workout", livePath: workout.jsonRelativePath)
|
|
}
|
|
|
|
/// Writes a tombstone stub then removes the live file. Other devices learn of
|
|
/// the delete via the stub even if they were offline for the file removal.
|
|
private func softDelete(id: String, kind: String, livePath: String) async {
|
|
guard let store, let tombstones else { return }
|
|
do {
|
|
try await tombstones.writeTombstone(Tombstone(id: id, deletedAt: Date(), kind: kind))
|
|
try await store.remove(at: livePath)
|
|
lastSyncError = nil
|
|
} catch {
|
|
report("Failed to delete \(id)", error)
|
|
}
|
|
}
|
|
|
|
// MARK: - Import / reconcile
|
|
|
|
private func importFile(relativePath: String) async {
|
|
guard let store, let tombstones else { return }
|
|
let data: Data
|
|
do {
|
|
data = try await store.readData(from: relativePath)
|
|
} catch {
|
|
// Includes eviction-download timeouts — log loudly, never silently
|
|
// treat an unreadable file as absent. The next monitor event or
|
|
// reconcile retries it.
|
|
log.error("import: read failed for \(relativePath, privacy: .public): \(error)")
|
|
report("Failed to read \(relativePath)", error)
|
|
return
|
|
}
|
|
|
|
if relativePath.hasPrefix("Splits/") {
|
|
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 }
|
|
CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
|
|
} else if relativePath.hasPrefix("Workouts/") {
|
|
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 }
|
|
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
|
|
}
|
|
}
|
|
|
|
/// Full sync against the current file set — imports new/changed files and
|
|
/// prunes entities whose file is gone or tombstoned. Runs on connect so
|
|
/// changes accumulated while the app was closed are picked up.
|
|
private func reconcile() async {
|
|
guard let store, let tombstones else { return }
|
|
isSyncing = true
|
|
defer { isSyncing = false }
|
|
|
|
// IDs from stub filenames, not stub contents — an evicted stub must
|
|
// still count as a tombstone or the deleted record resurrects.
|
|
let tombstoned = await tombstones.listStubIDs()
|
|
let dataFiles = await store.list().filter { !$0.hasPrefix("Stubs/") }
|
|
|
|
var liveSplitIDs = Set<String>()
|
|
var liveWorkoutIDs = Set<String>()
|
|
var unreadablePaths = Set<String>()
|
|
|
|
for path in dataFiles {
|
|
let data: Data
|
|
do {
|
|
data = try await store.readData(from: path)
|
|
} catch {
|
|
// A failed read (eviction-download timeout, coordination error)
|
|
// is not proof the record is gone — remember the path so the
|
|
// prune below keeps its cache entity. Skipping silently here is
|
|
// how evicted records used to vanish on storage-constrained
|
|
// devices.
|
|
log.error("reconcile: read failed for \(path, privacy: .public): \(error)")
|
|
report("Failed to read \(path)", error)
|
|
unreadablePaths.insert(path)
|
|
continue
|
|
}
|
|
if path.hasPrefix("Splits/") {
|
|
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 }
|
|
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
|
|
liveSplitIDs.insert(doc.id)
|
|
} else if path.hasPrefix("Workouts/") {
|
|
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 }
|
|
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
|
|
liveWorkoutIDs.insert(doc.id)
|
|
}
|
|
}
|
|
|
|
// 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
|
|
// right now; pruning would make an eviction look like a deletion).
|
|
if let splits = try? context.fetch(FetchDescriptor<Split>()) {
|
|
for s in splits where !liveSplitIDs.contains(s.id) && !unreadablePaths.contains(s.jsonRelativePath) {
|
|
context.delete(s)
|
|
}
|
|
}
|
|
if let workouts = try? context.fetch(FetchDescriptor<Workout>()) {
|
|
for w in workouts where !liveWorkoutIDs.contains(w.id) && !unreadablePaths.contains(w.jsonRelativePath) {
|
|
context.delete(w)
|
|
}
|
|
}
|
|
do {
|
|
try context.save()
|
|
// A fully clean pass supersedes any earlier failure; a pass with
|
|
// unreadable files keeps its own report visible.
|
|
if unreadablePaths.isEmpty { lastSyncError = nil }
|
|
} catch {
|
|
report("Cache save failed", error)
|
|
}
|
|
onCacheChanged?()
|
|
}
|
|
|
|
// MARK: - Cache deletes
|
|
|
|
private func deleteCachedEntity(id: String) {
|
|
if let s = CacheMapper.fetchSplit(id: id, in: context) { context.delete(s) }
|
|
if let w = CacheMapper.fetchWorkout(id: id, in: context) { context.delete(w) }
|
|
}
|
|
|
|
private func deleteCachedEntity(jsonRelativePath path: String) {
|
|
if let splits = try? context.fetch(FetchDescriptor<Split>(predicate: #Predicate { $0.jsonRelativePath == path })) {
|
|
splits.forEach(context.delete)
|
|
}
|
|
if let workouts = try? context.fetch(FetchDescriptor<Workout>(predicate: #Predicate { $0.jsonRelativePath == path })) {
|
|
workouts.forEach(context.delete)
|
|
}
|
|
}
|
|
|
|
private func idFromStubPath(_ path: String) -> String {
|
|
(path as NSString).lastPathComponent.replacingOccurrences(of: ".json", with: "")
|
|
}
|
|
|
|
/// Remove any live data file for `id` when its tombstone arrives. Matches by
|
|
/// filename identity across every non-`Stubs/` path — deliberately NOT
|
|
/// `resolveExistingPath(forID:)`, which would also match the stub itself.
|
|
private func removeLiveFile(forID id: String) async {
|
|
guard let store else { return }
|
|
let target = "\(id).json"
|
|
let livePaths = await store.list().filter {
|
|
!$0.hasPrefix("Stubs/") && ($0 as NSString).lastPathComponent == target
|
|
}
|
|
for path in livePaths {
|
|
try? await store.remove(at: path)
|
|
}
|
|
}
|
|
|
|
// MARK: - Maintenance
|
|
|
|
private func cleanupOldStubs() {
|
|
guard let tombstones else { return }
|
|
let exempt = SeedLibrary.seedIDs
|
|
Task.detached(priority: .utility) {
|
|
// Downloads evicted stubs to read their deletedAt, prunes past the
|
|
// 30-day grace period. Seed tombstones veto resurrection permanently, so
|
|
// they're exempt and never pruned.
|
|
try? await tombstones.prune(exempting: exempt)
|
|
}
|
|
}
|
|
|
|
// MARK: - Seeding
|
|
|
|
/// True when the container holds no documents at all — not one data file and not
|
|
/// one tombstone. A user with only workouts, or any tombstone (i.e. a prior
|
|
/// delete), is not a new user, so we never auto-seed over them. Placeholder-aware
|
|
/// by construction (`store.list()` sees evicted files too).
|
|
private func containerEmpty() async -> Bool {
|
|
guard let store else { return false }
|
|
return await store.list().isEmpty
|
|
}
|
|
|
|
/// Write the starter library exactly once, only into a verifiably empty container
|
|
/// (the true first run). Deferred and re-checked after a settle delay: an empty
|
|
/// listing right after connect can just mean the metadata index hasn't populated
|
|
/// yet, and seeding into that illusion would duplicate a library that's about to
|
|
/// arrive.
|
|
private func autoSeedIfEmpty() async {
|
|
guard let store, await containerEmpty() else { return }
|
|
// Give the metadata index a chance to surface existing files before we conclude
|
|
// the account is genuinely new.
|
|
try? await Task.sleep(for: .seconds(5))
|
|
guard iCloudStatus == .available, await containerEmpty() else { return }
|
|
|
|
for seed in SeedLibrary.seeds {
|
|
do {
|
|
// Verbatim bundle bytes: byte-identical files across devices make a
|
|
// same-path conflict (two devices seeding at once) semantically empty.
|
|
try await store.writeData(seed.data, to: seed.doc.relativePath)
|
|
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
|
} catch {
|
|
report("Failed to seed \(seed.doc.name)", error)
|
|
}
|
|
}
|
|
do { try context.save() } catch { report("Cache save failed", error) }
|
|
onCacheChanged?()
|
|
}
|
|
|
|
/// Add one seed fresh: write its verbatim bundle bytes and upsert the cache. The
|
|
/// on-demand "Add Starter Splits" path uses this for a seed that's neither present
|
|
/// nor tombstoned.
|
|
func writeSeed(_ seed: SeedLibrary.Seed) async {
|
|
guard let store else { return }
|
|
do {
|
|
try await store.writeData(seed.data, to: seed.doc.relativePath)
|
|
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
|
try context.save()
|
|
lastSyncError = nil
|
|
} catch {
|
|
report("Failed to add starter split \(seed.doc.name)", error)
|
|
}
|
|
onCacheChanged?()
|
|
}
|
|
|
|
/// Bring a previously-deleted seed back: lift its veto stub, then write its
|
|
/// verbatim bundle bytes. The bundle is the canonical restore source (seed content
|
|
/// is immutable), so this restores from the bundle rather than reconstructing from
|
|
/// the stub — the seed's stub is only ever a minimal veto marker. The stub is
|
|
/// removed first so neither a racing observer event nor a reconcile re-vetoes the
|
|
/// file we're about to write while the stub still exists.
|
|
func restoreSeed(_ seed: SeedLibrary.Seed) async {
|
|
guard let store, let tombstones else { return }
|
|
do {
|
|
try await tombstones.removeStub(at: "\(seed.id).json")
|
|
try await store.writeData(seed.data, to: seed.doc.relativePath)
|
|
CacheMapper.upsertSplit(seed.doc, relativePath: seed.doc.relativePath, into: context)
|
|
try context.save()
|
|
lastSyncError = nil
|
|
} catch {
|
|
report("Failed to restore starter split \(seed.doc.name)", error)
|
|
}
|
|
onCacheChanged?()
|
|
}
|
|
|
|
/// Whether a tombstone exists for `id` (placeholder-aware). The on-demand seed
|
|
/// path checks this to restore rather than re-create a deleted seed.
|
|
func isTombstoned(id: String) async -> Bool {
|
|
guard let tombstones else { return false }
|
|
return await tombstones.stubExists(id: id)
|
|
}
|
|
|
|
// MARK: - Error Reporting
|
|
|
|
/// Record a non-fatal sync failure: log it and publish it as `lastSyncError`
|
|
/// for the UI to render. Replaces silent `try?` / print-only handling.
|
|
private func report(_ message: String, _ error: Error? = nil) {
|
|
let full = error.map { "\(message): \($0.localizedDescription)" } ?? message
|
|
print("[Sync] \(full)")
|
|
lastSyncError = full
|
|
}
|
|
}
|