Define Web Sessions preparation persistence contract
This commit is contained in:
parent
a5cdbdf2b9
commit
ec17fca45d
5 changed files with 130 additions and 5 deletions
83
docs/design/web_sessions_v1.md
Normal file
83
docs/design/web_sessions_v1.md
Normal file
|
|
@ -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_<uuid-v4>` 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_<uuid-v4>`, occurrence IDs use `spe_<uuid-v4>`, revision IDs use
|
||||
`spr_<uuid-v4>`, and execution IDs use the existing `se_<uuid-v4>` 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.
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue