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
+2 -1
View File
@@ -1,3 +1,4 @@
import IndieSync
import Foundation
import SwiftData
@@ -58,7 +59,7 @@ enum SplitSeeder {
)
}
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,
createdAt: Date(), updatedAt: Date(), exercises: exercises,
activityType: split.activity.rawValue
-228
View File
@@ -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"
])
}
}
-111
View File
@@ -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
}
}
+55 -49
View File
@@ -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()
}
}
@@ -7,6 +7,7 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import IndieSync
import SwiftUI
import SwiftData
@@ -207,7 +208,7 @@ struct ExerciseListView: View {
)
}
let doc = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchema,
schemaVersion: WorkoutDocument.currentSchemaVersion,
id: ULID.make(),
splitID: split.id,
splitName: split.name,
+2 -1
View File
@@ -7,6 +7,7 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import IndieSync
import SwiftUI
import SwiftData
@@ -157,7 +158,7 @@ struct SplitAddEditView: View {
// Create new split
let existing = (try? modelContext.fetch(FetchDescriptor<Split>())) ?? []
let doc = SplitDocument(
schemaVersion: SplitDocument.currentSchema,
schemaVersion: SplitDocument.currentSchemaVersion,
id: ULID.make(),
name: name,
color: color,
@@ -7,6 +7,7 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import IndieSync
import SwiftUI
import SwiftData
@@ -7,6 +7,7 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import IndieSync
import SwiftUI
import SwiftData
@@ -7,6 +7,7 @@
// Copyright 2025 Rouslan Zenetl. All Rights Reserved.
//
import IndieSync
import SwiftUI
import SwiftData
@@ -238,7 +239,7 @@ struct SplitPickerSheet: View {
// A freshly started workout has no `end` only completion stamps it.
let doc = WorkoutDocument(
schemaVersion: WorkoutDocument.currentSchema,
schemaVersion: WorkoutDocument.currentSchemaVersion,
id: ULID.make(),
splitID: split.id,
splitName: split.name,