diff --git a/CHANGELOG.md b/CHANGELOG.md index 55b64a7..af91e93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased — 0.1.2 +- Fixed the Web history deletion failure for Program-origin completed + sessions. The history detail query now reads explicit MAX results from + `max_results`; mobile history import seeds a deterministic causal live + revision when one is missing; and desktop schema v28 gives durable Program + execution provenance an explicit terminal `deleted` state. Deletion remains + revision-guarded, atomic and replay-safe. Retained Program-execution + generations cannot resurrect a causally deleted session, and Web errors now + distinguish stale, absent, invalid and internal failures without exposing + database details. + - Added the Program execution flow. Android schema v25 can start or resume one Program session through the existing durable singleton, preserves stable Program provenance on completion, and presents explicit `À faire`, `En cours` diff --git a/README.md b/README.md index 3fc3ec1..8430be1 100644 --- a/README.md +++ b/README.md @@ -320,7 +320,7 @@ not contain cloud credentials and does not run `rclone`. As of 2026-09-18: - `TRAINLOG_FORMAT_V1=PASS/FROZEN`; -- desktop SQLite schema v27 and Android SQLite schema v25; +- desktop SQLite schema v28 and Android SQLite schema v25; - Notcurses is the only active desktop terminal backend; - direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's all-files access setting; diff --git a/docs/current_state.md b/docs/current_state.md index 76c6c34..78b1cac 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -4,7 +4,7 @@ The opt-in full-generation path is operational on the private daily installation. The loopback Web control and foreground Android coordinator use the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and persistent peer identities. The latest controlled rollout verified real -desktop and Android backups, desktop schema v26 to v27, and an in-place private +desktop and Android backups, desktop schema v26 to v28, and an in-place private Android update from versionCode 16 to 17 with schema v24 to v25. The installed APK remains `com.labfytools.trainlog` 0.1.2, is non-debuggable, and retains certificate SHA-256 @@ -57,7 +57,7 @@ parallel implementation of its rules. | Boundary | Current state | |---|---| | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | -| Desktop SQLite | schema v27 | +| Desktop SQLite | schema v28 | | Android SQLite | schema v25 | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Desktop terminal backend | Notcurses only | @@ -83,6 +83,7 @@ parallel implementation of its rules. | Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) | | Programs presentation, Android projection, and deletion | `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` (private coordinated deployment and restart/replay validated) | | Program execution flow | `TRAINLOG_PROGRAM_EXECUTION_FLOW_V1=PASS` (private coordinated deployment, real execution, correlated ACK, restart, replay, and deletion validated) | +| Web history deletion incident | `TRAINLOG_HISTORY_DELETE_HTTP500_FIX_V1=IMPLEMENTED` (Program-origin completed sessions use causal deletion and terminal execution provenance) | | Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` | | Web sync API V1 | `TRAINLOG_WEB_SYNC_API_V1=PASS/FROZEN` | | Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` | diff --git a/docs/database.md b/docs/database.md index ada232e..c72b0ca 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,10 +1,22 @@ # Desktop database +## Schema v28: terminal Program execution provenance + +Schema v28 extends `program_session_executions.state` with the terminal +`deleted` value. When a completed Program-origin session is causally deleted, +the same transaction retires its execution provenance, removes the active +history row, appends the immutable delete operation and advances the durable +session tombstone. The provenance identity and timestamps remain intact; Web +Program projections no longer claim that the missing session is completed. +The v27 to v28 migration preserves all existing rows and creates no causal or +deletion record. + ## Schema v27: durable Program execution provenance Schema v27 adds `program_session_executions`, the stable link from one desktop-owned Program session to its Android execution `session_id`. The state -advances only from `in_progress` to `completed`; a Program session and an +advances from `in_progress` to `completed`; schema v28 additionally permits +the terminal `completed` to `deleted` transition. A Program session and an execution session are each unique. Completed links are accepted only after the referenced completed session has been imported. The migration is additive, transactional, creates no execution row, and preserves Program definitions, @@ -75,8 +87,8 @@ is a later, separately versioned migration. ## 1. Status ```text -TRAINLOG_DATABASE_SCHEMA_VERSION=27 -DATABASE_SCHEMA_V27=IMPLEMENTED +TRAINLOG_DATABASE_SCHEMA_VERSION=28 +DATABASE_SCHEMA_V28=IMPLEMENTED TRAINLOG_FORMAT_V1=FROZEN ``` diff --git a/docs/reviews/history_delete_http500_fix_v1_evidence.md b/docs/reviews/history_delete_http500_fix_v1_evidence.md new file mode 100644 index 0000000..92c81a5 --- /dev/null +++ b/docs/reviews/history_delete_http500_fix_v1_evidence.md @@ -0,0 +1,46 @@ +# Web history deletion HTTP 500 corrective evidence + +Date: 2026-09-18 + +## Incident and root cause + +The disposable Program rollout session +`se_6bb66f59-a6fb-4759-a75d-c1a79ea107e8` was confirmed read-only as the +completed execution of `Séance jetable de bascule` in the already deleted +Program `BASCULE PROGRAM EXECUTION V1 — JETABLE — 2026-09-18`. It contained one +occurrence and one performed set. + +On an SQLite backup copy, the deployed history detail endpoint returned HTTP +500 before the deletion mutation was reached. SQLite reported primary code 1, +extended code 1, and `no such column: se.max_weight_kg` while preparing the +history-entry query. The field belongs to `max_results`, not `session_entries`. +The same inspection also found no current causal revision for this imported +session and showed that completed Program execution provenance needed an +explicit terminal lifecycle state before its session could be removed. + +## Corrective contract + +The history detail query reads `max_results`. Mobile history import seeds a +missing causal live revision with the canonical deterministic snapshot +revision. Desktop schema v28 permits a Program execution to move from +`completed` to terminal `deleted` in the same transaction as the revision- +guarded session deletion and tombstone publication. An old completed execution +fact is dominated only by a matching durable deleted causal state. Browser +responses distinguish stale, absent, invalid and internal failures, while +bounded server diagnostics include the SQLite codes and failing stage without +exposing SQL or paths. + +## Automated evidence + +- GCC Meson: 91/91 tests passed. +- Clang built the complete target set; the five affected native tests passed. +- ASan/UBSan: the five affected native tests passed. +- Web TypeScript, 99 Vitest tests, production build and two real-Firefox + Sessions scenarios passed. +- Android debug assembly and unit tests passed without Android source or schema + changes. +- JSON/import validators, changed-C formatting and `git diff --check` passed. + +The rollout-specific backup hashes, deployed commit, causal operation, +generation/ACK identifiers and restart/non-resurrection results are appended +after the controlled private rollout. No public tag or release is created. diff --git a/docs/roadmap.md b/docs/roadmap.md index a8b07ed..25d9f04 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -24,7 +24,7 @@ distinction between measured values and estimates. ## Current baseline -Desktop schema v27, Android schema v25, Notcurses, direct +Desktop schema v28, Android schema v25, Notcurses, direct `Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session Generator V1 is hidden pending V2. AI session-draft diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index fde7aad..c4c93dc 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -177,6 +177,13 @@ is idempotent; identity reuse or regression is a hard conflict. Because the descriptor is optional, peers predating this companion keep exchanging all existing required domains without a capability failure. +Desktop schema v28 adds terminal `deleted` provenance for a completed session +removed through causal history deletion. A retained older +`program-executions-v1` fact is skipped only when the matching session has a +durable deleted causal state; absence of that tombstone remains a hard error. +This dominance rule preserves provenance while preventing old generations from +resurrecting active history. + `trainlog-exercise-aliases` v1 is the separate EXERCISE_MERGE_V1 identity companion. Its root contains exactly `format`, `version`, and `aliases`; every entry contains exactly `source_exercise_id` and `canonical_exercise_id` using diff --git a/docs/tests.md b/docs/tests.md index 26d7cbd..67725f5 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -43,10 +43,15 @@ still rejecting a truncated object inside that current generation. `web_session_deletions` exercises proposal withdrawal replay, preservation of a derived preparation, active-draft conflict, causal draft/session deletion and -completed-session finalization. The HTTP suite covers stale and successful -proposal DELETE requests and post-delete deep-link absence. Vitest covers the +completed-session finalization. It also builds a Program-origin completed +session, deletes it through the production command, verifies one causal +operation/tombstone and terminal execution provenance, then replays the exact +request. The HTTP suite covers real history detail, stale and successful +session DELETE requests, invalid identity, post-delete deep-link absence and an +injected SQLite failure with complete rollback. Vitest covers the focus-managed confirmation, Cancel and Escape zero-mutation paths, the real -DELETE call and focus restoration. AI companion tests prove that V2 exports a +DELETE call, server-confirmed refresh, useful typed errors and focus +restoration. AI companion tests prove that V2 exports a withdrawal without the proposal and that Android's tombstone rejects later V1 replay. @@ -85,6 +90,14 @@ through Program deletion, restart and old-generation replay without duplication or resurrection. The desktop populated migration test now continues through schema v27 and proves the new execution ledger starts empty. +`schema_v28_migration` migrates a populated v27 fixture, preserves Program, +session and execution identities byte-for-byte, admits the terminal deleted +state, reports `integrity_check=ok` and an empty foreign-key check, and proves +that migration invents no causal operation or deletion. The mobile V3 importer +regression proves deterministic live causal revision seeding and exact replay. +The Program execution exchange regression proves an old retained completed +fact cannot override a session tombstone or duplicate terminal provenance. + `test_web_sessions_browser.py` runs headless Firefox against the embedded production assets and C HTTP server with private temporary XDG roots. It creates only a synthetic ready preparation and delivery, verifies French then persisted diff --git a/tests/test_program_execution_exchange.py b/tests/test_program_execution_exchange.py index 1951d39..3c012c2 100644 --- a/tests/test_program_execution_exchange.py +++ b/tests/test_program_execution_exchange.py @@ -33,7 +33,14 @@ class ProgramExecutionExchangeTest(unittest.TestCase): session_id TEXT NOT NULL UNIQUE, state TEXT NOT NULL, observed_at TEXT NOT NULL); - PRAGMA user_version=27; + CREATE TABLE sync_causal_state( + target_kind TEXT NOT NULL, + target_id TEXT NOT NULL, + current_revision_id TEXT NOT NULL, + deleted INTEGER NOT NULL, + operation_id TEXT, + PRIMARY KEY(target_kind,target_id)); + PRAGMA user_version=28; """) db.execute("INSERT INTO programs VALUES(?)", ( "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", @@ -95,6 +102,31 @@ class ProgramExecutionExchangeTest(unittest.TestCase): with self.assertRaisesRegex(ValueError, "missing history"): program_execution_exchange.apply_executions(db, root) + def test_old_execution_generation_cannot_resurrect_causally_deleted_history(self): + self.assertEqual((1, 0, 0), self.apply("completed")[0]) + with closing(sqlite3.connect(self.database)) as db: + db.execute("DELETE FROM sessions WHERE session_id=?", ( + "se_cccccccc-cccc-4ccc-8ccc-cccccccccccc", + )) + db.execute( + "UPDATE program_session_executions SET state='deleted' WHERE session_id=?", + ("se_cccccccc-cccc-4ccc-8ccc-cccccccccccc",), + ) + db.execute( + "INSERT INTO sync_causal_state VALUES('session',?,'del_fixture',1,'del_fixture')", + ("se_cccccccc-cccc-4ccc-8ccc-cccccccccccc",), + ) + db.commit() + + result, row = self.apply("completed") + self.assertEqual((0, 0, 1), result) + self.assertEqual(("deleted", "se_cccccccc-cccc-4ccc-8ccc-cccccccccccc"), row) + with closing(sqlite3.connect(self.database)) as db: + self.assertEqual(0, db.execute( + "SELECT COUNT(*) FROM sessions WHERE session_id=?", + ("se_cccccccc-cccc-4ccc-8ccc-cccccccccccc",), + ).fetchone()[0]) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_session_exchange_v3.py b/tests/test_session_exchange_v3.py index eb8ece3..45755fe 100644 --- a/tests/test_session_exchange_v3.py +++ b/tests/test_session_exchange_v3.py @@ -46,6 +46,20 @@ CREATE TABLE session_followup_revisions(revision_id TEXT PRIMARY KEY,followup_id PRAGMA user_version=16; """ +V20_SCHEMA = V16_SCHEMA.replace("PRAGMA user_version=16;", "") + """ +CREATE TABLE sync_note_state( + owner_kind TEXT NOT NULL,owner_id TEXT NOT NULL,revision_id TEXT NOT NULL, + parent_revision_id TEXT,value TEXT,PRIMARY KEY(owner_kind,owner_id)); +CREATE TABLE sync_causal_operations( + operation_id TEXT PRIMARY KEY,target_kind TEXT NOT NULL,target_id TEXT NOT NULL, + creator_id TEXT NOT NULL,predecessor_revision_id TEXT NOT NULL,created_at TEXT NOT NULL, + payload_sha256 TEXT NOT NULL,publication_context TEXT); +CREATE TABLE sync_causal_state( + target_kind TEXT NOT NULL,target_id TEXT NOT NULL,current_revision_id TEXT NOT NULL, + deleted INTEGER NOT NULL,operation_id TEXT,PRIMARY KEY(target_kind,target_id)); +PRAGMA user_version=20; +""" + def run(command, ok=True): result = subprocess.run(command, text=True, capture_output=True) @@ -127,6 +141,28 @@ def main(): ("sxe_walk", "none", 0, None, None, None, None, None), ("sxe_max", "none", 0, None, None, None, None, "leg_press"), ] + + # A schema with causal exchange support records the deterministic live + # snapshot revision on first import. Exact replay preserves it instead + # of replacing it with a peer-local random revision. + causal_db = root / "causal.sqlite" + with sqlite3.connect(causal_db) as con: + con.executescript(V20_SCHEMA) + run([sys.executable, str(IMPORTER), str(artifact), "--database", str(causal_db)]) + with sqlite3.connect(causal_db) as con: + revisions_before = con.execute( + "SELECT target_id,current_revision_id,deleted FROM sync_causal_state " + "WHERE target_kind='session' ORDER BY target_id" + ).fetchall() + assert len(revisions_before) == 2 + assert all(revision.startswith("lv_") and deleted == 0 + for _, revision, deleted in revisions_before) + run([sys.executable, str(IMPORTER), str(artifact), "--database", str(causal_db)]) + with sqlite3.connect(causal_db) as con: + assert con.execute( + "SELECT target_id,current_revision_id,deleted FROM sync_causal_state " + "WHERE target_kind='session' ORDER BY target_id" + ).fetchall() == revisions_before exported = root / "pc-v3.json" run([sys.executable, str(EXPORTER), str(exported), "--database", str(db)]) roundtrip = json.loads(exported.read_text()) diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index 2d93c53..f8491ab 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -66,7 +66,7 @@ class DeploymentToolsTest(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) inventory = json.loads((output / "candidate-inventory.json").read_text()) self.assertEqual(inventory["product_version"], "0.1.2") - self.assertEqual(inventory["desktop_schema"], 27) + self.assertEqual(inventory["desktop_schema"], 28) self.assertEqual(inventory["android_schema"], 25) self.assertIn("session-preparations-v2", inventory["protocols"]) self.assertIn("programs-v1", inventory["protocols"]) diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index 0c76f8c..7e7a543 100644 --- a/tools/causal_delete_exchange.py +++ b/tools/causal_delete_exchange.py @@ -38,8 +38,8 @@ def digest(value) -> str: def require_schema(db: sqlite3.Connection) -> None: version = db.execute("PRAGMA user_version").fetchone()[0] - if version not in (20, 21, 22, 23, 24, 25, 26, 27): - raise CausalError(f"desktop schema v20-v25 required, found v{version}") + if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28): + raise CausalError(f"desktop schema v20-v28 required, found v{version}") def target_snapshot(db: sqlite3.Connection, kind: str, target: str): diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py index 560dd7a..04b3f43 100644 --- a/tools/execution_draft_exchange.py +++ b/tools/execution_draft_exchange.py @@ -182,8 +182,8 @@ def causal_payload(draft): def require_schema(connection): version = connection.execute("PRAGMA user_version").fetchone()[0] - if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27): - raise LifecycleError(f"desktop schema v19-v25 required, found v{version}") + if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28): + raise LifecycleError(f"desktop schema v19-v28 required, found v{version}") def import_document(connection, document, own_transaction=True): diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index d23c5ed..1f9032b 100644 --- a/tools/export_ai_history.py +++ b/tools/export_ai_history.py @@ -12,7 +12,7 @@ from pathlib import Path from trainlog_sqlite import configure_connection -SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27) +SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28) MEASUREMENT_FIELDS = ( "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", @@ -125,7 +125,7 @@ def build_export(connection, generated_at): """Build the raw export using only explicitly ordered SELECT statements.""" schema_version = connection.execute("PRAGMA user_version").fetchone()[0] if schema_version not in SUPPORTED_SCHEMA_VERSIONS: - raise ValueError("schema desktop v15-v26 requis") + raise ValueError("schema desktop v15-v28 requis") root = { "format": "TRAINLOG_AI_EXPORT", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index c219ccc..d125488 100644 --- a/tools/export_ai_session_drafts.py +++ b/tools/export_ai_session_drafts.py @@ -20,9 +20,9 @@ ID_PREFIX = "aid_" def ensure_publication_state(connection): """Install the additive v18 cursor for direct syncd/tool invocations.""" owned_transaction = not connection.in_transaction - supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27) + supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v18-v25 requis") + raise ValueError("schema desktop v18-v28 requis") columns = {row[1] for row in connection.execute( "PRAGMA table_info(ai_session_drafts)")} if "published_at" not in columns: diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 48af33f..33ca994 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -28,9 +28,9 @@ def main(): args = parser.parse_args() connection = connect_database(args.database) try: - supported_versions = tuple(range(8, 28)) + supported_versions = tuple(range(8, 29)) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v16 requis") + raise ValueError("schema desktop v8 à v28 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index e33669d..65c71eb 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -24,9 +24,9 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(8, 28)) + supported_versions = tuple(range(8, 29)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v16 requis") + raise ValueError("schema desktop v8 à v28 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal equipment protection requires the staged artifact") equipment = [dict(row) for row in connection.execute( diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index 1908b3a..a92df91 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -17,8 +17,8 @@ def main(): con = connect_database(args.database) try: if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27): - raise ValueError("schema desktop v12-v25 requis") + 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28): + raise ValueError("schema desktop v12-v28 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "ORDER BY source_exercise_id COLLATE BINARY" diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index b4d92ce..e2e98c6 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -41,7 +41,7 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(11, 28)) + supported_versions = tuple(range(11, 29)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise ValueError("schema desktop v11-v16 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index cd0028d..612468d 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -25,7 +25,7 @@ def main(): con.row_factory = sqlite3.Row try: version = con.execute("PRAGMA user_version").fetchone()[0] - if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27): + if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28): raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") rows = con.execute(""" SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index 991cbdb..4ccb3fc 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -71,7 +71,7 @@ def main(): # CONTRACT: v8 adds only desktop-local custom equipment. The PC # catalogue artifact is unchanged, but it must read the current # canonical desktop schema rather than accept a stale pre-v8 database. - if version not in range(8, 28): + if version not in range(8, 29): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 564e7d8..5f18f83 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -83,9 +83,9 @@ def main(complete_causal_envelope=False): con.row_factory = sqlite3.Row try: schema_version = con.execute("PRAGMA user_version").fetchone()[0] - if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27): + if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28): raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") - supported_versions = range(11, 28) + supported_versions = range(11, 29) legacy_v2_schema = args.version == 2 and schema_version == 10 if schema_version not in supported_versions and not legacy_v2_schema: raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)") diff --git a/tools/export_programs.py b/tools/export_programs.py index 52cd035..cdb34f4 100644 --- a/tools/export_programs.py +++ b/tools/export_programs.py @@ -16,8 +16,8 @@ def nullable(row, index): def export_programs(database: Path) -> dict: with closing(connect_database(database)) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27): - raise ValueError("desktop schema v26 or v27 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28): + raise ValueError("desktop schema v26, v27 or v28 required") programs = [] for row in db.execute( "SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at," diff --git a/tools/export_session_preparations.py b/tools/export_session_preparations.py index ad81ffa..1cfae2a 100644 --- a/tools/export_session_preparations.py +++ b/tools/export_session_preparations.py @@ -17,9 +17,9 @@ MAX_WITHDRAWALS = 128 def build_export(connection: sqlite3.Connection) -> dict: - supported_versions = (24, 25, 26, 27) + supported_versions = (24, 25, 26, 27, 28) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("desktop schema v24, v25, v26 or v27 required") + raise ValueError("desktop schema v24, v25, v26, v27 or v28 required") deliveries = [] rows = connection.execute( "SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state," diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index 737a588..640bfd4 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -10,9 +10,9 @@ def main(complete_causal_envelope=False): parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() db=connect_database(args.database) try: - supported_versions = range(15, 28) + supported_versions = range(15, 29) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v15-v25 requis") + raise ValueError("schema desktop v15-v28 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact") roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py index fbe271e..41bc286 100644 --- a/tools/import_ai_session_draft.py +++ b/tools/import_ai_session_draft.py @@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None): INVARIANT: validation and every child insert share one IMMEDIATE transaction; any failure leaves no draft, child, or import-identity row behind. """ - supported_versions = range(18, 28) + supported_versions = range(18, 29) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v18-v25 requis") + raise ImportFailure("schema desktop v18-v28 requis") draft, entries = validate(payload, connection) digest = canonical_digest(payload) imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index 4794073..0f2ae96 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -187,7 +187,7 @@ def main(): payload = json.loads(args.artifact.read_text(encoding="utf-8")) connection = connect_database(args.database) try: - supported_versions = range(8, 28) + supported_versions = range(8, 29) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: fail("schema desktop v8 à v21 requis") proof_path = args.mobile_export diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 097a0cd..4ae102c 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -53,7 +53,7 @@ def validate(payload, reserved): def apply_definitions(connection, definitions, complete_causal_envelope=False): """Apply validated definitions without owning the surrounding transaction.""" - supported_versions = range(8, 28) + supported_versions = range(8, 29) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: fail("schema desktop v8 à v21 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index b413c7a..0e43f05 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -56,7 +56,7 @@ def load(path): def apply_aliases(con, aliases): """Apply parsed aliases inside the caller-owned transaction.""" - supported_versions = range(12, 28) + supported_versions = range(12, 29) if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: fail("schema desktop v12-v21 requis") for source, canonical in aliases: diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index ce821a1..6a014be 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False): """Apply validated zone rows without owning transaction boundaries.""" - supported_versions = range(11, 28) + supported_versions = range(11, 29) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v11-v25 requis") + raise ImportFailure("schema desktop v11-v28 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact") connection.execute("PRAGMA foreign_keys=ON");grouped={} # CONTRACT: resolve complete alias groups before the first write; direct diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index e8ffe0d..a15d670 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False): if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide") parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27): - fail("schema desktop v17-v25 requis") + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28): + fail("schema desktop v17-v28 requis") applied=equal=ancestor=pending=0;seen=set() for item in root["exercises"]: validate(item);eid=item["exercise_id"] diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index ee33ebe..f189f52 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -15,6 +15,7 @@ from pathlib import Path from validate_json import TrainlogSemanticError, parse_timestamp from exercise_names import ExerciseNameCatalogError, load_exercise_names from trainlog_sqlite import connect_database +from causal_delete_exchange import live_revision FORMAT = "trainlog-mobile-export" @@ -846,9 +847,9 @@ def require_supported_schema(connection): # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # readable for legacy artifacts and are never made to fake that table. - if version not in range(5, 28): + if version not in range(5, 29): raise ImportFailure( - f"base desktop schema v5 à v25 attendue, version trouvée: {version}" + f"base desktop schema v5 à v28 attendue, version trouvée: {version}" ) @@ -1978,8 +1979,8 @@ def apply_payload(connection, payload, trace_exercises=False): require_supported_schema(connection) schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] - if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27): - raise ImportFailure("mobile V4 exige le schéma desktop v19 à v25") + if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28): + raise ImportFailure("mobile V4 exige le schéma desktop v19 à v28") if schema_version >= 20: protected = {(row[0], row[1]) for row in connection.execute( "SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} @@ -2000,6 +2001,30 @@ def apply_payload(connection, payload, trace_exercises=False): mapping = import_exercises(connection, payload, report, trace_exercises) import_sessions(connection, payload, mapping, report) import_body(connection, payload, report) + if schema_version >= 20: + for session in sorted(payload["sessions"], key=lambda value: value["session_id"]): + session_id = session["session_id"] + state = connection.execute( + "SELECT deleted FROM sync_causal_state " + "WHERE target_kind='session' AND target_id=?", + (session_id,), + ).fetchone() + if state is not None: + continue + # WHY: peers without a previously exchanged mutable revision must + # converge on the same predecessor before either can publish a + # causal delete. CONTRACT: use the canonical live snapshot shared + # with the deletion consumer. INVARIANT: no deletion row or random + # peer-local revision is invented by an ordinary history import. + revision = live_revision(connection, "session", session_id) + if revision is None: + raise ImportFailure("révision causale de séance introuvable: " + session_id) + connection.execute( + "INSERT INTO sync_causal_state(" + "target_kind,target_id,current_revision_id,deleted,operation_id) " + "VALUES('session',?,?,0,NULL)", + (session_id, revision), + ) return report diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index 25a2874..61f351a 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions): def apply_feedback(db,root,complete_causal_envelope=False): roots_added=roots_skipped=revisions_added=revisions_skipped=0 db.execute("PRAGMA foreign_keys=ON") - supported_versions = range(15, 28) + supported_versions = range(15, 29) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v15-v25 requis") + fail("schema desktop v15-v28 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact") seen=set() for index,item in enumerate(root["exercise_feedback"]): diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index 446d195..fac555b 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -98,7 +98,7 @@ def main() -> int: ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ).strip(), "product_version": "0.1.2", - "desktop_schema": 27, + "desktop_schema": 28, "android_schema": 25, "protocols": [ "mobile-export-v3", diff --git a/tools/program_execution_exchange.py b/tools/program_execution_exchange.py index c8462c6..293cc3f 100644 --- a/tools/program_execution_exchange.py +++ b/tools/program_execution_exchange.py @@ -87,8 +87,8 @@ def load(path: Path) -> dict: def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]: - if db.execute("PRAGMA user_version").fetchone()[0] != 27: - fail("desktop schema v27 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28): + fail("desktop schema v27 or v28 required") added = 0 advanced = 0 skipped = 0 @@ -99,13 +99,6 @@ def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int] ).fetchone() if program_session is None or program_session[0] != item["program_id"]: fail("program execution references an unknown or mismatched session") - if item["state"] == "completed": - completed = db.execute( - "SELECT 1 FROM sessions WHERE session_id=?", (item["session_id"],) - ).fetchone() - if completed is None: - fail("completed program execution references missing history") - existing = db.execute( "SELECT program_id,session_id,state,observed_at " "FROM program_session_executions WHERE program_session_id=?", @@ -114,6 +107,27 @@ def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int] expected = ( item["program_id"], item["session_id"], item["state"], item["observed_at"] ) + if existing is not None and existing[0] == item["program_id"] and \ + existing[1] == item["session_id"] and existing[2] == "deleted": + tombstone = db.execute( + "SELECT 1 FROM sync_causal_state " + "WHERE target_kind='session' AND target_id=? AND deleted=1", + (item["session_id"],), + ).fetchone() + if tombstone is None: + fail("deleted program execution lacks causal session tombstone") + # CONTRACT: a retained Android generation may repeat an execution + # fact after the desktop has causally deleted its completed + # history. The tombstone dominates that older fact and the durable + # terminal provenance row remains unchanged. + skipped += 1 + continue + if item["state"] == "completed": + completed = db.execute( + "SELECT 1 FROM sessions WHERE session_id=?", (item["session_id"],) + ).fetchone() + if completed is None: + fail("completed program execution references missing history") if existing is None: db.execute( "INSERT INTO program_session_executions(" diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index 9ad6a2e..f80cf5d 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -206,9 +206,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None): def require_schema(db: sqlite3.Connection) -> None: - supported_versions = (24, 25, 26, 27) + supported_versions = (24, 25, 26, 27, 28) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise GenerationError("desktop schema v24, v25, v26 or v27 required") + raise GenerationError("desktop schema v24, v25, v26, v27 or v28 required") def peer_identity(db: sqlite3.Connection, kind: str) -> str: diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index 354ebbf..4d97241 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -17,7 +17,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 27 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 28 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/meson.build b/tui/meson.build index 08fbb15..31b018d 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -756,6 +756,15 @@ test_schema_v26_migration = executable( test('schema_v26_migration', test_schema_v26_migration) +test_schema_v28_migration = executable( + 'test_schema_v28_migration', + 'tests/test_schema_v28_migration.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) + +test('schema_v28_migration', test_schema_v28_migration) + test_schema_v7_migration = executable( 'test_schema_v7_migration', 'tests/test_schema_v7_migration.c', @@ -802,6 +811,12 @@ test( args: [meson.project_source_root() / 'tests/test_session_exchange_v3.py'], ) +test( + 'program_execution_exchange', + python3_trainlog_tests, + args: [meson.project_source_root() / 'tests/test_program_execution_exchange.py'], +) + test( 'sync_gap_characterization', python3_trainlog_tests, diff --git a/tui/src/database.c b/tui/src/database.c index 8f030be..68fd43d 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -1249,6 +1249,31 @@ static const char *const MIGRATE_V26_TO_V27_SQL = "ON program_session_executions(program_id,state);" "PRAGMA user_version=27;COMMIT;"; +/* WHY: a causally deleted completed session may retain immutable Program + * provenance after the session row disappears. CONTRACT: v28 adds only the + * terminal `deleted` execution state and preserves every existing identity and + * timestamp. INVARIANT: migration neither deletes history nor invents a + * Program execution or causal deletion. */ +static const char *const MIGRATE_V27_TO_V28_SQL = + "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "CREATE TABLE program_session_executions_v28(" + "program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(program_session_id) " + "ON DELETE RESTRICT," + "program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT," + "session_id TEXT NOT NULL UNIQUE," + "state TEXT NOT NULL CHECK(state IN('in_progress','completed','deleted')) ," + "observed_at TEXT NOT NULL," + "CHECK(length(program_id)>0 AND length(session_id)>0));" + "INSERT INTO program_session_executions_v28 " + "SELECT program_session_id,program_id,session_id,state,observed_at " + "FROM program_session_executions;" + "DROP INDEX program_session_executions_program;" + "DROP TABLE program_session_executions;" + "ALTER TABLE program_session_executions_v28 RENAME TO program_session_executions;" + "CREATE INDEX program_session_executions_program " + "ON program_session_executions(program_id,state);" + "PRAGMA user_version=28;COMMIT;PRAGMA foreign_keys=ON;"; + /* CONTRACT: migration fixtures may retain additive v25 columns while * deliberately lowering user_version. Add each provenance column only when * absent; a genuine v24 database still receives both before opening returns. */ @@ -2038,7 +2063,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, } else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 || version == 16 || version == 17 || version == 18 || version == 19 || version == 20 || version == 21 || version == 22 || version == 23 || version == 24 || version == 25 || - version == 26 || version == 27) { + version == 26 || version == 27 || version == 28) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -2177,6 +2202,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status == TRAINLOG_STATUS_OK && version < 27) { status = execute_sql(database, MIGRATE_V26_TO_V27_SQL); } + if (status == TRAINLOG_STATUS_OK && version < 28) { + status = execute_sql(database, MIGRATE_V27_TO_V28_SQL); + } if (status == TRAINLOG_STATUS_OK) { status = ensure_v18_ai_draft_publication_state(database); } @@ -2196,7 +2224,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status != TRAINLOG_STATUS_OK) { set_open_diagnostic(output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v27" : "migrate database to schema v27", + version == 0 ? "create schema v28" : "migrate database to schema v28", database->connection, SQLITE_ERROR); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); diff --git a/tui/src/web_programs.c b/tui/src/web_programs.c index 2f5cab1..654847b 100644 --- a/tui/src/web_programs.c +++ b/tui/src/web_programs.c @@ -384,9 +384,11 @@ static TrainlogStatus add_program_sessions(TrainlogDatabase *database, "SELECT ps.program_session_id,ps.position,ps.title,ps.session_type,ps.planned_for," "ps.note,CASE WHEN pe.state='completed' THEN 'completed' " "WHEN pe.state='in_progress' THEN 'in_progress' " + "WHEN pe.state='deleted' THEN 'deleted' " "WHEN EXISTS(SELECT 1 FROM session_preparations sp WHERE " "sp.source_program_session_id=ps.program_session_id AND sp.withdrawn_at IS NULL) " - "THEN 'prepared' ELSE 'todo' END,pe.session_id " + "THEN 'prepared' ELSE 'todo' END," + "CASE WHEN pe.state='deleted' THEN NULL ELSE pe.session_id END " "FROM program_sessions ps LEFT JOIN program_session_executions pe " "ON pe.program_session_id=ps.program_session_id " "WHERE ps.program_id=?1 ORDER BY ps.position,ps.program_session_id"; diff --git a/tui/src/web_session_deletions.c b/tui/src/web_session_deletions.c index 8383b3c..940d877 100644 --- a/tui/src/web_session_deletions.c +++ b/tui/src/web_session_deletions.c @@ -23,9 +23,24 @@ typedef struct DeleteContext { const char *target_id; const char *expected_revision; const char *request_id; + const char *failure_stage; char created_at[TRAINLOG_TIMESTAMP_MAX + 1U]; } DeleteContext; +static void log_database_failure(TrainlogDatabase *database, const DeleteContext *context) { + const int primary = sqlite3_errcode(database->connection); + const int extended = sqlite3_extended_errcode(database->connection); + const char *message = sqlite3_errmsg(database->connection); + + (void)fprintf(stderr, + "Trainlog Web session deletion failed: stage=%s sqlite_primary=%d " + "sqlite_extended=%d diagnostic=%s\n", + context->failure_stage, + primary, + extended, + message == NULL ? "unavailable" : message); +} + static bool timestamp_now(char output[TRAINLOG_TIMESTAMP_MAX + 1U]) { time_t now = time(NULL); struct tm utc; @@ -263,14 +278,84 @@ static TrainlogStatus insert_causal_operation(TrainlogDatabase *database, return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; } +/* WHY: Program provenance is durable evidence even after a user removes the + * completed workout from active history. CONTRACT: only a completed execution + * may enter the terminal deleted state. INVARIANT: the transition and session + * deletion share the surrounding transaction, so neither can commit alone. */ +static TrainlogStatus retire_program_execution(TrainlogDatabase *database, DeleteContext *context) { + sqlite3_stmt *statement = NULL; + const char *state; + int result; + + context->failure_stage = "load_program_execution"; + result = sqlite3_prepare_v2(database->connection, + "SELECT state FROM program_session_executions " + "WHERE session_id=?1", + -1, + &statement, + NULL); + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_step(statement); + } + if (result == SQLITE_DONE) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_OK; + } + if (result != SQLITE_ROW) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_DATABASE_ERROR; + } + state = (const char *)sqlite3_column_text(statement, 0); + if (state == NULL || strcmp(state, "completed") != 0) { + (void)sqlite3_finalize(statement); + return TRAINLOG_STATUS_CONFLICT; + } + if (sqlite3_finalize(statement) != SQLITE_OK) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + + statement = NULL; + context->failure_stage = "retire_program_execution"; + result = sqlite3_prepare_v2(database->connection, + "UPDATE program_session_executions " + "SET state='deleted' " + "WHERE session_id=?1 AND state='completed'", + -1, + &statement, + NULL); + if (result == SQLITE_OK) { + result = sqlite3_bind_text(statement, 1, context->target_id, -1, SQLITE_TRANSIENT); + } + if (result == SQLITE_OK) { + result = sqlite3_step(statement); + } + if (statement != NULL && sqlite3_finalize(statement) != SQLITE_OK && result == SQLITE_DONE) { + result = SQLITE_ERROR; + } + if (result != SQLITE_DONE) { + return TRAINLOG_STATUS_DATABASE_ERROR; + } + return sqlite3_changes(database->connection) == 1 ? TRAINLOG_STATUS_OK + : TRAINLOG_STATUS_CONFLICT; +} + static TrainlogStatus apply_causal_effect(TrainlogDatabase *database, - const DeleteContext *context, + DeleteContext *context, const char *causal_kind, const char *operation_id) { sqlite3_stmt *statement = NULL; int result; if (strcmp(context->kind, "history") == 0) { + TrainlogStatus status = retire_program_execution(database, context); + + if (status != TRAINLOG_STATUS_OK) { + return status; + } + context->failure_stage = "detach_body_observations"; result = sqlite3_prepare_v2( database->connection, "UPDATE body_observations SET session_row_id=NULL WHERE session_row_id=(SELECT id " @@ -293,6 +378,8 @@ static TrainlogStatus apply_causal_effect(TrainlogDatabase *database, return TRAINLOG_STATUS_DATABASE_ERROR; } } + context->failure_stage = + strcmp(context->kind, "draft") == 0 ? "delete_draft" : "delete_session"; result = sqlite3_prepare_v2(database->connection, strcmp(context->kind, "draft") == 0 ? "DELETE FROM execution_drafts WHERE session_id=?1" @@ -320,6 +407,7 @@ static TrainlogStatus apply_causal_effect(TrainlogDatabase *database, if (written < 0 || (size_t)written >= sizeof(final_revision)) { return TRAINLOG_STATUS_SYSTEM_ERROR; } + context->failure_stage = "store_session_finalization"; result = sqlite3_prepare_v2( database->connection, "INSERT OR IGNORE INTO execution_draft_finalizations VALUES(?1,?2,?3)", @@ -347,6 +435,7 @@ static TrainlogStatus apply_causal_effect(TrainlogDatabase *database, return TRAINLOG_STATUS_DATABASE_ERROR; } } + context->failure_stage = "store_causal_state"; result = sqlite3_prepare_v2(database->connection, "INSERT OR REPLACE INTO sync_causal_state VALUES(?1,?2,?3,1,?3)", -1, @@ -370,14 +459,14 @@ static TrainlogStatus apply_causal_effect(TrainlogDatabase *database, return result == SQLITE_DONE ? TRAINLOG_STATUS_OK : TRAINLOG_STATUS_DATABASE_ERROR; } -static TrainlogStatus delete_causal_resource(TrainlogDatabase *database, - const DeleteContext *context) { +static TrainlogStatus delete_causal_resource(TrainlogDatabase *database, DeleteContext *context) { const char *causal_kind = strcmp(context->kind, "draft") == 0 ? "execution_draft" : "session"; char current_revision[TRAINLOG_ID_MAX + 1U]; char operation_id[TRAINLOG_GENERATED_ID_CAPACITY]; bool active_draft; TrainlogStatus status; + context->failure_stage = "load_current_revision"; status = load_current_revision( database, context->kind, context->target_id, current_revision, &active_draft); if (status != TRAINLOG_STATUS_OK) { @@ -386,6 +475,7 @@ static TrainlogStatus delete_causal_resource(TrainlogDatabase *database, if (active_draft || strcmp(current_revision, context->expected_revision) != 0) { return TRAINLOG_STATUS_CONFLICT; } + context->failure_stage = "check_causal_capacity"; status = causal_capacity_available(database); if (status != TRAINLOG_STATUS_OK) { return status; @@ -393,6 +483,7 @@ static TrainlogStatus delete_causal_resource(TrainlogDatabase *database, if (trainlog_id_generate("del", operation_id, sizeof(operation_id)) != TRAINLOG_STATUS_OK) { return TRAINLOG_STATUS_SYSTEM_ERROR; } + context->failure_stage = "insert_causal_operation"; status = insert_causal_operation(database, context, causal_kind, current_revision, operation_id); if (status == TRAINLOG_STATUS_OK) { @@ -552,7 +643,7 @@ TrainlogStatus trainlog_web_session_delete_json(TrainlogDatabase *database, if (database == NULL || kind == NULL || target_id == NULL || expected_revision == NULL || request_id == NULL || output_json == NULL || output_size == NULL || target_id[0] == '\0' || - expected_revision[0] == '\0' || request_id[0] == '\0' || + expected_revision[0] == '\0' || request_id[0] == '\0' || strchr(target_id, '/') != NULL || strlen(target_id) > TRAINLOG_ID_MAX || strlen(expected_revision) > TRAINLOG_ID_MAX || strlen(request_id) > 128U || (strcmp(kind, "proposal") != 0 && strcmp(kind, "draft") != 0 && @@ -565,20 +656,25 @@ TrainlogStatus trainlog_web_session_delete_json(TrainlogDatabase *database, context.target_id = target_id; context.expected_revision = expected_revision; context.request_id = request_id; + context.failure_stage = "begin_transaction"; if (!timestamp_now(context.created_at)) { return TRAINLOG_STATUS_SYSTEM_ERROR; } if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) { + log_database_failure(database, &context); return TRAINLOG_STATUS_DATABASE_ERROR; } + context.failure_stage = "replay_request"; status = replay_request(database, &context, output_json, output_size, &replayed); if (status == TRAINLOG_STATUS_OK && replayed) { - status = sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK - ? TRAINLOG_STATUS_OK - : TRAINLOG_STATUS_DATABASE_ERROR; - return status; + context.failure_stage = "commit_replayed_request"; + if (sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) { + return TRAINLOG_STATUS_OK; + } + status = TRAINLOG_STATUS_DATABASE_ERROR; } if (status == TRAINLOG_STATUS_OK) { + context.failure_stage = "apply_deletion"; status = strcmp(kind, "proposal") == 0 ? delete_proposal(database, &context) : delete_causal_resource(database, &context); } @@ -586,11 +682,17 @@ TrainlogStatus trainlog_web_session_delete_json(TrainlogDatabase *database, status = build_response(&context, output_json, output_size); } if (status == TRAINLOG_STATUS_OK) { + context.failure_stage = "store_deletion_request"; status = store_request(database, &context, *output_json); } - if (status == TRAINLOG_STATUS_OK && - sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) { - return TRAINLOG_STATUS_OK; + if (status == TRAINLOG_STATUS_OK) { + context.failure_stage = "commit_transaction"; + if (sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) == SQLITE_OK) { + return TRAINLOG_STATUS_OK; + } + } + if (status == TRAINLOG_STATUS_DATABASE_ERROR || status == TRAINLOG_STATUS_OK) { + log_database_failure(database, &context); } (void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL); free(*output_json); diff --git a/tui/src/web_sessions.c b/tui/src/web_sessions.c index aa8781b..7a93697 100644 --- a/tui/src/web_sessions.c +++ b/tui/src/web_sessions.c @@ -351,8 +351,10 @@ static TrainlogStatus detail_for_history(TrainlogDatabase *database, "SELECT " "se.entry_id,se.position,x.exercise_id,x.name,se.equipment_id,se.recording_mode,se." "tracking_mode,se.load_mode,se.target_sets,se.target_reps,se.target_duration_seconds,se." - "target_weight_kg,se.rest_seconds,se.notes,se.max_weight_kg FROM session_exercises se JOIN " - "exercises x ON x.id=se.exercise_row_id JOIN sessions s ON s.id=se.session_row_id WHERE " + "target_weight_kg,se.rest_seconds,se.notes,mr.max_weight_kg FROM session_exercises se JOIN " + "exercises x ON x.id=se.exercise_row_id JOIN sessions s ON s.id=se.session_row_id LEFT " + "JOIN " + "max_results mr ON mr.session_exercise_row_id=se.id WHERE " "s.session_id=?1 ORDER BY se.position,se.entry_id"; static const char SET_SQL[] = "SELECT ps.position,ps.reps,ps.duration_seconds,ps.weight_kg FROM performed_sets ps JOIN " diff --git a/tui/tests/test_schema_v26_migration.c b/tui/tests/test_schema_v26_migration.c index 61e6cc5..34c1b44 100644 --- a/tui/tests/test_schema_v26_migration.c +++ b/tui/tests/test_schema_v26_migration.c @@ -432,12 +432,12 @@ static int run_test(void) { CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(database, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 27); + CHECK(version == 28); trainlog_database_close(database); database = NULL; CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK); - CHECK(scalar(raw, "PRAGMA user_version") == 27); + CHECK(scalar(raw, "PRAGMA user_version") == 28); CHECK(capture_projection(raw, BUSINESS_PROJECTIONS, sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]), diff --git a/tui/tests/test_schema_v28_migration.c b/tui/tests/test_schema_v28_migration.c new file mode 100644 index 0000000..f15f098 --- /dev/null +++ b/tui/tests/test_schema_v28_migration.c @@ -0,0 +1,157 @@ +/** + * @file test_schema_v28_migration.c + * @brief Populated v27 to v28 Program-execution lifecycle migration. + */ +#define _POSIX_C_SOURCE 200809L + +#include + +#include +#include +#include +#include +#include + +#include "trainlog/database.h" + +#define CHECK(expression) \ + do { \ + if (!(expression)) { \ + (void)fprintf(stderr, "CHECK failed at %s:%d: %s\n", __FILE__, __LINE__, #expression); \ + goto cleanup; \ + } \ + } while (0) + +static bool execute(sqlite3 *database, const char *sql) { + char *error = NULL; + int result = sqlite3_exec(database, sql, NULL, NULL, &error); + + if (result != SQLITE_OK) { + (void)fprintf(stderr, "fixture SQL failed: %s\n", error == NULL ? "unknown" : error); + } + sqlite3_free(error); + return result == SQLITE_OK; +} + +static int scalar(sqlite3 *database, const char *sql) { + sqlite3_stmt *statement = NULL; + int value = -1; + + if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK && + sqlite3_step(statement) == SQLITE_ROW) { + value = sqlite3_column_int(statement, 0); + } + (void)sqlite3_finalize(statement); + return value; +} + +static bool execution_matches(sqlite3 *database, const char *state) { + sqlite3_stmt *statement = NULL; + bool matches = false; + + if (sqlite3_prepare_v2(database, + "SELECT program_session_id,program_id,session_id,state,observed_at " + "FROM program_session_executions", + -1, + &statement, + NULL) == SQLITE_OK && + sqlite3_step(statement) == SQLITE_ROW) { + matches = + strcmp((const char *)sqlite3_column_text(statement, 0), "pgs_v28_fixture") == 0 && + strcmp((const char *)sqlite3_column_text(statement, 1), "pg_v28_fixture") == 0 && + strcmp((const char *)sqlite3_column_text(statement, 2), "se_v28_fixture") == 0 && + strcmp((const char *)sqlite3_column_text(statement, 3), state) == 0 && + strcmp((const char *)sqlite3_column_text(statement, 4), "2026-09-18T20:00:00Z") == 0; + } + (void)sqlite3_finalize(statement); + return matches; +} + +static int run_test(void) { + char path[] = "/tmp/trainlog-schema-v28-XXXXXX"; + TrainlogDatabase *production = NULL; + sqlite3 *database = NULL; + int descriptor = mkstemp(path); + int version = 0; + int result = EXIT_FAILURE; + + CHECK(descriptor >= 0); + CHECK(close(descriptor) == 0); + CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); + trainlog_database_close(production); + production = NULL; + + CHECK(sqlite3_open(path, &database) == SQLITE_OK); + CHECK(execute(database, + "PRAGMA foreign_keys=OFF;BEGIN IMMEDIATE;" + "CREATE TABLE program_session_executions_v27(" + "program_session_id TEXT PRIMARY KEY REFERENCES program_sessions(" + "program_session_id) ON DELETE RESTRICT," + "program_id TEXT NOT NULL REFERENCES programs(program_id) ON DELETE RESTRICT," + "session_id TEXT NOT NULL UNIQUE," + "state TEXT NOT NULL CHECK(state IN('in_progress','completed')) ," + "observed_at TEXT NOT NULL," + "CHECK(length(program_id)>0 AND length(session_id)>0));" + "DROP INDEX program_session_executions_program;" + "DROP TABLE program_session_executions;" + "ALTER TABLE program_session_executions_v27 RENAME TO " + "program_session_executions;" + "CREATE INDEX program_session_executions_program " + "ON program_session_executions(program_id,state);" + "INSERT INTO programs(program_id,title,state,created_at,updated_at,revision_id," + "source_format,source_version,source_payload_sha256) VALUES(" + "'pg_v28_fixture','Migration fixture','active','2026-09-18T19:00:00Z'," + "'2026-09-18T19:00:00Z','pgr_v28_fixture','trainlog-program',1," + "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');" + "INSERT INTO program_sessions(program_session_id,program_id,position,title," + "session_type) VALUES('pgs_v28_fixture','pg_v28_fixture',0,'Fixture session'," + "'training');" + "INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES(" + "'se_v28_fixture','2026-09-18T19:30:00Z','2026-09-18T20:00:00Z','training');" + "INSERT INTO program_session_executions VALUES('pgs_v28_fixture'," + "'pg_v28_fixture','se_v28_fixture','completed','2026-09-18T20:00:00Z');" + "PRAGMA user_version=27;COMMIT;PRAGMA foreign_keys=ON;")); + CHECK(scalar(database, "PRAGMA user_version") == 27); + CHECK(execution_matches(database, "completed")); + CHECK(scalar(database, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0); + CHECK(sqlite3_close(database) == SQLITE_OK); + database = NULL; + + CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); + CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); + CHECK(version == 28); + trainlog_database_close(production); + production = NULL; + + CHECK(sqlite3_open(path, &database) == SQLITE_OK); + CHECK(scalar(database, + "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") == 1); + CHECK(scalar(database, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0); + CHECK(execution_matches(database, "completed")); + CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_operations") == 0); + CHECK(scalar(database, "SELECT COUNT(*) FROM sync_causal_state WHERE deleted=1") == 0); + CHECK(execute(database, + "UPDATE program_session_executions SET state='deleted' " + "WHERE session_id='se_v28_fixture';")); + CHECK(execution_matches(database, "deleted")); + result = EXIT_SUCCESS; + +cleanup: + if (production != NULL) { + trainlog_database_close(production); + } + if (database != NULL) { + (void)sqlite3_close(database); + } + (void)unlink(path); + return result; +} + +int main(void) { + int result = run_test(); + + if (result == EXIT_SUCCESS) { + (void)puts("PASS populated schema v27 to v28 migration"); + } + return result; +} diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 9c4b79e..9501f64 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) { TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); CHECK(strncmp(diagnostic, - "migrate database to schema v27: SQLite rc=", - strlen("migrate database to schema v27: SQLite rc=")) == 0); + "migrate database to schema v28: SQLite rc=", + strlen("migrate database to schema v28: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) { CHECK(fd >= 0); CHECK(close(fd) == 0); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(sqlite3_exec(raw, "PRAGMA user_version=28;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=29;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); CHECK(database == NULL); - CHECK(strstr(diagnostic, "schema version 28 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 29 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 5797add..430dfb6 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -154,7 +154,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 v27") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v28") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); diff --git a/tui/tests/test_web_programs.c b/tui/tests/test_web_programs.c index 25f7c89..6151194 100644 --- a/tui/tests/test_web_programs.c +++ b/tui/tests/test_web_programs.c @@ -193,7 +193,7 @@ static bool import_archive_and_prepare(void) { char *second_entry; CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); - CHECK(scalar(database, "PRAGMA user_version") == 27); + CHECK(scalar(database, "PRAGMA user_version") == 28); CHECK(scalar(database, "SELECT COUNT(*) FROM pragma_table_info('program_deletions') WHERE " "name IN('generation_id','acknowledged_at')") == 2); diff --git a/tui/tests/test_web_server.c b/tui/tests/test_web_server.c index 229c911..242a789 100644 --- a/tui/tests/test_web_server.c +++ b/tui/tests/test_web_server.c @@ -248,6 +248,35 @@ static bool test_http_contract(TrainlogDatabase *database) { NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(database->connection, + "INSERT INTO programs(program_id,title,state,created_at,updated_at," + "revision_id,source_format,source_version,source_payload_sha256) VALUES(" + "'pg_77777777-7777-4777-8777-777777777777','HTTP Program','active'," + "'2026-09-18T10:00:00Z','2026-09-18T10:00:00Z','pgr_http'," + "'trainlog-program',1," + "'cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc');" + "INSERT INTO program_sessions(program_session_id,program_id,position,title," + "session_type) VALUES('pgs_88888888-8888-4888-8888-888888888888'," + "'pg_77777777-7777-4777-8777-777777777777',0,'HTTP execution','training');" + "INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES(" + "'se_99999999-9999-4999-8999-999999999999','2026-09-18T12:00:00Z'," + "'2026-09-18T13:00:00Z','training'),(" + "'se_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa','2026-09-18T14:00:00Z'," + "'2026-09-18T15:00:00Z','training');" + "INSERT INTO sync_causal_state VALUES(" + "'session','se_99999999-9999-4999-8999-999999999999','lv_http',0,NULL),(" + "'session','se_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa','lv_failure',0,NULL);" + "INSERT INTO program_session_executions VALUES(" + "'pgs_88888888-8888-4888-8888-888888888888'," + "'pg_77777777-7777-4777-8777-777777777777'," + "'se_99999999-9999-4999-8999-999999999999','completed'," + "'2026-09-18T13:00:00Z');" + "CREATE TRIGGER inject_session_delete_failure BEFORE DELETE ON sessions " + "WHEN OLD.session_id='se_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' BEGIN " + "SELECT RAISE(ABORT,'injected session deletion failure');END;", + NULL, + NULL, + NULL) == SQLITE_OK); (void)unlink("/tmp/trainlog-web-test.db.sync-run.json"); CHECK(reserve_port(&port, 0) == -2); child = fork(); @@ -378,6 +407,95 @@ static bool test_http_contract(TrainlogDatabase *database) { response, sizeof(response)) && strstr(response, "HTTP/1.1 404") != NULL); + CHECK(exchange(port, + "GET /api/v1/sessions/history/" + "se_99999999-9999-4999-8999-999999999999 HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n\r\n", + response, + sizeof(response)) && + strstr(response, "HTTP/1.1 200") != NULL && + strstr(response, "\"revision_id\":\"lv_http\"") != NULL); + (void)snprintf( + request, + sizeof(request), + "DELETE /api/v1/sessions/history/" + "se_99999999-9999-4999-8999-999999999999 HTTP/1.1\r\n" + "Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n" + "Content-Type: application/json\r\nIf-Match: \"stale\"\r\n" + "X-Trainlog-CSRF-Token: %s\r\nX-Trainlog-Request-ID: request-http-history-stale\r\n" + "Content-Length: 2\r\n\r\n{}", + (unsigned int)port, + token); + CHECK(exchange(port, request, response, sizeof(response)) && + strstr(response, "HTTP/1.1 409") != NULL && + strstr(response, "deletion_conflict") != NULL); + (void)snprintf( + request, + sizeof(request), + "DELETE /api/v1/sessions/history/" + "se_99999999-9999-4999-8999-999999999999 HTTP/1.1\r\n" + "Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n" + "Content-Type: application/json\r\nIf-Match: \"lv_http\"\r\n" + "X-Trainlog-CSRF-Token: %s\r\nX-Trainlog-Request-ID: request-http-history-delete\r\n" + "Content-Length: 2\r\n\r\n{}", + (unsigned int)port, + token); + CHECK(exchange(port, request, response, sizeof(response)) && + strstr(response, "HTTP/1.1 200") != NULL && + strstr(response, "\"state\":\"deleted\"") != NULL); + CHECK(exchange(port, request, response, sizeof(response)) && + strstr(response, "HTTP/1.1 200") != NULL); + CHECK(exchange(port, + "GET /api/v1/sessions/program/" + "pg_77777777-7777-4777-8777-777777777777 HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n\r\n", + response, + sizeof(response)) && + strstr(response, "HTTP/1.1 200") != NULL && + strstr(response, "\"execution_state\":\"deleted\"") != NULL && + strstr(response, "\"execution_session_id\":null") != NULL); + CHECK(exchange(port, + "GET /api/v1/sessions/history/" + "se_99999999-9999-4999-8999-999999999999 HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n\r\n", + response, + sizeof(response)) && + strstr(response, "HTTP/1.1 404") != NULL); + (void)snprintf( + request, + sizeof(request), + "DELETE /api/v1/sessions/history/not/a/session HTTP/1.1\r\n" + "Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n" + "Content-Type: application/json\r\nIf-Match: \"revision\"\r\n" + "X-Trainlog-CSRF-Token: %s\r\nX-Trainlog-Request-ID: request-http-invalid-delete\r\n" + "Content-Length: 2\r\n\r\n{}", + (unsigned int)port, + token); + CHECK(exchange(port, request, response, sizeof(response)) && + strstr(response, "HTTP/1.1 422") != NULL && + strstr(response, "invalid_deletion") != NULL); + (void)snprintf( + request, + sizeof(request), + "DELETE /api/v1/sessions/history/" + "se_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa HTTP/1.1\r\n" + "Host: 127.0.0.1\r\nOrigin: http://127.0.0.1:%u\r\n" + "Content-Type: application/json\r\nIf-Match: \"lv_failure\"\r\n" + "X-Trainlog-CSRF-Token: %s\r\nX-Trainlog-Request-ID: request-http-db-failure\r\n" + "Content-Length: 2\r\n\r\n{}", + (unsigned int)port, + token); + CHECK(exchange(port, request, response, sizeof(response)) && + strstr(response, "HTTP/1.1 500") != NULL && + strstr(response, "sessions_unavailable") != NULL); + CHECK(exchange(port, + "GET /api/v1/sessions/history/" + "se_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n\r\n", + response, + sizeof(response)) && + strstr(response, "HTTP/1.1 200") != NULL && + strstr(response, "\"revision_id\":\"lv_failure\"") != NULL); } CHECK(exchange(port, "GET /api/v1/dashboard HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n", diff --git a/tui/tests/test_web_session_deletions.c b/tui/tests/test_web_session_deletions.c index c409d4b..f7e19c5 100644 --- a/tui/tests/test_web_session_deletions.c +++ b/tui/tests/test_web_session_deletions.c @@ -9,6 +9,7 @@ #include "database_internal.h" #include "trainlog/database.h" #include "trainlog/status.h" +#include "trainlog/web_programs.h" #include "trainlog/web_session_deletions.h" #define CHECK(condition) \ @@ -176,10 +177,106 @@ static bool active_draft_conflicts_without_mutation(void) { return true; } +static bool completed_program_execution_deletion_is_atomic_and_idempotent(void) { + char path[] = "/tmp/trainlog-web-delete-program-execution-XXXXXX"; + TrainlogDatabase *database = NULL; + char *response = NULL; + char *replayed = NULL; + char *program_detail = NULL; + size_t response_size = 0U; + size_t replayed_size = 0U; + size_t program_detail_size = 0U; + int descriptor = mkstemp(path); + + CHECK(descriptor >= 0); + CHECK(close(descriptor) == 0); + CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); + CHECK(sqlite3_exec(database->connection, + "INSERT INTO programs(program_id,title,state,created_at,updated_at," + "revision_id,source_format,source_version,source_payload_sha256) VALUES(" + "'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa','Disposable Program','active'," + "'2026-09-18T10:00:00Z','2026-09-18T10:00:00Z','pgr_fixture'," + "'trainlog-program',1," + "'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa');" + "INSERT INTO program_sessions(program_session_id,program_id,position,title," + "session_type) VALUES(" + "'pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'," + "'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',0,'Executed','training'),(" + "'pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc'," + "'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',1,'Untouched','training');" + "INSERT INTO sessions(session_id,started_at,ended_at,session_type) VALUES(" + "'se_dddddddd-dddd-4ddd-8ddd-dddddddddddd','2026-09-18T10:00:00Z'," + "'2026-09-18T11:00:00Z','training'),(" + "'se_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee','2026-09-17T10:00:00Z'," + "'2026-09-17T11:00:00Z','training');" + "INSERT INTO sync_causal_state VALUES(" + "'session','se_dddddddd-dddd-4ddd-8ddd-dddddddddddd','lv_fixture',0,NULL);" + "INSERT INTO program_session_executions VALUES(" + "'pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'," + "'pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'," + "'se_dddddddd-dddd-4ddd-8ddd-dddddddddddd','completed'," + "'2026-09-18T11:00:00Z');", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(trainlog_web_session_delete_json(database, + "history", + "se_dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "lv_fixture", + "request-delete-program-history", + &response, + &response_size) == TRAINLOG_STATUS_OK); + CHECK(response != NULL && strstr(response, "\"state\":\"deleted\"") != NULL); + CHECK(scalar(database, + "SELECT COUNT(*) FROM sessions WHERE " + "session_id='se_dddddddd-dddd-4ddd-8ddd-dddddddddddd'") == 0); + CHECK(scalar(database, + "SELECT COUNT(*) FROM sessions WHERE " + "session_id='se_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee'") == 1); + CHECK(scalar(database, "SELECT COUNT(*) FROM programs") == 1); + CHECK(scalar(database, "SELECT COUNT(*) FROM program_sessions") == 2); + CHECK(scalar(database, + "SELECT COUNT(*) FROM program_session_executions WHERE state='deleted' AND " + "session_id='se_dddddddd-dddd-4ddd-8ddd-dddddddddddd' AND " + "observed_at='2026-09-18T11:00:00Z'") == 1); + CHECK(scalar(database, + "SELECT COUNT(*) FROM sync_causal_operations WHERE target_kind='session' AND " + "target_id='se_dddddddd-dddd-4ddd-8ddd-dddddddddddd'") == 1); + CHECK(scalar(database, + "SELECT COUNT(*) FROM sync_causal_state WHERE target_kind='session' AND " + "target_id='se_dddddddd-dddd-4ddd-8ddd-dddddddddddd' AND deleted=1") == 1); + CHECK(trainlog_web_programs_detail_json(database, + "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + &program_detail, + &program_detail_size) == TRAINLOG_STATUS_OK); + CHECK(program_detail != NULL && program_detail_size > 0U && + strstr(program_detail, "\"execution_state\":\"deleted\"") != NULL && + strstr(program_detail, "\"execution_session_id\":null") != NULL); + CHECK(trainlog_web_session_delete_json(database, + "history", + "se_dddddddd-dddd-4ddd-8ddd-dddddddddddd", + "lv_fixture", + "request-delete-program-history-replay", + &replayed, + &replayed_size) == TRAINLOG_STATUS_OK); + CHECK(replayed != NULL && response_size == replayed_size && strcmp(response, replayed) == 0); + CHECK(scalar(database, + "SELECT COUNT(*) FROM sync_causal_operations WHERE target_kind='session' AND " + "target_id='se_dddddddd-dddd-4ddd-8ddd-dddddddddddd'") == 1); + CHECK(scalar(database, "SELECT COUNT(*) FROM web_session_deletion_requests") == 1); + free(program_detail); + free(replayed); + free(response); + trainlog_database_close(database); + CHECK(unlink(path) == 0); + return true; +} + int main(void) { return proposal_deletion_is_durable_and_preserves_derivatives() && draft_and_history_use_causal_deletion() && - active_draft_conflicts_without_mutation() + active_draft_conflicts_without_mutation() && + completed_program_execution_deletion_is_atomic_and_idempotent() ? EXIT_SUCCESS : EXIT_FAILURE; } diff --git a/web/src/api/programs.ts b/web/src/api/programs.ts index cbef209..c041e2d 100644 --- a/web/src/api/programs.ts +++ b/web/src/api/programs.ts @@ -39,7 +39,7 @@ export interface ProgramSession { session_type: 'training' | 'max_test' planned_for: string | null note: string | null - execution_state: 'todo' | 'prepared' | 'in_progress' | 'completed' + execution_state: 'todo' | 'prepared' | 'in_progress' | 'completed' | 'deleted' execution_session_id: string | null occurrences: ProgramOccurrence[] } diff --git a/web/src/api/sessions.test.ts b/web/src/api/sessions.test.ts index c26df0f..ba9b8e3 100644 --- a/web/src/api/sessions.test.ts +++ b/web/src/api/sessions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { fetchAllSessionPages } from './sessions' +import { deleteSessionResource, fetchAllSessionPages } from './sessions' function item(identity: string) { return { @@ -37,3 +37,31 @@ describe('Sessions pagination', () => { await expect(fetchAllSessionPages('preparations')).rejects.toThrow('curseur') }) }) + +describe('Session deletion errors', () => { + it.each([ + [409, 'deletion_conflict', 'Cette séance a changé.'], + [404, 'not_found', 'Cette séance n’existe plus'], + [422, 'invalid_deletion', 'ne peut pas être supprimée'], + [500, 'sessions_unavailable', 'Aucune donnée n’a été modifiée'], + ])('maps HTTP %s to a useful French message', async (status, error, message) => { + vi.stubGlobal('fetch', vi.fn((input: RequestInfo | URL) => { + if (String(input) === '/api/v1/sync/status') { + return Promise.resolve({ + ok: true, + status: 200, + headers: new Headers({ 'X-Trainlog-CSRF-Token': 'a'.repeat(64) }), + json: () => Promise.resolve({ phase: 'idle', result: 'ok' }), + }) + } + return Promise.resolve({ + ok: false, + status, + json: () => Promise.resolve({ error }), + }) + })) + + await expect(deleteSessionResource('history', 'se_target', 'lv_target')) + .rejects.toThrow(message) + }) +}) diff --git a/web/src/api/sessions.ts b/web/src/api/sessions.ts index b966dcc..7b291c4 100644 --- a/web/src/api/sessions.ts +++ b/web/src/api/sessions.ts @@ -177,6 +177,17 @@ function requestId(): string { return `web-${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}` } +function deletionErrorMessage(value: unknown, status: number): string { + const reason = object(value) && typeof value.error === 'string' ? value.error : '' + const messages: Record = { + deletion_conflict: 'Cette séance a changé. Rechargez la liste avant de réessayer.', + not_found: 'Cette séance n’existe plus dans l’historique.', + invalid_deletion: 'Cette séance ne peut pas être supprimée.', + sessions_unavailable: 'La suppression a échoué. Aucune donnée n’a été modifiée.', + } + return messages[reason] ?? `La suppression a échoué (HTTP ${status}).` +} + export async function savePreparation( input: PreparationInput, identity?: string, @@ -289,9 +300,6 @@ export async function deleteSessionResource( ) const value: unknown = await response.json() if (!response.ok) { - const reason = object(value) && typeof value.error === 'string' - ? value.error - : `HTTP ${response.status}` - throw new Error(reason) + throw new Error(deletionErrorMessage(value, response.status)) } } diff --git a/web/src/routes/ProgramsTab.tsx b/web/src/routes/ProgramsTab.tsx index 16853b7..a70f54e 100644 --- a/web/src/routes/ProgramsTab.tsx +++ b/web/src/routes/ProgramsTab.tsx @@ -68,6 +68,7 @@ function ProgramDetailView({ programId, onBack, onChanged }: { prepared: 'Préparée', in_progress: 'En cours', completed: 'Effectuée', + deleted: 'Retirée', })[state] return
@@ -135,6 +136,7 @@ function ProgramDetailView({ programId, onBack, onChanged }: { {session.execution_state === 'prepared' && Préparation disponible} {session.execution_state === 'in_progress' && Séance en cours sur Android} {session.execution_state === 'completed' && ✓ Séance effectuée} + {session.execution_state === 'deleted' && Exécution retirée de l’historique} )}
diff --git a/web/src/routes/SessionsPage.test.tsx b/web/src/routes/SessionsPage.test.tsx index e60275a..ccbb78b 100644 --- a/web/src/routes/SessionsPage.test.tsx +++ b/web/src/routes/SessionsPage.test.tsx @@ -184,4 +184,64 @@ describe('Sessions page', () => { expect(screen.queryByRole('button', { name: 'Supprimer la préparation' })).not.toBeInTheDocument() expect(screen.getByText(/Dérivée de la proposition/)).toHaveTextContent('Proposition source') }) + + it('deletes completed Program history only after confirmation and refreshes honestly', async () => { + window.history.replaceState(null, '', '/seances') + let deleted = false + const target = { + identity: 'se_disposable', title: 'training', date: '2026-09-18', state: 'completed', + occurrence_count: 1, sort_timestamp: '2026-09-18T21:46:52+02:00', + } + const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => { + const url = String(input) + if (url === '/api/v1/sync/status') { + return response({ phase: 'idle', result: 'ok' }).then((value) => ({ + ...value, headers: new Headers({ 'X-Trainlog-CSRF-Token': 'a'.repeat(64) }), + })) + } + if (url.includes('/api/v1/sessions/history?')) { + return response(page('history', deleted ? [] : [target])) + } + if (url.includes('/api/v1/sessions/history/se_disposable') && init?.method === 'DELETE') { + deleted = true + return response({ api_version: 1, kind: 'history', identity: target.identity, + state: 'deleted', deleted_at: '2026-09-18T22:00:00Z' }) + } + if (url.includes('/api/v1/sessions/history/se_disposable')) { + if (deleted) return Promise.resolve({ ok: false, status: 404, + json: () => Promise.resolve({ error: 'not_found' }) }) + return response({ api_version: 1, kind: 'history', identity: target.identity, + revision_id: 'lv_disposable', session_type: 'training', + started_at: '2026-09-18T21:46:52+02:00', + ended_at: '2026-09-18T21:50:52+02:00', notes: null, occurrences: [] }) + } + if (url.includes('/api/v1/sessions/')) { + return response(page('empty', [])) + } + throw new Error(`unexpected request ${url}`) + }) + vi.stubGlobal('fetch', fetchMock) + render() + + fireEvent.click(screen.getByRole('tab', { name: 'Historique' })) + await screen.findByText('training') + const trigger = screen.getByRole('button', { name: 'Supprimer la séance de l’historique' }) + fireEvent.click(trigger) + const dialog = screen.getByRole('alertdialog') + expect(dialog).toHaveTextContent('La séance sera retirée de l’historique') + fireEvent.click(within(dialog).getByRole('button', { + name: 'Supprimer la séance de l’historique', + })) + + await waitFor(() => expect(screen.getByRole('status')).toHaveTextContent( + 'Séance supprimée de l’historique.', + )) + expect(screen.queryByText('training')).not.toBeInTheDocument() + expect(fetchMock.mock.calls.filter(([, options]) => + (options as RequestInit | undefined)?.method === 'DELETE')).toHaveLength(1) + + window.history.pushState(null, '', '/seances/history/se_disposable') + window.dispatchEvent(new PopStateEvent('popstate')) + await waitFor(() => expect(screen.getByRole('alert')).toHaveTextContent('Fiche indisponible')) + }) }) diff --git a/web/src/theme/app.css b/web/src/theme/app.css index 0497b70..ae379e7 100644 --- a/web/src/theme/app.css +++ b/web/src/theme/app.css @@ -176,6 +176,7 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett .program-execution-state.state-todo { background: var(--surface-1); color: var(--subtext-1); } .program-execution-state.state-prepared, .program-execution-state.state-in_progress { background: color-mix(in srgb, var(--yellow) 18%, transparent); color: var(--yellow); } .program-execution-state.state-completed { background: color-mix(in srgb, var(--green) 18%, transparent); color: var(--green); } +.program-execution-state.state-deleted { background: color-mix(in srgb, var(--red) 14%, transparent); color: var(--red); } .import-preview { margin: 1rem 0; padding: 1rem; border: 1px solid var(--blue); border-radius: .75rem; background: var(--mantle); } .visually-hidden { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; } @media (max-width: 600px) {