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
+5 -55
View File
@@ -1,4 +1,5 @@
import Foundation
import IndieSync
/// On-disk JSON shape for each aggregate. Independent from the SwiftData cache
/// 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.
var activityType: Int?
static let currentSchema = 1
static let currentSchemaVersion = 1
var relativePath: String { "Splits/\(id).json" }
}
@@ -69,7 +70,7 @@ struct WorkoutDocument: Codable, Sendable, Equatable, Identifiable {
// Bumped 12 when `metrics` was added: the captured HR/calorie data is
// irreplaceable, so the forward-gate must quarantine these files on older apps
// 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) }
@@ -158,58 +159,7 @@ struct WorkoutMetrics: Codable, Sendable, Equatable {
// MARK: - Forward-compatibility gate
/// A file whose `schemaVersion` exceeds the reader's `currentSchema` was written
/// by a newer app version; it must be quarantined, not partially decoded (Codable
/// 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 }
}
// `VersionedDocument` (the `isReadable` quarantine gate for files written by a
// newer app version), `Tombstone`, and `DocumentCoder` all live in IndieSync now.
extension SplitDocument: 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
}()
}