From ec17fca45df8c0f7dbfd0de513a10193152b60ed Mon Sep 17 00:00:00 2001 From: fy59 Date: Thu, 17 Sep 2026 20:13:21 +0200 Subject: [PATCH] Define Web Sessions preparation persistence contract --- docs/design/web_sessions_v1.md | 83 ++++++++++++++++++++++++++++ tui/include/trainlog/database.h | 2 +- tui/src/database.c | 44 ++++++++++++++- tui/tests/test_schema_v7_migration.c | 4 +- tui/tests/test_schema_v9_migration.c | 2 +- 5 files changed, 130 insertions(+), 5 deletions(-) create mode 100644 docs/design/web_sessions_v1.md diff --git a/docs/design/web_sessions_v1.md b/docs/design/web_sessions_v1.md new file mode 100644 index 0000000..8d09f18 --- /dev/null +++ b/docs/design/web_sessions_v1.md @@ -0,0 +1,83 @@ +# Trainlog Web Sessions V1 contract + +Status: implementation contract for `TRAINLOG_WEB_SESSIONS_V1`. + +## Scope and domain boundaries + +The Sessions route has three independent views: + +- Preparation lists manual preparations and immutable imported AI proposals. +- Resume lists lifecycle-backed execution drafts that are not finalized or causally deleted. +- History lists completed sessions. A missing `ended_at` is rendered as an unknown end time; it + does not make a historical row an active draft. + +An AI proposal is never mutated by viewing it or by deriving a manual preparation. Derivation is +an explicit command which creates a new `sp_` preparation and records the proposal ID and +payload fingerprint used. A manual preparation is not an execution draft or completed session. + +## Manual preparation persistence + +Desktop schema v23 adds: + +- `session_preparations`, the mutable head and delivery state; +- `session_preparation_revisions`, immutable revision metadata; +- `session_preparation_entries`, immutable ordered occurrence snapshots; +- `session_preparation_requests`, durable HTTP idempotency results; +- `session_preparation_deliveries`, the preparation-revision to execution identity relation. + +Preparation IDs use `sp_`, occurrence IDs use `spe_`, revision IDs use +`spr_`, and execution IDs use the existing `se_` namespace. Revision content is +immutable. Updating a preparation inserts a complete successor revision and atomically advances +the head. Occurrence IDs survive reordering and compatible edits. Two occurrences of one exercise +remain distinct. + +`editing_state` (`draft` or `ready`) and `delivery_state` (`local`, `pending`, `acknowledged`, +`remote_unknown`, or `cancelled`) are independent. Marking a complete revision ready changes no +Android state. Preparing it for Android creates one durable delivery and deterministic execution +identity per explicit command. Retrying the command with its idempotency key returns that same +identity. Later preparation edits never alter a published revision or execution. + +## Validation + +Titles and notes are UTF-8 and bounded by the existing name/note limits. A preparation may be +saved with no occurrences while it is a draft. Ready revisions require a non-empty title and at +least one occurrence. Every occurrence resolves a non-retired exercise and a compatible equipment +choice at commit time. `SETS/REPS`, `SETS/DURATION`, and `CONTINUOUS/DURATION` are the only legal +profiles. Continuous work has a duration target and never a target set. Assistance, external load, +and no load remain distinct. JSON `null`, zero, and absence retain distinct meanings; non-finite +numbers are rejected by the JSON parser and Core validation. + +Every update supplies the expected revision and is checked in the write transaction. A mismatch +returns a conflict without partial mutation. Request idempotency is persistent, so a lost HTTP +response can be replayed without creating another preparation. + +## Read API + +Lists use bounded offset pages with deterministic stable-ID tie breaking. The response returns a +snapshot revision. If a write changes that revision between page requests, the client discards its +accumulated pages and explicitly reloads. Search matches the title or an exercise display name. +Dates mean planned calendar dates for preparations and occurrence dates for history. Zones are +offered only where the persisted exercise relations provide them. + +Stable details are addressed by kind and identity under `/api/v1/sessions/...`. Unknown API routes +remain JSON 404 responses; recognized non-API paths use the embedded SPA fallback. + +## Synchronization and Android + +Ready deliveries use a separate `trainlog-session-preparations` V1 generation participant. It does +not change `TRAINLOG_FORMAT_V1`, mobile V3/V4, or execution-drafts V1. The artifact contains complete +immutable revision snapshots and explicit cancellation state. Android consumes a delivery into a +pending prepared-session store. Starting it is an explicit Android action: an occupied active-draft +singleton is never overwritten. Consumption and start are idempotent by delivery, preparation, +revision, execution, and occurrence IDs. + +Delivery is shown as acknowledged only when the correlated generation ACK proves that the exact +artifact containing that revision was consumed. Publication time or a global successful sync is +not sufficient evidence. Old peers which do not advertise this participant are rejected before a +generation containing it is published. + +## Explicit exclusions + +This V1 does not implement Programs, Analysis, or the standalone Exercises route. It does not add +live Web workout capture, historical correction, automatic training prescriptions, proposal +acceptance, or automatic proposal-to-execution conversion. diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 4ebc245..f4a3244 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -16,7 +16,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 22 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 23 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/src/database.c b/tui/src/database.c index d044a5d..f093269 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -884,6 +884,45 @@ static const char *const MIGRATE_V21_TO_V22_SQL = "audit_json TEXT NOT NULL);" "PRAGMA user_version=22;COMMIT;"; +/* WHY: a Web-authored preparation is neither an imported AI proposal nor an + * execution draft. CONTRACT: v23 stores immutable preparation revisions, + * stable ordered occurrences, durable request replay, and delivery identity + * without changing completed history or frozen exchange formats. INVARIANT: + * migration invents no preparation, execution, delivery, or acknowledgement. */ +static const char *const MIGRATE_V22_TO_V23_SQL = + "BEGIN IMMEDIATE;" + "CREATE TABLE session_preparations(" + "preparation_id TEXT PRIMARY KEY,current_revision_id TEXT NOT NULL,created_at TEXT NOT NULL," + "updated_at TEXT NOT NULL,editing_state TEXT NOT NULL CHECK(editing_state IN('draft','ready'))," + "delivery_state TEXT NOT NULL DEFAULT 'local' CHECK(delivery_state IN('local','pending','acknowledged','remote_unknown','cancelled'))," + "source_proposal_id TEXT,source_payload_sha256 TEXT);" + "CREATE TABLE session_preparation_revisions(" + "revision_id TEXT PRIMARY KEY,preparation_id TEXT NOT NULL REFERENCES session_preparations(preparation_id) ON DELETE RESTRICT," + "parent_revision_id TEXT,title TEXT NOT NULL,session_type TEXT NOT NULL CHECK(session_type IN('training','max_test'))," + "planned_for TEXT,notes TEXT,created_at TEXT NOT NULL," + "CHECK(length(CAST(title AS BLOB))<=200),CHECK(notes IS NULL OR length(CAST(notes AS BLOB))<=4000)," + "UNIQUE(preparation_id,revision_id));" + "CREATE TABLE session_preparation_entries(" + "revision_id TEXT NOT NULL REFERENCES session_preparation_revisions(revision_id) ON DELETE RESTRICT," + "entry_id TEXT NOT NULL,position INTEGER NOT NULL CHECK(position>=0),exercise_id TEXT NOT NULL," + "equipment_id TEXT,recording_mode TEXT NOT NULL CHECK(recording_mode IN('sets','continuous'))," + "tracking_mode TEXT NOT NULL CHECK(tracking_mode IN('reps','duration')),data_fields INTEGER NOT NULL," + "load_mode TEXT NOT NULL CHECK(load_mode IN('none','external','assistance')),rest_seconds INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400)," + "target_sets INTEGER,target_reps INTEGER,target_duration_seconds INTEGER,target_weight_kg REAL,notes TEXT," + "PRIMARY KEY(revision_id,entry_id),UNIQUE(revision_id,position)," + "CHECK(notes IS NULL OR length(CAST(notes AS BLOB))<=4000));" + "CREATE INDEX session_preparation_entries_exercise ON session_preparation_entries(exercise_id);" + "CREATE TABLE session_preparation_requests(" + "request_id TEXT PRIMARY KEY,command TEXT NOT NULL,preparation_id TEXT NOT NULL,revision_id TEXT NOT NULL," + "response_json TEXT NOT NULL,created_at TEXT NOT NULL);" + "CREATE TABLE session_preparation_deliveries(" + "delivery_id TEXT PRIMARY KEY,preparation_id TEXT NOT NULL REFERENCES session_preparations(preparation_id)," + "revision_id TEXT NOT NULL REFERENCES session_preparation_revisions(revision_id),execution_session_id TEXT NOT NULL UNIQUE," + "state TEXT NOT NULL CHECK(state IN('pending','acknowledged','remote_unknown','cancelled'))," + "created_at TEXT NOT NULL,generation_id TEXT,acknowledged_at TEXT," + "UNIQUE(preparation_id,revision_id,delivery_id));" + "PRAGMA user_version=23;COMMIT;"; + /* CONTRACT: v18 was already deployed before bounded draft publication was * added. Keep its version number and add only the nullable lifecycle cursor; * NULL deliberately means retryable/not yet published. */ @@ -1755,6 +1794,9 @@ static TrainlogStatus initialize_or_validate_schema( if (status == TRAINLOG_STATUS_OK && version < 22) { status = execute_sql(database, MIGRATE_V21_TO_V22_SQL); } + if (status == TRAINLOG_STATUS_OK && version < 23) { + status = execute_sql(database, MIGRATE_V22_TO_V23_SQL); + } if (status == TRAINLOG_STATUS_OK) { status = ensure_v18_ai_draft_publication_state(database); } @@ -1770,7 +1812,7 @@ static TrainlogStatus initialize_or_validate_schema( set_open_diagnostic( output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v22" : "migrate database to schema v22", + version == 0 ? "create schema v23" : "migrate database to schema v23", database->connection, SQLITE_ERROR ); diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index c1bf49b..27d1bb4 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -134,8 +134,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strncmp(diagnostic, "migrate database to schema v22: SQLite rc=", - strlen("migrate database to schema v22: SQLite rc=")) == 0); + CHECK(strncmp(diagnostic, "migrate database to schema v23: SQLite rc=", + strlen("migrate database to schema v23: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 6af49ee..60f4ef8 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -151,7 +151,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strstr(diagnostic, "migrate database to schema v22") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v23") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);