Fix causal deletion of Program sessions

This commit is contained in:
fy59 2026-09-18 23:04:01 +02:00
parent 894c944636
commit 68af06b470
55 changed files with 912 additions and 96 deletions

View file

@ -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`

View file

@ -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;

View file

@ -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` |

View file

@ -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
```

View file

@ -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.

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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()

View file

@ -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())

View file

@ -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"])

View file

@ -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):

View file

@ -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):

View file

@ -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",

View file

@ -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:

View file

@ -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"))

View file

@ -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(

View file

@ -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"

View file

@ -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():

View file

@ -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,

View file

@ -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}"

View file

@ -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)")

View file

@ -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,"

View file

@ -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,"

View file

@ -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()

View file

@ -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")

View file

@ -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

View file

@ -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():

View file

@ -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:

View file

@ -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

View file

@ -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"]

View file

@ -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

View file

@ -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"]):

View file

@ -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",

View file

@ -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("

View file

@ -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:

View file

@ -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;

View file

@ -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,

View file

@ -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);

View file

@ -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";

View file

@ -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);

View file

@ -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 "

View file

@ -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]),

View file

@ -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 <sqlite3.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#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;
}

View file

@ -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;

View file

@ -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);

View file

@ -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);

View file

@ -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",

View file

@ -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;
}

View file

@ -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[]
}

View file

@ -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)
})
})

View file

@ -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<string, string> = {
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))
}
}

View file

@ -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 <article className="session-detail program-detail">
<div className="sessions-toolbar">
@ -135,6 +136,7 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
{session.execution_state === 'prepared' && <span>Préparation disponible</span>}
{session.execution_state === 'in_progress' && <span>Séance en cours sur Android</span>}
{session.execution_state === 'completed' && <span>✓ Séance effectuée</span>}
{session.execution_state === 'deleted' && <span>Exécution retirée de l’historique</span>}
</li>)}
</ol>
</article>

View file

@ -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(<SessionsPage />)
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'))
})
})

View file

@ -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) {