Migrate the sync file layer onto the IndieSync package

Replaces the app-local copies of the extracted storage core with the
IndieSync 0.1.0 package (pinned from the new tag): ICloudFileManager ->
DocumentFileStore + TombstoneStore, ICloudFileMonitor ->
MetadataObserver (batched events with the content-date churn gate),
and the shared ULID / DocumentCoder / Tombstone / VersionedDocument
pieces. SyncEngine stays app-specific and is rewired onto the package
types; documents rename currentSchema -> currentSchemaVersion to adopt
the package protocol. ULID.make() remains as a shim minting the string
form the app keys on.

Behavior-preserving: identical JSON bytes (same coder config), same
stub wire format (kind encodes as the same strings), same soft-delete
ordering, same 30-day prune. WorkoutDocument keeps its local-calendar
month bucketing rather than adopting TimeBucketedLayout's UTC paths --
changing derivation would strand existing files. The watch target
links the package too (ULID + DocumentCoder via Shared); iOS, watchOS,
and widget targets all build.
This commit is contained in:
2026-07-05 08:04:29 -04:00
parent 3e63adf363
commit 1f2df491db
18 changed files with 98 additions and 524 deletions
+55 -49
View File
@@ -1,4 +1,5 @@
import Foundation
import IndieSync
import SwiftData
import Observation
import os
@@ -38,8 +39,9 @@ final class SyncEngine {
private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync")
private let modelContainer: ModelContainer
private var fileManager: ICloudFileManager?
private var monitor: ICloudFileMonitor?
private var store: DocumentFileStore?
private var tombstones: TombstoneStore?
private var monitor: MetadataObserver?
private var monitorTask: Task<Void, Never>?
private var connectAttempt = 0
@@ -107,7 +109,7 @@ final class SyncEngine {
guard let containerURL = resolved else { return }
log.info("connect[\(attempt)]: container URL = \(containerURL.path, privacy: .public)")
let fm = ICloudFileManager(containerURL: containerURL)
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
@@ -121,17 +123,18 @@ final class SyncEngine {
}
}
log.info("connect[\(attempt)]: preparing directories…")
await fm.prepareDirectories()
await store.prepareDirectories(["Splits", "Workouts", "Stubs"])
safety.cancel()
guard attempt == connectAttempt else { return }
self.fileManager = fm
self.store = store
self.tombstones = TombstoneStore(store: store)
iCloudStatus = .available
log.info("connect[\(attempt)]: directories ready → available")
WorkoutsModelContainer.persistCurrentIdentityToken()
await reconcile()
startMonitoring(documentsURL: fm.documentsURL)
startMonitoring(documentsURL: store.rootURL)
cleanupOldStubs()
}
@@ -149,27 +152,29 @@ final class SyncEngine {
private func startMonitoring(documentsURL: URL) {
monitorTask?.cancel()
let monitor = ICloudFileMonitor(documentsURL: documentsURL)
let monitor = MetadataObserver(documentsURL: documentsURL)
self.monitor = monitor
monitor.start()
monitorTask = Task { [weak self] in
for await event in monitor.events() {
await self?.handle(event)
for await batch in monitor.events() {
await self?.handle(batch)
}
}
}
private func handle(_ event: ICloudFileMonitor.FileChangeEvent) async {
switch event {
case .added(let path), .modified(let path):
if path.hasPrefix("Stubs/") {
deleteCachedEntity(id: idFromStubPath(path))
} else {
await importFile(relativePath: path)
}
case .removed(let path):
if !path.hasPrefix("Stubs/") {
deleteCachedEntity(jsonRelativePath: path)
private func handle(_ batch: [FileChangeEvent]) async {
for event in batch {
switch event {
case .added(let path), .modified(let path):
if path.hasPrefix("Stubs/") {
deleteCachedEntity(id: idFromStubPath(path))
} 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) }
@@ -195,9 +200,9 @@ final class SyncEngine {
// MARK: - Public CRUD (write path: files only)
func save(split doc: SplitDocument) async {
guard let fm = fileManager else { return }
guard let store else { return }
do {
try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath)
try await store.write(doc, to: doc.relativePath)
lastSyncError = nil
} catch {
report("Failed to save split", error)
@@ -206,9 +211,9 @@ final class SyncEngine {
}
func save(workout doc: WorkoutDocument) async {
guard let fm = fileManager else { return }
guard let store else { return }
do {
try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath)
try await store.write(doc, to: doc.relativePath)
lastSyncError = nil
} catch {
report("Failed to save workout", error)
@@ -220,18 +225,20 @@ final class SyncEngine {
}
func delete(split: Split) async {
await softDelete(id: split.id, kind: .split, livePath: split.jsonRelativePath)
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)
await softDelete(id: workout.id, kind: "workout", livePath: workout.jsonRelativePath)
}
private func softDelete(id: String, kind: Tombstone.Kind, livePath: String) async {
guard let fm = fileManager else { return }
let tombstone = Tombstone(id: id, kind: kind, deletedAt: Date())
/// 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 fm.writeTombstoneAndRemove(tombstone, livePath: livePath)
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)
@@ -241,10 +248,10 @@ final class SyncEngine {
// MARK: - Import / reconcile
private func importFile(relativePath: String) async {
guard let fm = fileManager else { return }
guard let store, let tombstones else { return }
let data: Data
do {
data = try await fm.read(relativePath: relativePath)
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
@@ -255,12 +262,12 @@ final class SyncEngine {
}
if relativePath.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { return }
if await fm.stubExists(id: doc.id) { try? await fm.remove(relativePath: relativePath); 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 }
CacheMapper.upsertSplit(doc, relativePath: relativePath, into: context)
} else if relativePath.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { return }
if await fm.stubExists(id: doc.id) { try? await fm.remove(relativePath: relativePath); 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 }
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
}
}
@@ -269,14 +276,14 @@ final class SyncEngine {
/// 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 fm = fileManager else { return }
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 fm.listTombstoneIDs()
let dataFiles = await fm.listDataFiles()
let tombstoned = await tombstones.listStubIDs()
let dataFiles = await store.list().filter { !$0.hasPrefix("Stubs/") }
var liveSplitIDs = Set<String>()
var liveWorkoutIDs = Set<String>()
@@ -285,7 +292,7 @@ final class SyncEngine {
for path in dataFiles {
let data: Data
do {
data = try await fm.read(relativePath: path)
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
@@ -298,13 +305,13 @@ final class SyncEngine {
continue
}
if path.hasPrefix("Splits/") {
guard let doc = try? DocumentCoder.decoder.decode(SplitDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); 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 }
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
liveSplitIDs.insert(doc.id)
} else if path.hasPrefix("Workouts/") {
guard let doc = try? DocumentCoder.decoder.decode(WorkoutDocument.self, from: data), doc.isReadable else { continue }
if tombstoned.contains(doc.id) { try? await fm.remove(relativePath: path); 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 }
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
liveWorkoutIDs.insert(doc.id)
}
@@ -357,12 +364,11 @@ final class SyncEngine {
// MARK: - Maintenance
private func cleanupOldStubs() {
guard let fm = fileManager else { return }
guard let tombstones else { return }
Task.detached(priority: .utility) {
let cutoff = Date().addingTimeInterval(-Tombstone.gracePeriod)
for tombstone in await fm.listTombstones() where tombstone.deletedAt < cutoff {
try? await fm.remove(relativePath: tombstone.relativePath)
}
// Downloads evicted stubs to read their deletedAt, prunes past the
// 30-day grace period.
try? await tombstones.prune()
}
}