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
+12 -7
View File
@@ -52,18 +52,23 @@ enum SeedLibrary {
static func seed(id: String) -> Seed? { seeds.first { $0.id == id } } static func seed(id: String) -> Seed? { seeds.first { $0.id == id } }
/// True when `doc` is unchanged from its pristine seed in every field except /// True when `doc` is unchanged from its pristine seed in every field that counts
/// `updatedAt`. The edit sheets stamp `updatedAt` on every Save, so a no-op save /// as *content* i.e. ignoring `updatedAt` and `order`, both of which are
/// would otherwise read as an edit and fork the seed; normalizing both timestamps /// presentation/bookkeeping rather than content. The edit sheets stamp `updatedAt`
/// to a common value before the `==` isolates real changes. A non-seed id has no /// on every Save, so a no-op save would otherwise read as an edit and fork the seed;
/// and dragging a starter to reorder the list writes a new `order` to it a reorder
/// must NOT fork a curated starter into a user routine. Normalizing both fields to a
/// common value before the `==` isolates real content changes. A non-seed id has no
/// seed to compare against treated as pristine (callers gate on `isSeed` first). /// seed to compare against treated as pristine (callers gate on `isSeed` first).
static func isPristine(_ doc: RoutineDocument) -> Bool { static func isPristine(_ doc: RoutineDocument) -> Bool {
guard let seed = seed(id: doc.id) else { return true } guard let seed = seed(id: doc.id) else { return true }
var edited = doc var edited = doc
var pristine = seed.doc var pristine = seed.doc
let common = Date(timeIntervalSince1970: 0) let commonDate = Date(timeIntervalSince1970: 0)
edited.updatedAt = common edited.updatedAt = commonDate
pristine.updatedAt = common pristine.updatedAt = commonDate
edited.order = 0
pristine.order = 0
return edited == pristine return edited == pristine
} }
} }
+10
View File
@@ -93,9 +93,19 @@ enum SeedReconcilePlanner {
/// live doc's `schemaVersion` to the seed's before the `==` so an older wrapper of /// live doc's `schemaVersion` to the seed's before the `==` so an older wrapper of
/// identical content reads as up to date (no churn), while a genuine content /// identical content reads as up to date (no churn), while a genuine content
/// change reads as different (upgrade). /// change reads as different (upgrade).
///
/// `order` and `updatedAt` are likewise normalized away: dragging a starter to
/// reorder the Library list writes a new `order` (and stamps `updatedAt`) onto the
/// seed's fixed-ULID file. That is presentation, not content a live seed whose
/// only drift is the user's ordering must read `.upToDate` so the on-connect
/// reconcile never clobbers it back to the bundle's default order. A genuine content
/// change still differs and upgrades. (Accepted tradeoff: a real bundle-shipped seed
/// revision does upgrade verbatim and resets that one seed's order rare, harmless.)
static func semanticallyEqual(live: RoutineDocument, seed: RoutineDocument) -> Bool { static func semanticallyEqual(live: RoutineDocument, seed: RoutineDocument) -> Bool {
var normalized = live var normalized = live
normalized.schemaVersion = seed.schemaVersion normalized.schemaVersion = seed.schemaVersion
normalized.order = seed.order
normalized.updatedAt = seed.updatedAt
return normalized == seed return normalized == seed
} }
+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 /// Rewrite `routineID` on every workout that references `oldID`, so routine lookups
/// (category grouping, add-exercise, plan mirroring, health estimates) keep /// (category grouping, add-exercise, plan mirroring, health estimates) keep
/// resolving after a seed fork durably, across relaunches, and on the watch, /// 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. /// `reconcileSeeds` to upgrade. Returns how many seeds were (re)written.
@discardableResult @discardableResult
func restoreSeeds() async -> Int { 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/") }) let livePaths = Set(await store.list().filter { !$0.hasPrefix("Stubs/") })
var restored = 0 var restored = 0
for seed in SeedLibrary.seeds { for seed in SeedLibrary.seeds {
let liveNames = Set(((try? context.fetch(FetchDescriptor<Routine>())) ?? []).map(\.name)) if await restoreSeedIfEligible(seed, livePaths: livePaths) { restored += 1 }
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 restored > 0 { if restored > 0 {
@@ -1087,6 +1102,58 @@ final class SyncEngine {
return restored 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 /// 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). /// whether the write succeeded (so the caller can flush + notify once at the end).
@discardableResult @discardableResult
+30
View File
@@ -93,6 +93,36 @@ struct SeedLibraryTests {
#expect(!SeedLibrary.isPristine(edited)) #expect(!SeedLibrary.isPristine(edited))
} }
/// Dragging a starter to reorder the Library list writes a new `order` to it. Order
/// is presentation, not content, so a reorder must NOT fork the seed an order-only
/// change reads as pristine.
@Test func pristineIgnoresOrderOnlyChange() throws {
let seed = try #require(SeedLibrary.seeds.first)
var edited = seed.doc
edited.order += 5
#expect(SeedLibrary.isPristine(edited))
}
/// A reorder save also stamps `updatedAt`; order + updatedAt together must still read
/// as pristine so a drag never forks a starter.
@Test func pristineIgnoresOrderAndUpdatedAtChange() throws {
let seed = try #require(SeedLibrary.seeds.first)
var edited = seed.doc
edited.order += 3
edited.updatedAt = Date()
#expect(SeedLibrary.isPristine(edited))
}
/// Order tolerance must not mask a genuine content edit: a real change (here, a
/// different rest length) is still detected even when `order` also differs.
@Test func pristineDetectsContentChangeDespiteOrderChange() throws {
let seed = try #require(SeedLibrary.seeds.first)
var edited = seed.doc
edited.order += 2
edited.restSeconds = (seed.doc.restSeconds ?? 60) + 30
#expect(!SeedLibrary.isPristine(edited))
}
/// A non-seed id has no seed to compare against; `isPristine` defaults to /// A non-seed id has no seed to compare against; `isPristine` defaults to
/// true since callers are expected to gate on `isSeed` before asking. /// true since callers are expected to gate on `isSeed` before asking.
@Test func pristineDefaultsTrueForNonSeedID() throws { @Test func pristineDefaultsTrueForNonSeedID() throws {
@@ -85,6 +85,44 @@ struct SeedReconcilePlannerTests {
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .skip(.quarantined)) #expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .skip(.quarantined))
} }
// MARK: - Order tolerance (user reordered their starters)
/// The user dragged this starter to a new position, writing a new `order` to its
/// fixed-ULID file. That is presentation, not content, so reconcile must read it as
/// up to date and never clobber the ordering back to the bundle's default.
@Test func orderOnlyDriftIsUpToDate() {
let bundle = seed() // order 0
var live = bundle
live.order = 7 // user reordered their starters
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .skip(.upToDate))
}
/// A reorder save also stamps `updatedAt`; order + updatedAt drift together must
/// still read as up to date.
@Test func orderAndUpdatedAtDriftIsUpToDate() {
let bundle = seed()
var live = bundle
live.order = 4
live.updatedAt = Date() // a reorder save bumps updatedAt too
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .skip(.upToDate))
}
/// Order tolerance must not mask a genuine content upgrade: when the bundle content
/// changed AND the user had reordered (order differs too), it still upgrades the
/// order normalization can't swallow the real content diff.
@Test func contentUpgradeWinsOverOrderTolerance() {
let bundle = seed(exercises: [exercise(name: "Bench Press"), exercise(name: "Overhead Press", order: 1)])
var live = seed(exercises: [exercise(name: "Bench Press")]) // bundle gained an exercise
live.order = 9 // and the user had reordered
let input = SeedReconcileInput(seedID: bundle.id, seedDoc: bundle, liveDoc: live, hasStub: false)
#expect(SeedReconcilePlanner.decision(for: input, liveRoutineNames: []) == .upgrade)
}
// MARK: - No live file // MARK: - No live file
/// No live file but a stub exists the user deleted this seed. The veto stands. /// No live file but a stub exists the user deleted this seed. The veto stands.