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
|
||||
|
||||
/// 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 1→2 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
|
||||
}()
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import IndieSync
|
||||
import Foundation
|
||||
import SwiftData
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ extension ExerciseDocument {
|
||||
|
||||
extension SplitDocument {
|
||||
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,
|
||||
createdAt: split.createdAt, updatedAt: split.updatedAt,
|
||||
exercises: split.exercisesArray.map(ExerciseDocument.init(from:)),
|
||||
@@ -43,7 +43,7 @@ extension WorkoutLogDocument {
|
||||
|
||||
extension WorkoutDocument {
|
||||
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,
|
||||
status: workout.statusRaw, createdAt: workout.createdAt, updatedAt: workout.updatedAt,
|
||||
logs: workout.logsArray.map(WorkoutLogDocument.init(from:)),
|
||||
|
||||
+8
-65
@@ -1,68 +1,11 @@
|
||||
import Foundation
|
||||
import IndieSync
|
||||
|
||||
/// Minimal ULID generator.
|
||||
///
|
||||
/// 26-character Crockford base32 string: 10 chars of millisecond timestamp +
|
||||
/// 16 chars of randomness. Lexicographic order matches chronological order,
|
||||
/// so workout documents sort newest-last by id and file listings stay ordered.
|
||||
///
|
||||
/// Spec: https://github.com/ulid/spec
|
||||
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)
|
||||
}
|
||||
// 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
|
||||
// app-wide `ULID.make()` minting entry point unchanged.
|
||||
extension ULID {
|
||||
/// Mints a fresh ULID for the current instant, as its canonical
|
||||
/// 26-character Crockford base32 string.
|
||||
static func make() -> String { ULID().stringValue }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user