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
+9 -10
View File
@@ -24,20 +24,19 @@ A workout tracking app for iPhone and Apple Watch, built with Swift 6 and SwiftU
### 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).
- **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.
- **Key types** (under `Workouts/Sync/`):
- `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.
- `ICloudFileManager` (`actor`) — all `NSFileCoordinator` file I/O, off the main thread.
- `ICloudFileMonitor` (`@MainActor`) — wraps the `NSMetadataQuery`, emitting added/modified/removed deltas as an `AsyncStream`.
- **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**:
- `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.
- 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.
- **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.
### Data Layer (three shapes + a stateless mapper)
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.
- **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`.
### Core Aggregates
@@ -60,7 +59,7 @@ All in `Shared/Model/`:
- 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.
- 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
- `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
- 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.
- Mint IDs with `ULID.make()`; they are stable across cache rebuilds.
### 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
- SwiftUI-first: `NavigationStack`, `@Query`-backed lists, form-based add/edit, sheet presentations, and swipe actions.