Machine-based exercises now carry ordered name/value comfort settings (seat height, back-rest position, ...) on both ExerciseDocument and, as a plan-time snapshot, WorkoutLogDocument — the optional array doubles as the machine flag. Editable from the exercise editor's new Machine section and from the workout row's settings sheet, whose mid-workout edits write back to the originating split (workout saved first, so seed clone-on-edit repointing can't clobber the log edit). Schema tightening rides the same rev: splits bump to v2 (weight-reminder fields and the unused exercise category removed), workouts to v3 (the derived `completed` flag removed; status is the single source). Starter seeds regenerated at v2 with unchanged ULIDs; SwiftData cache schema bumped to rebuild. SCHEMA.md documents the shapes. Claude-Session: https://claude.ai/code/session_01LEoff8bXGBS83tK1c55Mf7
7.2 KiB
SCHEMA.md
The on-disk JSON document schemas — the app's source of truth. The canonical
definitions are the Codable structs in Shared/Model/Documents.swift (enums in
Shared/Model/Enums.swift); this file is a readable summary. If they disagree,
the code wins.
Encoding (IndieSync DocumentCoder): pretty-printed JSON with sorted keys;
dates as ISO-8601 strings. One file per aggregate root:
| Aggregate | Path in iCloud container |
|---|---|
| Split (embeds its exercises) | Splits/<ULID>.json |
| Workout (embeds its logs) | Workouts/YYYY/MM/<ULID>.json (local-calendar month of start) |
| Tombstone (soft-delete stub) | Stubs/<same relative path as the deleted file> |
schemaVersion gates forward compatibility (VersionedDocument): files written
by a newer app version are quarantined, not rewritten. Fields marked not
schema-bumped were added as optionals without a version bump — an older app
silently drops them on rewrite, which is accepted as preferable to quarantine.
The same shapes are also the iPhone↔Watch wire format, and each has a mirrored
SwiftData cache entity (Shared/Model/Entities.swift) kept in sync by
Mappers.swift.
SplitDocument — currentSchemaVersion: 2
| Property | Type | Optional | Notes |
|---|---|---|---|
schemaVersion |
Int | Forward-compatibility gate. Bumped 1→2 when the weight-reminder fields and category were removed from ExerciseDocument and machineSettings was added — removing required fields means older apps can't decode new files, so they must quarantine, not rewrite |
|
id |
String | ULID | |
name |
String | ||
color |
String | Theme color name | |
systemImage |
String | SF Symbol name | |
order |
Int | Sort position in the splits list | |
createdAt |
Date | ||
updatedAt |
Date | ||
exercises |
[ExerciseDocument] | Embedded aggregate members | |
activityType |
Int | ✓ | Raw WorkoutActivityType; nil → traditionalStrength. Not schema-bumped |
ExerciseDocument (embedded in SplitDocument)
| Property | Type | Optional | Notes |
|---|---|---|---|
id |
String | ULID | |
name |
String | Also keys the motion rig / picker catalog | |
order |
Int | Sort position within the split | |
sets |
Int | ||
reps |
Int | ||
weight |
Int | Unit-less integer; WeightUnit only relabels display |
|
loadType |
Int | Raw LoadType |
|
durationSeconds |
Int | Total seconds; 0 when not a timed exercise | |
machineSettings |
[MachineSetting] | ✓ | Ordered machine comfort settings; nil → not a machine exercise, empty → machine exercise with nothing recorded. Doubles as the machine-based flag |
WorkoutDocument — currentSchemaVersion: 3
| Property | Type | Optional | Notes |
|---|---|---|---|
schemaVersion |
Int | Bumped 1→2 when metrics was added (captured HR data is irreplaceable, so older apps must quarantine, not strip). Bumped 2→3 when the derived completed flag was removed from WorkoutLogDocument and machineSettings was added — dropping a required field means older apps can't decode new files, so they must quarantine |
|
id |
String | ULID (chronological — drives month bucketing and sorting) | |
splitID |
String | ✓ | Denormalized reference; no live relationship |
splitName |
String | ✓ | Denormalized snapshot of the split's name |
start |
Date | Also determines the file's YYYY/MM bucket (never re-bucketed after write) |
|
end |
Date | ✓ | Stamped by recomputeStatusFromLogs() when all logs resolve |
status |
String | Raw WorkoutStatus; derived from logs, never set directly |
|
createdAt |
Date | ||
updatedAt |
Date | ||
logs |
[WorkoutLogDocument] | Embedded aggregate members | |
metrics |
WorkoutMetrics | ✓ | Nil until the workout completes and a writer fills it in |
WorkoutLogDocument (embedded in WorkoutDocument)
| Property | Type | Optional | Notes |
|---|---|---|---|
id |
String | ULID | |
exerciseName |
String | Snapshot by name — no reference to the exercise's ULID | |
order |
Int | Sort position within the workout | |
sets |
Int | Planned sets (snapshot from the exercise) | |
reps |
Int | Planned reps (snapshot) | |
weight |
Int | Snapshot; unit-less integer | |
loadType |
Int | Raw LoadType (snapshot) |
|
durationSeconds |
Int | Total seconds; 0 when not a timed exercise | |
currentStateIndex |
Int | Progress through the set sequence | |
status |
String | Raw WorkoutStatus; mutate only via transition(to:). Completion is derived: completed == (status == "completed") |
|
notes |
String | ✓ | |
date |
Date | ||
machineSettings |
[MachineSetting] | ✓ | Snapshot of the exercise's machine settings at plan time; nil → not a machine exercise, empty → nothing recorded |
startedAt |
Date | ✓ | First move to .inProgress. Not schema-bumped |
completedAt |
Date | ✓ | Last move to .completed. Not schema-bumped |
MachineSetting (embedded in ExerciseDocument and WorkoutLogDocument)
A single free-form comfort setting for a machine-based exercise. value is a
string, not a number, because machine dials aren't uniformly numeric.
| Property | Type | Optional | Notes |
|---|---|---|---|
name |
String | e.g. "Seat Height", "Back Rest Incline" | |
value |
String | Free-form, e.g. "4", "3rd hole", "45°" |
WorkoutMetrics (embedded in WorkoutDocument, schema v2)
| Property | Type | Optional | Notes |
|---|---|---|---|
activeEnergyKcal |
Double | ✓ | |
avgHeartRate |
Double | ✓ | bpm; watch sessions only |
maxHeartRate |
Double | ✓ | bpm; watch sessions only |
minHeartRate |
Double | ✓ | bpm; watch sessions only |
totalVolume |
Double | ✓ | Σ sets×reps×weight across weighted logs |
hrZoneSeconds |
[Double] | ✓ | Seconds in each of 5 HR zones, low→high |
healthKitWorkoutUUID |
String | ✓ | Presence is the phone/watch Health-write dedupe signal |
source |
String | Raw MetricSource |
|
recordedAt |
Date |
Tombstone (IndieSync, minimal stub)
A delete writes a stub to Stubs/…, then removes the live file. A full stub
is a verbatim copy of the record's JSON plus an injected deletedAt; the
minimal stub below is the fallback when the source is unreadable. Stubs are
pruned after a 30-day grace period, except starter-seed stubs (exempt, and they
veto seed resurrection forever).
| Property | Type | Optional | Notes |
|---|---|---|---|
id |
String | The deleted record's ULID | |
deletedAt |
Date | ||
kind |
String | ✓ | App-defined category |
Enum raw values
WorkoutStatus (String — on WorkoutDocument.status, WorkoutLogDocument.status):
notStarted, inProgress, completed, skipped
LoadType (Int — on ExerciseDocument.loadType, WorkoutLogDocument.loadType):
0 none · 1 weight · 2 duration
WorkoutActivityType (Int — on SplitDocument.activityType):
0 traditionalStrength · 1 functionalStrength · 2 hiit · 3 coreTraining · 4 cardio · 5 cycling
MetricSource (String — on WorkoutMetrics.source):
watch, phoneEstimate
WeightUnit (lb/kg) is a display setting only — it is never persisted in
documents, and stored weight integers are never rewritten when it changes.