Files
workouts/SCHEMA.md
T
rzen 8c8f22850e Merge watch workout pushes per log instead of whole-document
ingestFromWatch arbitrated by whole-document updatedAt, so concurrent edits
to the same workout on both devices (phone edits exercise A while the watch
completes exercise B) lost one side wholesale — the newer snapshot replaced
the other (H1).

Reconcile per log instead. WorkoutMergePlanner (pure, deterministic) unions
logs by id, resolves each by newest per-log updatedAt, and applies
phone-authored deletion tombstones so an absent log is never ambiguous
between "deleted on the phone" and "just added on the watch". Edits to
different exercises now commute — delivery order and offline gaps stop
mattering. A stale/duplicate push merges back to exactly the cached doc, so
ingest re-pushes authoritative state rather than writing.

The per-log updatedAt scaffolding shipped (unused) in schema v4; it's now
stamped by transition(to:) on status flips and a new touch() at the
content-only edit sites (order, notes, machine settings, adjusted entries,
new logs) on both phone and watch. deletedLogIDs is new: additive on the
wire and cache, phone-authored (deleteLog), pruned after a 30-day grace.
Because an older build rewriting a file would strip the tombstones and
resurrect a deleted exercise, WorkoutDocument schema bumps 4->5 (forward
gate quarantines old builds) and the cache bumps 5->6.

recomputeStatusFromLogs takes an injectable now: so the merge recomputes
status/end deterministically. WorkoutMergePlannerTests pins the decision
table (commute, per-log newer-wins, legacy-nil, watch-add, tombstone
honored/resurrect/union/prune, status recompute, no-op re-push);
WorkoutDocumentMapperTests gains the deletedLogIDs round-trip.
2026-07-09 08:35:16 -04:00

164 lines
9.2 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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: 3`
| 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. Bumped 2→3 when `ExerciseDocument.weight` went Int → Double — an older app decoding a fractional weight into `Int` fails, so it must quarantine |
| `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` | Double | | Unit-less; `WeightUnit` only relabels display. Int → Double in schema v3 (old integer values decode natively) |
| `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: 5`
| 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. Bumped 3→4 when `WorkoutLogDocument.weight` went Int → Double and per-set actuals (`setEntries`) were added — a fractional weight fails an older decode, and a rewrite would strip the irreplaceable actuals. Bumped 4→5 when per-log `updatedAt` began being written and `deletedLogIDs` was added for the per-log merge — a rewrite by an older app would strip the deletion tombstones and resurrect a deleted exercise, so older apps 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 |
| `deletedLogIDs` | [String: Date] | ✓ | Per-log deletion tombstones (`logID → when deleted`), phone-authored. Disambiguate an absent log during the per-log merge so a stale watch push can't resurrect a removed exercise; pruned after a 30-day grace. Added in schema v5 (`WorkoutMergePlanner`) |
## 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` | Double | | Snapshot; unit-less. Int → Double in schema v4 (old integer values decode natively) |
| `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* |
| `setEntries` | [SetEntry] | ✓ | Per-set actuals in set order; nil → legacy file / nothing recorded. Appended as sets complete (pre-filled from the plan); `transition(to:)` fills the missing tail on `.completed` and clears on `.notStarted`; `.skipped` keeps partials. Added in schema v4 |
| `updatedAt` | Date | ✓ | Per-log modification time driving the per-log merge (`WorkoutMergePlanner`); stamped by `transition(to:)` on status flips and `touch()` at content-only edit sites. Nil in files written before v5. Added (unused) in schema v4, written since v5 |
## 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°" |
## SetEntry (embedded in WorkoutLogDocument, schema v4)
One performed set. Which fields are set follows the log's `LoadType`:
weight → `reps` + `weight`; none (bodyweight) → `reps`; duration → `seconds`.
| Property | Type | Optional | Notes |
|---|---|---|---|
| `reps` | Int | ✓ | |
| `weight` | Double | ✓ | Unit-less, like the plan snapshot |
| `seconds` | Int | ✓ | Held/worked seconds for duration exercises |
| `completedAt` | Date | | When the set finished |
## 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 values are never rewritten when it changes.