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:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user