Fix causal deletion of Program sessions
This commit is contained in:
parent
894c944636
commit
68af06b470
55 changed files with 912 additions and 96 deletions
10
CHANGELOG.md
10
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`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
||||
|
|
|
|||
46
docs/reviews/history_delete_http500_fix_v1_evidence.md
Normal file
46
docs/reviews/history_delete_http500_fix_v1_evidence.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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)")
|
||||
|
|
|
|||
|
|
@ -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,"
|
||||
|
|
|
|||
|
|
@ -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,"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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"]):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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("
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 "
|
||||
|
|
|
|||
|
|
@ -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]),
|
||||
|
|
|
|||
157
tui/tests/test_schema_v28_migration.c
Normal file
157
tui/tests/test_schema_v28_migration.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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'))
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue