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:
@@ -24,20 +24,19 @@ A workout tracking app for iPhone and Apple Watch, built with Swift 6 and SwiftU
|
|||||||
### Persistence & Sync (iCloud Drive documents)
|
### Persistence & Sync (iCloud Drive documents)
|
||||||
- **Source of truth**: JSON documents in the iCloud Drive container `iCloud.dev.rzen.indie.Workouts`. One file per aggregate: `Splits/<ULID>.json` and `Workouts/YYYY/MM/<ULID>.json` (month-bucketed; ULIDs sort chronologically).
|
- **Source of truth**: JSON documents in the iCloud Drive container `iCloud.dev.rzen.indie.Workouts`. One file per aggregate: `Splits/<ULID>.json` and `Workouts/YYYY/MM/<ULID>.json` (month-bucketed; ULIDs sort chronologically).
|
||||||
- **SwiftData cache**: `WorkoutsModelContainer` (`Shared/Persistence/`) builds a local SwiftData store as a *rebuildable read-through cache*, created with `cloudKitDatabase: .none`. It is wiped on a schema-version bump or an iCloud account change. Views read it via `@Query`.
|
- **SwiftData cache**: `WorkoutsModelContainer` (`Shared/Persistence/`) builds a local SwiftData store as a *rebuildable read-through cache*, created with `cloudKitDatabase: .none`. It is wiped on a schema-version bump or an iCloud account change. Views read it via `@Query`.
|
||||||
- **One-way data flow (iPhone)**: view → `SyncEngine.save(...)` writes a file → `ICloudFileMonitor` (an `NSMetadataQuery`) emits a delta → `CacheMapper` upserts SwiftData → `@Query` views refresh. The phone is the **sole writer** of iCloud Drive.
|
- **One-way data flow (iPhone)**: view → `SyncEngine.save(...)` writes a file → `MetadataObserver` (an `NSMetadataQuery`) emits a delta batch → `CacheMapper` upserts SwiftData → `@Query` views refresh. The phone is the **sole writer** of iCloud Drive.
|
||||||
- **Key types** (under `Workouts/Sync/`):
|
- **Key types**:
|
||||||
- `SyncEngine` (`@Observable @MainActor`) — orchestrator. `connect()` resolves the ubiquity container and reconciles; `save`/`delete` write files; `handle(event:)` applies observer deltas; `ingestFromWatch(_:)` writes + upserts the cache directly. Exposes `iCloudStatus` and an `onCacheChanged` callback.
|
- `SyncEngine` (`@Observable @MainActor`, in `Workouts/Sync/`) — the app-specific orchestrator. `connect()` resolves the ubiquity container and reconciles; `save`/`delete` write files; `handle(_:)` applies observer delta batches; `ingestFromWatch(_:)` writes + upserts the cache directly. Exposes `iCloudStatus` and an `onCacheChanged` callback.
|
||||||
- `ICloudFileManager` (`actor`) — all `NSFileCoordinator` file I/O, off the main thread.
|
- The file-side core comes from the **IndieSync** SPM package (`https://git.rzen.dev/rzen/indie-sync.git`): `DocumentFileStore` (actor — all `NSFileCoordinator` I/O, conflict resolution, eviction-safe reads, placeholder-aware enumeration), `TombstoneStore` (soft-delete stubs, resurrection veto, grace-period pruning), `MetadataObserver` (`@MainActor` — wraps the `NSMetadataQuery`, emitting added/modified/removed batches as an `AsyncStream`), plus `ULID`, `DocumentCoder`, `SyncError`, and the `VersionedDocument` protocol. See the `icloud-sync-engine` skill for the architecture invariants.
|
||||||
- `ICloudFileMonitor` (`@MainActor`) — wraps the `NSMetadataQuery`, emitting added/modified/removed deltas as an `AsyncStream`.
|
|
||||||
- **Soft deletes**: a delete writes a `Tombstone` to `Stubs/<id>.json` then removes the live file (so offline devices still learn of the delete); stubs are pruned after a 30-day grace period.
|
- **Soft deletes**: a delete writes a `Tombstone` to `Stubs/<id>.json` then removes the live file (so offline devices still learn of the delete); stubs are pruned after a 30-day grace period.
|
||||||
- **iCloud is required**: `RootGateView` (`Workouts/Views/`) gates the app on `SyncEngine.iCloudStatus` — there is no local-only mode.
|
- **iCloud is required**: `RootGateView` (`Workouts/Views/`) gates the app on `SyncEngine.iCloudStatus` — there is no local-only mode.
|
||||||
|
|
||||||
### Data Layer (three shapes + a stateless mapper)
|
### Data Layer (three shapes + a stateless mapper)
|
||||||
All in `Shared/Model/`:
|
All in `Shared/Model/`:
|
||||||
- **Codable documents** (`Documents.swift`) — the on-disk / wire format: `SplitDocument` (embeds `[ExerciseDocument]`) and `WorkoutDocument` (embeds `[WorkoutLogDocument]`), plus `Tombstone`, a `VersionedDocument` schema gate (quarantines files from newer app versions), and a shared `DocumentCoder`.
|
- **Codable documents** (`Documents.swift`) — the on-disk / wire format: `SplitDocument` (embeds `[ExerciseDocument]`) and `WorkoutDocument` (embeds `[WorkoutLogDocument]`), conforming to IndieSync's `VersionedDocument` schema gate (quarantines files from newer app versions). `Tombstone` and `DocumentCoder` come from IndieSync. `WorkoutDocument.relativePath` keeps the app's own local-calendar month bucketing (predates IndieSync's UTC `TimeBucketedLayout`; changing it would strand existing files).
|
||||||
- **SwiftData `@Model` cache entities** (`Entities.swift`) — `Split`, `Exercise`, `Workout`, `WorkoutLog`; each keyed by a stable `id: String` ULID (`@Attribute(.unique)`), with cascade relationships and a `jsonRelativePath` back to its file.
|
- **SwiftData `@Model` cache entities** (`Entities.swift`) — `Split`, `Exercise`, `Workout`, `WorkoutLog`; each keyed by a stable `id: String` ULID (`@Attribute(.unique)`), with cascade relationships and a `jsonRelativePath` back to its file.
|
||||||
- **Stateless mappers** (`Mappers.swift`) — `init(from: entity)` for cache → document; `CacheMapper.upsert…` for document → cache (used *only* by the observer, reconcile, and the watch bridge).
|
- **Stateless mappers** (`Mappers.swift`) — `init(from: entity)` for cache → document; `CacheMapper.upsert…` for document → cache (used *only* by the observer, reconcile, and the watch bridge).
|
||||||
- **Identifiers** (`ULID.swift`) — 26-char Crockford base32, chronologically sortable.
|
- **Identifiers** (`ULID.swift`) — a thin shim over IndieSync's `ULID`: `ULID.make()` mints the 26-char Crockford base32 string (chronologically sortable) that documents and entities key on.
|
||||||
- **Enums** (`Enums.swift`) — `WorkoutStatus`, `LoadType`.
|
- **Enums** (`Enums.swift`) — `WorkoutStatus`, `LoadType`.
|
||||||
|
|
||||||
### Core Aggregates
|
### Core Aggregates
|
||||||
@@ -60,7 +59,7 @@ All in `Shared/Model/`:
|
|||||||
- iOS 26 / watchOS 26; Swift 6 with `SWIFT_STRICT_CONCURRENCY: complete` on both targets.
|
- iOS 26 / watchOS 26; Swift 6 with `SWIFT_STRICT_CONCURRENCY: complete` on both targets.
|
||||||
- Build number is set from the git commit count by `Scripts/update_build_number.sh` (a post-build phase); `Scripts/` also holds the App Store / TestFlight release pipeline.
|
- Build number is set from the git commit count by `Scripts/update_build_number.sh` (a post-build phase); `Scripts/` also holds the App Store / TestFlight release pipeline.
|
||||||
- Entitlements: iOS uses CloudDocuments + HealthKit; watch uses HealthKit only. **No CloudKit service, no App Group.**
|
- Entitlements: iOS uses CloudDocuments + HealthKit; watch uses HealthKit only. **No CloudKit service, no App Group.**
|
||||||
- SPM packages: `IndieAbout` (in-app About / changelog / license UI) and `Yams` (YAML parsing).
|
- SPM packages: `IndieSync` (file-side iCloud sync core), `IndieAbout` (in-app About / changelog / license UI), and `Yams` (YAML parsing).
|
||||||
|
|
||||||
### Key Directories
|
### Key Directories
|
||||||
- `Shared/` (compiled into both targets): `Model/`, `Persistence/`, `Connectivity/`, `Utils/`, `Screenshots/`
|
- `Shared/` (compiled into both targets): `Model/`, `Persistence/`, `Connectivity/`, `Utils/`, `Screenshots/`
|
||||||
@@ -77,12 +76,12 @@ Seeded on demand, never automatically — an empty cache at launch is indistingu
|
|||||||
|
|
||||||
### Data Model Changes
|
### Data Model Changes
|
||||||
- A new persisted field must be added in three places: the Codable `…Document` (on-disk shape), the `@Model` cache entity, and *both directions* of the mapper in `Mappers.swift`.
|
- A new persisted field must be added in three places: the Codable `…Document` (on-disk shape), the `@Model` cache entity, and *both directions* of the mapper in `Mappers.swift`.
|
||||||
- Bump a document's `currentSchema` when its on-disk shape changes incompatibly.
|
- Bump a document's `currentSchemaVersion` when its on-disk shape changes incompatibly.
|
||||||
- All writes go through `SyncEngine` (`save` / `delete`) — never mutate the SwiftData cache directly, since it is rebuilt from files.
|
- All writes go through `SyncEngine` (`save` / `delete`) — never mutate the SwiftData cache directly, since it is rebuilt from files.
|
||||||
- Mint IDs with `ULID.make()`; they are stable across cache rebuilds.
|
- Mint IDs with `ULID.make()`; they are stable across cache rebuilds.
|
||||||
|
|
||||||
### Concurrency
|
### Concurrency
|
||||||
- Code is Swift 6 strict-concurrency clean. UI and orchestration types are `@MainActor`; file I/O lives in the `ICloudFileManager` actor.
|
- Code is Swift 6 strict-concurrency clean. UI and orchestration types are `@MainActor`; file I/O lives in IndieSync's `DocumentFileStore` actor.
|
||||||
|
|
||||||
### UI
|
### UI
|
||||||
- SwiftUI-first: `NavigationStack`, `@Query`-backed lists, form-based add/edit, sheet presentations, and swipe actions.
|
- SwiftUI-first: `NavigationStack`, `@Query`-backed lists, form-based add/edit, sheet presentations, and swipe actions.
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import IndieSync
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
/// Wire format for the iPhone↔Watch bridge. The phone is the only device that
|
/// Wire format for the iPhone↔Watch bridge. The phone is the only device that
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import IndieSync
|
||||||
|
|
||||||
/// On-disk JSON shape for each aggregate. Independent from the SwiftData cache
|
/// On-disk JSON shape for each aggregate. Independent from the SwiftData cache
|
||||||
/// entities so the wire format can evolve without dragging the cache schema.
|
/// entities so the wire format can evolve without dragging the cache schema.
|
||||||
@@ -29,7 +30,7 @@ struct SplitDocument: Codable, Sendable, Equatable, Identifiable {
|
|||||||
/// quarantining the user's whole routine.
|
/// quarantining the user's whole routine.
|
||||||
var activityType: Int?
|
var activityType: Int?
|
||||||
|
|
||||||
static let currentSchema = 1
|
static let currentSchemaVersion = 1
|
||||||
|
|
||||||
var relativePath: String { "Splits/\(id).json" }
|
var relativePath: String { "Splits/\(id).json" }
|
||||||
}
|
}
|
||||||
@@ -69,7 +70,7 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
|
|||||||
// Bumped 1→2 when `metrics` was added: the captured HR/calorie data is
|
// Bumped 1→2 when `metrics` was added: the captured HR/calorie data is
|
||||||
// irreplaceable, so the forward-gate must quarantine these files on older apps
|
// irreplaceable, so the forward-gate must quarantine these files on older apps
|
||||||
// rather than let them strip `metrics` on rewrite.
|
// rather than let them strip `metrics` on rewrite.
|
||||||
static let currentSchema = 2
|
static let currentSchemaVersion = 2
|
||||||
|
|
||||||
var relativePath: String { Self.relativePath(id: id, start: start) }
|
var relativePath: String { Self.relativePath(id: id, start: start) }
|
||||||
|
|
||||||
@@ -158,58 +159,7 @@ struct WorkoutMetrics: Codable, Sendable, Equatable {
|
|||||||
|
|
||||||
// MARK: - Forward-compatibility gate
|
// MARK: - Forward-compatibility gate
|
||||||
|
|
||||||
/// A file whose `schemaVersion` exceeds the reader's `currentSchema` was written
|
// `VersionedDocument` (the `isReadable` quarantine gate for files written by a
|
||||||
/// by a newer app version; it must be quarantined, not partially decoded (Codable
|
// newer app version), `Tombstone`, and `DocumentCoder` all live in IndieSync now.
|
||||||
/// silently drops unknown keys) and later rewritten — which would downgrade it
|
|
||||||
/// and lose the newer fields.
|
|
||||||
protocol VersionedDocument {
|
|
||||||
var schemaVersion: Int { get }
|
|
||||||
static var currentSchema: Int { get }
|
|
||||||
}
|
|
||||||
|
|
||||||
extension SplitDocument: VersionedDocument {}
|
extension SplitDocument: VersionedDocument {}
|
||||||
extension WorkoutDocument: VersionedDocument {}
|
extension WorkoutDocument: VersionedDocument {}
|
||||||
|
|
||||||
extension VersionedDocument {
|
|
||||||
/// True if this build can safely read (and rewrite) the document.
|
|
||||||
var isReadable: Bool { schemaVersion <= Self.currentSchema }
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Soft-delete tombstone
|
|
||||||
|
|
||||||
/// Lives at `Stubs/<id>.json` and tells every device "this aggregate has been
|
|
||||||
/// deleted" — important when a remote device missed the `.removed` event for the
|
|
||||||
/// live file because it was offline. After `gracePeriod` any device can prune it.
|
|
||||||
struct Tombstone: Codable, Sendable, Equatable {
|
|
||||||
enum Kind: String, Codable, Sendable {
|
|
||||||
case split
|
|
||||||
case workout
|
|
||||||
}
|
|
||||||
|
|
||||||
var id: String // the aggregate's ULID
|
|
||||||
var kind: Kind
|
|
||||||
var deletedAt: Date
|
|
||||||
|
|
||||||
var relativePath: String { "Stubs/\(id).json" }
|
|
||||||
|
|
||||||
static let gracePeriod: TimeInterval = 30 * 24 * 60 * 60
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - JSON coding
|
|
||||||
|
|
||||||
/// Shared encoder/decoder. ISO-8601 dates and sorted keys for human-readable,
|
|
||||||
/// diff-friendly files when the container is browsed via the Files app.
|
|
||||||
enum DocumentCoder {
|
|
||||||
static let encoder: JSONEncoder = {
|
|
||||||
let e = JSONEncoder()
|
|
||||||
e.outputFormatting = [.prettyPrinted, .sortedKeys]
|
|
||||||
e.dateEncodingStrategy = .iso8601
|
|
||||||
return e
|
|
||||||
}()
|
|
||||||
|
|
||||||
static let decoder: JSONDecoder = {
|
|
||||||
let d = JSONDecoder()
|
|
||||||
d.dateDecodingStrategy = .iso8601
|
|
||||||
return d
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import IndieSync
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ extension ExerciseDocument {
|
|||||||
|
|
||||||
extension SplitDocument {
|
extension SplitDocument {
|
||||||
init(from split: Split) {
|
init(from split: Split) {
|
||||||
self.init(schemaVersion: Self.currentSchema, id: split.id, name: split.name,
|
self.init(schemaVersion: Self.currentSchemaVersion, id: split.id, name: split.name,
|
||||||
color: split.color, systemImage: split.systemImage, order: split.order,
|
color: split.color, systemImage: split.systemImage, order: split.order,
|
||||||
createdAt: split.createdAt, updatedAt: split.updatedAt,
|
createdAt: split.createdAt, updatedAt: split.updatedAt,
|
||||||
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)),
|
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)),
|
||||||
@@ -43,7 +43,7 @@ extension WorkoutLogDocument {
|
|||||||
|
|
||||||
extension WorkoutDocument {
|
extension WorkoutDocument {
|
||||||
init(from workout: Workout) {
|
init(from workout: Workout) {
|
||||||
self.init(schemaVersion: Self.currentSchema, id: workout.id, splitID: workout.splitID,
|
self.init(schemaVersion: Self.currentSchemaVersion, id: workout.id, splitID: workout.splitID,
|
||||||
splitName: workout.splitName, start: workout.start, end: workout.end,
|
splitName: workout.splitName, start: workout.start, end: workout.end,
|
||||||
status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
|
status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
|
||||||
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
|
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
|
||||||
|
|||||||
+8
-65
@@ -1,68 +1,11 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import IndieSync
|
||||||
|
|
||||||
/// Minimal ULID generator.
|
// The ULID implementation lives in the IndieSync package. Workouts' documents
|
||||||
///
|
// and cache entities key on the 26-char string form, so this shim keeps the
|
||||||
/// 26-character Crockford base32 string: 10 chars of millisecond timestamp +
|
// app-wide `ULID.make()` minting entry point unchanged.
|
||||||
/// 16 chars of randomness. Lexicographic order matches chronological order,
|
extension ULID {
|
||||||
/// so workout documents sort newest-last by id and file listings stay ordered.
|
/// Mints a fresh ULID for the current instant, as its canonical
|
||||||
///
|
/// 26-character Crockford base32 string.
|
||||||
/// Spec: https://github.com/ulid/spec
|
static func make() -> String { ULID().stringValue }
|
||||||
enum ULID {
|
|
||||||
/// Crockford base32 alphabet — no I, L, O, U to avoid visual confusion.
|
|
||||||
private static let alphabet: [Character] = Array("0123456789ABCDEFGHJKMNPQRSTVWXYZ")
|
|
||||||
|
|
||||||
/// Mints a fresh ULID for the current instant.
|
|
||||||
static func make() -> String {
|
|
||||||
let timestamp = UInt64(Date().timeIntervalSince1970 * 1000)
|
|
||||||
var randomness = [UInt8](repeating: 0, count: 10)
|
|
||||||
_ = randomness.withUnsafeMutableBytes { buffer in
|
|
||||||
SecRandomCopyBytes(kSecRandomDefault, buffer.count, buffer.baseAddress!)
|
|
||||||
}
|
|
||||||
return encode(timestamp: timestamp, randomness: randomness)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// True if `s` is a 26-char ULID using the Crockford alphabet.
|
|
||||||
static func isValid(_ s: String) -> Bool {
|
|
||||||
guard s.count == 26 else { return false }
|
|
||||||
let allowed = Set(alphabet)
|
|
||||||
return s.allSatisfy { allowed.contains($0) }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Decodes the timestamp portion of a ULID, if valid.
|
|
||||||
static func timestamp(of ulid: String) -> Date? {
|
|
||||||
guard ulid.count == 26 else { return nil }
|
|
||||||
let prefix = ulid.prefix(10)
|
|
||||||
var value: UInt64 = 0
|
|
||||||
let lookup = Dictionary(uniqueKeysWithValues: alphabet.enumerated().map { ($1, UInt64($0)) })
|
|
||||||
for char in prefix {
|
|
||||||
guard let digit = lookup[char] else { return nil }
|
|
||||||
value = (value << 5) | digit
|
|
||||||
}
|
|
||||||
return Date(timeIntervalSince1970: Double(value) / 1000)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Internal
|
|
||||||
|
|
||||||
private static func encode(timestamp: UInt64, randomness: [UInt8]) -> String {
|
|
||||||
precondition(randomness.count == 10, "ULID randomness must be 10 bytes (80 bits)")
|
|
||||||
// 48-bit timestamp → 10 base32 chars (50 bits, top 2 unused).
|
|
||||||
var output = [Character](repeating: "0", count: 26)
|
|
||||||
var t = timestamp & ((1 << 48) - 1)
|
|
||||||
for i in stride(from: 9, through: 0, by: -1) {
|
|
||||||
output[i] = alphabet[Int(t & 0x1F)]
|
|
||||||
t >>= 5
|
|
||||||
}
|
|
||||||
// 80-bit randomness → 16 base32 chars.
|
|
||||||
var bigEnd = randomness
|
|
||||||
for i in stride(from: 25, through: 10, by: -1) {
|
|
||||||
var carry: UInt16 = 0
|
|
||||||
for j in 0..<bigEnd.count {
|
|
||||||
let combined = UInt16(bigEnd[j]) | (carry << 8)
|
|
||||||
bigEnd[j] = UInt8(combined >> 5)
|
|
||||||
carry = combined & 0x1F
|
|
||||||
}
|
|
||||||
output[i] = alphabet[Int(UInt8(carry))]
|
|
||||||
}
|
|
||||||
return String(output)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#if DEBUG
|
#if DEBUG
|
||||||
|
import IndieSync
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import IndieSync
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import IndieSync
|
||||||
import Foundation
|
import Foundation
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
@@ -58,7 +59,7 @@ enum SplitSeeder {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
return SplitDocument(
|
return SplitDocument(
|
||||||
schemaVersion: SplitDocument.currentSchema, id: ULID.make(),
|
schemaVersion: SplitDocument.currentSchemaVersion, id: ULID.make(),
|
||||||
name: split.name, color: split.color, systemImage: split.icon, order: order,
|
name: split.name, color: split.color, systemImage: split.icon, order: order,
|
||||||
createdAt: Date(), updatedAt: Date(), exercises: exercises,
|
createdAt: Date(), updatedAt: Date(), exercises: exercises,
|
||||||
activityType: split.activity.rawValue
|
activityType: split.activity.rawValue
|
||||||
|
|||||||
@@ -1,228 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// All iCloud Drive file I/O, isolated to an actor so blocking `NSFileCoordinator`
|
|
||||||
/// calls stay off the main thread. Paths are relative to the container's
|
|
||||||
/// `Documents/` directory (e.g. `Splits/<ULID>.json`, `Stubs/<ULID>.json`).
|
|
||||||
actor ICloudFileManager {
|
|
||||||
let documentsURL: URL
|
|
||||||
|
|
||||||
init(containerURL: URL) {
|
|
||||||
self.documentsURL = containerURL.appendingPathComponent("Documents", isDirectory: true)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Create the directory skeleton. Actor-isolated (NOT in init) so the
|
|
||||||
/// potentially-blocking file touch runs on the actor's executor, never main.
|
|
||||||
func prepareDirectories() {
|
|
||||||
for sub in ["Splits", "Workouts", "Stubs"] {
|
|
||||||
let url = documentsURL.appendingPathComponent(sub, isDirectory: true)
|
|
||||||
try? FileManager.default.createDirectory(at: url, withIntermediateDirectories: true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Coordinated primitives
|
|
||||||
|
|
||||||
func write(_ data: Data, to relativePath: String) throws {
|
|
||||||
let fileURL = documentsURL.appendingPathComponent(relativePath)
|
|
||||||
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(),
|
|
||||||
withIntermediateDirectories: true)
|
|
||||||
var coordError: NSError?
|
|
||||||
var writeError: Error?
|
|
||||||
NSFileCoordinator().coordinate(writingItemAt: fileURL, options: .forReplacing, error: &coordError) { url in
|
|
||||||
do { try data.write(to: url, options: .atomic) }
|
|
||||||
catch { writeError = error }
|
|
||||||
}
|
|
||||||
if let error = coordError ?? writeError { throw error }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Eviction-safe, conflict-safe read: resolves any iCloud conflict versions,
|
|
||||||
/// materializes an evicted file (bounded wait), then does the coordinated
|
|
||||||
/// read — so callers never decode a dataless placeholder or a stale conflict
|
|
||||||
/// sibling. Throws on download timeout rather than skipping — a silently
|
|
||||||
/// dropped evicted file looks to the user like the record was deleted.
|
|
||||||
func read(relativePath: String) async throws -> Data {
|
|
||||||
resolveConflictsIfAny(at: documentsURL.appendingPathComponent(relativePath))
|
|
||||||
try await ensureDownloaded(relativePath: relativePath)
|
|
||||||
let fileURL = documentsURL.appendingPathComponent(relativePath)
|
|
||||||
var coordError: NSError?
|
|
||||||
var result: Result<Data, Error>?
|
|
||||||
NSFileCoordinator().coordinate(readingItemAt: fileURL, options: [], error: &coordError) { url in
|
|
||||||
do { result = .success(try Data(contentsOf: url)) }
|
|
||||||
catch { result = .failure(error) }
|
|
||||||
}
|
|
||||||
if let coordError { throw coordError }
|
|
||||||
switch result {
|
|
||||||
case .success(let data): return data
|
|
||||||
case .failure(let error): throw error
|
|
||||||
case .none: throw CocoaError(.fileReadUnknown)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func remove(relativePath: String) throws {
|
|
||||||
let fileURL = documentsURL.appendingPathComponent(relativePath)
|
|
||||||
guard FileManager.default.fileExists(atPath: fileURL.path) else { return }
|
|
||||||
var coordError: NSError?
|
|
||||||
var deleteError: Error?
|
|
||||||
NSFileCoordinator().coordinate(writingItemAt: fileURL, options: .forDeleting, error: &coordError) { url in
|
|
||||||
do { try FileManager.default.removeItem(at: url) }
|
|
||||||
catch { deleteError = error }
|
|
||||||
}
|
|
||||||
if let error = coordError ?? deleteError { throw error }
|
|
||||||
}
|
|
||||||
|
|
||||||
func fileExists(_ relativePath: String) -> Bool {
|
|
||||||
FileManager.default.fileExists(atPath: documentsURL.appendingPathComponent(relativePath).path)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Soft delete
|
|
||||||
|
|
||||||
/// 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.
|
|
||||||
func writeTombstoneAndRemove(_ tombstone: Tombstone, livePath: String) throws {
|
|
||||||
let data = try DocumentCoder.encoder.encode(tombstone)
|
|
||||||
try write(data, to: tombstone.relativePath)
|
|
||||||
try remove(relativePath: livePath)
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Enumeration
|
|
||||||
|
|
||||||
/// Relative paths of all live data files (`Splits/…`, `Workouts/…`), excluding `Stubs/`.
|
|
||||||
func listDataFiles() -> [String] {
|
|
||||||
listJSON().filter { !$0.hasPrefix("Stubs/") }
|
|
||||||
}
|
|
||||||
|
|
||||||
/// All tombstones currently on disk (evicted stubs are downloaded first —
|
|
||||||
/// old stubs are routinely evicted, and cleanup needs their `deletedAt`).
|
|
||||||
func listTombstones() async -> [Tombstone] {
|
|
||||||
var tombstones: [Tombstone] = []
|
|
||||||
for path in listJSON() where path.hasPrefix("Stubs/") {
|
|
||||||
guard let data = try? await read(relativePath: path) else { continue }
|
|
||||||
if let tombstone = try? DocumentCoder.decoder.decode(Tombstone.self, from: data) {
|
|
||||||
tombstones.append(tombstone)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return tombstones
|
|
||||||
}
|
|
||||||
|
|
||||||
/// IDs of all tombstones, derived from stub filenames alone. Unlike
|
|
||||||
/// `listTombstones()` this needs no reads, so an evicted stub still counts —
|
|
||||||
/// reconcile must never treat a record as live because its stub happened to
|
|
||||||
/// be evicted.
|
|
||||||
func listTombstoneIDs() -> Set<String> {
|
|
||||||
Set(listJSON().filter { $0.hasPrefix("Stubs/") }
|
|
||||||
.map { ($0 as NSString).lastPathComponent.replacingOccurrences(of: ".json", with: "") })
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether a deletion stub exists for an id — placeholder-aware, so an
|
|
||||||
/// evicted stub still vetoes a resurrecting live file.
|
|
||||||
func stubExists(id: String) -> Bool {
|
|
||||||
let stubs = documentsURL.appendingPathComponent("Stubs", isDirectory: true)
|
|
||||||
return FileManager.default.fileExists(atPath: stubs.appendingPathComponent("\(id).json").path)
|
|
||||||
|| FileManager.default.fileExists(atPath: stubs.appendingPathComponent(".\(id).json.icloud").path)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Deliberately does NOT pass `.skipsHiddenFiles`: iOS materializes evicted
|
|
||||||
/// iCloud files as hidden placeholder dotfiles (`.<name>.json.icloud`), and
|
|
||||||
/// skipping hidden files would drop every evicted record from the listing —
|
|
||||||
/// reconcile would then prune their cache entities as "file gone".
|
|
||||||
/// Placeholder names are mapped back to their real `<name>.json`.
|
|
||||||
private func listJSON() -> [String] {
|
|
||||||
let base = documentsURL.path + "/"
|
|
||||||
guard let enumerator = FileManager.default.enumerator(
|
|
||||||
at: documentsURL,
|
|
||||||
includingPropertiesForKeys: nil,
|
|
||||||
options: []
|
|
||||||
) else { return [] }
|
|
||||||
var seen: Set<String> = []
|
|
||||||
var paths: [String] = []
|
|
||||||
for case let url as URL in enumerator {
|
|
||||||
let full = url.path
|
|
||||||
guard full.hasPrefix(base) else { continue }
|
|
||||||
guard let real = Self.realRelativePath(fromRaw: String(full.dropFirst(base.count))),
|
|
||||||
seen.insert(real).inserted else { continue }
|
|
||||||
paths.append(real)
|
|
||||||
}
|
|
||||||
return paths
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Map a raw relative path from the enumerator — which may be an eviction
|
|
||||||
/// placeholder like `Workouts/2026/03/.<ULID>.json.icloud` — to the real
|
|
||||||
/// path `Workouts/2026/03/<ULID>.json`. Returns nil for non-JSON entries.
|
|
||||||
nonisolated static func realRelativePath(fromRaw raw: String) -> String? {
|
|
||||||
var components = raw.split(separator: "/", omittingEmptySubsequences: true).map(String.init)
|
|
||||||
guard var name = components.popLast() else { return nil }
|
|
||||||
if name.hasPrefix("."), name.hasSuffix(".icloud") {
|
|
||||||
name = String(name.dropFirst().dropLast(".icloud".count))
|
|
||||||
}
|
|
||||||
guard name.hasSuffix(".json") else { return nil }
|
|
||||||
components.append(name)
|
|
||||||
return components.joined(separator: "/")
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Conflict resolution
|
|
||||||
|
|
||||||
/// If iCloud Drive created conflict versions (concurrent writes from
|
|
||||||
/// different devices), pick whichever has the latest modification date,
|
|
||||||
/// promote it to the canonical file, and mark every conflict resolved.
|
|
||||||
/// Whole-file last-writer-wins by mod-date — honest for one-file-per-record
|
|
||||||
/// data. Left unresolved, conflict siblings accumulate silently and a
|
|
||||||
/// coordinated read returns whichever version the filesystem hands back.
|
|
||||||
private func resolveConflictsIfAny(at fileURL: URL) {
|
|
||||||
let conflicts = NSFileVersion.unresolvedConflictVersionsOfItem(at: fileURL) ?? []
|
|
||||||
guard !conflicts.isEmpty else { return }
|
|
||||||
|
|
||||||
let currentDate = NSFileVersion.currentVersionOfItem(at: fileURL)?.modificationDate ?? .distantPast
|
|
||||||
var winnerDate = currentDate
|
|
||||||
var winnerVersion: NSFileVersion?
|
|
||||||
|
|
||||||
for version in conflicts {
|
|
||||||
if let date = version.modificationDate, date > winnerDate {
|
|
||||||
winnerDate = date
|
|
||||||
winnerVersion = version
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Promote the winner and prune the losers inside a coordinated write —
|
|
||||||
// these are real file mutations (replaceItem / removeOtherVersions) and
|
|
||||||
// must not race a concurrent write or the iCloud daemon; NSFileVersion
|
|
||||||
// does not coordinate internally.
|
|
||||||
var coordError: NSError?
|
|
||||||
NSFileCoordinator().coordinate(writingItemAt: fileURL, options: .forReplacing, error: &coordError) { url in
|
|
||||||
if let winner = winnerVersion {
|
|
||||||
do {
|
|
||||||
try winner.replaceItem(at: url, options: [])
|
|
||||||
} catch {
|
|
||||||
print("[Sync] Conflict promotion failed for \(url.lastPathComponent): \(error)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for version in conflicts {
|
|
||||||
version.isResolved = true
|
|
||||||
}
|
|
||||||
try? NSFileVersion.removeOtherVersionsOfItem(at: url)
|
|
||||||
}
|
|
||||||
if let coordError {
|
|
||||||
print("[Sync] Conflict coordination failed for \(fileURL.lastPathComponent): \(coordError)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Eviction
|
|
||||||
|
|
||||||
/// Triggers a download for an evicted file and polls until it materializes.
|
|
||||||
/// `startDownloadingUbiquitousItem` is fire-and-forget with no completion
|
|
||||||
/// callback, so a bounded poll (30s) is the only way to wait; on timeout this
|
|
||||||
/// throws rather than letting the caller read a placeholder.
|
|
||||||
func ensureDownloaded(relativePath: String) async throws {
|
|
||||||
let fileURL = documentsURL.appendingPathComponent(relativePath)
|
|
||||||
let values = try fileURL.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey])
|
|
||||||
guard let status = values.ubiquitousItemDownloadingStatus, status != .current else { return }
|
|
||||||
|
|
||||||
try FileManager.default.startDownloadingUbiquitousItem(at: fileURL)
|
|
||||||
for _ in 0..<60 {
|
|
||||||
try await Task.sleep(for: .milliseconds(500))
|
|
||||||
let updated = try fileURL.resourceValues(forKeys: [.ubiquitousItemDownloadingStatusKey])
|
|
||||||
if let s = updated.ubiquitousItemDownloadingStatus, s == .current { return }
|
|
||||||
}
|
|
||||||
throw CocoaError(.fileReadNoPermission, userInfo: [
|
|
||||||
NSLocalizedDescriptionKey: "Timed out downloading \(relativePath) from iCloud"
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
import Foundation
|
|
||||||
|
|
||||||
/// Wraps a single `NSMetadataQuery` over the container's `Documents/` scope and
|
|
||||||
/// emits add/modify/remove events (paths relative to `Documents/`) via an
|
|
||||||
/// `AsyncStream`. `@MainActor` because `NSMetadataQuery` posts on, and must be
|
|
||||||
/// driven from, the main thread.
|
|
||||||
@MainActor
|
|
||||||
final class ICloudFileMonitor {
|
|
||||||
enum FileChangeEvent: Sendable {
|
|
||||||
case added(relativePath: String)
|
|
||||||
case modified(relativePath: String)
|
|
||||||
case removed(relativePath: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
private let documentsURL: URL
|
|
||||||
private var query: NSMetadataQuery?
|
|
||||||
private var knownFiles: Set<String> = []
|
|
||||||
private var continuation: AsyncStream<FileChangeEvent>.Continuation?
|
|
||||||
|
|
||||||
init(documentsURL: URL) {
|
|
||||||
self.documentsURL = documentsURL
|
|
||||||
}
|
|
||||||
|
|
||||||
func events() -> AsyncStream<FileChangeEvent> {
|
|
||||||
AsyncStream { continuation in
|
|
||||||
self.continuation = continuation
|
|
||||||
continuation.onTermination = { @Sendable _ in
|
|
||||||
Task { @MainActor in self.stop() }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func start() {
|
|
||||||
let query = NSMetadataQuery()
|
|
||||||
query.searchScopes = [NSMetadataQueryUbiquitousDocumentsScope]
|
|
||||||
query.predicate = NSPredicate(format: "%K LIKE '*.json'", NSMetadataItemFSNameKey)
|
|
||||||
self.query = query
|
|
||||||
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self, selector: #selector(queryDidFinishGathering(_:)),
|
|
||||||
name: .NSMetadataQueryDidFinishGathering, object: query)
|
|
||||||
NotificationCenter.default.addObserver(
|
|
||||||
self, selector: #selector(queryDidUpdate(_:)),
|
|
||||||
name: .NSMetadataQueryDidUpdate, object: query)
|
|
||||||
|
|
||||||
query.start()
|
|
||||||
}
|
|
||||||
|
|
||||||
func stop() {
|
|
||||||
query?.stop()
|
|
||||||
if let query { NotificationCenter.default.removeObserver(self, name: nil, object: query) }
|
|
||||||
query = nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Notifications
|
|
||||||
|
|
||||||
@objc private func queryDidFinishGathering(_ notification: Notification) {
|
|
||||||
query?.disableUpdates()
|
|
||||||
defer { query?.enableUpdates() }
|
|
||||||
// Seed the baseline only; do NOT emit (else every existing file would fire
|
|
||||||
// `.added` on each launch). The engine's connect-time `reconcile()` does the
|
|
||||||
// initial import; this query then reports only live deltas after the baseline.
|
|
||||||
knownFiles = Set(currentRelativePaths())
|
|
||||||
}
|
|
||||||
|
|
||||||
@objc private func queryDidUpdate(_ notification: Notification) {
|
|
||||||
query?.disableUpdates()
|
|
||||||
defer { query?.enableUpdates() }
|
|
||||||
|
|
||||||
let currentFiles = Set(currentRelativePaths())
|
|
||||||
|
|
||||||
for file in currentFiles.subtracting(knownFiles) {
|
|
||||||
continuation?.yield(.added(relativePath: file))
|
|
||||||
}
|
|
||||||
for file in knownFiles.subtracting(currentFiles) {
|
|
||||||
continuation?.yield(.removed(relativePath: file))
|
|
||||||
}
|
|
||||||
if let updated = notification.userInfo?[NSMetadataQueryUpdateChangedItemsKey] as? [NSMetadataItem] {
|
|
||||||
for item in updated {
|
|
||||||
if let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL,
|
|
||||||
let path = relativePath(from: url), knownFiles.contains(path) {
|
|
||||||
continuation?.yield(.modified(relativePath: path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
knownFiles = currentFiles
|
|
||||||
}
|
|
||||||
|
|
||||||
// MARK: - Helpers
|
|
||||||
|
|
||||||
private func currentRelativePaths() -> [String] {
|
|
||||||
guard let query else { return [] }
|
|
||||||
var paths: [String] = []
|
|
||||||
for i in 0..<query.resultCount {
|
|
||||||
if let item = query.result(at: i) as? NSMetadataItem,
|
|
||||||
let url = item.value(forAttribute: NSMetadataItemURLKey) as? URL,
|
|
||||||
let path = relativePath(from: url) {
|
|
||||||
paths.append(path)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return paths
|
|
||||||
}
|
|
||||||
|
|
||||||
private func relativePath(from url: URL) -> String? {
|
|
||||||
let base = documentsURL.path + "/"
|
|
||||||
let full = url.path
|
|
||||||
guard full.hasPrefix(base) else { return nil }
|
|
||||||
let relative = String(full.dropFirst(base.count))
|
|
||||||
return relative.hasSuffix(".json") ? relative : nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
import IndieSync
|
||||||
import SwiftData
|
import SwiftData
|
||||||
import Observation
|
import Observation
|
||||||
import os
|
import os
|
||||||
@@ -38,8 +39,9 @@ final class SyncEngine {
|
|||||||
|
|
||||||
private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync")
|
private let log = Logger(subsystem: "dev.rzen.indie.Workouts", category: "sync")
|
||||||
private let modelContainer: ModelContainer
|
private let modelContainer: ModelContainer
|
||||||
private var fileManager: ICloudFileManager?
|
private var store: DocumentFileStore?
|
||||||
private var monitor: ICloudFileMonitor?
|
private var tombstones: TombstoneStore?
|
||||||
|
private var monitor: MetadataObserver?
|
||||||
private var monitorTask: Task<Void, Never>?
|
private var monitorTask: Task<Void, Never>?
|
||||||
private var connectAttempt = 0
|
private var connectAttempt = 0
|
||||||
|
|
||||||
@@ -107,7 +109,7 @@ final class SyncEngine {
|
|||||||
guard let containerURL = resolved else { return }
|
guard let containerURL = resolved else { return }
|
||||||
log.info("connect[\(attempt)]: container URL = \(containerURL.path, privacy: .public)")
|
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
|
// 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
|
// 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…")
|
log.info("connect[\(attempt)]: preparing directories…")
|
||||||
await fm.prepareDirectories()
|
await store.prepareDirectories(["Splits", "Workouts", "Stubs"])
|
||||||
safety.cancel()
|
safety.cancel()
|
||||||
guard attempt == connectAttempt else { return }
|
guard attempt == connectAttempt else { return }
|
||||||
|
|
||||||
self.fileManager = fm
|
self.store = store
|
||||||
|
self.tombstones = TombstoneStore(store: store)
|
||||||
iCloudStatus = .available
|
iCloudStatus = .available
|
||||||
log.info("connect[\(attempt)]: directories ready → available")
|
log.info("connect[\(attempt)]: directories ready → available")
|
||||||
WorkoutsModelContainer.persistCurrentIdentityToken()
|
WorkoutsModelContainer.persistCurrentIdentityToken()
|
||||||
|
|
||||||
await reconcile()
|
await reconcile()
|
||||||
startMonitoring(documentsURL: fm.documentsURL)
|
startMonitoring(documentsURL: store.rootURL)
|
||||||
cleanupOldStubs()
|
cleanupOldStubs()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,27 +152,29 @@ final class SyncEngine {
|
|||||||
|
|
||||||
private func startMonitoring(documentsURL: URL) {
|
private func startMonitoring(documentsURL: URL) {
|
||||||
monitorTask?.cancel()
|
monitorTask?.cancel()
|
||||||
let monitor = ICloudFileMonitor(documentsURL: documentsURL)
|
let monitor = MetadataObserver(documentsURL: documentsURL)
|
||||||
self.monitor = monitor
|
self.monitor = monitor
|
||||||
monitor.start()
|
monitor.start()
|
||||||
monitorTask = Task { [weak self] in
|
monitorTask = Task { [weak self] in
|
||||||
for await event in monitor.events() {
|
for await batch in monitor.events() {
|
||||||
await self?.handle(event)
|
await self?.handle(batch)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func handle(_ event: ICloudFileMonitor.FileChangeEvent) async {
|
private func handle(_ batch: [FileChangeEvent]) async {
|
||||||
switch event {
|
for event in batch {
|
||||||
case .added(let path), .modified(let path):
|
switch event {
|
||||||
if path.hasPrefix("Stubs/") {
|
case .added(let path), .modified(let path):
|
||||||
deleteCachedEntity(id: idFromStubPath(path))
|
if path.hasPrefix("Stubs/") {
|
||||||
} else {
|
deleteCachedEntity(id: idFromStubPath(path))
|
||||||
await importFile(relativePath: path)
|
} else {
|
||||||
}
|
await importFile(relativePath: path)
|
||||||
case .removed(let path):
|
}
|
||||||
if !path.hasPrefix("Stubs/") {
|
case .removed(let path):
|
||||||
deleteCachedEntity(jsonRelativePath: path)
|
if !path.hasPrefix("Stubs/") {
|
||||||
|
deleteCachedEntity(jsonRelativePath: path)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
do { try context.save() } catch { report("Cache save failed", error) }
|
do { try context.save() } catch { report("Cache save failed", error) }
|
||||||
@@ -195,9 +200,9 @@ final class SyncEngine {
|
|||||||
// MARK: - Public CRUD (write path: files only)
|
// MARK: - Public CRUD (write path: files only)
|
||||||
|
|
||||||
func save(split doc: SplitDocument) async {
|
func save(split doc: SplitDocument) async {
|
||||||
guard let fm = fileManager else { return }
|
guard let store else { return }
|
||||||
do {
|
do {
|
||||||
try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath)
|
try await store.write(doc, to: doc.relativePath)
|
||||||
lastSyncError = nil
|
lastSyncError = nil
|
||||||
} catch {
|
} catch {
|
||||||
report("Failed to save split", error)
|
report("Failed to save split", error)
|
||||||
@@ -206,9 +211,9 @@ final class SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func save(workout doc: WorkoutDocument) async {
|
func save(workout doc: WorkoutDocument) async {
|
||||||
guard let fm = fileManager else { return }
|
guard let store else { return }
|
||||||
do {
|
do {
|
||||||
try await fm.write(try DocumentCoder.encoder.encode(doc), to: doc.relativePath)
|
try await store.write(doc, to: doc.relativePath)
|
||||||
lastSyncError = nil
|
lastSyncError = nil
|
||||||
} catch {
|
} catch {
|
||||||
report("Failed to save workout", error)
|
report("Failed to save workout", error)
|
||||||
@@ -220,18 +225,20 @@ final class SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func delete(split: Split) async {
|
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 {
|
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 {
|
/// Writes a tombstone stub then removes the live file. Other devices learn of
|
||||||
guard let fm = fileManager else { return }
|
/// the delete via the stub even if they were offline for the file removal.
|
||||||
let tombstone = Tombstone(id: id, kind: kind, deletedAt: Date())
|
private func softDelete(id: String, kind: String, livePath: String) async {
|
||||||
|
guard let store, let tombstones else { return }
|
||||||
do {
|
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
|
lastSyncError = nil
|
||||||
} catch {
|
} catch {
|
||||||
report("Failed to delete \(id)", error)
|
report("Failed to delete \(id)", error)
|
||||||
@@ -241,10 +248,10 @@ final class SyncEngine {
|
|||||||
// MARK: - Import / reconcile
|
// MARK: - Import / reconcile
|
||||||
|
|
||||||
private func importFile(relativePath: String) async {
|
private func importFile(relativePath: String) async {
|
||||||
guard let fm = fileManager else { return }
|
guard let store, let tombstones else { return }
|
||||||
let data: Data
|
let data: Data
|
||||||
do {
|
do {
|
||||||
data = try await fm.read(relativePath: relativePath)
|
data = try await store.readData(from: relativePath)
|
||||||
} catch {
|
} catch {
|
||||||
// Includes eviction-download timeouts — log loudly, never silently
|
// Includes eviction-download timeouts — log loudly, never silently
|
||||||
// treat an unreadable file as absent. The next monitor event or
|
// treat an unreadable file as absent. The next monitor event or
|
||||||
@@ -255,12 +262,12 @@ final class SyncEngine {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if relativePath.hasPrefix("Splits/") {
|
if relativePath.hasPrefix("Splits/") {
|
||||||
guard let doc = try? DocumentCoder.decoder.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 fm.stubExists(id: doc.id) { try? await fm.remove(relativePath: relativePath); return }
|
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); 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.decoder.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 fm.stubExists(id: doc.id) { try? await fm.remove(relativePath: relativePath); return }
|
if await tombstones.stubExists(id: doc.id) { try? await store.remove(at: relativePath); return }
|
||||||
CacheMapper.upsertWorkout(doc, relativePath: relativePath, into: context)
|
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
|
/// prunes entities whose file is gone or tombstoned. Runs on connect so
|
||||||
/// changes accumulated while the app was closed are picked up.
|
/// changes accumulated while the app was closed are picked up.
|
||||||
private func reconcile() async {
|
private func reconcile() async {
|
||||||
guard let fm = fileManager else { return }
|
guard let store, let tombstones else { return }
|
||||||
isSyncing = true
|
isSyncing = true
|
||||||
defer { isSyncing = false }
|
defer { isSyncing = false }
|
||||||
|
|
||||||
// IDs from stub filenames, not stub contents — an evicted stub must
|
// IDs from stub filenames, not stub contents — an evicted stub must
|
||||||
// still count as a tombstone or the deleted record resurrects.
|
// still count as a tombstone or the deleted record resurrects.
|
||||||
let tombstoned = await fm.listTombstoneIDs()
|
let tombstoned = await tombstones.listStubIDs()
|
||||||
let dataFiles = await fm.listDataFiles()
|
let dataFiles = await store.list().filter { !$0.hasPrefix("Stubs/") }
|
||||||
|
|
||||||
var liveSplitIDs = Set<String>()
|
var liveSplitIDs = Set<String>()
|
||||||
var liveWorkoutIDs = Set<String>()
|
var liveWorkoutIDs = Set<String>()
|
||||||
@@ -285,7 +292,7 @@ final class SyncEngine {
|
|||||||
for path in dataFiles {
|
for path in dataFiles {
|
||||||
let data: Data
|
let data: Data
|
||||||
do {
|
do {
|
||||||
data = try await fm.read(relativePath: path)
|
data = try await store.readData(from: path)
|
||||||
} catch {
|
} catch {
|
||||||
// A failed read (eviction-download timeout, coordination error)
|
// A failed read (eviction-download timeout, coordination error)
|
||||||
// is not proof the record is gone — remember the path so the
|
// is not proof the record is gone — remember the path so the
|
||||||
@@ -298,13 +305,13 @@ final class SyncEngine {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if path.hasPrefix("Splits/") {
|
if path.hasPrefix("Splits/") {
|
||||||
guard let doc = try? DocumentCoder.decoder.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 fm.remove(relativePath: path); continue }
|
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
|
||||||
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
|
CacheMapper.upsertSplit(doc, relativePath: path, into: context)
|
||||||
liveSplitIDs.insert(doc.id)
|
liveSplitIDs.insert(doc.id)
|
||||||
} else if path.hasPrefix("Workouts/") {
|
} else if path.hasPrefix("Workouts/") {
|
||||||
guard let doc = try? DocumentCoder.decoder.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 fm.remove(relativePath: path); continue }
|
if tombstoned.contains(doc.id) { try? await store.remove(at: path); continue }
|
||||||
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
|
CacheMapper.upsertWorkout(doc, relativePath: path, into: context)
|
||||||
liveWorkoutIDs.insert(doc.id)
|
liveWorkoutIDs.insert(doc.id)
|
||||||
}
|
}
|
||||||
@@ -357,12 +364,11 @@ final class SyncEngine {
|
|||||||
// MARK: - Maintenance
|
// MARK: - Maintenance
|
||||||
|
|
||||||
private func cleanupOldStubs() {
|
private func cleanupOldStubs() {
|
||||||
guard let fm = fileManager else { return }
|
guard let tombstones else { return }
|
||||||
Task.detached(priority: .utility) {
|
Task.detached(priority: .utility) {
|
||||||
let cutoff = Date().addingTimeInterval(-Tombstone.gracePeriod)
|
// Downloads evicted stubs to read their deletedAt, prunes past the
|
||||||
for tombstone in await fm.listTombstones() where tombstone.deletedAt < cutoff {
|
// 30-day grace period.
|
||||||
try? await fm.remove(relativePath: tombstone.relativePath)
|
try? await tombstones.prune()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import IndieSync
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
@@ -207,7 +208,7 @@ struct ExerciseListView: View {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
let doc = WorkoutDocument(
|
let doc = WorkoutDocument(
|
||||||
schemaVersion: WorkoutDocument.currentSchema,
|
schemaVersion: WorkoutDocument.currentSchemaVersion,
|
||||||
id: ULID.make(),
|
id: ULID.make(),
|
||||||
splitID: split.id,
|
splitID: split.id,
|
||||||
splitName: split.name,
|
splitName: split.name,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import IndieSync
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
@@ -157,7 +158,7 @@ struct SplitAddEditView: View {
|
|||||||
// Create new split
|
// Create new split
|
||||||
let existing = (try? modelContext.fetch(FetchDescriptor<Split>())) ?? []
|
let existing = (try? modelContext.fetch(FetchDescriptor<Split>())) ?? []
|
||||||
let doc = SplitDocument(
|
let doc = SplitDocument(
|
||||||
schemaVersion: SplitDocument.currentSchema,
|
schemaVersion: SplitDocument.currentSchemaVersion,
|
||||||
id: ULID.make(),
|
id: ULID.make(),
|
||||||
name: name,
|
name: name,
|
||||||
color: color,
|
color: color,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import IndieSync
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import IndieSync
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
|
||||||
//
|
//
|
||||||
|
|
||||||
|
import IndieSync
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import SwiftData
|
import SwiftData
|
||||||
|
|
||||||
@@ -238,7 +239,7 @@ struct SplitPickerSheet: View {
|
|||||||
|
|
||||||
// A freshly started workout has no `end` — only completion stamps it.
|
// A freshly started workout has no `end` — only completion stamps it.
|
||||||
let doc = WorkoutDocument(
|
let doc = WorkoutDocument(
|
||||||
schemaVersion: WorkoutDocument.currentSchema,
|
schemaVersion: WorkoutDocument.currentSchemaVersion,
|
||||||
id: ULID.make(),
|
id: ULID.make(),
|
||||||
splitID: split.id,
|
splitID: split.id,
|
||||||
splitName: split.name,
|
splitName: split.name,
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ packages:
|
|||||||
IndieAbout:
|
IndieAbout:
|
||||||
url: https://git.rzen.dev/rzen/indie-about.git
|
url: https://git.rzen.dev/rzen/indie-about.git
|
||||||
from: "0.2.2"
|
from: "0.2.2"
|
||||||
|
IndieSync:
|
||||||
|
url: https://git.rzen.dev/rzen/indie-sync.git
|
||||||
|
from: "0.1.0"
|
||||||
Yams:
|
Yams:
|
||||||
url: https://github.com/jpsim/Yams
|
url: https://github.com/jpsim/Yams
|
||||||
from: "6.0.0"
|
from: "6.0.0"
|
||||||
@@ -45,6 +48,7 @@ targets:
|
|||||||
type: file
|
type: file
|
||||||
dependencies:
|
dependencies:
|
||||||
- package: IndieAbout
|
- package: IndieAbout
|
||||||
|
- package: IndieSync
|
||||||
- package: Yams
|
- package: Yams
|
||||||
- target: Workouts Watch App
|
- target: Workouts Watch App
|
||||||
postBuildScripts:
|
postBuildScripts:
|
||||||
@@ -81,6 +85,7 @@ targets:
|
|||||||
- "Resources/Info-*.plist"
|
- "Resources/Info-*.plist"
|
||||||
- "Resources/*.entitlements"
|
- "Resources/*.entitlements"
|
||||||
dependencies:
|
dependencies:
|
||||||
|
- package: IndieSync
|
||||||
- target: Workouts Watch Widget
|
- target: Workouts Watch Widget
|
||||||
embed: true
|
embed: true
|
||||||
postBuildScripts:
|
postBuildScripts:
|
||||||
|
|||||||
Reference in New Issue
Block a user