trainlog/docs/database.md

548 lines
18 KiB
Markdown

# Desktop database
## 1. Status
```text
TRAINLOG_DATABASE_SCHEMA_VERSION=11
DATABASE_SCHEMA_V11=PASS
TRAINLOG_FORMAT_V1=FROZEN
```
The desktop SQLite database is the canonical long-term Trainlog history.
Its schema evolves independently from all JSON exchange-format versions.
## 2. Versioning
Schema version uses:
```sql
PRAGMA user_version;
```
Current value:
```text
11
```
The independent actual-set loads documented in the current desktop, Android
and V2 flows use the existing ordered `performed_sets.weight_kg` field and the
corresponding durable draft-set field. The per-set column itself pre-existed,
but desktop v10 is required: v9 -> v10 rebuilds `performed_sets` solely to
widen actual `weight_kg` from finite `> 0` to finite `>= 0`.
Supported historical databases are migrated explicitly through the implemented
migration chain. A database newer than the running binary understands is
rejected.
Version 7 assigns `session_exercises.entry_id` to each stable occurrence.
`exercise_id` remains only the catalogue identity and may occur more than once
in a session. The v6 → v7 migration rebuilds the obsolete uniqueness
constraint while retaining rows, sets, continuous activities, weights and
equipment associations.
Version 8 adds local `custom_equipment` definitions. The v7 → v8 migration is
additive: it retains all historic occurrences and their `equipment_id` values.
Supplied definitions continue to be generated from `catalog/equipment-v1.json`;
custom definitions exist only in the desktop database.
Version 9 adds the one-to-one `max_results` table. The v8 → v9 migration
converts a legacy `max_test` occurrence only when it contains exactly one
performed set with `reps = 1`, no duration, and a positive weight. The stable
session, occurrence, exercise, position, and equipment identities are retained.
Multiple attempts and every other ambiguous shape remain as historical sets.
Version 10 rebuilds only `performed_sets`. Its explicit projection preserves
every row ID, owning occurrence, position, repetitions-or-duration, and
existing `NULL` or positive `weight_kg` value unchanged; it permits a new
explicit zero actual load. The migration is transactional. Regression coverage
checks lossless migration, rollback after an injected rebuild-name collision,
`PRAGMA integrity_check`, `PRAGMA foreign_key_check`, restored foreign-key
enforcement, and rejection of negative loads or invalid metric shapes.
Version 11 is additive. It creates `exercise_body_zones` and the private
`exercise_body_zone_sync` comparison baseline, then inserts only the exact
stable-ID mappings declared with evidence in `catalog/body-zones-v1.json`.
There is no exercise-ID, occurrence-ID, session, performed-set, MAX, equipment
or body-observation rewrite. The migration is one transaction and uncertain
historical exercises remain valid with no relation.
A schema fixture must represent the real historical structure. Rewriting only
`user_version` is not an acceptable migration test.
## 3. Connection invariants
Every connection enables:
```sql
PRAGMA foreign_keys = ON;
```
A bounded SQLite busy timeout is configured by the core.
## 4. Tables
### `exercises`
Canonical desktop exercise catalog.
```text
id
exercise_id UNIQUE stable identity
name
normalized_name UNIQUE normalized display form
tracking_mode reps | duration
recording_mode sets | continuous
data_fields bounded bit mask
```
Rules include:
- continuous implies duration tracking;
- unknown supplemental field bits are rejected;
- normalized names remain unique.
### `exercise_body_zones`
Direct exercise-to-zone relations:
```text
exercise_row_id foreign key -> exercises(id), cascade delete
zone_id stable ID from body-zones-v1.json
role primary | secondary
PRIMARY KEY exercise_row_id, zone_id
partial UNIQUE one role=primary row per exercise
```
The composite key prevents one zone from being both primary and secondary.
Public writers validate that every ID exists and is not a group. An exercise
with no rows is explicitly unclassified; a secondary-only state is rejected as
corruption. Parent relations are derived from the manifest during filtering
and are not stored here.
`exercise_body_zone_sync(exercise_row_id, synced_state)` is internal sync
metadata, not domain data. Its deterministic state records a nullable primary
and byte-sorted secondary IDs so one-sided edits can be distinguished from a
simultaneous conflict. It is never exposed as a translated value or used to
merge secondary sets by union. The publishing peer records the exact snapshot
as its baseline only after the companion has been published successfully.
### `sessions`
```text
id
session_id UNIQUE stable identity
started_at
ended_at nullable
session_type training | max_test
notes nullable
```
### `session_exercises`
Ordered exercise occurrence inside one session.
```text
session_row_id
exercise_row_id
entry_id UNIQUE stable occurrence identity
recording_mode
data_fields
position
load_mode
rest_seconds
target_sets
target_reps
target_duration_seconds
target_weight_kg
equipment_id nullable equipment identity
notes
```
Current desktop history snapshots `recording_mode` and `data_fields` in the
session row. `tracking_mode` remains associated with the referenced exercise
catalog identity.
An occurrence `equipment_id` resolves to either a supplied manifest definition
or a desktop-local `custom_equipment` definition. An ID that cannot be
resolved is retained as historic data and is explicitly visible to the user;
it is never silently converted to `NULL`.
### `custom_equipment`
Desktop-local custom equipment definitions. They supplement, but do not alter
or replace, the supplied catalogue generated from `catalog/equipment-v1.json`.
They synchronize through the separate `trainlog-equipment-definitions` v1
artifact; session/mobile and association V2 artifacts retain their existing
shapes. Definition reconciliation is additive: omission does not delete, an
equal same-ID row is idempotent, a divergent same-ID row conflicts, and IDs in
the supplied manifest are reserved.
`SETS` rows support two target shapes in schema v5:
```text
explicit planned target
target_sets + exactly one target metric
actual-only mobile observation
target_sets = NULL
target_reps = NULL
target_duration_seconds = NULL
```
This v5 rule is what permits heterogeneous mobile performed sets without
inventing a fake uniform target.
`CONTINUOUS` rows are targetless and require:
```text
load_mode = none
rest_seconds = 0
target_weight_kg = NULL
```
### `performed_sets`
Ordered actual set records.
```text
session_exercise_row_id
position
reps nullable
duration_seconds nullable
weight_kg nullable
```
Exactly one primary actual metric is present:
```text
reps
or
duration_seconds
```
Actual repetitions may be zero.
Each row is independent; heterogeneous repetition sequences are first-class
data. Its nullable `weight_kg` is likewise occurrence-set data: blank is
distinct from an explicit zero and from a planned target weight. When present,
an actual weight is finite and `>= 0`.
Planned `target_weight_kg` remains distinct planning metadata and, when
present, is finite and `> 0`; it is never copied into an actual set.
### `continuous_activity`
One-to-one actual record for a continuous session exercise.
```text
session_exercise_row_id UNIQUE
duration_seconds
speed_kmh nullable
distance_km nullable
```
Continuous activity never creates a fake performed set.
### `max_results`
One-to-one explicit result for an occurrence in a `max_test` session.
```text
session_exercise_row_id PRIMARY KEY, foreign key
max_weight_kg finite positive weight
```
The occurrence has no `performed_sets` or `continuous_activity` row. The
referenced exercise owns the performance identity; nullable
`session_exercises.equipment_id` remains contextual metadata.
### `body_observations`
```text
observation_id UNIQUE
observed_at
session_row_id optional UNIQUE link
body_weight_kg
neck_cm
shoulders_cm
chest_cm
waist_cm
hips_cm
left_arm_cm
right_arm_cm
left_forearm_cm
right_forearm_cm
left_thigh_cm
right_thigh_cm
left_calf_cm
right_calf_cm
notes
```
At least one body metric must be present.
## 5. Identifier generation
Official desktop creator prefixes:
```text
ex_ exercise
se_ session
bo_ body observation
sy_ synchronization run
sxe_ session-exercise occurrence
```
All use random UUIDv4 values.
Exchange parsers may accept other schema-valid opaque identities where their
contract explicitly permits it.
## 6. Transactions
Multi-row user operations are atomic.
Persisted session correction replaces session child rows transactionally while
preserving the parent:
```text
session_id
started_at
ended_at
session_type
session notes
linked body observation
```
Removing an exercise from a persisted session is therefore a transactional
replacement of the remaining child set.
A failed replacement rolls back to the previously persisted session.
Body-observation editing preserves its stable identity, timestamp, and optional
session link.
Exercise creation/editing and replacement of all direct body-zone relations is
one transaction. Invalid or duplicate IDs leave both exercise metadata and the
prior relation set unchanged.
## 7. Mobile import semantics
`tools/import_mobile_export.py` validates the complete mobile snapshot before
committing database changes.
Properties:
```text
schema-v5-through-v11 aware
transactional
idempotent by stable IDs
profile-aware catalog reconciliation
heterogeneous performed sets preserved
no fake target generated
continuous activity kept separate
```
For active V2 reconciliation, an identical normalized name never suffices by
itself. Different exercise identities may coalesce only when recording and
tracking modes match, other represented invariants remain compatible, and
their bounded `data_fields` masks are comparable by inclusion. The existing
desktop identity is deterministic canonical ownership. The mask union retains
the richer capability, while `session_exercises.data_fields` and all child
values remain unchanged. A missing historic optional value stays `NULL`.
Incomparable masks or modes reject the entire mobile-import transaction.
On v11, a safe duplicate-row merge also preserves the only non-empty body-zone
state. Different non-empty states conflict; no relation list is unioned.
## 8. Units
Canonical desktop persistence:
```text
weight/load kg
body circumference cm
duration/rest seconds
speed km/h
distance km
```
## 9. Android database
The Android SQLite database is independent.
Current Android-local version: **10**. The explicit migration chain adds the
durable draft in v4, equipment references in v5, per-set load in v6, occurrence
identity/multi-occurrence support in v7, and the widened custom-equipment
definition graph in v8. Version 9 adds completed/draft explicit max rows, raw
max and load form text, and the optional stable source session used to resume a
completed Test max.
Version 10 adds `exercise_body_zones` and `exercise_body_zone_sync`, validates
IDs against the shared manifest asset, and seeds the same stable-ID mappings as
desktop v11 without modifying completed or draft work.
| Table | Ownership |
| --- | --- |
| `active_session_draft` | Single `id = 1` row, session type, selected catalog row, raw form text including MAX, optional resumed source session, update time |
| `draft_session_exercises` | Ordered draft exercises and profile snapshots |
| `draft_performed_sets` | Ordered heterogeneous repetition or duration actuals |
| `draft_continuous_activity` | Duration and configured speed/distance without synthetic sets |
| `draft_max_results` | Positive explicit max weight, one-to-one with a draft occurrence |
| `max_results` | Positive explicit max weight, one-to-one with a completed occurrence |
| `equipment`, `equipment_aliases` | Supplied and user-created definitions used by selectors and occurrence FKs |
| `exercise_equipment`, `catalog_exercise_equipment` | Persisted manifest relationship metadata retained across migrations and identity reconciliation |
| `exercise_body_zones` | Direct primary/secondary stable zone IDs; no derived parent rows |
| `exercise_body_zone_sync` | Private common-state baseline for explicit conflict detection |
Foreign keys remain enabled. Draft deletion cascades only through draft child
tables; it cannot delete catalog entries or completed history. The repository
commits completed-session insertion and draft removal together, rolling back
both on failure. Repeating finalization after success cannot create another
completed session. Completed `started_at` semantics are unchanged by this repair.
Migration tests cover historical v4 and v7 shapes rather than changing only
`user_version`. The physical Samsung and desktop v9 migrations preserved every
existing domain row after fresh coherent backups, with successful integrity and
foreign-key checks. Reconciliation validation also uses coherent Android and
desktop v9 copies before applying migrations to the real stores. SQLite files
are never synchronization artifacts.
The schemas deliberately differ where ownership differs. Desktop sessions own
`ended_at`, notes, planned targets and session-specific load semantics; the
Android capture schema does not. Desktop body observations may link to a
session and carry notes; Android body observations contain capture metrics only.
Android occurrences snapshot `tracking_mode` as well as recording mode and
fields, whereas the current desktop schema resolves tracking mode through the
catalog exercise. Desktop stores occurrence `equipment_id` as stable text so
unknown historic IDs remain visible; Android stores a foreign key to its local
equipment definition.
Audit limitation: Android persists supplied exercise/equipment relationship
tables, but the current session selector searches the complete equipment list
instead of filtering or ranking it through those relations. The tables are
retained because migration and identity reconciliation already preserve them;
making them authoritative UI policy is future catalog work, not a v9 cleanup.
Desktop and Android schema versions are not required to match.
Do not synchronize SQLite database files.
## 10. Validation
```bash
meson compile -C build
meson test -C build --print-errorlogs
```
Migration-specific regression coverage includes:
```text
schema_v5_migration
schema_v7_migration
body_zones
body_zone_catalog_validation
body_zone_sync
exercise_reconciliation
max_results
max_sync
```
The current normal desktop suite contains 39 tests.
## 11. Explicit and legacy measured maxima
Schema v9 persists a weight maximum in `max_results`, separate from
`performed_sets`. A new max occurrence therefore contains no repetition or set
count. Only `sessions.session_type = max_test` may own this row.
Exercise performance points carry the originating session type so the
measured-max layer can distinguish explicit tests from ordinary training.
The performance reader also preserves the prior measured-max interpretation for
ambiguous legacy max-test sets that the migration deliberately did not convert.
Rules for those legacy rows remain:
```text
training session
never becomes measured max implicitly
max_test + external
greatest successful actual load
tie -> greatest reps/duration
max_test + assistance
lowest successful assistance
tie -> greatest reps/duration
max_test + no load
greatest successful reps/duration
```
A zero-repetition failed legacy attempt is not a successful measurement.
The current measured result is the newest successful max-test point. The record
is the best max-test point using the same load mode.
For explicit rows, `max_weight_kg` is the canonical result. Latest-max history
groups by `exercise_id`, then reports date and occurrence equipment context;
it never groups by machine.
## 12. Equipment and occurrence migration
Desktop schema v6 added nullable `session_exercises.equipment_id`. Schema v7
adds the non-null stable `entry_id` and removes the obsolete
`UNIQUE(session_row_id, exercise_row_id)` constraint. The v6 -> v7 rebuild
preserves primary keys, completed sessions, ordered sets, continuous activities,
per-set weights and equipment values.
Schema v8 adds local `custom_equipment` definitions through an additive v7 ->
v8 migration. Existing occurrence links remain unchanged. They resolve through
the supplied manifest or the local custom table; unknown historic references
remain explicit rather than being discarded.
Schema v9 adds explicit max results through the bounded v8 -> v9 conversion
described above. It never chooses among multiple one-repetition attempts and
never deletes an ambiguous source row.
Android schema v6 added nullable `weight_kg` to completed and durable draft
set rows. Schema v7 assigns stable `entry_id` values to completed and draft
occurrences. Actual per-set weights remain independent values, so heterogeneous
sets and weights survive edit, finalization, reopen and V2 exchange.
Android schema v8 non-destructively migrates the v7 equipment reference graph
so custom definitions may use `load_semantics = none`. Historic completed
occurrences, draft occurrences, and their equipment references remain intact.
Android schema v9 applies the same bounded legacy conversion to completed and
durable-draft occurrences. A resumed max-test draft preserves its source
`session_id`; atomic finalization replaces that session's ordered children
instead of generating a second session.
Desktop schema v11 and Android schema v10 then add only body-zone relation and
sync-baseline tables. Both seed exact manifest mappings by stable exercise ID;
neither migration changes the occurrence/equipment/MAX graph described above.
## 13. Body analytics persistence rule
Body analytics still require no dedicated schema change; schemas v9 through
v11 do not alter their measurement storage.
Canonical persistence continues to contain only measurements actually entered
by the user.
These values remain derived at display time and are not persisted:
```text
body-fat estimate
estimated fat mass
estimated lean mass
waist/hip ratio
shoulder/waist ratio
chest/waist ratio
left/right asymmetry percentages
```
The optional estimation profile is desktop configuration, not database
history.