Treat order as presentation in seed logic; add per-seed restore and routine duplication

Drag-to-reorder writes Routine.order through the document, which used to
read as a content edit: isPristine would fork a starter for a mere reorder,
and reconcile's semantic compare would clobber a reordered seed file back
to bundle order. Both now normalize order (and updatedAt) away — a fixed-
ULID file still never holds user content; ordering is bookkeeping.

SyncEngine gains restoreSeed(id:) — the per-seed analog of the bulk
restore, sharing one restoreSeedIfEligible core — and duplicate(routine:),
which copies any routine (starter or not) to a fresh ULID with fresh
exercise ids, a unique "… Copy" name, and last position in the list.

Claude-Session: https://claude.ai/code/session_01H8VxUX4ckjU3vRF5M4L5FV
This commit is contained in:
2026-07-15 10:45:02 -04:00
parent 36dad21233
commit 16b61946d5
5 changed files with 178 additions and 28 deletions
+88 -21
View File
@@ -395,6 +395,40 @@ final class SyncEngine {
}
}
/// Duplicate any routine user-authored or a starter into a fresh, fully
/// independent user routine. Unlike clone-on-edit (which forks a *seed* under the
/// covers and preserves its identity), a duplicate of a starter is deliberately a
/// plain user routine: the copy gets a brand-new random ULID, so it is NOT a seed
/// no starter badge, and editing it never triggers clone-on-edit. Every exercise also
/// gets a fresh ULID (mandatory: `Exercise.id` is `@Attribute(.unique)` and both
/// routines stay live, so shared ids would violate the constraint), with order and
/// content otherwise preserved. The copy is named with the next free " Copy" variant
/// and appended to the end of the routine list. Returns the new routine's id, or nil
/// when the engine isn't connected.
@discardableResult
func duplicate(routine: Routine) async -> String? {
guard store != nil else { return nil }
var doc = RoutineDocument(from: routine)
doc.id = ULID.make()
let now = Date()
doc.createdAt = now
doc.updatedAt = now
// Fresh id per exercise both the source and the copy stay live, and
// `Exercise.id` is unique-constrained, so the copy can't reuse the originals.
for i in doc.exercises.indices {
doc.exercises[i].id = ULID.make()
}
// Append after the current last routine, and pick a name unique among live ones.
let liveRoutines = (try? context.fetch(FetchDescriptor<Routine>())) ?? []
doc.order = (liveRoutines.map(\.order).max() ?? -1) + 1
doc.name = RoutineNaming.uniqueName(base: routine.name, existing: Set(liveRoutines.map(\.name)))
// A fresh ULID means this is not a seed, so `save` takes the plain path (no fork).
return await save(routine: doc)
}
/// Rewrite `routineID` on every workout that references `oldID`, so routine lookups
/// (category grouping, add-exercise, plan mirroring, health estimates) keep
/// resolving after a seed fork durably, across relaunches, and on the watch,
@@ -1052,32 +1086,13 @@ final class SyncEngine {
/// `reconcileSeeds` to upgrade. Returns how many seeds were (re)written.
@discardableResult
func restoreSeeds() async -> Int {
guard let store, let tombstones else { return 0 }
guard let store, tombstones != nil else { return 0 }
let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })
var restored = 0
for seed in SeedLibrary.seeds {
let liveNames = Set(((try? context.fetch(FetchDescriptor<Routine>())) ?? []).map(\.name))
guard SeedReconcilePlanner.shouldRestore(
hasLiveFile: livePaths.contains(seed.doc.relativePath),
seedName: seed.doc.name,
liveRoutineNames: liveNames
) else { continue }
do {
// Lift the veto: drop the seed's tombstone stub if one exists, then
// write the verbatim bundle bytes.
if await tombstones.stubExists(id: seed.id) {
try await tombstones.removeStub(at: "\(seed.id).json")
}
try await store.writeData(seed.data, to: seed.doc.relativePath)
CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
restored += 1
lastSyncError = nil
} catch {
report("Failed to restore starter routine \(seed.doc.name)", error)
}
if await restoreSeedIfEligible(seed, livePaths: livePaths) { restored += 1 }
}
if restored > 0 {
@@ -1087,6 +1102,58 @@ final class SyncEngine {
return restored
}
/// The per-seed analog of `restoreSeeds()` restore ONE starter routine by its fixed
/// seed id (used by the Library tab's per-row restore). Runs the identical gate and
/// writes as the bulk path via the shared `restoreSeedIfEligible` core: a no-op unless
/// that seed is both absent and free of a live same-name routine, in which case it
/// lifts the veto stub and writes the current bundle bytes. Returns whether it wrote
/// false for an unknown id, a not-connected engine, or a seed deliberately left alone.
func restoreSeed(id: String) async -> Bool {
guard let store, tombstones != nil else { return false }
guard let seed = SeedLibrary.seed(id: id) else { return false }
let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })
let wrote = await restoreSeedIfEligible(seed, livePaths: livePaths)
if wrote {
do { try context.save() } catch { report("Cache save failed", error) }
onCacheChanged?()
}
return wrote
}
/// Shared per-seed restore core for `restoreSeeds()` (bulk) and `restoreSeed(id:)`
/// (single). Gates on `SeedReconcilePlanner.shouldRestore` restore only a seed that
/// is absent and whose name is free then lifts its veto stub (if any) and writes the
/// verbatim bundle bytes, mirroring the cache entity. Live routine names are re-read
/// here (not passed in) so a prior write in the bulk loop is seen. Returns whether it
/// wrote; the caller owns the single `context.save()` + `onCacheChanged` fan-out.
private func restoreSeedIfEligible(_ seed: SeedLibrary.Seed, livePaths: Set<String>) async -> Bool {
guard let store, let tombstones else { return false }
let liveNames = Set(((try? context.fetch(FetchDescriptor<Routine>())) ?? []).map(\.name))
guard SeedReconcilePlanner.shouldRestore(
hasLiveFile: livePaths.contains(seed.doc.relativePath),
seedName: seed.doc.name,
liveRoutineNames: liveNames
) else { return false }
do {
// Lift the veto: drop the seed's tombstone stub if one exists, then write
// the verbatim bundle bytes.
if await tombstones.stubExists(id: seed.id) {
try await tombstones.removeStub(at: "\(seed.id).json")
}
try await store.writeData(seed.data, to: seed.doc.relativePath)
CacheMapper.upsertRoutine(seed.doc, relativePath: seed.doc.relativePath, into: context)
lastSyncError = nil
return true
} catch {
report("Failed to restore starter routine \(seed.doc.name)", error)
return false
}
}
/// Write a seed's verbatim bundle bytes and mirror it into the cache. Returns
/// whether the write succeeded (so the caller can flush + notify once at the end).
@discardableResult