trainlog/docs/database.md

18 KiB

Desktop database

1. Status

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:

PRAGMA user_version;

Current value:

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:

PRAGMA foreign_keys = ON;

A bounded SQLite busy timeout is configured by the core.

4. Tables

exercises

Canonical desktop exercise catalog.

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:

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

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.

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:

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:

load_mode = none
rest_seconds = 0
target_weight_kg = NULL

performed_sets

Ordered actual set records.

session_exercise_row_id
position
reps                 nullable
duration_seconds     nullable
weight_kg            nullable

Exactly one primary actual metric is present:

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.

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.

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

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:

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:

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:

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:

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

meson compile -C build
meson test -C build --print-errorlogs

Migration-specific regression coverage includes:

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:

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:

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.