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:
@@ -52,18 +52,23 @@ enum SeedLibrary {
|
||||
|
||||
static func seed(id: String) -> Seed? { seeds.first { $0.id == id } }
|
||||
|
||||
/// True when `doc` is unchanged from its pristine seed in every field except
|
||||
/// `updatedAt`. The edit sheets stamp `updatedAt` on every Save, so a no-op save
|
||||
/// would otherwise read as an edit and fork the seed; normalizing both timestamps
|
||||
/// to a common value before the `==` isolates real changes. A non-seed id has no
|
||||
/// True when `doc` is unchanged from its pristine seed in every field that counts
|
||||
/// as *content* — i.e. ignoring `updatedAt` and `order`, both of which are
|
||||
/// presentation/bookkeeping rather than content. The edit sheets stamp `updatedAt`
|
||||
/// 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).
|
||||
static func isPristine(_ doc: RoutineDocument) -> Bool {
|
||||
guard let seed = seed(id: doc.id) else { return true }
|
||||
var edited = doc
|
||||
var pristine = seed.doc
|
||||
let common = Date(timeIntervalSince1970: 0)
|
||||
edited.updatedAt = common
|
||||
pristine.updatedAt = common
|
||||
let commonDate = Date(timeIntervalSince1970: 0)
|
||||
edited.updatedAt = commonDate
|
||||
pristine.updatedAt = commonDate
|
||||
edited.order = 0
|
||||
pristine.order = 0
|
||||
return edited == pristine
|
||||
}
|
||||
}
|
||||
|
||||
@@ -93,9 +93,19 @@ enum SeedReconcilePlanner {
|
||||
/// 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
|
||||
/// 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 {
|
||||
var normalized = live
|
||||
normalized.schemaVersion = seed.schemaVersion
|
||||
normalized.order = seed.order
|
||||
normalized.updatedAt = seed.updatedAt
|
||||
return normalized == seed
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -93,6 +93,36 @@ struct SeedLibraryTests {
|
||||
#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
|
||||
/// true since callers are expected to gate on `isSeed` before asking.
|
||||
@Test func pristineDefaultsTrueForNonSeedID() throws {
|
||||
|
||||
@@ -85,6 +85,44 @@ struct SeedReconcilePlannerTests {
|
||||
#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
|
||||
|
||||
/// No live file but a stub exists → the user deleted this seed. The veto stands.
|
||||
|
||||
Reference in New Issue
Block a user