Compare commits

...

3 commits

Author SHA1 Message Date
fy59
db722588f8 docs: record Programs Android rollout 2026-09-18 20:45:13 +02:00
fy59
f431b6ec9c test(programs): close migration and sync gates 2026-09-18 20:01:42 +02:00
fy59
20fc427c2f feat(programs): add deletion and Android projection 2026-09-18 19:17:34 +02:00
81 changed files with 2958 additions and 147 deletions

View file

@ -170,7 +170,7 @@ It is not the canonical analytics store.
The Android UI is driven by exercise metadata, never by exercise-name The Android UI is driven by exercise metadata, never by exercise-name
heuristics. heuristics.
Android local SQLite schema v23 retains the exactly-one durable active-session Android local SQLite schema v24 retains the exactly-one durable active-session
draft introduced in v4. draft introduced in v4.
Every meaningful draft/form mutation is persisted by the repository. Back, Every meaningful draft/form mutation is persisted by the repository. Back,
backgrounding and process death never delete the draft. Home offers explicit backgrounding and process death never delete the draft. Home offers explicit
@ -184,7 +184,7 @@ from actual occurrence data after the additive v10 -> v11 migration. Existing
rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the rows retain `load_mode=none`, zero rest and NULL targets. Android v12 adds the
durable flattened exercise-alias mapping used to resolve retired creator IDs. durable flattened exercise-alias mapping used to resolve retired creator IDs.
Desktop schema v12 owns the same alias compatibility boundary. Later additive Desktop schema v12 owns the same alias compatibility boundary. Later additive
desktop migrations reach v24 and Android migrations reach v23 without changing desktop migrations reach v26 and Android migrations reach v24 without changing
that identity contract. The active that identity contract. The active
completed-session exchange is the separate strict completed-session exchange is the separate strict
`trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1` `trainlog-mobile-export` V3; V1/V2 remain readable and `TRAINLOG_FORMAT_V1`
@ -241,13 +241,14 @@ Desktop SQLite schema is versioned with:
PRAGMA user_version; PRAGMA user_version;
``` ```
The current desktop schema is v24. Schema v9 added an occurrence-owned explicit The current desktop schema is v26. Schema v9 added an occurrence-owned explicit
maximum result; v10/v11 added body-zone and planning metadata; v12 added durable maximum result; v10/v11 added body-zone and planning metadata; v12 added durable
flattened exercise aliases; v13-v18 add machine metadata, feedback and flattened exercise aliases; v13-v18 add machine metadata, feedback and
revisions, occurrence tracking snapshots, causal profile state, and AI proposal revisions, occurrence tracking snapshots, causal profile state, and AI proposal
storage/publication state; v19-v21 add lifecycle, causal deletion, and staged storage/publication state; v19-v21 add lifecycle, causal deletion, and staged
generation/ACK ledgers; v22-v24 add generation archival, manual preparations, generation/ACK ledgers; v22-v24 add generation archival, manual preparations,
and durable preparation withdrawal. None changes `TRAINLOG_FORMAT_V1`. and durable preparation withdrawal; v25 adds Programs; and v26 additively adds
logical Program deletion. None changes `TRAINLOG_FORMAT_V1`.
Every incompatible schema evolution requires an explicit migration and Every incompatible schema evolution requires an explicit migration and
regression coverage. regression coverage.

View file

@ -2,6 +2,26 @@
## Unreleased — 0.1.2 ## Unreleased — 0.1.2
- Completed the private coordinated deployment of
`TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1`. Desktop schema v26 adds
terminal logical Program deletion with
request/deleted revisions, durable response replay, generation linkage and
acknowledgment state; source definitions and derived preparations remain,
while deleted Programs cannot resurrect or prepare. The responsive Sessions
Programs cards/timeline now use imported/session/usage/provenance data and
protect a discrete accessible trash confirmation with focus restoration and
Escape/Cancel no-op behavior. Android schema v24 adds the read-only synced
Programs list/detail projection. The optional staged controlled-generation
`trainlog-programs` V1 (`programs-v1.json`) publishes nondeleted Programs and
unacknowledged tombstones under capability `programs-v1`; it changes neither
`TRAINLOG_FORMAT_V1`, mobile V3, catalog exchange, nor preparations. Real
Firefox Programs screenshots and real rollout evidence are recorded in
`docs/reviews/programs_presentation_android_delete_v1_evidence.md`. The
signed private Android update advanced versionCode 15 to 16 without
uninstall or data clearing. A real disposable Program tombstone was consumed
and acknowledged, and a final correlated exchange after component restart
proved persistence, idempotence, and non-resurrection.
- Completed the private grouped rollout of Web Sessions deletion and Program - Completed the private grouped rollout of Web Sessions deletion and Program
V1 from desktop source `a0fc48f082b76dba2ae3a56fceb492169de89f89`. V1 from desktop source `a0fc48f082b76dba2ae3a56fceb492169de89f89`.
Desktop migrated from schema v24 to v25 after backup, restoration, and Desktop migrated from schema v24 to v25 after backup, restoration, and

View file

@ -17,7 +17,7 @@ over direct MTP; it never copies SQLite database files between devices.
|---|---| |---|---|
| Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. | | Android | Capture and quickly correct sets, repetitions, loads, durations, and continuous activities; reorder active and completed-session occurrences; capture feedback, J+1 follow-ups, body measurements, and AI proposals; trigger sync; show quick summaries. |
| Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. | | Desktop TUI | Administer, inspect, maintain, import/export, correct canonical history, and provide technical tools. |
| Local Web (0.1.2 development) | Display the Dashboard and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and embedded Programs workflow through typed Trainlog Core/API boundaries. Analyse, the standalone Programmes route, and Exercises remain placeholders. | | Local Web (0.1.2 development) | Display the Dashboard and the implemented Sessions inventory, deletion, manual preparation, Android-delivery, and embedded Programs workflow through typed Trainlog Core/API boundaries. Programs has responsive detail, terminal logical deletion, and a privately deployed read-only Android projection. Analyse, the standalone Programmes route, and Exercises remain placeholders. |
No interface reconstructs business truth from SQLite tables. The local Web is No interface reconstructs business truth from SQLite tables. The local Web is
a sibling adapter, not an extension of the TUI. On `main`, its loopback-only a sibling adapter, not an extension of the TUI. On `main`, its loopback-only
@ -320,7 +320,7 @@ not contain cloud credentials and does not run `rclone`.
As of 2026-09-18: As of 2026-09-18:
- `TRAINLOG_FORMAT_V1=PASS/FROZEN`; - `TRAINLOG_FORMAT_V1=PASS/FROZEN`;
- desktop SQLite schema v25 and Android SQLite schema v23; - desktop SQLite schema v26 and Android SQLite schema v24;
- Notcurses is the only active desktop terminal backend; - Notcurses is the only active desktop terminal backend;
- direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's - direct storage is `/storage/emulated/0/Documents/Trainlog` under Android's
all-files access setting; all-files access setting;
@ -335,6 +335,9 @@ As of 2026-09-18:
`WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`; `WEB_DASHBOARD_V1=PASS/FROZEN` and `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN`;
Programs is implemented inside Sessions; Analyse, Exercises, and the Programs is implemented inside Sessions; Analyse, Exercises, and the
standalone top-level Programmes route remain placeholders; standalone top-level Programmes route remain placeholders;
- `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`: the coordinated
private desktop/Android deployment, real Firefox presentation, correlated
`programs-v1` deletion ACK, restart/replay, and non-resurrection checks pass;
- `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`; - `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`;
- `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged - `TRAINLOG_SYNC_GENERATION_ACK_V1=PASS/FROZEN` through explicit staged
services; automatic synchronization still selects V3. The trusted local services; automatic synchronization still selects V3. The trusted local

View file

@ -219,7 +219,7 @@ internal class AndroidBackupService(private val context: Context) {
manifest.getInt("version") == VERSION && manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName && manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") && manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..23 manifest.getInt("schema") in 17..24
) { ) {
"Unsupported backup." "Unsupported backup."
} }
@ -313,7 +313,7 @@ internal class AndroidBackupService(private val context: Context) {
} }
) )
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..23) { "Unsupported Android schema ${db.version}." } require(db.version in 17..24) { "Unsupported Android schema ${db.version}." }
return db.version return db.version
} finally { } finally {
db.close() db.close()

View file

@ -164,6 +164,7 @@ internal class SyncGenerationForegroundCoordinator(private val repository: Train
"execution-draft-v1", "execution-draft-v1",
"generation-archive-v1", "generation-archive-v1",
"session-preparations-v2", "session-preparations-v2",
"programs-v1",
) )
), ),
) )

View file

@ -70,7 +70,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
private val SUPPORTED = private val SUPPORTED =
(ANDROID_KINDS.values + (ANDROID_KINDS.values +
Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) + Kind("trainlog-ai-session-drafts", 2, "ai-proposals-v2.json", false) +
Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false)) Kind("trainlog-session-preparations", 2, "session-preparations-v2.json", false) +
Kind("trainlog-programs", 1, "programs-v1.json", false))
.map { it.format to it.version } .map { it.format to it.version }
.toSet() .toSet()
@ -658,6 +659,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
is ExerciseAliasImportResult.Applied, is ExerciseAliasImportResult.Applied,
is ExecutionDraftImportResult.Applied, is ExecutionDraftImportResult.Applied,
is AiSessionDraftImportResult.Applied, is AiSessionDraftImportResult.Applied,
is ProgramsImportResult.Applied,
is CausalDeleteResult.Applied, is CausalDeleteResult.Applied,
is CausalDeleteResult.Unchanged -> null is CausalDeleteResult.Unchanged -> null
else -> result.toString() else -> result.toString()
@ -799,6 +801,10 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
apply("causal-deletions") { apply("causal-deletions") {
repository.applyCausalDeletionExportV1Json(checkNotNull(a["causal-deletions"])) repository.applyCausalDeletionExportV1Json(checkNotNull(a["causal-deletions"]))
} }
if ("programs-v1" in a)
apply("programs-v1") {
repository.applyProgramsV1Json(checkNotNull(a["programs-v1"]))
}
apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) } apply("catalog") { repository.applyPcCatalogJson(checkNotNull(a["catalog"])) }
apply("exercise-profile-state") { apply("exercise-profile-state") {
repository.applyExerciseProfileStateJson( repository.applyExerciseProfileStateJson(

View file

@ -38,6 +38,10 @@ import com.labfytools.trainlog.model.SessionLoadMode
import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.model.TrackingMode
import com.labfytools.trainlog.model.SyncedProgramDetail
import com.labfytools.trainlog.model.SyncedProgramOccurrence
import com.labfytools.trainlog.model.SyncedProgramSession
import com.labfytools.trainlog.model.SyncedProgramSummary
import org.json.JSONArray import org.json.JSONArray
import org.json.JSONObject import org.json.JSONObject
import java.text.Normalizer import java.text.Normalizer
@ -218,7 +222,14 @@ sealed interface MobileSessionImportResult {
data object DatabaseError : MobileSessionImportResult data object DatabaseError : MobileSessionImportResult
} }
sealed interface ProgramsImportResult {
data class Applied(val imported: Int, val deleted: Int, val skipped: Int) : ProgramsImportResult
data class Invalid(val message: String) : ProgramsImportResult
data object DatabaseError : ProgramsImportResult
}
private class SyncLifecycleConflict(val ownerId: String) : RuntimeException() private class SyncLifecycleConflict(val ownerId: String) : RuntimeException()
private class ProgramsImportInvalid(message: String) : RuntimeException(message)
sealed interface CreateEquipmentResult { sealed interface CreateEquipmentResult {
data class Created(val equipment: EquipmentCatalogEntry) : CreateEquipmentResult data class Created(val equipment: EquipmentCatalogEntry) : CreateEquipmentResult
@ -8056,6 +8067,299 @@ class TrainlogRepository(
secondaryZoneIds.forEach { insert(it, "secondary") } secondaryZoneIds.forEach { insert(it, "secondary") }
} }
/**
* Applies the desktop-owned read-only program projection.
*
* WHY: snapshot omission is not proof of deletion on an intermittently connected phone.
* CONTRACT: the complete document is validated before one transactional replacement pass;
* explicit tombstones are applied before live rows. INVARIANT: a durable tombstone always
* wins over a later stale snapshot and never touches drafts, preparations, history or catalog.
*/
fun applyProgramsV1Json(json: String): ProgramsImportResult {
if (json.toByteArray(StandardCharsets.UTF_8).size > 8 * 1024 * 1024 ||
!jsonHasUniqueObjectKeys(json)) return ProgramsImportResult.Invalid("Invalid Programs JSON.")
val root = try { JSONObject(json) } catch (_: Exception) {
return ProgramsImportResult.Invalid("Invalid Programs JSON.")
}
fun JSONObject.exact(vararg names: String): Boolean =
keys().asSequence().toSet() == names.toSet()
fun bounded(value: Any?, maximum: Int, nullable: Boolean = false): Boolean {
if (value == null || value == JSONObject.NULL) return nullable
val text = value as? String ?: return false
return text.isNotBlank() && text.toByteArray(StandardCharsets.UTF_8).size <= maximum
}
fun nullableBounded(value: Any?, maximum: Int): Boolean =
value == JSONObject.NULL || bounded(value, maximum)
fun date(value: Any?): Boolean = value == JSONObject.NULL ||
(value is String && try { LocalDate.parse(value); true } catch (_: Exception) { false })
fun timestamp(value: Any?): Boolean = value is String &&
try { OffsetDateTime.parse(value); true } catch (_: Exception) { false }
fun integer(value: Any?, minimum: Int, maximum: Int): Boolean =
(value is Int || value is Long) && (value as Number).toLong() in minimum.toLong()..maximum.toLong()
fun optionalInteger(value: Any?, minimum: Int, maximum: Int): Boolean =
value == JSONObject.NULL || integer(value, minimum, maximum)
fun optionalWeight(value: Any?): Boolean = value == JSONObject.NULL ||
(value is Number && value.toDouble().isFinite() && value.toDouble() >= 0.0)
try {
if (!root.exact("format", "version", "generated_at", "programs", "deletions") ||
root.optString("format") != "trainlog-programs" || root.opt("version") !is Int ||
root.getInt("version") != 1 || !timestamp(root.opt("generated_at")) ||
root.opt("programs") !is JSONArray || root.opt("deletions") !is JSONArray)
throw ProgramsImportInvalid("Unsupported Programs document.")
val programs = root.getJSONArray("programs")
val deletions = root.getJSONArray("deletions")
if (programs.length() > 256 || deletions.length() > 4096)
throw ProgramsImportInvalid("Programs document exceeds its item bound.")
val programIds = mutableSetOf<String>()
val sessionIds = mutableSetOf<String>()
val deletionIds = mutableSetOf<String>()
val deletedProgramIds = mutableSetOf<String>()
for (index in 0 until deletions.length()) {
val deletion = deletions.optJSONObject(index)
?: throw ProgramsImportInvalid("Invalid program deletion.")
if (!deletion.exact("deletion_id", "program_id", "predecessor_revision_id", "revision_id", "requested_at") ||
!bounded(deletion.opt("deletion_id"), 128) ||
!bounded(deletion.opt("program_id"), 128) ||
!bounded(deletion.opt("predecessor_revision_id"), 128) ||
!bounded(deletion.opt("revision_id"), 128) ||
!timestamp(deletion.opt("requested_at")) ||
!deletionIds.add(deletion.getString("deletion_id")) ||
!deletedProgramIds.add(deletion.getString("program_id")))
throw ProgramsImportInvalid("Invalid or duplicate program deletion.")
}
for (programIndex in 0 until programs.length()) {
val program = programs.optJSONObject(programIndex)
?: throw ProgramsImportInvalid("Invalid program.")
if (!program.exact(
"program_id", "revision_id", "title", "note", "state", "start_date",
"end_date", "created_at", "updated_at", "source_format", "source_version",
"source_payload_sha256", "sessions",
) ||
!bounded(program.opt("program_id"), 128) || !programIds.add(program.getString("program_id")) ||
!bounded(program.opt("revision_id"), 128) || !bounded(program.opt("title"), 200) ||
!nullableBounded(program.opt("note"), 4000) || program.optString("state") !in setOf("active", "archived") ||
!date(program.opt("start_date")) || !date(program.opt("end_date")) ||
!timestamp(program.opt("created_at")) || !timestamp(program.opt("updated_at")) ||
!bounded(program.opt("source_format"), 64) || !integer(program.opt("source_version"), 1, Int.MAX_VALUE) ||
(program.opt("source_payload_sha256") as? String)?.matches(Regex("^[0-9a-f]{64}$")) != true ||
program.opt("sessions") !is JSONArray)
throw ProgramsImportInvalid("Invalid program metadata.")
val sessions = program.getJSONArray("sessions")
if (sessions.length() !in 1..64) throw ProgramsImportInvalid("Invalid program session count.")
for (sessionIndex in 0 until sessions.length()) {
val session = sessions.optJSONObject(sessionIndex)
?: throw ProgramsImportInvalid("Invalid program session.")
if (!session.exact("program_session_id", "title", "session_type", "planned_for", "note", "occurrences") ||
!bounded(session.opt("program_session_id"), 128) || !sessionIds.add(session.getString("program_session_id")) ||
!bounded(session.opt("title"), 200) || session.optString("session_type") !in setOf("training", "max_test") ||
!date(session.opt("planned_for")) || !nullableBounded(session.opt("note"), 4000) ||
session.opt("occurrences") !is JSONArray)
throw ProgramsImportInvalid("Invalid program session metadata.")
val occurrences = session.getJSONArray("occurrences")
if (occurrences.length() !in 1..64) throw ProgramsImportInvalid("Invalid program occurrence count.")
val entryIds = mutableSetOf<String>()
for (entryIndex in 0 until occurrences.length()) {
val entry = occurrences.optJSONObject(entryIndex)
?: throw ProgramsImportInvalid("Invalid program occurrence.")
if (!entry.exact(
"entry_id", "exercise_id", "equipment_id", "load_mode",
"rest_seconds", "target_sets", "target_reps",
"target_duration_seconds", "target_weight_kg", "notes",
) ||
!bounded(entry.opt("entry_id"), 128) || !entryIds.add(entry.getString("entry_id")) ||
!bounded(entry.opt("exercise_id"), 128) || !nullableBounded(entry.opt("equipment_id"), 128) ||
entry.optString("load_mode") !in setOf("none", "external", "assistance") ||
!integer(entry.opt("rest_seconds"), 0, 86400) ||
!optionalInteger(entry.opt("target_sets"), 1, 99) ||
!optionalInteger(entry.opt("target_reps"), 1, 10000) ||
!optionalInteger(entry.opt("target_duration_seconds"), 1, 604800) ||
!optionalWeight(entry.opt("target_weight_kg")) || !nullableBounded(entry.opt("notes"), 4000) ||
(entry.isNull("target_reps") == entry.isNull("target_duration_seconds")))
throw ProgramsImportInvalid("Invalid program occurrence values.")
}
}
}
val db = database.writableDatabase
db.beginTransaction()
try {
var deleted = 0
var imported = 0
var skipped = 0
for (index in 0 until deletions.length()) {
val deletion = deletions.getJSONObject(index)
val values = arrayOf(
deletion.getString("deletion_id"), deletion.getString("program_id"),
deletion.getString("predecessor_revision_id"), deletion.getString("revision_id"),
deletion.getString("requested_at"),
)
val existing = db.rawQuery(
"SELECT deletion_id,predecessor_revision_id,revision_id,requested_at FROM synced_program_deletions WHERE program_id=?",
arrayOf(values[1]),
).use { cursor -> if (!cursor.moveToFirst()) null else List(4) { cursor.getString(it) } }
if (existing != null && existing != listOf(values[0], values[2], values[3], values[4]))
throw ProgramsImportInvalid("Program deletion identity conflict.")
if (existing == null) {
db.execSQL("INSERT INTO synced_program_deletions VALUES(?,?,?,?,?)", values)
deleted++
} else skipped++
db.delete("synced_programs", "program_id=?", arrayOf(values[1]))
}
for (programIndex in 0 until programs.length()) {
val program = programs.getJSONObject(programIndex)
val id = program.getString("program_id")
val tombstoned = db.rawQuery("SELECT 1 FROM synced_program_deletions WHERE program_id=?", arrayOf(id))
.use { it.moveToFirst() }
if (tombstoned) { skipped++; continue }
val digest = causalDigest(program)
val existing = db.rawQuery("SELECT revision_id,snapshot_sha256 FROM synced_programs WHERE program_id=?", arrayOf(id))
.use { cursor -> if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1) }
if (existing?.first == program.getString("revision_id")) {
if (existing.second != digest) throw ProgramsImportInvalid("Program revision identity conflict.")
skipped++
continue
}
db.delete("synced_programs", "program_id=?", arrayOf(id))
db.execSQL(
"INSERT INTO synced_programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)",
arrayOf<Any?>(id, program.getString("revision_id"), program.getString("title"), program.optStringOrNull("note"),
program.getString("state"), program.optStringOrNull("start_date"), program.optStringOrNull("end_date"),
program.getString("created_at"), program.getString("updated_at"), program.getString("source_format"),
program.getInt("source_version"), program.getString("source_payload_sha256"), digest),
)
val sessions = program.getJSONArray("sessions")
for (sessionIndex in 0 until sessions.length()) {
val session = sessions.getJSONObject(sessionIndex)
val sessionId = session.getString("program_session_id")
db.execSQL("INSERT INTO synced_program_sessions VALUES(?,?,?,?,?,?,?)", arrayOf<Any?>(
sessionId, id, sessionIndex, session.getString("title"), session.getString("session_type"),
session.optStringOrNull("planned_for"), session.optStringOrNull("note")))
val entries = session.getJSONArray("occurrences")
for (entryIndex in 0 until entries.length()) {
val entry = entries.getJSONObject(entryIndex)
db.execSQL("INSERT INTO synced_program_entries VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", arrayOf<Any?>(
sessionId, entry.getString("entry_id"), entryIndex, entry.getString("exercise_id"),
entry.optStringOrNull("equipment_id"), entry.getString("load_mode"), entry.getInt("rest_seconds"),
entry.optIntOrNull("target_sets"), entry.optIntOrNull("target_reps"),
entry.optIntOrNull("target_duration_seconds"), entry.optDoubleOrNull("target_weight_kg"),
entry.optStringOrNull("notes"), id))
}
}
imported++
}
db.setTransactionSuccessful()
return ProgramsImportResult.Applied(imported, deleted, skipped)
} finally { db.endTransaction() }
} catch (error: ProgramsImportInvalid) {
return ProgramsImportResult.Invalid(error.message ?: "Invalid Programs document.")
} catch (_: Exception) {
return ProgramsImportResult.DatabaseError
}
}
fun listSyncedPrograms(): List<SyncedProgramSummary> = database.readableDatabase.rawQuery(
"SELECT p.program_id,p.title,p.state,p.start_date,p.end_date,COUNT(s.program_session_id) " +
"FROM synced_programs p LEFT JOIN synced_program_sessions s ON s.program_id=p.program_id " +
"GROUP BY p.program_id ORDER BY CASE p.state WHEN 'active' THEN 0 ELSE 1 END,p.title COLLATE NOCASE,p.program_id",
null,
).use { cursor ->
buildList {
while (cursor.moveToNext()) {
add(
SyncedProgramSummary(
cursor.getString(0),
cursor.getString(1),
cursor.getString(2),
if (cursor.isNull(3)) null else cursor.getString(3),
if (cursor.isNull(4)) null else cursor.getString(4),
cursor.getInt(5),
),
)
}
}
}
fun getSyncedProgram(programId: String): SyncedProgramDetail? {
val db = database.readableDatabase
data class Header(
val title: String,
val note: String?,
val state: String,
val startDate: String?,
val endDate: String?,
)
val header = db.rawQuery(
"SELECT title,note,state,start_date,end_date FROM synced_programs WHERE program_id=?",
arrayOf(programId),
).use { cursor ->
if (!cursor.moveToFirst()) null else Header(
cursor.getString(0),
if (cursor.isNull(1)) null else cursor.getString(1),
cursor.getString(2),
if (cursor.isNull(3)) null else cursor.getString(3),
if (cursor.isNull(4)) null else cursor.getString(4),
)
} ?: return null
val sessions = mutableListOf<SyncedProgramSession>()
db.rawQuery(
"SELECT program_session_id,title,session_type,planned_for,note " +
"FROM synced_program_sessions WHERE program_id=? " +
"ORDER BY position,program_session_id",
arrayOf(programId),
).use { sessionCursor ->
while (sessionCursor.moveToNext()) {
val sessionId = sessionCursor.getString(0)
val entries = mutableListOf<SyncedProgramOccurrence>()
db.rawQuery(
"SELECT pe.entry_id,pe.exercise_id," +
"COALESCE(e.name,canonical.name,pe.exercise_id),pe.equipment_id," +
"pe.load_mode,pe.rest_seconds,pe.target_sets,pe.target_reps," +
"pe.target_duration_seconds,pe.target_weight_kg,pe.notes " +
"FROM synced_program_entries pe " +
"LEFT JOIN exercises e ON e.exercise_id=pe.exercise_id " +
"LEFT JOIN exercise_aliases alias ON alias.source_exercise_id=pe.exercise_id " +
"LEFT JOIN exercises canonical " +
"ON canonical.exercise_id=alias.canonical_exercise_id " +
"WHERE pe.program_session_id=? ORDER BY pe.position,pe.entry_id",
arrayOf(sessionId),
).use { entry ->
while (entry.moveToNext()) {
entries += SyncedProgramOccurrence(
entry.getString(0),
entry.getString(1),
entry.getString(2),
if (entry.isNull(3)) null else entry.getString(3),
entry.getString(4),
entry.getInt(5),
if (entry.isNull(6)) null else entry.getInt(6),
if (entry.isNull(7)) null else entry.getInt(7),
if (entry.isNull(8)) null else entry.getInt(8),
if (entry.isNull(9)) null else entry.getDouble(9),
if (entry.isNull(10)) null else entry.getString(10),
)
}
}
sessions += SyncedProgramSession(
sessionId,
sessionCursor.getString(1),
sessionCursor.getString(2),
if (sessionCursor.isNull(3)) null else sessionCursor.getString(3),
if (sessionCursor.isNull(4)) null else sessionCursor.getString(4),
entries,
)
}
}
return SyncedProgramDetail(
programId,
header.title,
header.note,
header.state,
header.startDate,
header.endDate,
sessions,
)
}
private fun normalizeName( private fun normalizeName(
value: String, value: String,
): String { ): String {
@ -8143,6 +8447,9 @@ private fun JSONObject.optDoubleOrNull(key: String): Double? =
private fun JSONObject.optIntOrNull(key: String): Int? = private fun JSONObject.optIntOrNull(key: String): Int? =
if (has(key) && !isNull(key)) getInt(key) else null if (has(key) && !isNull(key)) getInt(key) else null
private fun JSONObject.optStringOrNull(key: String): String? =
if (has(key) && !isNull(key)) getString(key) else null
private fun jsonHasUniqueObjectKeys(json: String): Boolean = try { private fun jsonHasUniqueObjectKeys(json: String): Boolean = try {
JsonReader(StringReader(json)).use { reader -> JsonReader(StringReader(json)).use { reader ->
fun consumeValue() { fun consumeValue() {
@ -8231,7 +8538,7 @@ private class TrainlogDatabaseHelper(
appContext, appContext,
databaseName, databaseName,
null, null,
23, 24,
) { ) {
override fun onConfigure( override fun onConfigure(
db: SQLiteDatabase, db: SQLiteDatabase,
@ -8279,6 +8586,7 @@ private class TrainlogDatabaseHelper(
createSyncGenerationArchiveTable(db) createSyncGenerationArchiveTable(db)
createSessionPreparationTables(db) createSessionPreparationTables(db)
createSessionPreparationWithdrawalTable(db) createSessionPreparationWithdrawalTable(db)
createSyncedProgramTables(db)
seedEquipment(db) seedEquipment(db)
} }
@ -8468,6 +8776,14 @@ private class TrainlogDatabaseHelper(
createSessionPreparationWithdrawalTable(db) createSessionPreparationWithdrawalTable(db)
version = 23 version = 23
} }
if (version < 24 && newVersion >= 24) {
/* WHY: reusable Programs remain desktop-owned planning definitions.
* CONTRACT: v24 adds only a read-only projection and durable deletion
* tombstones. INVARIANT: no draft, preparation, history, catalog or
* body row is rewritten, inferred or removed by this migration. */
createSyncedProgramTables(db)
version = 24
}
if (version != newVersion) { if (version != newVersion) {
error( error(
@ -8477,6 +8793,46 @@ private class TrainlogDatabaseHelper(
} }
} }
private fun createSyncedProgramTables(db: SQLiteDatabase) {
db.execSQL(
"""CREATE TABLE IF NOT EXISTS synced_programs(
program_id TEXT PRIMARY KEY, revision_id TEXT NOT NULL, title TEXT NOT NULL,
note TEXT, state TEXT NOT NULL CHECK(state IN('active','archived')),
start_date TEXT, end_date TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL,
source_format TEXT NOT NULL, source_version INTEGER NOT NULL,
source_payload_sha256 TEXT NOT NULL, snapshot_sha256 TEXT NOT NULL
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS synced_program_sessions(
program_session_id TEXT PRIMARY KEY,
program_id TEXT NOT NULL REFERENCES synced_programs(program_id) ON DELETE CASCADE,
position INTEGER NOT NULL CHECK(position>=0), title TEXT NOT NULL,
session_type TEXT NOT NULL CHECK(session_type IN('training','max_test')),
planned_for TEXT, note TEXT, UNIQUE(program_id,position)
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS synced_program_entries(
program_session_id TEXT NOT NULL REFERENCES synced_program_sessions(program_session_id) ON DELETE CASCADE,
entry_id TEXT NOT NULL, position INTEGER NOT NULL CHECK(position>=0), exercise_id TEXT NOT NULL,
equipment_id TEXT, load_mode TEXT NOT NULL CHECK(load_mode IN('none','external','assistance')),
rest_seconds INTEGER NOT NULL CHECK(rest_seconds BETWEEN 0 AND 86400),
target_sets INTEGER, target_reps INTEGER, target_duration_seconds INTEGER,
target_weight_kg REAL, notes TEXT, program_id TEXT NOT NULL,
PRIMARY KEY(program_session_id,entry_id), UNIQUE(program_session_id,position)
)""".trimIndent(),
)
db.execSQL("CREATE INDEX IF NOT EXISTS synced_program_entries_exercise ON synced_program_entries(exercise_id)")
db.execSQL(
"""CREATE TABLE IF NOT EXISTS synced_program_deletions(
deletion_id TEXT PRIMARY KEY, program_id TEXT NOT NULL UNIQUE,
predecessor_revision_id TEXT NOT NULL, revision_id TEXT NOT NULL,
requested_at TEXT NOT NULL
)""".trimIndent(),
)
}
private fun createSyncDataLifecycleTables(db: SQLiteDatabase) { private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {
fun addColumn(table: String, name: String, declaration: String) { fun addColumn(table: String, name: String, declaration: String) {
if (!tableHasColumn(db, table, name)) { if (!tableHasColumn(db, table, name)) {

View file

@ -0,0 +1,44 @@
package com.labfytools.trainlog.model
/** Read-only desktop-owned program projection exposed to Android presentation. */
data class SyncedProgramSummary(
val programId: String,
val title: String,
val state: String,
val startDate: String?,
val endDate: String?,
val sessionCount: Int,
)
data class SyncedProgramDetail(
val programId: String,
val title: String,
val note: String?,
val state: String,
val startDate: String?,
val endDate: String?,
val sessions: List<SyncedProgramSession>,
)
data class SyncedProgramSession(
val programSessionId: String,
val title: String,
val sessionType: String,
val plannedFor: String?,
val note: String?,
val occurrences: List<SyncedProgramOccurrence>,
)
data class SyncedProgramOccurrence(
val entryId: String,
val exerciseId: String,
val exerciseName: String,
val equipmentId: String?,
val loadMode: String,
val restSeconds: Int,
val targetSets: Int?,
val targetReps: Int?,
val targetDurationSeconds: Int?,
val targetWeightKg: Double?,
val notes: String?,
)

View file

@ -66,6 +66,7 @@ fun AndroidAppShell(
AppRoute.Home -> R.string.nav_home; AppRoute.Sessions -> R.string.nav_sessions AppRoute.Home -> R.string.nav_home; AppRoute.Sessions -> R.string.nav_sessions
AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator AppRoute.SessionEditor -> R.string.route_session_editor; AppRoute.SessionGenerator -> R.string.route_session_generator
AppRoute.AiSessionDrafts -> R.string.route_ai_drafts; AppRoute.CompletedSessions -> R.string.route_completed_sessions AppRoute.AiSessionDrafts -> R.string.route_ai_drafts; AppRoute.CompletedSessions -> R.string.route_completed_sessions
AppRoute.Programs -> R.string.route_programs; is AppRoute.ProgramDetail -> R.string.route_program_detail
is AppRoute.SessionDetail -> R.string.route_session_detail; is AppRoute.SessionCorrection -> R.string.route_session_correction is AppRoute.SessionDetail -> R.string.route_session_detail; is AppRoute.SessionCorrection -> R.string.route_session_correction
AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail AppRoute.Exercises -> R.string.nav_exercises; is AppRoute.ExerciseDetail -> R.string.route_exercise_detail
is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create is AppRoute.ExerciseEdit -> R.string.route_exercise_edit; is AppRoute.ExerciseCreate -> R.string.route_exercise_create

View file

@ -23,6 +23,8 @@ sealed interface AppRoute {
data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS } data object SessionGenerator : AppRoute { override val section = AppSection.SESSIONS }
data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS } data object AiSessionDrafts : AppRoute { override val section = AppSection.SESSIONS }
data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS } data object CompletedSessions : AppRoute { override val section = AppSection.SESSIONS }
data object Programs : AppRoute { override val section = AppSection.SESSIONS }
data class ProgramDetail(val programId: String) : AppRoute { override val section = AppSection.SESSIONS }
data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS } data class SessionDetail(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS } data class SessionCorrection(val sessionId: String) : AppRoute { override val section = AppSection.SESSIONS }
data object Exercises : AppRoute { override val section = AppSection.EXERCISES } data object Exercises : AppRoute { override val section = AppSection.EXERCISES }

View file

@ -0,0 +1,79 @@
package com.labfytools.trainlog.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag
import com.labfytools.trainlog.R
import com.labfytools.trainlog.data.TrainlogRepository
/** Presentation-only list for the desktop-owned Programs projection. */
@Composable
fun ProgramsScreen(repository: TrainlogRepository, onOpen: (String) -> Unit) {
val strings = localizedContext()
val programs = repository.listSyncedPrograms()
TrainlogScreen(strings.getString(R.string.route_programs)) {
if (programs.isEmpty()) TrainlogInfo(strings.getString(R.string.programs_none))
programs.forEach { program ->
TrainlogFrame(program.title, active = program.state == "active") {
TrainlogInfo(
listOfNotNull(
strings.getString(if (program.state == "active") R.string.program_active else R.string.program_archived),
program.startDate?.let { strings.getString(R.string.program_start_date, formatDate(it)) },
program.endDate?.let { strings.getString(R.string.program_end_date, formatDate(it)) },
strings.resources.getQuantityString(
R.plurals.program_session_count,
program.sessionCount,
program.sessionCount,
),
).joinToString(" · "),
)
TrainlogButton(
strings.getString(R.string.program_open),
{ onOpen(program.programId) },
Modifier.testTag("program-${program.programId}"),
)
}
}
}
}
/** CONTRACT: this route exposes no start, edit, archive, or delete mutation. */
@Composable
fun ProgramDetailScreen(repository: TrainlogRepository, programId: String) {
val strings = localizedContext()
val program = repository.getSyncedProgram(programId)
TrainlogScreen(strings.getString(R.string.route_program_detail)) {
if (program == null) {
TrainlogInfo(strings.getString(R.string.program_not_found))
return@TrainlogScreen
}
TrainlogFrame(program.title, active = program.state == "active") {
TrainlogInfo(strings.getString(if (program.state == "active") R.string.program_active else R.string.program_archived))
program.startDate?.let { TrainlogInfo(strings.getString(R.string.program_start_date, formatDate(it))) }
program.endDate?.let { TrainlogInfo(strings.getString(R.string.program_end_date, formatDate(it))) }
program.note?.let { TrainlogInfo(it) }
}
program.sessions.forEachIndexed { index, session ->
TrainlogFrame("${index + 1}. ${session.title}") {
TrainlogInfo(
listOfNotNull(
strings.getString(if (session.sessionType == "max_test") R.string.session_max_test else R.string.session_training),
session.plannedFor?.let { strings.getString(R.string.planned_for, formatDate(it)) },
).joinToString(" · "),
)
session.note?.let { TrainlogInfo(it) }
session.occurrences.forEachIndexed { occurrenceIndex, occurrence ->
val target = occurrence.targetReps?.let { strings.getString(R.string.program_target_reps, it) }
?: occurrence.targetDurationSeconds?.let { strings.getString(R.string.program_target_duration, it) }.orEmpty()
val sets = occurrence.targetSets?.let { strings.getString(R.string.program_target_sets, it) }.orEmpty()
val weight = occurrence.targetWeightKg?.let { strings.getString(R.string.program_target_weight, it) }.orEmpty()
TrainlogInfo(
"${occurrenceIndex + 1}. ${occurrence.exerciseName}" +
listOf(sets, target, weight).filter { it.isNotEmpty() }.joinToString(" · ", prefix = " · "),
)
occurrence.notes?.let { TrainlogInfo(it) }
}
}
}
}
}

View file

@ -46,6 +46,7 @@ fun SessionsHub(
onManual: () -> Unit, onManual: () -> Unit,
onDrafts: () -> Unit, onDrafts: () -> Unit,
onHistory: () -> Unit, onHistory: () -> Unit,
onPrograms: () -> Unit = {},
) { ) {
val colors = LocalTrainlogColors.current val colors = LocalTrainlogColors.current
val strings = localizedContext() val strings = localizedContext()
@ -67,6 +68,8 @@ fun SessionsHub(
} }
TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory, TrainlogButton(strings.getString(R.string.route_completed_sessions), onHistory,
Modifier.fillMaxWidth().testTag("sessions-history-action")) Modifier.fillMaxWidth().testTag("sessions-history-action"))
TrainlogButton(strings.getString(R.string.route_programs), onPrograms,
Modifier.fillMaxWidth().testTag("sessions-programs-action"))
} }
} }

View file

@ -106,7 +106,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
{ open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) }, { open(AppRoute.BodyMeasurements) }, { open(AppRoute.SessionDetail(it)) }, { open(AppRoute.Sync) },
) )
AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession, AppRoute.Sessions -> SessionsHub(activeDraft, pendingAiDraftCount, { open(AppRoute.SessionEditor) }, ::openManualSession,
{ open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }) { open(AppRoute.AiSessionDrafts) }, { open(AppRoute.CompletedSessions) }, { open(AppRoute.Programs) })
AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ }) AppRoute.SessionEditor -> SessionScreen(repository, catalogRevision, { draftRevision++; back() }, { open(AppRoute.ExerciseCreate(AppRoute.SessionEditor)) }, { exportSnapshot(); draftRevision++ })
AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, { AppRoute.SessionGenerator -> SessionGeneratorScreen(repository, generatorState, { back() }, { generatorState.abandon(); draftRevision++; open(AppRoute.SessionEditor) }, {
draftMessage = strings.getString(R.string.existing_draft_warning) draftMessage = strings.getString(R.string.existing_draft_warning)
@ -124,6 +124,8 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
onStarted = { open(AppRoute.SessionEditor) }, onStarted = { open(AppRoute.SessionEditor) },
) )
AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) } AppRoute.CompletedSessions -> HistoryScreen(repository, { back() }) { open(AppRoute.SessionDetail(it)) }
AppRoute.Programs -> ProgramsScreen(repository) { open(AppRoute.ProgramDetail(it)) }
is AppRoute.ProgramDetail -> ProgramDetailScreen(repository, route.programId)
is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) } is AppRoute.SessionDetail -> SessionDetailScreen(repository, route.sessionId, { back() }, { open(AppRoute.SessionCorrection(route.sessionId)) }) { draftRevision++; open(AppRoute.SessionEditor) }
is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved -> is AppRoute.SessionCorrection -> CompletedSessionCorrectionScreen(repository, route.sessionId) { saved ->
if (saved) exportSnapshot() if (saved) exportSnapshot()
@ -164,6 +166,7 @@ fun TrainlogApp(repository: TrainlogRepository, exporter: SyncExporter, inbox: S
private fun AppRoute.stateKey(): String = when (this) { private fun AppRoute.stateKey(): String = when (this) {
is AppRoute.SessionDetail -> "session:${sessionId}" is AppRoute.SessionDetail -> "session:${sessionId}"
is AppRoute.SessionCorrection -> "session-correction:${sessionId}" is AppRoute.SessionCorrection -> "session-correction:${sessionId}"
is AppRoute.ProgramDetail -> "program:${programId}"
is AppRoute.ExerciseDetail -> "exercise:${exerciseId}" is AppRoute.ExerciseDetail -> "exercise:${exerciseId}"
is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}" is AppRoute.ExerciseEdit -> "exercise-edit:${exerciseId}:${caller.section}"
is AppRoute.ExerciseCreate -> "exercise-create:${caller.section}" is AppRoute.ExerciseCreate -> "exercise-create:${caller.section}"

View file

@ -12,6 +12,20 @@
<string name="route_session_generator">Plan a session</string> <string name="route_session_generator">Plan a session</string>
<string name="route_ai_drafts">Drafts</string> <string name="route_ai_drafts">Drafts</string>
<string name="route_completed_sessions">Completed sessions</string> <string name="route_completed_sessions">Completed sessions</string>
<string name="route_programs">Programs</string>
<string name="route_program_detail">Program details</string>
<string name="programs_none">No synchronized programs.</string>
<string name="program_active">ACTIVE</string>
<string name="program_archived">ARCHIVED</string>
<string name="program_start_date">Start: %1$s</string>
<string name="program_end_date">End: %1$s</string>
<string name="program_open">View program</string>
<string name="program_not_found">Program not found.</string>
<string name="program_target_sets">%1$d sets</string>
<string name="program_target_reps">%1$d repetitions</string>
<string name="program_target_duration">%1$d s</string>
<string name="program_target_weight">%1$.1f kg</string>
<plurals name="program_session_count"><item quantity="one">%d session</item><item quantity="other">%d sessions</item></plurals>
<string name="route_session_detail">Session details</string> <string name="route_session_detail">Session details</string>
<string name="route_session_correction">Edit session</string> <string name="route_session_correction">Edit session</string>
<string name="route_exercise_detail">Exercise details</string> <string name="route_exercise_detail">Exercise details</string>

View file

@ -12,6 +12,20 @@
<string name="route_session_generator">Programmer une séance</string> <string name="route_session_generator">Programmer une séance</string>
<string name="route_ai_drafts">Brouillons</string> <string name="route_ai_drafts">Brouillons</string>
<string name="route_completed_sessions">Séances effectuées</string> <string name="route_completed_sessions">Séances effectuées</string>
<string name="route_programs">Programmes</string>
<string name="route_program_detail">Détail du programme</string>
<string name="programs_none">Aucun programme synchronisé.</string>
<string name="program_active">ACTIF</string>
<string name="program_archived">ARCHIVÉ</string>
<string name="program_start_date">Début : %1$s</string>
<string name="program_end_date">Fin : %1$s</string>
<string name="program_open">Afficher le programme</string>
<string name="program_not_found">Programme introuvable.</string>
<string name="program_target_sets">%1$d séries</string>
<string name="program_target_reps">%1$d répétitions</string>
<string name="program_target_duration">%1$d s</string>
<string name="program_target_weight">%1$.1f kg</string>
<plurals name="program_session_count"><item quantity="one">%d séance</item><item quantity="other">%d séances</item></plurals>
<string name="route_session_detail">Détail de la séance</string> <string name="route_session_detail">Détail de la séance</string>
<string name="route_session_correction">Modifier la séance</string> <string name="route_session_correction">Modifier la séance</string>
<string name="route_exercise_detail">Fiche exercice</string> <string name="route_exercise_detail">Fiche exercice</string>

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() } TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(23, scalar(db, "PRAGMA user_version")) assertEquals(24, scalar(db, "PRAGMA user_version"))
for (table in listOf("session_exercises", "draft_session_exercises")) { for (table in listOf("session_exercises", "draft_session_exercises")) {
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))

View file

@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
migrated.close() migrated.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(23, scalar(db, "PRAGMA user_version")) assertEquals(24, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and /* v13 intentionally adds the frozen machine identities and
* exercise-owned metadata; all non-exercise facts stay exact. */ * exercise-owned metadata; all non-exercise facts stay exact. */
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }

View file

@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
migrated.close() migrated.close()
} }
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(23, scalar(db, "PRAGMA user_version")) assertEquals(24, scalar(db, "PRAGMA user_version"))
assertEquals(before, listOf( assertEquals(before, listOf(
scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM sessions"),
scalar(db, "SELECT COUNT(*) FROM performed_sets"), scalar(db, "SELECT COUNT(*) FROM performed_sets"),

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor -> db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(23, cursor.getInt(0)) assertTrue(cursor.moveToFirst()); assertEquals(24, cursor.getInt(0))
} }
db.rawQuery("SELECT id FROM exercises", null).use { cursor -> db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
null, null,
android.database.sqlite.SQLiteDatabase.OPEN_READONLY, android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
) )
.use { assertEquals(23, it.version) } .use { assertEquals(24, it.version) }
} finally { } finally {
repository.close() repository.close()
context.deleteDatabase(name) context.deleteDatabase(name)

View file

@ -0,0 +1,127 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
@Config(sdk = [35])
class ProgramsProjectionRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
private fun program(title: String = "Cycle lisible"): JSONObject =
JSONObject()
.put("program_id", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("revision_id", "pgr_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
.put("title", title)
.put("note", "Lecture seule")
.put("state", "active")
.put("start_date", "2026-09-21")
.put("end_date", JSONObject.NULL)
.put("created_at", "2026-09-18T10:00:00Z")
.put("updated_at", "2026-09-18T11:00:00Z")
.put("source_format", "trainlog-program")
.put("source_version", 1)
.put("source_payload_sha256", "a".repeat(64))
.put(
"sessions",
JSONArray().put(
JSONObject()
.put("program_session_id", "pgs_cccccccc-cccc-4ccc-8ccc-cccccccccccc")
.put("title", "Séance A")
.put("session_type", "training")
.put("planned_for", JSONObject.NULL)
.put("note", JSONObject.NULL)
.put(
"occurrences",
JSONArray().put(
JSONObject()
.put("entry_id", "pge_dddddddd-dddd-4ddd-8ddd-dddddddddddd")
.put("exercise_id", "ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee")
.put("equipment_id", JSONObject.NULL)
.put("load_mode", "external")
.put("rest_seconds", 90)
.put("target_sets", 3)
.put("target_reps", 8)
.put("target_duration_seconds", JSONObject.NULL)
.put("target_weight_kg", 40.0)
.put("notes", JSONObject.NULL),
),
),
),
)
private fun artifact(programs: JSONArray, deletions: JSONArray = JSONArray()): String =
JSONObject()
.put("format", "trainlog-programs")
.put("version", 1)
.put("generated_at", "2026-09-18T12:00:00Z")
.put("programs", programs)
.put("deletions", deletions)
.toString()
private fun deletion(): JSONObject =
JSONObject()
.put("deletion_id", "pgd_ffffffff-ffff-4fff-8fff-ffffffffffff")
.put("program_id", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa")
.put("predecessor_revision_id", "pgr_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb")
.put("revision_id", "pgr_11111111-1111-4111-8111-111111111111")
.put("requested_at", "2026-09-18T12:30:00Z")
@Test
fun projectionReplayConflictDeletionAndRestartAreDurable() {
val name = "programs-${UUID.randomUUID()}.db"
var repository = TrainlogRepository(context, name)
try {
assertEquals(ProgramsImportResult.Applied(1, 0, 0), repository.applyProgramsV1Json(artifact(JSONArray().put(program()))))
assertEquals(ProgramsImportResult.Applied(0, 0, 1), repository.applyProgramsV1Json(artifact(JSONArray().put(program()))))
val summary = repository.listSyncedPrograms().single()
assertEquals(1, summary.sessionCount)
val detail = repository.getSyncedProgram(summary.programId)!!
assertEquals("ex_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee", detail.sessions.single().occurrences.single().exerciseName)
val conflict = repository.applyProgramsV1Json(artifact(JSONArray().put(program("Divergent"))))
assertTrue(conflict is ProgramsImportResult.Invalid)
assertEquals("Cycle lisible", repository.listSyncedPrograms().single().title)
assertEquals(ProgramsImportResult.Applied(0, 1, 0), repository.applyProgramsV1Json(artifact(JSONArray(), JSONArray().put(deletion()))))
assertTrue(repository.listSyncedPrograms().isEmpty())
} finally {
repository.close()
}
repository = TrainlogRepository(context, name)
try {
assertEquals(ProgramsImportResult.Applied(0, 0, 1), repository.applyProgramsV1Json(artifact(JSONArray().put(program()))))
assertTrue(repository.listSyncedPrograms().isEmpty())
assertTrue(repository.listEquipment().isNotEmpty())
} finally {
repository.close()
context.deleteDatabase(name)
}
}
@Test
fun malformedNestedDocumentDoesNotPartiallyDelete() {
val name = "programs-invalid-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
repository.applyProgramsV1Json(artifact(JSONArray().put(program())))
val malformed = program().apply { getJSONArray("sessions").getJSONObject(0).remove("title") }
val result = repository.applyProgramsV1Json(artifact(JSONArray().put(malformed), JSONArray().put(deletion())))
assertTrue(result is ProgramsImportResult.Invalid)
assertEquals(1, repository.listSyncedPrograms().size)
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

@ -260,7 +260,7 @@ class SessionPreparationExchangeTest {
repository = TrainlogRepository(context, name) repository = TrainlogRepository(context, name)
assertEquals(1, repository.listPreparedSessions().size) assertEquals(1, repository.listPreparedSessions().size)
repository.close() repository.close()
assertEquals("23", scalarText(name, "PRAGMA user_version")) assertEquals("24", scalarText(name, "PRAGMA user_version"))
assertEquals( assertEquals(
"0", "0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"), scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),

View file

@ -26,6 +26,224 @@ import org.robolectric.annotation.Config
class SyncGenerationServiceTest { class SyncGenerationServiceTest {
private val context: Context = ApplicationProvider.getApplicationContext() private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun desktopProgramDeletionGenerationIsAckedAndCannotResurrect() {
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}
.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
val fixture = java.io.File(repositoryRoot, "build/tui/sync-generation-fixture")
val generationTool =
java.io.File(repositoryRoot, "tools/sync_generation_exchange.py").absolutePath
val root = Files.createTempDirectory("trainlog-program-deletion-generation-").toFile()
val desktopDb = java.io.File(root, "desktop.sqlite")
val desktopOwned = java.io.File(root, "desktop-owned")
val desktopObjects = java.io.File(root, "desktop-objects")
val androidName = "program-deletion-generation-${UUID.randomUUID()}.db"
val programId = "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"
val deletionId = "program-delete-generation-regression"
var android = TrainlogRepository(context, androidName)
fun run(vararg command: String): String {
val process =
ProcessBuilder(*command)
.directory(repositoryRoot)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().readText()
assertEquals(
"command failed: ${command.joinToString(" ")}\n$output",
0,
process.waitFor(),
)
return output
}
fun captureAndPublish(service: SyncGenerationService): Pair<String, java.io.File> {
val captured =
run(
"python3",
generationTool,
"capture-desktop",
"--database",
desktopDb.absolutePath,
"--owned-root",
desktopOwned.absolutePath,
"--consumer-peer",
service.peerId(),
)
val generationId =
checkNotNull(Regex("generation_id=(gen_[^ ]+)").find(captured)).groupValues[1]
run(
"python3",
generationTool,
"publish",
"--database",
desktopDb.absolutePath,
"--generation-id",
generationId,
"--object-root",
desktopObjects.absolutePath,
)
return generationId to java.io.File(desktopObjects, "generations/$generationId")
}
fun acceptAndroidAck(generationId: String, acknowledgement: String): String {
val acknowledgementFile =
java.io.File(root, "$generationId-ack.json").apply {
writeText(acknowledgement)
}
return run(
"python3",
generationTool,
"accept-ack",
acknowledgementFile.absolutePath,
"--database",
desktopDb.absolutePath,
)
}
fun androidDeletionCount(): Int =
SQLiteDatabase.openDatabase(
context.getDatabasePath(androidName).absolutePath,
null,
SQLiteDatabase.OPEN_READONLY,
)
.use { db ->
db.rawQuery(
"SELECT COUNT(*) FROM synced_program_deletions WHERE program_id=?",
arrayOf(programId),
)
.use {
assertTrue(it.moveToFirst())
it.getInt(0)
}
}
try {
val created = run(fixture.absolutePath, desktopDb.absolutePath, "program-create")
val createdProgram = JSONObject(created.substringAfter("PROGRAM_CREATE=").trim())
val initialRevision = createdProgram.getString("revision_id")
assertEquals(programId, createdProgram.getString("program_id"))
var service = SyncGenerationService(android)
val (liveGenerationId, livePublished) = captureAndPublish(service)
val desktopPeer =
JSONObject(java.io.File(livePublished, "manifest.json").readText())
.getJSONObject("producer")
.getString("peer_id")
val livePrograms =
JSONObject(java.io.File(livePublished, "programs-v1.json").readText())
assertEquals(
programId,
livePrograms
.getJSONArray("programs")
.getJSONObject(0)
.getString("program_id"),
)
assertEquals(0, livePrograms.getJSONArray("deletions").length())
val liveAck = service.consume(livePublished)
assertEquals(programId, android.listSyncedPrograms().single().programId)
assertTrue(
acceptAndroidAck(liveGenerationId, liveAck).contains("ACK_ACCEPT=acknowledged")
)
val deleted =
run(
fixture.absolutePath,
desktopDb.absolutePath,
"program-delete",
initialRevision,
deletionId,
)
val replayedDelete =
run(
fixture.absolutePath,
desktopDb.absolutePath,
"program-delete",
initialRevision,
deletionId,
)
assertEquals(deleted, replayedDelete)
val deletionRevision =
JSONObject(deleted.substringAfter("PROGRAM_DELETE=").trim())
.getString("revision_id")
val (deletionGenerationId, deletionPublished) = captureAndPublish(service)
val deletedPrograms =
JSONObject(java.io.File(deletionPublished, "programs-v1.json").readText())
assertEquals(0, deletedPrograms.getJSONArray("programs").length())
assertEquals(1, deletedPrograms.getJSONArray("deletions").length())
val exportedDeletion = deletedPrograms.getJSONArray("deletions").getJSONObject(0)
assertEquals(deletionId, exportedDeletion.getString("deletion_id"))
assertEquals(initialRevision, exportedDeletion.getString("predecessor_revision_id"))
assertEquals(deletionRevision, exportedDeletion.getString("revision_id"))
val deletionAck = service.consume(deletionPublished)
assertTrue(android.listSyncedPrograms().isEmpty())
assertTrue(
acceptAndroidAck(deletionGenerationId, deletionAck)
.contains("ACK_ACCEPT=acknowledged")
)
val acknowledgedDeletion =
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT generation_id || '|' || (acknowledged_at IS NOT NULL) " +
"FROM program_deletions WHERE request_id='$deletionId';",
)
.trim()
assertEquals("$deletionGenerationId|1", acknowledgedDeletion)
android.close()
android = TrainlogRepository(context, androidName)
service = SyncGenerationService(android)
val reopenedDesktopPeer =
checkNotNull(
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
generationTool,
"peer-id",
"--database",
desktopDb.absolutePath,
)
)
)
.groupValues[1]
assertEquals(desktopPeer, reopenedDesktopPeer)
assertEquals(liveAck, service.consume(livePublished))
assertTrue(android.listSyncedPrograms().isEmpty())
assertEquals(1, androidDeletionCount())
val (cleanGenerationId, cleanPublished) = captureAndPublish(service)
val cleanPrograms =
JSONObject(java.io.File(cleanPublished, "programs-v1.json").readText())
assertEquals(0, cleanPrograms.getJSONArray("programs").length())
assertEquals(0, cleanPrograms.getJSONArray("deletions").length())
val cleanAck = service.consume(cleanPublished)
assertTrue(android.listSyncedPrograms().isEmpty())
assertEquals(1, androidDeletionCount())
assertTrue(
acceptAndroidAck(cleanGenerationId, cleanAck).contains("ACK_ACCEPT=acknowledged")
)
assertEquals(
"1",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT COUNT(*) FROM program_deletions WHERE program_id='$programId';",
)
.trim(),
)
} finally {
android.close()
context.deleteDatabase(androidName)
root.deleteRecursively()
}
}
@Test @Test
fun acknowledgedOutboundRemainsResumableUntilInboundIsTerminal() { fun acknowledgedOutboundRemainsResumableUntilInboundIsTerminal() {
val sourceName = "generation-resume-source-${UUID.randomUUID()}.db" val sourceName = "generation-resume-source-${UUID.randomUUID()}.db"
@ -111,7 +329,7 @@ class SyncGenerationServiceTest {
.use { db -> .use { db ->
db.rawQuery("PRAGMA user_version", null).use { db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst()) assertTrue(it.moveToFirst())
assertEquals(23, it.getInt(0)) assertEquals(24, it.getInt(0))
} }
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst()) assertTrue(it.moveToFirst())

View file

@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
).use { db -> ).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor -> db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
assertEquals(23, cursor.getInt(0)) assertEquals(24, cursor.getInt(0))
} }
db.rawQuery( db.rawQuery(
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
).use { db -> ).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor -> db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())
assertEquals(23, cursor.getInt(0)) assertEquals(24, cursor.getInt(0))
} }
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst()) assertTrue(cursor.moveToFirst())

View file

@ -17,7 +17,7 @@ evidence and chronology.
- [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume, - [Web Sessions V1](web_sessions.md) owns the implemented preparation, resume,
history and Android-delivery workflow. history and Android-delivery workflow.
- [Program format V1](program_format_v1.md) owns the strict desktop planning - [Program format V1](program_format_v1.md) owns the strict desktop planning
import contract used by the Sessions Programs subtab. import contract and program lifecycle used by the Sessions Programs subtab.
- [Android](android.md) owns field-companion capture, local persistence, - [Android](android.md) owns field-companion capture, local persistence,
glanceable summaries, permissions, and Android interaction rules. glanceable summaries, permissions, and Android interaction rules.
@ -77,6 +77,9 @@ evidence and chronology.
- [Review records](reviews/) retain historical audits, incidents, checkpoints, - [Review records](reviews/) retain historical audits, incidents, checkpoints,
and validation evidence. Their status and paths are historical. and validation evidence. Their status and paths are historical.
- [Programs presentation / Android delete evidence](reviews/programs_presentation_android_delete_v1_evidence.md)
records the implemented, not-deployed validation checkpoint and real-browser
screenshot evidence.
- [Changelog](../CHANGELOG.md) owns chronological change history. - [Changelog](../CHANGELOG.md) owns chronological change history.
- [Release notes](releases/) retain reusable, tag-scoped English source text - [Release notes](releases/) retain reusable, tag-scoped English source text
for public GitHub and Forgejo releases. for public GitHub and Forgejo releases.

View file

@ -16,6 +16,13 @@ and their active or completed execution data is untouched. The stored result is
Withdrawal replay is idempotent, and an older preparation delivery is skipped Withdrawal replay is idempotent, and an older preparation delivery is skipped
once the withdrawal identity for that preparation exists. once the withdrawal identity for that preparation exists.
Android schema v24 adds a read-only synchronized Programs projection:
`synced_programs`, `synced_program_sessions`, `synced_program_entries`, and
`synced_program_deletions`. It presents received program and session detail in
Sessions without offering import, archive, delete, preparation, or other
program mutation actions. The projection is separate from capture data, AI
proposals, manual preparations, and the active-session singleton.
## 1. Purpose ## 1. Purpose
The Android application is Trainlog's low-friction capture client. The Android application is Trainlog's low-friction capture client.
@ -68,7 +75,15 @@ including intermediate forms such as `0.`, `0,`, `.3`, and `,3`. Commit replaces
existing positive/non-negative domain rule. Distance remains explicitly km; no existing positive/non-negative domain rule. Distance remains explicitly km; no
unit guessing or automatic correction occurs. unit guessing or automatic correction occurs.
## Session exchange V3 ## Session exchange V3 and optional Programs companion V1
`trainlog-programs` V1 is an optional staged controlled-generation companion,
not a replacement for mobile export V3. The PC publishes `programs-v1.json` as
a full snapshot of nondeleted programs plus unacknowledged program-deletion
tombstones. Both peers advertise the `programs-v1` capability; Android applies
the projection and deletion state durably, and desktop records the correlated
ACK before marking the tombstone acknowledged. It changes neither
`TRAINLOG_FORMAT_V1`, mobile V3, catalog exchange, nor preparation exchange.
Completed session occurrences persist an `entry_id`; it is never regenerated Completed session occurrences persist an `entry_id`; it is never regenerated
for exchange. Android schema v14 added feedback roots and completion `ended_at` for exchange. Android schema v14 added feedback roots and completion `ended_at`

View file

@ -493,9 +493,15 @@ silently rendered as unclassified.
### Desktop ### Desktop
Desktop SQLite schema v24 is canonical long-term history. Its additive v23 -> Desktop SQLite schema v26 is canonical long-term history. Its additive v25 ->
v24 migration adds durable manual-preparation withdrawal identity without v26 migration adds terminal Program logical deletion and its durable replay,
deleting revisions, deliveries or workout facts. Its additive v18 -> generation, and acknowledgement ledger without deleting imported definitions,
derived preparations, or workout facts. Android schema v24 maintains the
separate read-only synchronized Programs projection; the optional staged
`trainlog-programs` V1 companion does not replace mobile V3 or change
`TRAINLOG_FORMAT_V1`. Its additive desktop v23 -> v24 migration adds durable
manual-preparation withdrawal identity without deleting revisions, deliveries
or workout facts. Its additive v18 ->
v19 migration stores causal notes and execution-draft lifecycle state without v19 migration stores causal notes and execution-draft lifecycle state without
activating a new transport. Its v15 -> v16 activating a new transport. Its v15 -> v16
transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the transactional rebuild adds the occurrence-owned `tracking_mode` snapshot; the

View file

@ -4,9 +4,9 @@ The opt-in full-generation path is operational on the private daily
installation. The loopback Web control and foreground Android coordinator use installation. The loopback Web control and foreground Android coordinator use
the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and the production direct-libmtp adapter, correlated manifest/ACK V1 objects, and
persistent peer identities. The latest controlled rollout verified real persistent peer identities. The latest controlled rollout verified real
desktop and Android backups, restoration and migration on copies, desktop desktop and Android backups, populated-copy migration, desktop schema v25 to
schema v24 to v25, and an in-place private Android update from versionCode 14 v26, and an in-place private Android update from versionCode 15 to 16. The
to 15. The installed APK remains `com.labfytools.trainlog` 0.1.2, is installed APK remains `com.labfytools.trainlog` 0.1.2, is
non-debuggable, and retains certificate SHA-256 non-debuggable, and retains certificate SHA-256
`aa56c97f2781a0d01f007f4444c3970deb58ad8327ca3da7b0b90f37dbe2ad25`. `aa56c97f2781a0d01f007f4444c3970deb58ad8327ca3da7b0b90f37dbe2ad25`.
One preparation created explicitly as disposable was withdrawn through the Web One preparation created explicitly as disposable was withdrawn through the Web
@ -57,8 +57,8 @@ parallel implementation of its rules.
| Boundary | Current state | | Boundary | Current state |
|---|---| |---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v25 | | Desktop SQLite | schema v26 |
| Android SQLite | schema v23 | | Android SQLite | schema v24 |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only | | Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` | | Trainlog product version | `0.1.2` development, synchronized across Android and desktop; latest stable release: `v0.1.1` |
@ -81,6 +81,7 @@ parallel implementation of its rules.
| Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` | | Web Dashboard V1 | `WEB_DASHBOARD_V1=PASS/FROZEN` |
| Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) | | Web Sessions V1 | `TRAINLOG_WEB_SESSIONS_V1=PASS/FROZEN` (controlled desktop/Android deployment validated) |
| Web Sessions deletion and Programs V1 | `TRAINLOG_WEB_SESSIONS_DELETE_AND_PROGRAMS_V1=PASS` (private grouped rollout validated) | | 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) |
| Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` | | Sync orchestrator/report V1 | `TRAINLOG_SYNC_ORCHESTRATOR_REPORT_V1=PASS/FROZEN` |
| Web sync API V1 | `TRAINLOG_WEB_SYNC_API_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` | | Web sync button V1 | `TRAINLOG_WEB_SYNC_BUTTON_V1=PASS/FROZEN` |
@ -231,9 +232,9 @@ explicit proposal derivation and generation-backed Android delivery. Programmes
is implemented as the fourth Sessions subtab; Analyse and Exercises remain is implemented as the fourth Sessions subtab; Analyse and Exercises remain
explicit placeholders, and no standalone Programs route exists. explicit placeholders, and no standalone Programs route exists.
Desktop schema v25 owns immutable manual-preparation revisions, stable ordered Desktop schema v26 owns immutable manual-preparation revisions, stable ordered
occurrences, persistent HTTP idempotency keys, delivery-to-execution identity, occurrences, persistent HTTP idempotency keys, delivery-to-execution identity,
and durable revision-bound withdrawal records. Android schema v23 stores and durable revision-bound withdrawal records. Android schema v24 stores
received preparations and permanent withdrawal results separately from AI received preparations and permanent withdrawal results separately from AI
proposals and the active singleton. Pending deliveries are cancelled by an proposals and the active singleton. Pending deliveries are cancelled by an
exact V2 withdrawal; started executions are preserved. An exact correlated ACK exact V2 withdrawal; started executions are preserved. An exact correlated ACK
@ -254,12 +255,36 @@ preparations and is propagated by `trainlog-ai-session-drafts` V2; inactive
execution drafts and completed sessions use revision-guarded causal deletion. execution drafts and completed sessions use revision-guarded causal deletion.
Active or concurrently changed drafts conflict instead of being erased. Active or concurrently changed drafts conflict instead of being erased.
Program V1 is desktop-owned planning data in schema v25. The Sessions Programs Program V1 is desktop-owned planning data in schema v26. The responsive
subtab lists, searches, filters, deep-links and archives programs. Local JSON Sessions Programs subtab provides cards and timeline detail backed by real
import is strict and bounded, previews through Core before committing, and has imported definitions, sessions, usage, and provenance; it keeps strict bounded
deterministic replay/conflict behavior. One explicit program-session action preview/commit import and deterministic replay/conflict behavior. A discrete,
creates a provenance-bearing manual preparation without Android delivery or accessible trash action uses confirmation, focus restoration, and Escape-safe
performed work. cancel semantics. Active and archived programs may be terminally logically
deleted; sources and derived preparations remain, while deleted programs cannot
resurrect or reprepare and archived programs cannot prepare.
Android schema v24 contains a read-only synchronized Programs projection with
program, session, entry, and deletion tables. Its Sessions list/detail has no
program mutation actions. The optional staged controlled-generation
`trainlog-programs` V1 companion publishes a PC-to-Android full nondeleted
snapshot plus unacknowledged tombstones under capability `programs-v1`; a
durable correlated ACK records acknowledgment. It changes neither
`TRAINLOG_FORMAT_V1`, mobile V3, catalog exchange, nor preparations.
The private Programs rollout migrated desktop schema v25 to v26 and Android
schema v23 to v24 after verified backups, then installed the signed
non-debuggable versionCode 16 APK without uninstalling or clearing data. The
real 24-session Program remained unique and readable on Web and Android. A
clearly disposable Program was imported, synchronized, cancelled once without
mutation, then logically deleted through the Web confirmation. Android consumed
the real tombstone and desktop recorded its correlated generation ACK. A first
post-restart attempt failed before generation with `device_unavailable` and
`PTP_ERROR_IO`; no success state was recorded. Releasing the stale bounded MTP
worker and restarting the local ADB server restored the same physical peer. The
subsequent correlated exchange completed in both directions, and store reopen
confirmed one durable Android tombstone, one acknowledged desktop deletion,
no duplicate Program, and no resurrection.
The controlled deployment preserved the installed Android signing identity and The controlled deployment preserved the installed Android signing identity and
advanced its private `versionCode` from 13 to 14 without uninstalling or advanced its private `versionCode` from 13 to 14 without uninstalling or

View file

@ -1,5 +1,19 @@
# Desktop database # Desktop database
## Schema v26: Programs presentation, Android projection, and logical deletion
Schema v26 adds nullable `programs.deleted_at` and the terminal
`program_deletions` logical-deletion ledger. A ledger row binds the stable
program identity to the request revision, deleted revision, response replay,
generation identity, and nullable `acknowledged_at`. Deletion is available for
active and archived programs. It retains the imported source definition and
all derived preparations, but a deleted program cannot be resurrected or used
to create another preparation. An archived program cannot be prepared.
The related Programs presentation reads imported definitions, their sessions,
usage, and provenance as real desktop data; it does not make planning targets
performed work. The v26 migration is additive and transactional.
## Schema v25: Web deletion requests and Program V1 ## Schema v25: Web deletion requests and Program V1
Schema v25 adds durable Program V1 definitions, their ordered session and Schema v25 adds durable Program V1 definitions, their ordered session and
@ -51,8 +65,8 @@ is a later, separately versioned migration.
## 1. Status ## 1. Status
```text ```text
TRAINLOG_DATABASE_SCHEMA_VERSION=25 TRAINLOG_DATABASE_SCHEMA_VERSION=26
DATABASE_SCHEMA_V25=IMPLEMENTED DATABASE_SCHEMA_V26=IMPLEMENTED
TRAINLOG_FORMAT_V1=FROZEN TRAINLOG_FORMAT_V1=FROZEN
``` ```
@ -71,7 +85,7 @@ PRAGMA user_version;
Current value: Current value:
```text ```text
25 26
``` ```
The independent actual-set loads documented in the current desktop, Android The independent actual-set loads documented in the current desktop, Android

View file

@ -13,7 +13,7 @@ identities remain opaque. Display titles are not identities. A program is not a
reusable session template, AI proposal, manual preparation, execution draft or reusable session template, AI proposal, manual preparation, execution draft or
completed session. completed session.
Desktop schema v25 owns the imported program and its exact source SHA-256. Desktop schema v26 owns the imported program and its exact source SHA-256.
Exact same-ID content replay is idempotent. The same ID with a different byte Exact same-ID content replay is idempotent. The same ID with a different byte
payload is an explicit conflict. A different ID may reuse a title because titles payload is an explicit conflict. A different ID may reuse a title because titles
have no identity semantics. have no identity semantics.
@ -60,3 +60,11 @@ session is a separate idempotent command that copies the ordered plan and stores
program/session provenance on a new `sp_<uuid-v4>` manual preparation. It does program/session provenance on a new `sp_<uuid-v4>` manual preparation. It does
not create a delivery, Android object, execution draft, completed session or not create a delivery, Android object, execution draft, completed session or
performed measurement. performed measurement.
Deletion is a separate terminal logical lifecycle transition. It is allowed
from active or archived state, records request/deleted revisions and durable
response replay in the desktop `program_deletions` ledger, and retains the
source definition and derived preparations. A deleted program cannot be
resurrected or prepared; an archived program cannot be prepared. The optional
`trainlog-programs` V1 synchronization companion is a separate PC-to-Android
projection and does not alter this import format.

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View file

@ -0,0 +1,128 @@
# Programs presentation, Android projection, and delete V1 evidence
## Scope and lifecycle
This record captures the implementation and validation evidence presently
available for `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1`.
The feature is implemented, validated, and deployed on the authorized private
desktop/Android pair. `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS`.
This is not a public release and does not change a FROZEN exchange contract.
## Implemented contract
- Desktop schema v26 adds `programs.deleted_at` and the terminal
`program_deletions` logical-deletion ledger. The ledger retains request and
deleted revisions, response replay, `generation_id`, and `acknowledged_at`.
Active and archived programs may be deleted. Source definitions and derived
preparations remain; deleted programs cannot resurrect or reprepare, and
archived programs cannot prepare.
- The Web Sessions Programs subtab renders responsive cards and timeline detail
from real imported, session, usage, and provenance data. Its discrete trash
action has destructive confirmation, focus management/restoration, and
Escape/Cancel paths that make no mutation.
- Android schema v24 adds the read-only `synced_programs`,
`synced_program_sessions`, `synced_program_entries`, and
`synced_program_deletions` projection. Android provides Sessions list/detail
consultation only; it exposes no Program mutation actions.
- `trainlog-programs` V1 is an optional staged controlled-generation companion
named `programs-v1.json`. With the `programs-v1` capability, desktop sends a
full snapshot of nondeleted programs and unacknowledged tombstones; Android
durably acknowledges correlated consumption. The companion does not change
`TRAINLOG_FORMAT_V1`, mobile export V3, catalog exchange, or preparations.
## Validation recorded
- Full Meson suite: **89/89**.
- Focused ASan/UBSan suite: **3/3**.
- Web Vitest: **94** tests.
- Web production build completed.
- Android unit suite: **230** tests, **5 skipped**; `assembleDebug` completed.
- Python Programs and synchronization suites completed.
- Format and diff validators completed, including `git diff --check`.
- Real Firefox Programs screenshots passed and are retained as
[desktop cards, 1440x1000](evidence/web-programs-v1-card-desktop.png) and
[mobile deletion dialog, exact 390x844](evidence/web-programs-v1-delete-dialog-mobile-390x844.png).
### Pre-switch migration and exchange gates
`schema_v26_migration` deterministically migrates a populated v25 fixture with
24 sessions, 48 entries, preparation provenance, and historical evidence. It
asserts the exact typed post-migration projections rather than only table or
column presence.
The integrated Android `SyncGenerationServiceTest` executes the real production
chain from a live Program through capture and publication, Android consumption,
Program deletion and tombstone publication, the real correlated ACK, desktop
acceptance and reopen. An exact old generation is idempotently recognized and
ACKed while the durable tombstone prevents Program resurrection; a second
exchange adds no duplicate data.
### Private real-copy v25 to v26 migration evidence
A private real-copy migration gate preserved user-data confidentiality while
validating the source backup at v25 and its post-migration v26 result. The
post-migration integrity check passed, foreign-key violations numbered **0**,
and the business-data SHA-256 was identical before and after migration:
`b264dd1b5fd7cdc599b2f6e2a20c01af794949e7871b8e3107ff3b29a06c6e11`.
The Program hash was
`1b14ba7d8dbfbd3d3b19b6584e399fce3f161e1aa20591ca21676530dc0ebd43`;
the 24 ordered sessions hash was
`593f91572cccb109f76175be249afad5b0e311c429b56ef0cee141a5deb7f3a4`;
the 164 entries hash was
`9ca6742dd168777b7653a857bc695dbf1f59f10abf489bfaae24660b69fdb1cc`;
and the two preparations hash was
`2e2733b2624248210c390e5d592fe03e4130775ddb24e7fdb93280b4f778ec25`.
The copy contained no `deleted_at` values and no
`program_deletions` rows. All 45 unchanged business tables matched their
canonical field-level hashes.
## Private deployment validation
The authorized rollout used source commit
`f431b6ec9cab68e0d41d04815ba41bac55787485`. The coordinated desktop archive
SHA-256 was
`072fb61c8ddc86ebd9fed650c3928ddfd07010823358e0cd6cfefb3de11d4597`;
the private release APK SHA-256 was
`bbf3fa5b3ddce3d83b13d22170c23ae0196991b31b70466f85fa124ed72fb297`.
The installed `com.labfytools.trainlog` remained non-debuggable and retained
certificate SHA-256
`aa56c97f2781a0d01f007f4444c3970deb58ad8327ca3da7b0b90f37dbe2ad25`.
The real desktop database migrated from schema v25 to v26 with
`integrity_check=ok`, no foreign-key violations, and unchanged field-level
Program data. The real Program retained its stable identity, title, active
state, 24 ordered sessions, 164 entries, occurrences, targets, equipment,
rests, notes, and preparation provenance. Android migrated from schema v23 to
v24 without changing any of its 46 pre-existing tables.
The real Web import created only the disposable Program
`pg_dd90fbc1-8131-41f1-9d49-f516e34a17da`. Its first confirmation cancellation
made no mutation. Confirmed deletion produced revision
`pgr_5c34b25a-678e-49e5-89a2-eecebd4e361f`; Android consumed its tombstone and
desktop correlated acknowledgment with generation
`gen_5a260b7c-6377-4805-82bb-d793fc41e35f`.
After both components restarted, the first attempted control run failed before
either generation was created: `device_unavailable` followed a real
`PTP_ERROR_IO`. The failure was retained and was not rewritten as success. A
stale `trainlog-sync-once --request-only` worker owned the MTP descriptor;
stopping only `trainlog-syncd` released it. A bounded libmtp probe found the
same Samsung SM-G990B, and restarting the local ADB server restored the same
serial and peer advertisement without reconnecting, reinstalling, clearing, or
restoring either store.
The final post-restart run
`sy_2b2320a8-e182-41a5-b238-ac04f0d4e5f2` completed with desktop generation
`gen_9afcc6f9-f7a5-4eec-a24e-696182addfe8`, Android generation
`gen_0b101149-5ba8-4ba2-9912-eca8cc9d9953`, two durable `consumed` ACKs, no
missing capability, and `sessions_reconciled=0`. A final Android backup has
schema v24, `integrity_check=ok`, no foreign-key violations, one synchronized
Program with 24 sessions, and exactly one tombstone for the disposable Program.
Its archive SHA-256 is
`cad5830062578984e77f7900246701b5e1a6bc7629054d012ef34c7cd5f36669`.
Desktop has schema v26, one acknowledged deletion ledger row, no visible
disposable Program, and one unchanged real Program. Closing and reopening the
services and Android application preserved those results. Both Trainlog user
services were active at closeout.

View file

@ -24,7 +24,7 @@ distinction between measured values and estimates.
## Current baseline ## Current baseline
Desktop schema v25, Android schema v23, Notcurses, direct Desktop schema v26, Android schema v24, Notcurses, direct
`Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body `Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body
Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are
implemented. Session Generator V1 is hidden pending V2. AI session-draft implemented. Session Generator V1 is hidden pending V2. AI session-draft
@ -59,7 +59,10 @@ explicit trusted opt-in. The authorized private direct-MTP rollout passed on
real hardware; automatic V3 selection remains unchanged without that opt-in. real hardware; automatic V3 selection remains unchanged without that opt-in.
The next separately contracted Web surface is Exercises. Sessions deletion and The next separately contracted Web surface is Exercises. Sessions deletion and
its Programs subtab are complete in software and are not reopened by that its Programs subtab are complete in software and are not reopened by that
future contract. future contract. `TRAINLOG_PROGRAMS_PRESENTATION_ANDROID_DELETE_V1=PASS` after
its coordinated private desktop/Android deployment, correlated deletion ACK,
and post-restart non-resurrection validation. It must not be reopened by the
Exercises contract.
`TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS` `TRAINLOG_SYNC_GAP_CONTRACT_V1=CONTRACT_FROZEN / IMPLEMENTATION_IN_PROGRESS`
defines the complete-sync target and its bounded dependency order. defines the complete-sync target and its bounded dependency order.

View file

@ -40,7 +40,7 @@ available because availability is independently guarded by causal state.
## Machine-exercise Phase 1 compatibility ## Machine-exercise Phase 1 compatibility
Desktop schema v25 and Android schema v23 retain mobile export V3, readable V1/V2 imports, Desktop schema v26 and Android schema v24 retain mobile export V3, readable V1/V2 imports,
equipment definitions V1, equipment associations V2, exercise aliases V1, and equipment definitions V1, equipment associations V2, exercise aliases V1, and
the BODY ZONES companion without wire-format changes. Machine metadata is not the BODY ZONES companion without wire-format changes. Machine metadata is not
silently added to a frozen artifact: stable exercise IDs and canonical names silently added to a frozen artifact: stable exercise IDs and canonical names
@ -144,11 +144,27 @@ does not publish to it or let it override an artifact in the new endpoint.
| PC -> Android | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 | | PC -> Android | `trainlog-exercise-body-zones-v1.json` | `trainlog-exercise-body-zones` v1 |
| PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 | | PC -> Android | `trainlog-exercise-aliases-v1.json` | `trainlog-exercise-aliases` v1 |
| PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals | | PC -> Android | `trainlog-ai-session-drafts-v1.json` | `trainlog-ai-session-drafts` v1/v2 companion; v2 adds proposal withdrawals |
| PC -> Android | `programs-v1.json` | optional staged `trainlog-programs` v1 companion: full nondeleted snapshot plus unacknowledged deletion tombstones |
| Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 | | Android -> PC agent | `trainlog-sync-request-v1.json` | `trainlog-sync-request` v1 |
| PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 | | PC agent -> Android | `trainlog-sync-receipt-v1.json` | `trainlog-sync-receipt` v1 |
No SQLite file is transferred. No SQLite file is transferred.
## Optional Programs companion V1
`trainlog-programs` V1 is a staged controlled-generation companion. It is
selected only when the peer capability set includes `programs-v1`. Its snapshot
contains the current nondeleted desktop programs, their planning definitions,
sessions and entries, and the unacknowledged terminal program-deletion
tombstones. Android schema v24 stores a read-only synchronized projection and
returns a durable correlated ACK. Desktop schema v26 records the exact
generation and only then sets `program_deletions.acknowledged_at`.
This companion is independent of `trainlog-program` V1 import, mobile export
V3, `trainlog-pc-catalog` V1, and `trainlog-session-preparations` V2. It does
not change `TRAINLOG_FORMAT_V1`, active default V3 transport, or preparation
semantics.
`trainlog-exercise-aliases` v1 is the separate EXERCISE_MERGE_V1 identity `trainlog-exercise-aliases` v1 is the separate EXERCISE_MERGE_V1 identity
companion. Its root contains exactly `format`, `version`, and `aliases`; every companion. Its root contains exactly `format`, `version`, and `aliases`; every
entry contains exactly `source_exercise_id` and `canonical_exercise_id` using entry contains exactly `source_exercise_id` and `canonical_exercise_id` using

View file

@ -51,13 +51,30 @@ withdrawal without the proposal and that Android's tombstone rejects later V1
replay. replay.
`web_programs` exercises validation preview without writes, transactional `web_programs` exercises validation preview without writes, transactional
import, exact replay, detail serialization, archive replay, preparation import, exact replay, responsive detail serialization, archive replay,
creation, provenance and absence of delivery. Schema migration coverage checks revision-guarded terminal deletion, response replay, preparation creation,
all v25 tables and columns. Programs Vitest covers preview before the only usage/provenance and absence of delivery. Schema migration coverage checks all
importing mutation and explicit preparation creation/navigation. The full HTTP, v26 tables and columns. `schema_v26_migration` additionally migrates a
deterministically populated v25 fixture containing 24 sessions and 48 entries,
with preparation provenance and history, and verifies exact typed projections
after migration. Programs Vitest covers preview before the only importing
mutation, explicit preparation creation/navigation, discrete trash confirmation,
focus restoration, and Escape/Cancel zero-mutation behavior. The full HTTP,
pagination, strict-parser and Core suites retain malformed, duplicate-key, pagination, strict-parser and Core suites retain malformed, duplicate-key,
bounds, catalog/profile/equipment, conflict and rollback coverage. bounds, catalog/profile/equipment, conflict and rollback coverage.
Android Programs projection tests cover schema-v23 to v24 migration, full
nondeleted `trainlog-programs` V1 snapshot import, durable deletion tombstones,
read-only Sessions list/detail data, exact replay, capability gating, and
correlated ACK behavior. The integrated `SyncGenerationServiceTest` invokes the
real production chain from a live Program through capture, publication, Android
consumption, deletion/tombstone publication, real ACK, desktop acceptance and
reopen; an exact old generation is idempotently recognized and ACKed while the
durable tombstone prevents Program resurrection, and a second exchange creates
no duplication. Python Programs/synchronization suites cover the staged
companion's full snapshot, unacknowledged tombstone publication, and ACK-driven
acknowledgment without changing mobile V3, catalog, or preparation artifacts.
`test_web_sessions_browser.py` runs headless Firefox against the embedded `test_web_sessions_browser.py` runs headless Firefox against the embedded
production assets and C HTTP server with private temporary XDG roots. It creates production assets and C HTTP server with private temporary XDG roots. It creates
only a synthetic ready preparation and delivery, verifies French then persisted only a synthetic ready preparation and delivery, verifies French then persisted

View file

@ -69,13 +69,23 @@ Inactive execution drafts and completed sessions use the existing causal
operation/state boundary. Active or stale drafts conflict, and completed-session operation/state boundary. Active or stale drafts conflict, and completed-session
deletion records a finalization so older snapshots cannot recreate the session. deletion records a finalization so older snapshots cannot recreate the session.
Programs are planning data owned by desktop schema v25. Import first runs the Programs are planning data owned by desktop schema v26. The Programs subtab
uses responsive cards and a timeline detail view backed by real imported
definitions, sessions, usage, and provenance data. Import first runs the
same strict Core validator in preview mode, then commits only after explicit same strict Core validator in preview mode, then commits only after explicit
confirmation. Exact content replay is idempotent; same-ID divergent content is confirmation. Exact content replay is idempotent; same-ID divergent content is
a conflict. Archiving preserves definitions and derived preparations. Creating a conflict. Archiving preserves definitions and derived preparations. Creating
a preparation from one program session copies targets and provenance into a a preparation from one active program session copies targets and provenance
new manual preparation but creates no delivery, execution or performed data. into a new manual preparation but creates no delivery, execution or performed
See [Program format V1](program_format_v1.md). data. Archived or deleted programs cannot be prepared.
The discrete right-edge Program trash action is accessible and opens a
confirmation dialog that names the program and its consequence. Focus moves
into that dialog, Escape and Cancel perform no mutation, and focus returns to
the initiating action. Deletion is terminal logical deletion: it is allowed for
active and archived programs, retains source and derived-preparation evidence,
and prevents resurrection and re-preparation. See
[Program format V1](program_format_v1.md).
This V1 does not provide live Web capture, historical correction, automatic This V1 does not provide live Web capture, historical correction, automatic
training generation, Analysis or the standalone Exercises route. training generation, Analysis or the standalone Exercises route.

View file

@ -0,0 +1,84 @@
#!/usr/bin/env python3
"""Programs V1 desktop projection export regressions."""
import sqlite3
import sys
import tempfile
import unittest
from contextlib import closing
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import export_programs
SCHEMA = """
CREATE TABLE programs(program_id TEXT PRIMARY KEY,title TEXT,note TEXT,state TEXT,
start_date TEXT,end_date TEXT,created_at TEXT,updated_at TEXT,revision_id TEXT,
source_format TEXT,source_version INTEGER,source_payload_sha256 TEXT,deleted_at TEXT);
CREATE TABLE program_sessions(program_session_id TEXT PRIMARY KEY,program_id TEXT,
position INTEGER,title TEXT,session_type TEXT,planned_for TEXT,note TEXT);
CREATE TABLE program_session_entries(program_session_id TEXT,entry_id TEXT,position INTEGER,
exercise_id TEXT,equipment_id TEXT,recording_mode TEXT,tracking_mode TEXT,data_fields INTEGER,
load_mode TEXT,rest_seconds INTEGER,target_sets INTEGER,target_reps INTEGER,
target_duration_seconds INTEGER,target_weight_kg REAL,notes TEXT);
CREATE TABLE program_deletions(program_id TEXT PRIMARY KEY,request_id TEXT,
expected_revision TEXT,deleted_revision TEXT,deleted_at TEXT,response_json TEXT,
generation_id TEXT,acknowledged_at TEXT);
PRAGMA user_version=26;
"""
class ProgramsExchangeTest(unittest.TestCase):
def setUp(self):
self.temporary = tempfile.TemporaryDirectory()
self.database = Path(self.temporary.name) / "trainlog.sqlite"
with closing(sqlite3.connect(self.database)) as db:
db.executescript(SCHEMA)
db.execute("INSERT INTO programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,NULL)", (
"pg_live", "Cycle", None, "active", "2026-09-21", None,
"2026-09-18T10:00:00Z", "2026-09-18T11:00:00Z", "pgr_live",
"trainlog-program", 1, "a" * 64,
))
db.execute("INSERT INTO program_sessions VALUES(?,?,?,?,?,?,?)",
("pgs_live", "pg_live", 0, "A", "training", None, None))
db.execute("INSERT INTO program_session_entries VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)",
("pgs_live", "pge_live", 0, "ex_unknown", None, "sets", "reps", 0,
"none", 60, 3, 8, None, None, None))
db.execute("INSERT INTO programs VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?)", (
"pg_deleted", "Deleted", None, "active", None, None,
"2026-09-18T10:00:00Z", "2026-09-18T11:00:00Z", "pgr_old",
"trainlog-program", 1, "b" * 64, "2026-09-18T12:00:00Z",
))
db.execute("INSERT INTO program_deletions VALUES(?,?,?,?,?,?,NULL,NULL)",
("pg_deleted", "pgd_delete", "pgr_old", "pgr_deleted",
"2026-09-18T12:00:00Z", "{}"))
db.execute("INSERT INTO program_deletions VALUES(?,?,?,?,?,?,?,?)",
("pg_ack", "pgd_ack", "pgr_ack_old", "pgr_ack_deleted",
"2026-09-18T12:00:00Z", "{}", "gen_old", "2026-09-18T13:00:00Z"))
db.commit()
def tearDown(self):
self.temporary.cleanup()
def test_full_live_snapshot_and_only_unacknowledged_deletions(self):
payload = export_programs.export_programs(self.database)
self.assertEqual("trainlog-programs", payload["format"])
self.assertEqual(["pg_live"], [program["program_id"] for program in payload["programs"]])
occurrence = payload["programs"][0]["sessions"][0]["occurrences"][0]
self.assertEqual((3, 8, None), (
occurrence["target_sets"], occurrence["target_reps"],
occurrence["target_duration_seconds"],
))
self.assertEqual([{
"deletion_id": "pgd_delete",
"program_id": "pg_deleted",
"predecessor_revision_id": "pgr_old",
"revision_id": "pgr_deleted",
"requested_at": "2026-09-18T12:00:00Z",
}], payload["deletions"])
if __name__ == "__main__":
unittest.main()

View file

@ -1,11 +1,15 @@
import json import json
import sqlite3 import sqlite3
import subprocess import subprocess
import sys
import tempfile import tempfile
import unittest import unittest
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parents[1] ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import sync_generation_exchange as generation
class DeploymentToolsTest(unittest.TestCase): class DeploymentToolsTest(unittest.TestCase):
@ -62,7 +66,9 @@ class DeploymentToolsTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr) self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text()) inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.2") self.assertEqual(inventory["product_version"], "0.1.2")
self.assertEqual(inventory["desktop_schema"], 26)
self.assertIn("session-preparations-v2", inventory["protocols"]) self.assertIn("session-preparations-v2", inventory["protocols"])
self.assertIn("programs-v1", inventory["protocols"])
self.assertTrue((output / "bin/trainlog").is_file()) self.assertTrue((output / "bin/trainlog").is_file())
self.assertTrue((output / "bin/trainlog-sync-once").is_file()) self.assertTrue((output / "bin/trainlog-sync-once").is_file())
self.assertTrue((output / "bin/trainlog-syncd").is_file()) self.assertTrue((output / "bin/trainlog-syncd").is_file())
@ -91,6 +97,16 @@ class DeploymentToolsTest(unittest.TestCase):
text=True, text=True,
) )
self.assertEqual(imported.returncode, 0, imported.stderr) self.assertEqual(imported.returncode, 0, imported.stderr)
packaged_tools = {
row["path"].removeprefix("tools/")
for row in inventory["files"]
if row["path"].startswith("tools/")
}
exporters = {
row[5]
for row in generation.ARTIFACTS
}
self.assertTrue(exporters <= packaged_tools)
self.assertTrue( self.assertTrue(
any( any(
row["path"] == "tools/trainlog_syncd.py" row["path"] == "tools/trainlog_syncd.py"

View file

@ -25,6 +25,252 @@ EVIDENCE = ROOT / "docs" / "reviews" / "evidence"
class BrowserSessionsTest(unittest.TestCase): class BrowserSessionsTest(unittest.TestCase):
def test_programs_v1_import_detail_and_discrete_delete_in_real_firefox(self):
if not GECKO.is_file():
self.skipTest("pinned geckodriver unavailable")
from selenium import webdriver
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from selenium.webdriver.firefox.service import Service
from selenium.webdriver.support.ui import WebDriverWait
import websocket
with tempfile.TemporaryDirectory(prefix="trainlog-web-programs-") as directory:
root = Path(directory)
xdg = root / "data"
config = root / "config"
xdg.mkdir()
config.mkdir()
with socket.socket() as reserved:
reserved.bind(("127.0.0.1", 0))
port = reserved.getsockname()[1]
environment = os.environ.copy()
environment.update(
{
"XDG_DATA_HOME": str(xdg),
"XDG_CONFIG_HOME": str(config),
"XDG_CACHE_HOME": str(root / "cache"),
}
)
server = subprocess.Popen(
[str(BUILD / "trainlog"), "--web", "--port", str(port)],
env=environment,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True,
)
driver = None
bidi = None
try:
for _ in range(200):
with socket.socket() as probe:
if probe.connect_ex(("127.0.0.1", port)) == 0:
break
time.sleep(0.025)
options = webdriver.FirefoxOptions()
options.add_argument("-headless")
options.set_capability("webSocketUrl", True)
driver = webdriver.Firefox(options=options, service=Service(str(GECKO)))
wait = WebDriverWait(driver, 15)
bidi = websocket.create_connection(
driver.capabilities["webSocketUrl"], suppress_origin=True
)
command_id = 0
def bidi_command(method: str, parameters: dict):
nonlocal command_id
command_id += 1
bidi.send(json.dumps({
"id": command_id,
"method": method,
"params": parameters,
}))
while True:
response = json.loads(bidi.recv())
if response.get("id") != command_id:
continue
if response.get("type") != "success":
raise AssertionError(response)
return response["result"]
context = bidi_command("browsingContext.getTree", {})["contexts"][0]["context"]
def set_viewport(width: int, height: int):
bidi_command(
"browsingContext.setViewport",
{
"context": context,
"viewport": {"width": width, "height": height},
"devicePixelRatio": 1,
},
)
wait.until(
lambda current: current.execute_script(
"return innerWidth===arguments[0]&&innerHeight===arguments[1]",
width,
height,
)
)
set_viewport(1440, 1000)
driver.get(f"http://127.0.0.1:{port}/seances")
wait.until(lambda current: "Aucun résultat" in current.page_source)
catalog = driver.execute_async_script(
"const done=arguments[0];"
"fetch('/api/v1/sessions/catalog?offset=0&limit=1')"
".then((response)=>response.json()).then(done).catch(String);"
)
self.assertIsInstance(catalog, dict)
self.assertTrue(catalog["items"])
exercise = catalog["items"][0]
occurrence = {
"entry_id": "pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc",
"exercise_id": exercise["exercise_id"],
"equipment_id": None,
"load_mode": "none",
"rest_seconds": 90,
"target_sets": 3 if exercise["recording_mode"] == "sets" else None,
"target_reps": 8 if exercise["tracking_mode"] == "reps" else None,
"target_duration_seconds": (
None if exercise["tracking_mode"] == "reps" else 600
),
"target_weight_kg": None,
"notes": None,
}
program = {
"format": "trainlog-program",
"version": 1,
"program": {
"program_id": "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"title": "Cycle navigateur V1",
"note": "Programme synthétique lisible.",
"state": "active",
"start_date": "2026-09-21",
"end_date": None,
"sessions": [
{
"program_session_id": "pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"title": "Séance A ordonnée",
"session_type": "training",
"planned_for": "2026-09-21",
"note": None,
"occurrences": [occurrence],
},
{
"program_session_id": "pgs_dddddddd-dddd-4ddd-8ddd-dddddddddddd",
"title": "Séance B ordonnée",
"session_type": "max_test",
"planned_for": "2026-09-23",
"note": "Contrôle.",
"occurrences": [{**occurrence,
"entry_id": "pge_eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee"}],
},
],
},
}
import_path = root / "programme-navigateur-v1.json"
import_path.write_text(json.dumps(program), encoding="utf-8")
driver.find_element(By.XPATH, "//button[normalize-space()='Programmes']").click()
wait.until(lambda current: "Importer un programme" in current.page_source)
driver.find_element(By.XPATH, "//button[normalize-space()='Importer un programme']").click()
driver.find_element(By.CSS_SELECTOR, "input[type='file']").send_keys(str(import_path))
wait.until(lambda current: "Aperçu avant import" in current.page_source)
self.assertIn("2 séances", driver.page_source)
driver.find_element(By.XPATH, "//button[normalize-space()='Importer']").click()
wait.until(lambda current: "Cycle navigateur V1" in current.page_source)
wait.until(lambda current: "Séance A ordonnée" in current.page_source)
detail = driver.find_element(By.CSS_SELECTOR, "main").text
self.assertIn("PROGRAMME · ACTIF", detail)
self.assertIn("Import trainlog-program v1", detail)
self.assertLess(detail.index("1. Séance A ordonnée"), detail.index("2. Séance B ordonnée"))
driver.find_element(
By.XPATH, "//button[normalize-space()='← Retour aux programmes']"
).click()
wait.until(lambda current: "Programme actif" in current.page_source)
card = driver.find_element(By.CSS_SELECTOR, ".program-row").text
self.assertIn("Cycle navigateur V1", card)
self.assertIn("2 séances · jamais utilisé", card)
self.assertIn("trainlog-program · importé", card)
EVIDENCE.mkdir(parents=True, exist_ok=True)
self.assertTrue(driver.save_screenshot(
str(EVIDENCE / "web-programs-v1-card-desktop.png")
))
trash = driver.find_element(
By.CSS_SELECTOR, "button[aria-label='Supprimer le programme Cycle navigateur V1']"
)
trash.click()
confirmation = wait.until(
lambda current: current.find_element(By.CSS_SELECTOR, "[role='alertdialog']")
)
self.assertIn("Supprimer le programme « Cycle navigateur V1 » ?", confirmation.text)
self.assertIn("séances sources", confirmation.text)
self.assertNotIn("Séance A ordonnée", confirmation.text)
set_viewport(390, 844)
self.assertTrue(driver.save_screenshot(
str(EVIDENCE / "web-programs-v1-delete-dialog-mobile-390x844.png")
))
confirmation.find_element(By.XPATH, ".//button[normalize-space()='Conserver']").click()
wait.until(lambda current: not current.find_elements(By.CSS_SELECTOR, "[role='alertdialog']"))
self.assertEqual(driver.switch_to.active_element.get_attribute("aria-label"), trash.get_attribute("aria-label"))
trash = driver.find_element(
By.CSS_SELECTOR, "button[aria-label='Supprimer le programme Cycle navigateur V1']"
)
trash.click()
wait.until(lambda current: current.find_element(By.CSS_SELECTOR, "[role='alertdialog']"))
driver.switch_to.active_element.send_keys(Keys.ESCAPE)
wait.until(lambda current: not current.find_elements(By.CSS_SELECTOR, "[role='alertdialog']"))
database_path = xdg / "trainlog" / "trainlog.db"
with sqlite3.connect(database_path) as database:
self.assertIsNone(database.execute(
"SELECT deleted_at FROM programs WHERE program_id=?",
(program["program"]["program_id"],),
).fetchone()[0])
driver.find_element(
By.CSS_SELECTOR, "button[aria-label='Supprimer le programme Cycle navigateur V1']"
).click()
confirmation = wait.until(
lambda current: current.find_element(By.CSS_SELECTOR, "[role='alertdialog']")
)
confirmation.find_element(
By.XPATH, ".//button[normalize-space()='Supprimer le programme']"
).click()
wait.until(
lambda current: "Aucun programme." in current.page_source
or current.find_elements(By.CSS_SELECTOR, "[role='alert']")
)
self.assertNotIn("Cycle navigateur V1", driver.find_element(By.CSS_SELECTOR, "main").text)
driver.refresh()
wait.until(lambda current: "Cycle navigateur V1" not in current.page_source)
with sqlite3.connect(database_path) as database:
deleted_at = database.execute(
"SELECT deleted_at FROM programs WHERE program_id=?",
(program["program"]["program_id"],),
).fetchone()[0]
source_sessions = database.execute(
"SELECT COUNT(*) FROM program_sessions WHERE program_id=?",
(program["program"]["program_id"],),
).fetchone()[0]
source_entries = database.execute(
"SELECT COUNT(*) FROM program_session_entries"
).fetchone()[0]
self.assertIsNotNone(deleted_at)
self.assertEqual(source_sessions, 2)
self.assertEqual(source_entries, 2)
finally:
if bidi is not None:
bidi.close()
if driver is not None:
driver.quit()
os.killpg(server.pid, signal.SIGTERM)
server.wait(timeout=10)
def test_create_preferences_reload_and_withdraw_by_real_click(self): def test_create_preferences_reload_and_withdraw_by_real_click(self):
if not GECKO.is_file(): if not GECKO.is_file():
self.skipTest("pinned geckodriver unavailable") self.skipTest("pinned geckodriver unavailable")

View file

@ -38,7 +38,7 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None: def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0] version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22, 23, 24, 25): if version not in (20, 21, 22, 23, 24, 25, 26):
raise CausalError(f"desktop schema v20-v25 required, found v{version}") raise CausalError(f"desktop schema v20-v25 required, found v{version}")

View file

@ -182,7 +182,7 @@ def causal_payload(draft):
def require_schema(connection): def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0] version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22, 23, 24, 25): if version not in (19, 20, 21, 22, 23, 24, 25, 26):
raise LifecycleError(f"desktop schema v19-v25 required, found v{version}") raise LifecycleError(f"desktop schema v19-v25 required, found v{version}")

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25) SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26)
MEASUREMENT_FIELDS = ( MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_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.""" """Build the raw export using only explicitly ordered SELECT statements."""
schema_version = connection.execute("PRAGMA user_version").fetchone()[0] schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if schema_version not in SUPPORTED_SCHEMA_VERSIONS: if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
raise ValueError("schema desktop v15-v18 requis") raise ValueError("schema desktop v15-v26 requis")
root = { root = {
"format": "TRAINLOG_AI_EXPORT", "format": "TRAINLOG_AI_EXPORT",

View file

@ -20,7 +20,7 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection): def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations.""" """Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction owned_transaction = not connection.in_transaction
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25) supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v25 requis") raise ValueError("schema desktop v18-v25 requis")
columns = {row[1] for row in connection.execute( columns = {row[1] for row in connection.execute(

View file

@ -28,7 +28,7 @@ def main():
args = parser.parse_args() args = parser.parse_args()
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
supported_versions = tuple(range(8, 26)) supported_versions = tuple(range(8, 27))
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v16 requis") raise ValueError("schema desktop v8 à v16 requis")
known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment = load_supplied_equipment_ids(args.catalog)

View file

@ -24,7 +24,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
supported_versions = tuple(range(8, 26)) supported_versions = tuple(range(8, 27))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v16 requis") raise ValueError("schema desktop v8 à 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='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): 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

@ -17,7 +17,7 @@ def main():
con = connect_database(args.database) con = connect_database(args.database)
try: try:
if con.execute("PRAGMA user_version").fetchone()[0] not in ( if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25): 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26):
raise ValueError("schema desktop v12-v25 requis") raise ValueError("schema desktop v12-v25 requis")
rows = con.execute( rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "

View file

@ -41,7 +41,7 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
supported_versions = tuple(range(11, 26)) supported_versions = tuple(range(11, 27))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v16 requis") 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(): 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 con.row_factory = sqlite3.Row
try: try:
version = con.execute("PRAGMA user_version").fetchone()[0] version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25): if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute(""" rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, 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 # CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current # catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database. # canonical desktop schema rather than accept a stale pre-v8 database.
if version not in range(8, 26): if version not in range(8, 27):
raise SystemExit( raise SystemExit(
"PC_CATALOG_EXPORT=FAIL " "PC_CATALOG_EXPORT=FAIL "
f"schema={version}" f"schema={version}"

View file

@ -83,9 +83,9 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0] 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): if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
supported_versions = range(11, 26) supported_versions = range(11, 27)
legacy_v2_schema = args.version == 2 and schema_version == 10 legacy_v2_schema = args.version == 2 and schema_version == 10
if schema_version not in supported_versions and not legacy_v2_schema: 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)") raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)")

107
tools/export_programs.py Normal file
View file

@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Export the desktop-owned Programs V1 Android projection."""
import argparse
import datetime as dt
import json
from contextlib import closing
from pathlib import Path
from trainlog_sqlite import connect_database
def nullable(row, index):
return None if row[index] is None else row[index]
def export_programs(database: Path) -> dict:
with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] != 26:
raise ValueError("desktop schema v26 required")
programs = []
for row in db.execute(
"SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at,"
"updated_at,source_format,source_version,source_payload_sha256 FROM programs "
"WHERE deleted_at IS NULL ORDER BY program_id"
):
sessions = []
for session in db.execute(
"SELECT program_session_id,title,session_type,planned_for,note FROM "
"program_sessions WHERE program_id=? ORDER BY position,program_session_id",
(row[0],),
):
occurrences = []
for entry in db.execute(
"SELECT entry_id,exercise_id,equipment_id,load_mode,rest_seconds,target_sets,"
"target_reps,target_duration_seconds,target_weight_kg,notes FROM "
"program_session_entries WHERE program_session_id=? ORDER BY position,entry_id",
(session[0],),
):
occurrences.append({
"entry_id": entry[0],
"exercise_id": entry[1],
"equipment_id": nullable(entry, 2),
"load_mode": entry[3],
"rest_seconds": entry[4],
"target_sets": nullable(entry, 5),
"target_reps": nullable(entry, 6),
"target_duration_seconds": nullable(entry, 7),
"target_weight_kg": nullable(entry, 8),
"notes": nullable(entry, 9),
})
sessions.append({
"program_session_id": session[0],
"title": session[1],
"session_type": session[2],
"planned_for": nullable(session, 3),
"note": nullable(session, 4),
"occurrences": occurrences,
})
programs.append({
"program_id": row[0],
"revision_id": row[1],
"title": row[2],
"note": nullable(row, 3),
"state": row[4],
"start_date": nullable(row, 5),
"end_date": nullable(row, 6),
"created_at": row[7],
"updated_at": row[8],
"source_format": row[9],
"source_version": row[10],
"source_payload_sha256": row[11],
"sessions": sessions,
})
deletions = [{
"deletion_id": row[0],
"program_id": row[1],
"predecessor_revision_id": row[2],
"revision_id": row[3],
"requested_at": row[4],
} for row in db.execute(
"SELECT request_id,program_id,expected_revision,deleted_revision,deleted_at "
"FROM program_deletions WHERE acknowledged_at IS NULL ORDER BY request_id"
)]
return {
"format": "trainlog-programs",
"version": 1,
"generated_at": dt.datetime.now().astimezone().isoformat(),
"programs": programs,
"deletions": deletions,
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("output", type=Path)
parser.add_argument("--database", type=Path, required=True)
args = parser.parse_args()
args.output.write_text(
json.dumps(export_programs(args.database), ensure_ascii=False, sort_keys=True,
separators=(",", ":")),
encoding="utf-8",
)
if __name__ == "__main__":
main()

View file

@ -17,9 +17,9 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict: def build_export(connection: sqlite3.Connection) -> dict:
supported_versions = (24, 25) supported_versions = (24, 25, 26)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24 or v25 required") raise ValueError("desktop schema v24, v25 or v26 required")
deliveries = [] deliveries = []
rows = connection.execute( rows = connection.execute(
"SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state," "SELECT d.delivery_id,d.preparation_id,d.revision_id,d.execution_session_id,d.state,"

View file

@ -10,7 +10,7 @@ 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() 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) db=connect_database(args.database)
try: try:
supported_versions = range(15, 26) supported_versions = range(15, 27)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v25 requis") raise ValueError("schema desktop v15-v25 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") 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")

View file

@ -193,7 +193,7 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction; INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind. any failure leaves no draft, child, or import-identity row behind.
""" """
supported_versions = range(18, 26) supported_versions = range(18, 27)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v25 requis") raise ImportFailure("schema desktop v18-v25 requis")
draft, entries = validate(payload, connection) draft, entries = validate(payload, connection)

View file

@ -187,7 +187,7 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8")) payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
supported_versions = range(8, 26) supported_versions = range(8, 27)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis") fail("schema desktop v8 à v21 requis")
proof_path = args.mobile_export proof_path = args.mobile_export

View file

@ -53,7 +53,7 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False): def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction.""" """Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 26) supported_versions = range(8, 27)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis") 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(): 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): def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction.""" """Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 26) supported_versions = range(12, 27)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v21 requis") fail("schema desktop v12-v21 requis")
for source, canonical in aliases: for source, canonical in aliases:

View file

@ -151,7 +151,7 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries.""" """Apply validated zone rows without owning transaction boundaries."""
supported_versions = range(11, 26) supported_versions = range(11, 27)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v25 requis") raise ImportFailure("schema desktop v11-v25 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") 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")

View file

@ -50,7 +50,7 @@ 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") 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 parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in ( if con.execute("PRAGMA user_version").fetchone()[0] not in (
17, 18, 19, 20, 21, 22, 23, 24, 25): 17, 18, 19, 20, 21, 22, 23, 24, 25, 26):
fail("schema desktop v17-v25 requis") fail("schema desktop v17-v25 requis")
applied=equal=ancestor=pending=0;seen=set() applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]: for item in root["exercises"]:

View file

@ -846,7 +846,7 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table. # readable for legacy artifacts and are never made to fake that table.
if version not in range(5, 26): if version not in range(5, 27):
raise ImportFailure( raise ImportFailure(
f"base desktop schema v5 à v25 attendue, version trouvée: {version}" f"base desktop schema v5 à v25 attendue, version trouvée: {version}"
) )
@ -1978,7 +1978,7 @@ def apply_payload(connection, payload, trace_exercises=False):
require_supported_schema(connection) require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] 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): if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v25") raise ImportFailure("mobile V4 exige le schéma desktop v19 à v25")
if schema_version >= 20: if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute( protected = {(row[0], row[1]) for row in connection.execute(

View file

@ -52,7 +52,7 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False): def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0 roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON") db.execute("PRAGMA foreign_keys=ON")
supported_versions = range(15, 26) supported_versions = range(15, 27)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v25 requis") fail("schema desktop v15-v25 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") 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")

View file

@ -38,6 +38,7 @@ RUNTIME_TOOLS = [
"export_training_feedback.py", "export_training_feedback.py",
"export_ai_session_drafts.py", "export_ai_session_drafts.py",
"export_session_preparations.py", "export_session_preparations.py",
"export_programs.py",
] ]
@ -96,8 +97,8 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(), ).strip(),
"product_version": "0.1.2", "product_version": "0.1.2",
"desktop_schema": 25, "desktop_schema": 26,
"android_schema": 23, "android_schema": 24,
"protocols": [ "protocols": [
"mobile-export-v3", "mobile-export-v3",
"mobile-history-v4", "mobile-history-v4",
@ -108,6 +109,7 @@ def main() -> int:
"generation-archive-v1", "generation-archive-v1",
"session-preparations-v2", "session-preparations-v2",
"ai-session-drafts-v2", "ai-session-drafts-v2",
"programs-v1",
], ],
"entry_points": [ "entry_points": [
"bin/trainlog", "bin/trainlog",

View file

@ -72,6 +72,8 @@ ARTIFACTS = (
"export_ai_session_drafts.py", ()), "export_ai_session_drafts.py", ()),
("session-preparations", "trainlog-session-preparations", 2, ("session-preparations", "trainlog-session-preparations", 2,
"session-preparations-v2.json", False, "export_session_preparations.py", ()), "session-preparations-v2.json", False, "export_session_preparations.py", ()),
("programs-v1", "trainlog-programs", 1, "programs-v1.json", False,
"export_programs.py", ()),
) )
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS}
@ -201,9 +203,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None: def require_schema(db: sqlite3.Connection) -> None:
supported_versions = (24, 25) supported_versions = (24, 25, 26)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24 or v25 required") raise GenerationError("desktop schema v24, v25 or v26 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str: def peer_identity(db: sqlite3.Connection, kind: str) -> str:
@ -436,6 +438,17 @@ def capture_desktop(database: Path, owned_root: Path, consumer: str,
existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone() existed = db.execute("SELECT 1 FROM sync_causal_publications WHERE operation_id=?", (operation["operation_id"],)).fetchone()
db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)", db.execute("INSERT INTO sync_causal_publications VALUES(?,?,?)",
(operation["operation_id"], generation, 0 if existed else 1)) (operation["operation_id"], generation, 0 if existed else 1))
programs_path = stage / "programs-v1.json"
if programs_path.is_file():
captured_programs = strict_json(programs_path.read_bytes(), MAX_ARTIFACT)
for deletion in captured_programs["deletions"]:
cursor = db.execute(
"UPDATE program_deletions SET generation_id=? WHERE request_id=? "
"AND deleted_revision=? AND generation_id IS NULL",
(generation, deletion["deletion_id"], deletion["revision_id"]),
)
if cursor.rowcount != 1:
raise GenerationError("program deletion changed during capture")
db.commit() db.commit()
return stage, manifest, checksum return stage, manifest, checksum
except Exception: except Exception:
@ -699,6 +712,12 @@ def accept_ack(database: Path, path: Path) -> str:
"WHERE generation_id=? AND acknowledged_at IS NULL", "WHERE generation_id=? AND acknowledged_at IS NULL",
(ack["consumed_at"], ack["generation_id"]), (ack["consumed_at"], ack["generation_id"]),
) )
if db.execute("PRAGMA user_version").fetchone()[0] >= 26:
db.execute(
"UPDATE program_deletions SET acknowledged_at=? WHERE generation_id=? "
"AND acknowledged_at IS NULL",
(ack["consumed_at"], ack["generation_id"]),
)
db.execute( db.execute(
"UPDATE session_preparations SET delivery_state='acknowledged' WHERE EXISTS(" "UPDATE session_preparations SET delivery_state='acknowledged' WHERE EXISTS("
"SELECT 1 FROM session_preparation_deliveries d WHERE " "SELECT 1 FROM session_preparation_deliveries d WHERE "

View file

@ -17,7 +17,7 @@
#include "trainlog/model.h" #include "trainlog/model.h"
#include "trainlog/status.h" #include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 25 #define TRAINLOG_DATABASE_SCHEMA_VERSION 26
typedef struct TrainlogDatabase TrainlogDatabase; typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -44,6 +44,16 @@ TrainlogStatus trainlog_web_programs_archive_json(TrainlogDatabase *database,
char **output_json, char **output_json,
size_t *output_size); size_t *output_size);
/* CONTRACT: deletion is a terminal logical transition. Exact retries replay
* the durable response; reusing a request ID for another semantic command is
* a conflict. Source definitions and derived preparations remain untouched. */
TrainlogStatus trainlog_web_programs_delete_json(TrainlogDatabase *database,
const char *program_id,
const char *expected_revision,
const char *request_id,
char **output_json,
size_t *output_size);
/* Creates a separate draft preparation and never changes or delivers the /* Creates a separate draft preparation and never changes or delivers the
* source program. request_id is forwarded to the preparation service. */ * source program. request_id is forwarded to the preparation service. */
TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database, TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,

View file

@ -747,6 +747,15 @@ test_schema_v9_migration = executable(
test('schema_v9_migration', test_schema_v9_migration) test('schema_v9_migration', test_schema_v9_migration)
test_schema_v26_migration = executable(
'test_schema_v26_migration',
'tests/test_schema_v26_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('schema_v26_migration', test_schema_v26_migration)
test_schema_v7_migration = executable( test_schema_v7_migration = executable(
'test_schema_v7_migration', 'test_schema_v7_migration',
'tests/test_schema_v7_migration.c', 'tests/test_schema_v7_migration.c',

View file

@ -1214,6 +1214,22 @@ static const char *const MIGRATE_V24_TO_V25_SQL =
"UNIQUE(resource_kind,target_id));" "UNIQUE(resource_kind,target_id));"
"PRAGMA user_version=25;COMMIT;"; "PRAGMA user_version=25;COMMIT;";
/* WHY: deleting a reusable plan must not erase imported definitions, source
* sessions, or preparations already derived from them. CONTRACT: v26 adds a
* terminal logical marker and a durable command ledger. INVARIANT: migration
* deletes nothing and marks no existing program as deleted. */
static const char *const MIGRATE_V25_TO_V26_SQL =
"BEGIN IMMEDIATE;"
"ALTER TABLE programs ADD COLUMN deleted_at TEXT;"
"CREATE TABLE program_deletions("
"program_id TEXT PRIMARY KEY REFERENCES programs(program_id) ON DELETE RESTRICT,"
"request_id TEXT NOT NULL UNIQUE,expected_revision TEXT NOT NULL,"
"deleted_revision TEXT NOT NULL,deleted_at TEXT NOT NULL,response_json TEXT NOT NULL,"
"generation_id TEXT,acknowledged_at TEXT);"
"CREATE INDEX program_deletions_pending ON program_deletions("
"generation_id,deleted_at,request_id) WHERE acknowledged_at IS NULL;"
"PRAGMA user_version=26;COMMIT;";
/* CONTRACT: migration fixtures may retain additive v25 columns while /* CONTRACT: migration fixtures may retain additive v25 columns while
* deliberately lowering user_version. Add each provenance column only when * deliberately lowering user_version. Add each provenance column only when
* absent; a genuine v24 database still receives both before opening returns. */ * absent; a genuine v24 database still receives both before opening returns. */
@ -2002,7 +2018,8 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
status = TRAINLOG_STATUS_OK; status = TRAINLOG_STATUS_OK;
} else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 || } else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 || version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25) { version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
version == 26) {
status = TRAINLOG_STATUS_OK; status = TRAINLOG_STATUS_OK;
} else { } else {
if (version == 1) { if (version == 1) {
@ -2135,6 +2152,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
status = ensure_v25_web_deletion_requests(database); status = ensure_v25_web_deletion_requests(database);
} }
if (status == TRAINLOG_STATUS_OK && version < 26) {
status = execute_sql(database, MIGRATE_V25_TO_V26_SQL);
}
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database); status = ensure_v18_ai_draft_publication_state(database);
} }
@ -2154,7 +2174,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status != TRAINLOG_STATUS_OK) { if (status != TRAINLOG_STATUS_OK) {
set_open_diagnostic(output_diagnostic, set_open_diagnostic(output_diagnostic,
output_diagnostic_capacity, output_diagnostic_capacity,
version == 0 ? "create schema v25" : "migrate database to schema v25", version == 0 ? "create schema v26" : "migrate database to schema v26",
database->connection, database->connection,
SQLITE_ERROR); SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -197,6 +197,14 @@ add_program_list_item(yyjson_mut_doc *document, yyjson_mut_val *items, sqlite3_s
document, item, "session_count", sqlite3_column_int64(statement, 5)) && document, item, "session_count", sqlite3_column_int64(statement, 5)) &&
add_column_text(document, item, "provenance", statement, 6) && add_column_text(document, item, "provenance", statement, 6) &&
add_column_text(document, item, "updated_at", statement, 7) && add_column_text(document, item, "updated_at", statement, 7) &&
add_column_text(document, item, "imported_at", statement, 8) &&
add_column_text(document, item, "revision_id", statement, 9) &&
yyjson_mut_obj_add_sint(
document, item, "preparation_count", sqlite3_column_int64(statement, 10)) &&
yyjson_mut_obj_add_strcpy(document,
item,
"usage",
sqlite3_column_int64(statement, 10) > 0 ? "used" : "unused") &&
yyjson_mut_arr_add_val(items, item); yyjson_mut_arr_add_val(items, item);
} }
@ -208,9 +216,14 @@ TrainlogStatus trainlog_web_programs_list_json(TrainlogDatabase *database,
char **output_json, char **output_json,
size_t *output_size) { size_t *output_size) {
static const char SQL[] = static const char SQL[] =
"SELECT p.program_id,p.title,p.state,p.start_date,p.end_date,COUNT(s.program_session_id)," "SELECT p.program_id,p.title,p.state,p.start_date,p.end_date,"
"p.source_format,p.updated_at FROM programs p LEFT JOIN program_sessions s ON " "COUNT(DISTINCT s.program_session_id),p.source_format,p.updated_at,p.created_at,"
"s.program_id=p.program_id WHERE (?1='' OR p.title LIKE '%'||?1||'%' COLLATE NOCASE) " "p.revision_id,COUNT(DISTINCT sp.preparation_id) "
"FROM programs p "
"LEFT JOIN program_sessions s ON s.program_id=p.program_id "
"LEFT JOIN session_preparations sp ON sp.source_program_id=p.program_id "
"WHERE p.deleted_at IS NULL AND "
"(?1='' OR p.title LIKE '%'||?1||'%' COLLATE NOCASE) "
"AND (?2='' OR p.state=?2) GROUP BY p.program_id ORDER BY CASE p.state WHEN 'active' " "AND (?2='' OR p.state=?2) GROUP BY p.program_id ORDER BY CASE p.state WHEN 'active' "
"THEN 0 ELSE 1 END,p.title COLLATE NOCASE,p.program_id LIMIT ?3 OFFSET ?4"; "THEN 0 ELSE 1 END,p.title COLLATE NOCASE,p.program_id LIMIT ?3 OFFSET ?4";
sqlite3_stmt *statement = NULL; sqlite3_stmt *statement = NULL;
@ -419,7 +432,7 @@ TrainlogStatus trainlog_web_programs_detail_json(TrainlogDatabase *database,
if (sqlite3_prepare_v2( if (sqlite3_prepare_v2(
database->connection, database->connection,
"SELECT program_id,title,note,state,start_date,end_date,created_at,updated_at," "SELECT program_id,title,note,state,start_date,end_date,created_at,updated_at,"
"revision_id,source_format,source_version,source_payload_sha256 " "revision_id,source_format,source_version,source_payload_sha256,deleted_at "
"FROM programs WHERE program_id=?1", "FROM programs WHERE program_id=?1",
-1, -1,
&statement, &statement,
@ -437,6 +450,11 @@ TrainlogStatus trainlog_web_programs_detail_json(TrainlogDatabase *database,
yyjson_mut_doc_free(document); yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_NOT_FOUND; return TRAINLOG_STATUS_NOT_FOUND;
} }
if (sqlite3_column_type(statement, 12) != SQLITE_NULL) {
(void)sqlite3_finalize(statement);
yyjson_mut_doc_free(document);
return TRAINLOG_STATUS_CONFLICT;
}
yyjson_mut_doc_set_root(document, root); yyjson_mut_doc_set_root(document, root);
(void)yyjson_mut_obj_add_uint(document, root, "api_version", 1U); (void)yyjson_mut_obj_add_uint(document, root, "api_version", 1U);
for (int column = 0; column < 12; ++column) { for (int column = 0; column < 12; ++column) {
@ -682,8 +700,10 @@ static TrainlogStatus program_insert_header(TrainlogDatabase *database,
const char *digest, const char *digest,
const char *now, const char *now,
const char *revision) { const char *revision) {
static const char SQL[] = "INSERT INTO programs " static const char SQL[] =
"VALUES(?1,?2,?3,?4,?5,?6,?7,?7,?8,'trainlog-program',1,?9)"; "INSERT INTO programs(program_id,title,note,state,start_date,end_date,created_at,"
"updated_at,revision_id,source_format,source_version,source_payload_sha256) "
"VALUES(?1,?2,?3,?4,?5,?6,?7,?7,?8,'trainlog-program',1,?9)";
sqlite3_stmt *statement = NULL; sqlite3_stmt *statement = NULL;
int result; int result;
@ -1138,7 +1158,8 @@ static TrainlogStatus program_apply_archive(TrainlogDatabase *database,
result = sqlite3_prepare_v2(database->connection, result = sqlite3_prepare_v2(database->connection,
"UPDATE programs SET state='archived',updated_at=?1,revision_id=?2 " "UPDATE programs SET state='archived',updated_at=?1,revision_id=?2 "
"WHERE program_id=?3 AND revision_id=?4 AND state='active'", "WHERE program_id=?3 AND revision_id=?4 AND state='active' AND "
"deleted_at IS NULL",
-1, -1,
&statement, &statement,
NULL); NULL);
@ -1189,7 +1210,9 @@ static TrainlogStatus program_store_archive_replay(TrainlogDatabase *database,
const char *now) { const char *now) {
sqlite3_stmt *statement = NULL; sqlite3_stmt *statement = NULL;
int result = sqlite3_prepare_v2(database->connection, int result = sqlite3_prepare_v2(database->connection,
"INSERT INTO program_requests VALUES(?1,'archive',?2,?3,?4)", "INSERT INTO program_requests("
"request_id,command,program_id,response_json,created_at) "
"VALUES(?1,'archive',?2,?3,?4)",
-1, -1,
&statement, &statement,
NULL); NULL);
@ -1260,6 +1283,213 @@ TrainlogStatus trainlog_web_programs_archive_json(TrainlogDatabase *database,
return TRAINLOG_STATUS_OK; return TRAINLOG_STATUS_OK;
} }
typedef enum ProgramDeleteReplay {
PROGRAM_DELETE_REPLAY_NONE = 0,
PROGRAM_DELETE_REPLAY_EXACT,
PROGRAM_DELETE_REPLAY_CONFLICT,
PROGRAM_DELETE_REPLAY_ERROR
} ProgramDeleteReplay;
static ProgramDeleteReplay program_replay_delete(TrainlogDatabase *database,
const char *program_id,
const char *expected_revision,
const char *request_id,
char **output_json,
size_t *output_size) {
sqlite3_stmt *statement = NULL;
ProgramDeleteReplay replay = PROGRAM_DELETE_REPLAY_ERROR;
int result;
result = sqlite3_prepare_v2(database->connection,
"SELECT program_id,expected_revision,response_json FROM "
"program_deletions WHERE request_id=?1",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
if (result == SQLITE_ROW) {
const char *known_program = (const char *)sqlite3_column_text(statement, 0);
const char *known_revision = (const char *)sqlite3_column_text(statement, 1);
const char *saved = (const char *)sqlite3_column_text(statement, 2);
if (known_program == NULL || known_revision == NULL || saved == NULL) {
replay = PROGRAM_DELETE_REPLAY_ERROR;
} else if (strcmp(known_program, program_id) != 0 ||
strcmp(known_revision, expected_revision) != 0) {
replay = PROGRAM_DELETE_REPLAY_CONFLICT;
} else {
*output_size = strlen(saved);
*output_json = strdup(saved);
replay =
*output_json == NULL ? PROGRAM_DELETE_REPLAY_ERROR : PROGRAM_DELETE_REPLAY_EXACT;
}
} else if (result == SQLITE_DONE) {
replay = PROGRAM_DELETE_REPLAY_NONE;
}
(void)sqlite3_finalize(statement);
if (replay != PROGRAM_DELETE_REPLAY_NONE) {
return replay;
}
result = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_requests WHERE request_id=?1",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, request_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement);
}
(void)sqlite3_finalize(statement);
if (result == SQLITE_ROW) {
return PROGRAM_DELETE_REPLAY_CONFLICT;
}
return result == SQLITE_DONE ? PROGRAM_DELETE_REPLAY_NONE : PROGRAM_DELETE_REPLAY_ERROR;
}
static TrainlogStatus program_build_delete_response(const char *program_id,
const char *revision,
const char *deleted_at,
char **output_json,
size_t *output_size) {
yyjson_mut_doc *response = yyjson_mut_doc_new(NULL);
yyjson_mut_val *root = response == NULL ? NULL : yyjson_mut_obj(response);
if (response == NULL || root == NULL) {
yyjson_mut_doc_free(response);
return TRAINLOG_STATUS_SYSTEM_ERROR;
}
yyjson_mut_doc_set_root(response, root);
(void)yyjson_mut_obj_add_uint(response, root, "api_version", 1U);
(void)yyjson_mut_obj_add_strcpy(response, root, "program_id", program_id);
(void)yyjson_mut_obj_add_strcpy(response, root, "deleted_at", deleted_at);
(void)yyjson_mut_obj_add_strcpy(response, root, "revision_id", revision);
return write_document(response, output_json, output_size);
}
TrainlogStatus trainlog_web_programs_delete_json(TrainlogDatabase *database,
const char *program_id,
const char *expected_revision,
const char *request_id,
char **output_json,
size_t *output_size) {
sqlite3_stmt *statement = NULL;
ProgramDeleteReplay replay;
char revision[80];
char now[32];
TrainlogStatus status = TRAINLOG_STATUS_OK;
int result;
if (database == NULL || program_id == NULL || program_id[0] == '\0' ||
expected_revision == NULL || expected_revision[0] == '\0' || request_id == NULL ||
request_id[0] == '\0' || strlen(request_id) > 128U || output_json == NULL ||
output_size == NULL || !timestamp_now(now) ||
trainlog_id_generate("pgr", revision, sizeof(revision)) != TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
*output_json = NULL;
*output_size = 0U;
if (sqlite3_exec(database->connection, "BEGIN IMMEDIATE", NULL, NULL, NULL) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
replay = program_replay_delete(
database, program_id, expected_revision, request_id, output_json, output_size);
if (replay == PROGRAM_DELETE_REPLAY_EXACT) {
(void)sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL);
return TRAINLOG_STATUS_OK;
}
if (replay == PROGRAM_DELETE_REPLAY_CONFLICT) {
status = TRAINLOG_STATUS_CONFLICT;
} else if (replay == PROGRAM_DELETE_REPLAY_ERROR) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
if (status == TRAINLOG_STATUS_OK) {
result =
sqlite3_prepare_v2(database->connection,
"UPDATE programs SET deleted_at=?1,updated_at=?1,revision_id=?2 "
"WHERE program_id=?3 AND revision_id=?4 AND deleted_at IS NULL",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, now, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, revision, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, program_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, expected_revision, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK : SQLITE_ERROR;
}
if (result == SQLITE_OK && sqlite3_changes(database->connection) != 1) {
status = TRAINLOG_STATUS_CONFLICT;
} else if (result != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)sqlite3_finalize(statement);
statement = NULL;
}
if (status == TRAINLOG_STATUS_OK) {
status = program_build_delete_response(program_id, revision, now, output_json, output_size);
}
if (status == TRAINLOG_STATUS_OK) {
result = sqlite3_prepare_v2(
database->connection,
"INSERT INTO program_deletions("
"program_id,request_id,expected_revision,deleted_revision,deleted_at,response_json) "
"VALUES(?1,?2,?3,?4,?5,?6)",
-1,
&statement,
NULL);
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 1, program_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 2, request_id, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 3, expected_revision, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 4, revision, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 5, now, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_bind_text(statement, 6, *output_json, -1, SQLITE_TRANSIENT);
}
if (result == SQLITE_OK) {
result = sqlite3_step(statement) == SQLITE_DONE ? SQLITE_OK : SQLITE_ERROR;
}
if (result != SQLITE_OK) {
status = TRAINLOG_STATUS_DATABASE_ERROR;
}
(void)sqlite3_finalize(statement);
}
if (status != TRAINLOG_STATUS_OK ||
sqlite3_exec(database->connection, "COMMIT", NULL, NULL, NULL) != SQLITE_OK) {
(void)sqlite3_exec(database->connection, "ROLLBACK", NULL, NULL, NULL);
free(*output_json);
*output_json = NULL;
*output_size = 0U;
return status == TRAINLOG_STATUS_OK ? TRAINLOG_STATUS_DATABASE_ERROR : status;
}
return TRAINLOG_STATUS_OK;
}
static yyjson_val *program_find_session(yyjson_val *root, const char *program_session_id) { static yyjson_val *program_find_session(yyjson_val *root, const char *program_session_id) {
yyjson_val *sessions = yyjson_obj_get(root, "sessions"); yyjson_val *sessions = yyjson_obj_get(root, "sessions");
size_t index; size_t index;
@ -1375,6 +1605,10 @@ TrainlogStatus trainlog_web_programs_prepare_json(TrainlogDatabase *database,
document = yyjson_read(detail, detail_size, YYJSON_READ_NOFLAG); document = yyjson_read(detail, detail_size, YYJSON_READ_NOFLAG);
free(detail); free(detail);
root = document == NULL ? NULL : yyjson_doc_get_root(document); root = document == NULL ? NULL : yyjson_doc_get_root(document);
if (root == NULL || !yyjson_equals_str(yyjson_obj_get(root, "state"), "active")) {
yyjson_doc_free(document);
return TRAINLOG_STATUS_CONFLICT;
}
session = root == NULL ? NULL : program_find_session(root, program_session_id); session = root == NULL ? NULL : program_find_session(root, program_session_id);
if (session == NULL) { if (session == NULL) {
yyjson_doc_free(document); yyjson_doc_free(document);

View file

@ -712,6 +712,46 @@ static enum MHD_Result handle_program_archive(TrainlogWebContext *context,
return queue_owned_programs_json(connection, status, json, json_size); return queue_owned_programs_json(connection, status, json, json_size);
} }
static enum MHD_Result handle_program_delete(TrainlogWebContext *context,
struct MHD_Connection *connection,
const char *identity,
size_t identity_length,
const char *method) {
static const char SUFFIX[] = "/delete";
const char *request_id =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, "X-Trainlog-Request-ID");
const char *if_match =
MHD_lookup_connection_value(connection, MHD_HEADER_KIND, MHD_HTTP_HEADER_IF_MATCH);
char program_id[TRAINLOG_ID_MAX + 1U];
char revision[TRAINLOG_ID_MAX + 1U];
char *json = NULL;
size_t json_size = 0U;
size_t program_length = identity_length - strlen(SUFFIX);
TrainlogStatus status;
if (strcmp(method, MHD_HTTP_METHOD_POST) != 0) {
return queue_json(connection,
MHD_HTTP_METHOD_NOT_ALLOWED,
"{\"error\":\"method_not_allowed\"}\n",
"POST");
}
if (!program_mutation_allowed(context, connection)) {
return queue_json(
connection, MHD_HTTP_FORBIDDEN, "{\"error\":\"mutation_forbidden\"}\n", NULL);
}
if (request_id == NULL || request_id[0] == '\0' || strlen(request_id) > 128U ||
!copy_path_component(program_id, sizeof(program_id), identity, program_length) ||
!parse_quoted_revision(if_match, revision, sizeof(revision))) {
return queue_json(connection,
MHD_HTTP_PRECONDITION_REQUIRED,
"{\"error\":\"precondition_required\"}\n",
NULL);
}
status = trainlog_web_programs_delete_json(
context->database, program_id, revision, request_id, &json, &json_size);
return queue_owned_programs_json(connection, status, json, json_size);
}
static enum MHD_Result handle_program_prepare(TrainlogWebContext *context, static enum MHD_Result handle_program_prepare(TrainlogWebContext *context,
struct MHD_Connection *connection, struct MHD_Connection *connection,
const char *identity, const char *identity,
@ -759,6 +799,7 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
TrainlogHttpRequestState *request) { TrainlogHttpRequestState *request) {
static const char PREFIX[] = "/api/v1/sessions/program/"; static const char PREFIX[] = "/api/v1/sessions/program/";
static const char ARCHIVE_SUFFIX[] = "/archive"; static const char ARCHIVE_SUFFIX[] = "/archive";
static const char DELETE_SUFFIX[] = "/delete";
const char *identity; const char *identity;
const char *sessions_marker; const char *sessions_marker;
char *json = NULL; char *json = NULL;
@ -783,6 +824,10 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) { strcmp(identity + identity_length - strlen(ARCHIVE_SUFFIX), ARCHIVE_SUFFIX) == 0) {
return handle_program_archive(context, connection, identity, identity_length, method); return handle_program_archive(context, connection, identity, identity_length, method);
} }
if (identity_length > strlen(DELETE_SUFFIX) &&
strcmp(identity + identity_length - strlen(DELETE_SUFFIX), DELETE_SUFFIX) == 0) {
return handle_program_delete(context, connection, identity, identity_length, method);
}
if (sessions_marker != NULL) { if (sessions_marker != NULL) {
return handle_program_prepare(context, connection, identity, sessions_marker, method); return handle_program_prepare(context, connection, identity, sessions_marker, method);
} }
@ -791,6 +836,10 @@ static enum MHD_Result handle_program_request(TrainlogWebContext *context,
connection, MHD_HTTP_METHOD_NOT_ALLOWED, "{\"error\":\"method_not_allowed\"}\n", "GET"); connection, MHD_HTTP_METHOD_NOT_ALLOWED, "{\"error\":\"method_not_allowed\"}\n", "GET");
} }
status = trainlog_web_programs_detail_json(context->database, identity, &json, &json_size); status = trainlog_web_programs_detail_json(context->database, identity, &json, &json_size);
if (status == TRAINLOG_STATUS_CONFLICT) {
free(json);
return queue_json(connection, MHD_HTTP_GONE, "{\"error\":\"program_deleted\"}\n", NULL);
}
return queue_owned_programs_json(connection, status, json, json_size); return queue_owned_programs_json(connection, status, json, json_size);
} }

View file

@ -732,7 +732,8 @@ static bool program_session_exists(TrainlogDatabase *database,
result = sqlite3_prepare_v2(database->connection, result = sqlite3_prepare_v2(database->connection,
"SELECT 1 FROM program_sessions s JOIN programs p ON " "SELECT 1 FROM program_sessions s JOIN programs p ON "
"p.program_id=s.program_id WHERE p.program_id=?1 AND " "p.program_id=s.program_id WHERE p.program_id=?1 AND "
"s.program_session_id=?2", "s.program_session_id=?2 AND p.state='active' AND "
"p.deleted_at IS NULL",
-1, -1,
&statement, &statement,
NULL); NULL);

View file

@ -1,47 +1,222 @@
/* Test-only producer fixture through the public desktop persistence API. */ /* Test-only producer fixture through the public desktop persistence API. */
#include <sqlite3.h>
#include <stdio.h> #include <stdio.h>
#include <stdlib.h>
#include <string.h> #include <string.h>
#include "trainlog/database.h" #include "trainlog/database.h"
#include "trainlog/web_programs.h"
int main(int argc, char **argv) static const char PROGRAM_JSON[] =
{ "{\"format\":\"trainlog-program\",\"version\":1,\"program\":{"
TrainlogDatabase *database = NULL; "\"program_id\":\"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\","
TrainlogSetInput set = { .reps = 9, .has_weight = true, .weight_kg = 32.5 }; "\"title\":\"Generation program\",\"note\":null,\"state\":\"active\","
"\"start_date\":\"2026-09-21\",\"end_date\":null,\"sessions\":[{"
"\"program_session_id\":\"pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb\","
"\"title\":\"Session A\",\"session_type\":\"training\","
"\"planned_for\":null,\"note\":null,\"occurrences\":[{"
"\"entry_id\":\"pge_cccccccc-cccc-4ccc-8ccc-cccccccccccc\","
"\"exercise_id\":\"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd\","
"\"equipment_id\":null,\"load_mode\":\"external\",\"rest_seconds\":90,"
"\"target_sets\":3,\"target_reps\":8,\"target_duration_seconds\":null,"
"\"target_weight_kg\":40.0,\"notes\":null}]}]}}";
static int create_program(TrainlogDatabase *database) {
char *response = NULL;
size_t response_size = 0U;
TrainlogStatus status;
status = trainlog_database_insert_exercise_profiled(database,
"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd",
"Program exercise fixture",
"program exercise fixture",
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS,
0U);
if (status != TRAINLOG_STATUS_OK) {
return 3;
}
status = trainlog_web_programs_import_json(
database, PROGRAM_JSON, strlen(PROGRAM_JSON), true, &response, &response_size);
free(response);
response = NULL;
if (status != TRAINLOG_STATUS_OK ||
trainlog_web_programs_detail_json(
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) !=
TRAINLOG_STATUS_OK) {
free(response);
return 4;
}
(void)printf("PROGRAM_CREATE=%s\n", response);
free(response);
return 0;
}
static int
delete_program(TrainlogDatabase *database, const char *expected_revision, const char *request_id) {
char *response = NULL;
size_t response_size = 0U;
TrainlogStatus status =
trainlog_web_programs_delete_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
expected_revision,
request_id,
&response,
&response_size);
if (status != TRAINLOG_STATUS_OK) {
free(response);
return 5;
}
(void)printf("PROGRAM_DELETE=%s\n", response);
free(response);
return 0;
}
static int create_session_fixture(TrainlogDatabase *database) {
TrainlogSetInput set = {.reps = 9, .has_weight = true, .weight_kg = 32.5};
TrainlogSessionExerciseInput exercise; TrainlogSessionExerciseInput exercise;
TrainlogSessionInput session; TrainlogSessionInput session;
if (argc != 2 || trainlog_database_open(argv[1], &database) != TRAINLOG_STATUS_OK) { if (trainlog_database_insert_exercise_profiled(database,
return 2; "ex_77777777-7777-4777-8777-777777777777",
} "Desktop generation fixture",
if (trainlog_database_insert_exercise_profiled( "desktop generation fixture",
database, "ex_77777777-7777-4777-8777-777777777777", TRAINLOG_TRACKING_REPS,
"Desktop generation fixture", "desktop generation fixture", TRAINLOG_RECORDING_SETS,
TRAINLOG_TRACKING_REPS, TRAINLOG_RECORDING_SETS, 0U) != TRAINLOG_STATUS_OK) { 0U) != TRAINLOG_STATUS_OK) {
trainlog_database_close(database); return 3; return 3;
} }
(void)memset(&exercise, 0, sizeof(exercise)); (void)memset(&exercise, 0, sizeof(exercise));
(void)snprintf(exercise.entry_id, sizeof(exercise.entry_id), "%s", (void)snprintf(exercise.entry_id,
"sxe_88888888-8888-4888-8888-888888888888"); sizeof(exercise.entry_id),
(void)snprintf(exercise.exercise_id, sizeof(exercise.exercise_id), "%s", "%s",
"ex_77777777-7777-4777-8777-777777777777"); "sxe_88888888-8888-4888-8888-888888888888");
(void)snprintf(exercise.exercise_id,
sizeof(exercise.exercise_id),
"%s",
"ex_77777777-7777-4777-8777-777777777777");
exercise.recording_mode = TRAINLOG_RECORDING_SETS; exercise.recording_mode = TRAINLOG_RECORDING_SETS;
exercise.tracking_mode = TRAINLOG_TRACKING_REPS; exercise.tracking_mode = TRAINLOG_TRACKING_REPS;
exercise.sets = &set; exercise.sets = &set;
exercise.set_count = 1U; exercise.set_count = 1U;
(void)memset(&session, 0, sizeof(session)); (void)memset(&session, 0, sizeof(session));
(void)snprintf(session.session_id, sizeof(session.session_id), "%s", (void)snprintf(session.session_id,
"se_99999999-9999-4999-8999-999999999999"); sizeof(session.session_id),
(void)snprintf(session.started_at, sizeof(session.started_at), "%s", "%s",
"2026-09-17T10:00:00+02:00"); "se_99999999-9999-4999-8999-999999999999");
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", (void)snprintf(
"2026-09-17T10:30:00+02:00"); session.started_at, sizeof(session.started_at), "%s", "2026-09-17T10:00:00+02:00");
(void)snprintf(session.ended_at, sizeof(session.ended_at), "%s", "2026-09-17T10:30:00+02:00");
session.exercises = &exercise; session.exercises = &exercise;
session.exercise_count = 1U; session.exercise_count = 1U;
if (trainlog_database_insert_session(database, &session) != TRAINLOG_STATUS_OK) { if (trainlog_database_insert_session(database, &session) != TRAINLOG_STATUS_OK) {
trainlog_database_close(database); return 4; return 4;
} }
trainlog_database_close(database);
(void)puts("SYNC_GENERATION_FIXTURE=PASS"); (void)puts("SYNC_GENERATION_FIXTURE=PASS");
return 0; return 0;
} }
static int
check_integrity(sqlite3 *connection, bool *output_integrity_ok, bool *output_foreign_keys_ok) {
sqlite3_stmt *statement = NULL;
const unsigned char *integrity_result;
int rc;
*output_integrity_ok = false;
*output_foreign_keys_ok = false;
rc = sqlite3_prepare_v2(connection, "PRAGMA integrity_check;", -1, &statement, NULL);
if (rc != SQLITE_OK) {
return 1;
}
rc = sqlite3_step(statement);
integrity_result = rc == SQLITE_ROW ? sqlite3_column_text(statement, 0) : NULL;
*output_integrity_ok =
integrity_result != NULL && strcmp((const char *)integrity_result, "ok") == 0;
if (sqlite3_finalize(statement) != SQLITE_OK || !*output_integrity_ok) {
return 1;
}
rc = sqlite3_prepare_v2(connection, "PRAGMA foreign_key_check;", -1, &statement, NULL);
if (rc != SQLITE_OK) {
return 1;
}
rc = sqlite3_step(statement);
*output_foreign_keys_ok = rc == SQLITE_DONE;
if (sqlite3_finalize(statement) != SQLITE_OK || !*output_foreign_keys_ok) {
return 1;
}
return 0;
}
static int migrate_only(const char *path) {
TrainlogDatabase *database = NULL;
char diagnostic[256];
sqlite3 *inspection = NULL;
bool integrity_ok;
bool foreign_key_check_ok;
int foreign_keys_enabled;
int schema_version;
int result = 2;
if (trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) !=
TRAINLOG_STATUS_OK) {
return result;
}
if (trainlog_database_schema_version(database, &schema_version) != TRAINLOG_STATUS_OK ||
trainlog_database_foreign_keys_enabled(database, &foreign_keys_enabled) !=
TRAINLOG_STATUS_OK) {
goto cleanup;
}
trainlog_database_close(database);
database = NULL;
if (sqlite3_open_v2(path, &inspection, SQLITE_OPEN_READONLY | SQLITE_OPEN_FULLMUTEX, NULL) !=
SQLITE_OK) {
goto cleanup;
}
if (check_integrity(inspection, &integrity_ok, &foreign_key_check_ok) != 0) {
goto cleanup;
}
(void)printf(
"MIGRATE_ONLY schema_version=%d integrity=%s foreign_keys=%s foreign_key_check=%s\n",
schema_version,
integrity_ok ? "ok" : "failed",
foreign_keys_enabled != 0 ? "enabled" : "disabled",
foreign_key_check_ok ? "ok" : "failed");
result = 0;
cleanup:
if (inspection != NULL) {
(void)sqlite3_close(inspection);
}
if (database != NULL) {
trainlog_database_close(database);
}
return result;
}
int main(int argc, char **argv) {
TrainlogDatabase *database = NULL;
int result;
if (argc == 3 && strcmp(argv[2], "migrate-only") == 0) {
return migrate_only(argv[1]);
}
if ((argc != 2 && argc != 3 && argc != 5) ||
trainlog_database_open(argv[1], &database) != TRAINLOG_STATUS_OK) {
return 2;
}
if (argc == 2) {
result = create_session_fixture(database);
} else if (argc == 3 && strcmp(argv[2], "program-create") == 0) {
result = create_program(database);
} else if (argc == 5 && strcmp(argv[2], "program-delete") == 0) {
result = delete_program(database, argv[3], argv[4]);
} else {
result = 2;
}
trainlog_database_close(database);
return result;
}

View file

@ -30,6 +30,7 @@ static int scalar(sqlite3 *database, const char *sql) {
int main(void) { int main(void) {
static const char DOWNGRADE_TO_V20[] = "PRAGMA foreign_keys=OFF;" static const char DOWNGRADE_TO_V20[] = "PRAGMA foreign_keys=OFF;"
"DROP TABLE web_session_deletion_requests;" "DROP TABLE web_session_deletion_requests;"
"DROP TABLE program_deletions;"
"DROP TABLE program_requests;" "DROP TABLE program_requests;"
"DROP TABLE program_session_entries;" "DROP TABLE program_session_entries;"
"DROP TABLE program_sessions;" "DROP TABLE program_sessions;"
@ -53,6 +54,7 @@ int main(void) {
TrainlogDatabase *database = NULL; TrainlogDatabase *database = NULL;
sqlite3 *raw = NULL; sqlite3 *raw = NULL;
size_t exercises = 0U; size_t exercises = 0U;
char diagnostic[256];
CHECK(file_descriptor >= 0); CHECK(file_descriptor >= 0);
CHECK(close(file_descriptor) == 0); CHECK(close(file_descriptor) == 0);
@ -70,7 +72,11 @@ int main(void) {
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, DOWNGRADE_TO_V20, NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_exec(raw, DOWNGRADE_TO_V20, NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open(path, &database) == TRAINLOG_STATUS_OK); if (trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) !=
TRAINLOG_STATUS_OK) {
(void)fprintf(stderr, "migration diagnostic: %s\n", diagnostic);
return 1;
}
CHECK(trainlog_database_exercise_count(database, &exercises) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_exercise_count(database, &exercises) == TRAINLOG_STATUS_OK);
CHECK(exercises > 0U); CHECK(exercises > 0U);
trainlog_database_close(database); trainlog_database_close(database);

View file

@ -0,0 +1,488 @@
/**
* @file test_schema_v26_migration.c
* @brief Lossless populated v25 to v26 migration evidence.
*/
#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)
typedef struct Projection {
char *text;
size_t length;
size_t capacity;
} Projection;
typedef struct ProjectionSpec {
const char *label;
const char *sql;
} ProjectionSpec;
static bool projection_append(Projection *projection, const char *value, size_t length) {
size_t required;
size_t capacity;
char *resized;
if (length > SIZE_MAX - projection->length - 1U) {
return false;
}
required = projection->length + length + 1U;
if (required > projection->capacity) {
capacity = projection->capacity == 0U ? 4096U : projection->capacity;
while (capacity < required) {
if (capacity > SIZE_MAX / 2U) {
return false;
}
capacity *= 2U;
}
resized = realloc(projection->text, capacity);
if (resized == NULL) {
return false;
}
projection->text = resized;
projection->capacity = capacity;
}
(void)memcpy(projection->text + projection->length, value, length);
projection->length += length;
projection->text[projection->length] = '\0';
return true;
}
static bool projection_append_value(Projection *projection, sqlite3_stmt *statement, int column) {
char number[128];
const unsigned char *text;
int length;
int written;
switch (sqlite3_column_type(statement, column)) {
case SQLITE_NULL:
return projection_append(projection, "N;", 2U);
case SQLITE_INTEGER:
written = snprintf(
number, sizeof(number), "I%lld;", (long long)sqlite3_column_int64(statement, column));
return written > 0 && (size_t)written < sizeof(number) &&
projection_append(projection, number, (size_t)written);
case SQLITE_FLOAT:
written =
snprintf(number, sizeof(number), "F%.17g;", sqlite3_column_double(statement, column));
return written > 0 && (size_t)written < sizeof(number) &&
projection_append(projection, number, (size_t)written);
case SQLITE_TEXT:
text = sqlite3_column_text(statement, column);
length = sqlite3_column_bytes(statement, column);
written = snprintf(number, sizeof(number), "T%d:", length);
return text != NULL && length >= 0 && written > 0 && (size_t)written < sizeof(number) &&
projection_append(projection, number, (size_t)written) &&
projection_append(projection, (const char *)text, (size_t)length) &&
projection_append(projection, ";", 1U);
default:
return false;
}
}
/* WHY: row counts cannot prove that nullable targets, floating-point loads, or
* ordered identities survived. CONTRACT: this helper records SQLite types,
* byte lengths, values, columns, and row boundaries for an explicitly ordered
* business query. INVARIANT: equal projections imply field-for-field equality. */
static bool capture_projection(sqlite3 *database,
const ProjectionSpec *specifications,
size_t specification_count,
Projection *projection) {
sqlite3_stmt *statement = NULL;
size_t index;
int column;
int result;
for (index = 0U; index < specification_count; ++index) {
if (!projection_append(
projection, specifications[index].label, strlen(specifications[index].label)) ||
!projection_append(projection, "\n", 1U) ||
sqlite3_prepare_v2(database, specifications[index].sql, -1, &statement, NULL) !=
SQLITE_OK) {
(void)sqlite3_finalize(statement);
return false;
}
while ((result = sqlite3_step(statement)) == SQLITE_ROW) {
for (column = 0; column < sqlite3_column_count(statement); ++column) {
if (!projection_append_value(projection, statement, column)) {
(void)sqlite3_finalize(statement);
return false;
}
}
if (!projection_append(projection, "\n", 1U)) {
(void)sqlite3_finalize(statement);
return false;
}
}
if (result != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
statement = NULL;
return false;
}
statement = NULL;
}
return true;
}
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 ? "no diagnostic" : 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 insert_program_sessions(sqlite3 *database) {
static const char INSERT_SESSION[] =
"INSERT INTO program_sessions(program_session_id,program_id,position,title,session_type,"
"planned_for,note) VALUES(?1,'pg_fixture',?2,?3,?4,?5,?6)";
static const char INSERT_ENTRY[] =
"INSERT INTO program_session_entries(program_session_id,entry_id,position,exercise_id,"
"equipment_id,recording_mode,tracking_mode,data_fields,load_mode,rest_seconds,target_sets,"
"target_reps,target_duration_seconds,target_weight_kg,notes)"
" VALUES(?1,?2,?3,?4,?5,'sets',?6,?7,?8,?9,?10,?11,?12,?13,?14)";
sqlite3_stmt *session = NULL;
sqlite3_stmt *entry = NULL;
char session_id[32];
char entry_id[32];
char title[64];
char planned_for[32];
char note[96];
int position;
int occurrence;
bool ok = false;
if (sqlite3_prepare_v2(database, INSERT_SESSION, -1, &session, NULL) != SQLITE_OK ||
sqlite3_prepare_v2(database, INSERT_ENTRY, -1, &entry, NULL) != SQLITE_OK) {
goto cleanup;
}
for (position = 0; position < 24; ++position) {
(void)snprintf(session_id, sizeof(session_id), "pgs_fixture_%02d", position + 1);
(void)snprintf(
title, sizeof(title), "Week %d · Session %d", position / 3 + 1, position + 1);
(void)snprintf(planned_for, sizeof(planned_for), "2026-10-%02dT18:00:00Z", position + 1);
(void)snprintf(
note, sizeof(note), "Progression block %d, session %d", position / 6 + 1, position + 1);
(void)sqlite3_bind_text(session, 1, session_id, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_int(session, 2, position);
(void)sqlite3_bind_text(session, 3, title, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_text(
session, 4, position == 23 ? "max_test" : "training", -1, SQLITE_STATIC);
(void)sqlite3_bind_text(session, 5, planned_for, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_text(session, 6, note, -1, SQLITE_TRANSIENT);
if (sqlite3_step(session) != SQLITE_DONE || sqlite3_reset(session) != SQLITE_OK ||
sqlite3_clear_bindings(session) != SQLITE_OK) {
goto cleanup;
}
for (occurrence = 0; occurrence < 2; ++occurrence) {
(void)snprintf(entry_id, sizeof(entry_id), "pge_%02d_%d", position + 1, occurrence + 1);
(void)snprintf(note,
sizeof(note),
occurrence == 0 ? "Controlled eccentric, RPE %d" : "Timed brace, RPE %d",
7 + position % 3);
(void)sqlite3_bind_text(entry, 1, session_id, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_text(entry, 2, entry_id, -1, SQLITE_TRANSIENT);
(void)sqlite3_bind_int(entry, 3, occurrence);
(void)sqlite3_bind_text(entry,
4,
occurrence == 0 ? "ex_fixture_press" : "ex_fixture_hold",
-1,
SQLITE_STATIC);
(void)sqlite3_bind_text(entry,
5,
occurrence == 0 ? "eq_fixture_stack" : "eq_fixture_mat",
-1,
SQLITE_STATIC);
(void)sqlite3_bind_text(
entry, 6, occurrence == 0 ? "reps" : "duration", -1, SQLITE_STATIC);
(void)sqlite3_bind_int(entry, 7, occurrence == 0 ? 1 : 2);
(void)sqlite3_bind_text(
entry, 8, occurrence == 0 ? "external" : "none", -1, SQLITE_STATIC);
(void)sqlite3_bind_int(entry, 9, occurrence == 0 ? 90 + position : 45 + position);
(void)sqlite3_bind_int(entry, 10, occurrence == 0 ? 4 : 3);
if (occurrence == 0) {
(void)sqlite3_bind_int(entry, 11, 8 + position % 5);
(void)sqlite3_bind_null(entry, 12);
(void)sqlite3_bind_double(entry, 13, 42.5 + (double)position * 1.25);
} else {
(void)sqlite3_bind_null(entry, 11);
(void)sqlite3_bind_int(entry, 12, 30 + position);
(void)sqlite3_bind_null(entry, 13);
}
(void)sqlite3_bind_text(entry, 14, note, -1, SQLITE_TRANSIENT);
if (sqlite3_step(entry) != SQLITE_DONE || sqlite3_reset(entry) != SQLITE_OK ||
sqlite3_clear_bindings(entry) != SQLITE_OK) {
goto cleanup;
}
}
}
ok = true;
cleanup:
(void)sqlite3_finalize(entry);
(void)sqlite3_finalize(session);
return ok;
}
/* CONTRACT: construct a populated v25 database without consulting any user
* path. The current initializer supplies all older schema objects; removing
* exactly the v26 additions recreates the immediate predecessor boundary. */
static bool build_v25_fixture(const char *path) {
TrainlogDatabase *production = NULL;
sqlite3 *database = NULL;
bool ok = false;
if (trainlog_database_open(path, &production) != TRAINLOG_STATUS_OK) {
return false;
}
if (trainlog_database_insert_exercise_profiled(production,
"ex_fixture_press",
"Cable press",
"cable press",
TRAINLOG_TRACKING_REPS,
TRAINLOG_RECORDING_SETS,
1U) != TRAINLOG_STATUS_OK ||
trainlog_database_insert_exercise_profiled(production,
"ex_fixture_hold",
"Pallof hold",
"pallof hold",
TRAINLOG_TRACKING_DURATION,
TRAINLOG_RECORDING_SETS,
2U) != TRAINLOG_STATUS_OK) {
trainlog_database_close(production);
return false;
}
trainlog_database_close(production);
if (sqlite3_open(path, &database) != SQLITE_OK ||
!execute(database,
"PRAGMA foreign_keys=OFF;"
"DROP INDEX program_deletions_pending;"
"DROP TABLE program_deletions;"
"ALTER TABLE programs DROP COLUMN deleted_at;"
"PRAGMA user_version=25;"
"PRAGMA foreign_keys=ON;") ||
!execute(
database,
"BEGIN IMMEDIATE;"
"INSERT INTO custom_equipment VALUES"
"('eq_fixture_stack','Cable stack','Stack','machine','external'),"
"('eq_fixture_mat','Exercise mat','Mat','surface','none');"
"INSERT INTO sessions(id,session_id,started_at,ended_at,session_type,notes) VALUES"
"(201,'se_fixture_history','2026-09-12T17:00:00Z','2026-09-12T17:52:00Z',"
"'training','Completed history sentinel');"
"INSERT INTO session_exercises(id,entry_id,session_row_id,exercise_row_id,"
"recording_mode,data_fields,position,load_mode,rest_seconds,target_sets,target_reps,"
"target_duration_seconds,target_weight_kg,equipment_id,notes,tracking_mode) VALUES"
"(301,'sxe_fixture_press',201,(SELECT id FROM exercises WHERE "
"exercise_id='ex_fixture_press'),'sets',1,0,'external',105,4,10,NULL,47.5,"
"'eq_fixture_stack','Historic occurrence sentinel','reps'),"
"(302,'sxe_fixture_hold',201,(SELECT id FROM exercises WHERE "
"exercise_id='ex_fixture_hold'),'sets',2,1,'none',45,3,NULL,35,NULL,"
"'eq_fixture_mat','Historic duration sentinel','duration');"
"INSERT INTO performed_sets(id,session_exercise_row_id,position,reps,duration_seconds,"
"weight_kg) VALUES(401,301,0,10,NULL,47.5),(402,301,1,9,NULL,47.5),"
"(403,302,0,NULL,35,NULL);"
"INSERT INTO body_observations(id,observation_id,observed_at,session_row_id,"
"body_weight_kg,waist_cm,left_arm_cm,right_arm_cm,notes) VALUES"
"(501,'bo_fixture','2026-09-12T16:55:00Z',201,78.4,82.1,35.2,35.4,"
"'Historical body sentinel');"
"INSERT INTO programs(program_id,title,note,state,start_date,end_date,created_at,"
"updated_at,revision_id,source_format,source_version,source_payload_sha256) VALUES"
"('pg_fixture','Eight-week strength progression','Imported coach plan','active',"
"'2026-10-01','2026-11-30','2026-09-15T08:00:00Z','2026-09-16T09:30:00Z',"
"'pgr_fixture_2','trainlog-program',1,"
"'0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef');") ||
!insert_program_sessions(database) ||
!execute(
database,
"INSERT INTO session_preparations(preparation_id,current_revision_id,created_at,"
"updated_at,editing_state,delivery_state,source_proposal_id,source_payload_sha256,"
"withdrawn_at,source_program_id,source_program_session_id) VALUES"
"('prep_fixture','prepr_fixture_2','2026-09-17T08:00:00Z',"
"'2026-09-17T08:15:00Z','ready','local',NULL,NULL,NULL,'pg_fixture','pgs_fixture_07');"
"INSERT INTO session_preparation_revisions(revision_id,preparation_id,"
"parent_revision_id,title,session_type,planned_for,notes,created_at) VALUES"
"('prepr_fixture_1','prep_fixture',NULL,'Week 3 · Session 7','training',"
"'2026-10-07T18:00:00Z','Initial Program-derived revision','2026-09-17T08:00:00Z'),"
"('prepr_fixture_2','prep_fixture','prepr_fixture_1','Week 3 · Session 7 adjusted',"
"'training','2026-10-07T18:30:00Z','Adjusted after review','2026-09-17T08:15:00Z');"
"INSERT INTO session_preparation_entries(revision_id,entry_id,position,exercise_id,"
"equipment_id,recording_mode,tracking_mode,data_fields,load_mode,rest_seconds,"
"target_sets,target_reps,target_duration_seconds,target_weight_kg,notes) VALUES"
"('prepr_fixture_1','prepe_fixture_1a',0,'ex_fixture_press','eq_fixture_stack',"
"'sets','reps',1,'external',96,4,10,NULL,50.0,'Program provenance press'),"
"('prepr_fixture_1','prepe_fixture_1b',1,'ex_fixture_hold','eq_fixture_mat',"
"'sets','duration',2,'none',51,3,NULL,36,NULL,'Program provenance hold'),"
"('prepr_fixture_2','prepe_fixture_2a',0,'ex_fixture_press','eq_fixture_stack',"
"'sets','reps',1,'external',100,5,8,NULL,52.5,'Adjusted press'),"
"('prepr_fixture_2','prepe_fixture_2b',1,'ex_fixture_hold','eq_fixture_mat',"
"'sets','duration',2,'none',55,3,NULL,40,NULL,'Adjusted hold');"
"COMMIT;") ||
scalar(database, "PRAGMA user_version") != 25 ||
scalar(database, "SELECT COUNT(*) FROM program_sessions") != 24 ||
scalar(database, "SELECT COUNT(*) FROM program_session_entries") != 48 ||
scalar(database, "SELECT COUNT(*) FROM pragma_foreign_key_check") != 0) {
goto cleanup;
}
ok = true;
cleanup:
if (database != NULL && sqlite3_close(database) != SQLITE_OK) {
ok = false;
}
return ok;
}
static int run_test(void) {
static const ProjectionSpec BUSINESS_PROJECTIONS[] = {
{"programs",
"SELECT program_id,title,note,state,start_date,end_date,created_at,updated_at,revision_id,"
"source_format,source_version,source_payload_sha256 FROM programs ORDER BY program_id"},
{"program_sessions",
"SELECT * FROM program_sessions ORDER BY program_id,position,program_session_id"},
{"program_session_entries",
"SELECT * FROM program_session_entries ORDER BY program_session_id,position,entry_id"},
{"session_preparations", "SELECT * FROM session_preparations ORDER BY preparation_id"},
{"session_preparation_revisions",
"SELECT * FROM session_preparation_revisions ORDER BY "
"preparation_id,created_at,revision_id"},
{"session_preparation_entries",
"SELECT * FROM session_preparation_entries ORDER BY revision_id,position,entry_id"},
{"exercises", "SELECT * FROM exercises ORDER BY id"},
{"custom_equipment", "SELECT * FROM custom_equipment ORDER BY equipment_id"},
{"sessions", "SELECT * FROM sessions ORDER BY id"},
{"session_exercises", "SELECT * FROM session_exercises ORDER BY id"},
{"performed_sets", "SELECT * FROM performed_sets ORDER BY id"},
{"body_observations", "SELECT * FROM body_observations ORDER BY id"},
{"profile_state", "SELECT * FROM exercise_profile_state ORDER BY exercise_row_id"},
{"profile_revisions",
"SELECT * FROM exercise_profile_revisions ORDER BY exercise_row_id,revision_id"},
};
static const ProjectionSpec UNCHANGED_SCHEMA[] = {
{"schema",
"SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' "
"AND name NOT IN('programs','program_deletions','program_deletions_pending') "
"ORDER BY type,name"},
};
char path[] = "/tmp/trainlog-v26-populated-XXXXXX";
char diagnostic[512] = {0};
int file_descriptor = -1;
sqlite3 *raw = NULL;
TrainlogDatabase *database = NULL;
Projection business_before = {0};
Projection business_after = {0};
Projection schema_before = {0};
Projection schema_after = {0};
int version = 0;
int schema_object_count_before = 0;
bool ok = false;
file_descriptor = mkstemp(path);
CHECK(file_descriptor >= 0);
CHECK(close(file_descriptor) == 0);
file_descriptor = -1;
CHECK(build_v25_fixture(path));
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 25);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('programs')") == 12);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE name IN('program_deletions',"
"'program_deletions_pending')") == 0);
schema_object_count_before =
scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'");
CHECK(schema_object_count_before > 0);
CHECK(capture_projection(raw,
BUSINESS_PROJECTIONS,
sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]),
&business_before));
CHECK(capture_projection(raw, UNCHANGED_SCHEMA, 1U, &schema_before));
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
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 == 26);
trainlog_database_close(database);
database = NULL;
CHECK(sqlite3_open_v2(path, &raw, SQLITE_OPEN_READONLY, NULL) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 26);
CHECK(capture_projection(raw,
BUSINESS_PROJECTIONS,
sizeof(BUSINESS_PROJECTIONS) / sizeof(BUSINESS_PROJECTIONS[0]),
&business_after));
CHECK(capture_projection(raw, UNCHANGED_SCHEMA, 1U, &schema_after));
CHECK(business_before.length == business_after.length);
CHECK(memcmp(business_before.text, business_after.text, business_before.length) == 0);
CHECK(schema_before.length == schema_after.length);
CHECK(memcmp(schema_before.text, schema_after.text, schema_before.length) == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") ==
1);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM programs WHERE deleted_at IS NOT NULL") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM program_deletions") == 0);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM pragma_table_info('programs') WHERE name='deleted_at' AND "
"type='TEXT' AND \"notnull\"=0") == 1);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE (type='table' AND "
"name='program_deletions') OR (type='index' AND "
"name='program_deletions_pending')") == 2);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_table_info('programs')") == 13);
CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE name NOT LIKE 'sqlite_%'") ==
schema_object_count_before + 2);
ok = true;
cleanup:
if (!ok && diagnostic[0] != '\0') {
(void)fprintf(stderr, "migration diagnostic: %s\n", diagnostic);
}
free(schema_after.text);
free(schema_before.text);
free(business_after.text);
free(business_before.text);
if (raw != NULL) {
(void)sqlite3_close(raw);
}
if (database != NULL) {
trainlog_database_close(database);
}
if (file_descriptor >= 0) {
(void)close(file_descriptor);
}
if (unlink(path) != 0) {
ok = false;
}
return ok ? 0 : 1;
}
int main(void) {
return run_test();
}

View file

@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
TRAINLOG_STATUS_DATABASE_ERROR); TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strncmp(diagnostic, CHECK(strncmp(diagnostic,
"migrate database to schema v25: SQLite rc=", "migrate database to schema v26: SQLite rc=",
strlen("migrate database to schema v25: SQLite rc=")) == 0); strlen("migrate database to schema v26: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL);
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
CHECK(fd >= 0); CHECK(fd >= 0);
CHECK(close(fd) == 0); CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=26;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_exec(raw, "PRAGMA user_version=27;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 26 is newer") != NULL); CHECK(strstr(diagnostic, "schema version 27 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0); CHECK(unlink(path) == 0);
return true; 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)) == CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_DATABASE_ERROR); TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v25") != NULL); CHECK(strstr(diagnostic, "migrate database to schema v26") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);

View file

@ -193,6 +193,13 @@ static bool import_archive_and_prepare(void) {
char *second_entry; char *second_entry;
CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_open(":memory:", &database) == TRAINLOG_STATUS_OK);
CHECK(scalar(database, "PRAGMA user_version") == 26);
CHECK(scalar(database,
"SELECT COUNT(*) FROM pragma_table_info('program_deletions') WHERE "
"name IN('generation_id','acknowledged_at')") == 2);
CHECK(scalar(database,
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND "
"name='program_deletions_pending'") == 1);
CHECK(trainlog_database_insert_exercise_profiled(database, CHECK(trainlog_database_insert_exercise_profiled(database,
"ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd", "ex_dddddddd-dddd-4ddd-8ddd-dddddddddddd",
"Exercise fixture", "Exercise fixture",
@ -284,8 +291,15 @@ static bool import_archive_and_prepare(void) {
&response, &response,
&response_size) == TRAINLOG_STATUS_OK); &response_size) == TRAINLOG_STATUS_OK);
CHECK(strstr(response, "\"state\":\"archived\"") != NULL); CHECK(strstr(response, "\"state\":\"archived\"") != NULL);
CHECK(extract_string(response, "revision_id", revision, sizeof(revision)));
free(response); free(response);
response = NULL; response = NULL;
CHECK(trainlog_web_programs_prepare_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"request-prepare-after-archive",
&response,
&response_size) == TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_web_programs_archive_json(database, CHECK(trainlog_web_programs_archive_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
@ -295,6 +309,59 @@ static bool import_archive_and_prepare(void) {
&response_size) == TRAINLOG_STATUS_OK); &response_size) == TRAINLOG_STATUS_OK);
CHECK(strstr(response, "\"state\":\"archived\"") != NULL); CHECK(strstr(response, "\"state\":\"archived\"") != NULL);
free(response); free(response);
response = NULL;
CHECK(trainlog_web_programs_delete_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
revision,
"request-delete-readable",
&response,
&response_size) == TRAINLOG_STATUS_OK);
CHECK(strstr(response, "\"deleted_at\":") != NULL);
free(response);
response = NULL;
CHECK(trainlog_web_programs_delete_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
revision,
"request-delete-readable",
&response,
&response_size) == TRAINLOG_STATUS_OK);
free(response);
response = NULL;
CHECK(trainlog_web_programs_delete_json(database,
"pg_11111111-1111-4111-8111-111111111111",
revision,
"request-delete-readable",
&response,
&response_size) == TRAINLOG_STATUS_CONFLICT);
CHECK(scalar(database, "SELECT COUNT(*) FROM programs") == 2);
CHECK(scalar(database, "SELECT COUNT(*) FROM program_sessions") == 2);
CHECK(scalar(database, "SELECT COUNT(*) FROM program_session_entries") == 2);
CHECK(scalar(database, "SELECT COUNT(*) FROM session_preparations") == 1);
CHECK(trainlog_web_programs_list_json(database, "", "", 0U, 24U, &response, &response_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(response, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa") == NULL);
CHECK(strstr(response, "\"preparation_count\":0") != NULL);
free(response);
response = NULL;
CHECK(trainlog_web_programs_detail_json(
database, "pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", &response, &response_size) ==
TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_web_programs_prepare_json(database,
"pg_aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"pgs_bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb",
"request-prepare-after-delete",
&response,
&response_size) == TRAINLOG_STATUS_CONFLICT);
CHECK(trainlog_web_programs_import_json(
database, PROGRAM_JSON, strlen(PROGRAM_JSON), true, &response, &response_size) ==
TRAINLOG_STATUS_OK);
CHECK(strstr(response, "\"imported\":false") != NULL);
CHECK(scalar(database, "SELECT COUNT(*) FROM programs WHERE deleted_at IS NOT NULL") == 1);
CHECK(scalar(database,
"SELECT COUNT(*) FROM program_deletions WHERE request_id LIKE "
"'request-delete-%' AND generation_id IS NULL AND acknowledged_at IS NULL") == 1);
free(response);
trainlog_database_close(database); trainlog_database_close(database);
return true; return true;
} }

View file

@ -9,6 +9,10 @@ export interface ProgramListItem {
session_count: number session_count: number
provenance: string provenance: string
updated_at: string updated_at: string
imported_at: string
revision_id: string
preparation_count: number
usage: 'used' | 'unused'
} }
export interface ProgramOccurrence { export interface ProgramOccurrence {
@ -93,10 +97,10 @@ async function errorReason(response: Response): Promise<string> {
return object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}` return object(value) && typeof value.error === 'string' ? value.error : `HTTP ${response.status}`
} }
function requestId(): string { function requestId(prefix = 'web'): string {
const bytes = new Uint8Array(16) const bytes = new Uint8Array(16)
crypto.getRandomValues(bytes) crypto.getRandomValues(bytes)
return `web-${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}` return `${prefix}_${Array.from(bytes, (value) => value.toString(16).padStart(2, '0')).join('')}`
} }
export async function fetchAllPrograms( export async function fetchAllPrograms(
@ -127,6 +131,10 @@ export async function fetchAllPrograms(
if (!object(candidate) || typeof candidate.program_id !== 'string' || if (!object(candidate) || typeof candidate.program_id !== 'string' ||
typeof candidate.title !== 'string' || typeof candidate.title !== 'string' ||
(candidate.state !== 'active' && candidate.state !== 'archived') || (candidate.state !== 'active' && candidate.state !== 'archived') ||
typeof candidate.imported_at !== 'string' ||
typeof candidate.revision_id !== 'string' ||
!Number.isSafeInteger(candidate.preparation_count) ||
(candidate.usage !== 'used' && candidate.usage !== 'unused') ||
identities.has(candidate.program_id)) { identities.has(candidate.program_id)) {
throw new TypeError('pagination de programmes incohérente') throw new TypeError('pagination de programmes incohérente')
} }
@ -156,13 +164,20 @@ export async function fetchProgram(programId: string, signal?: AbortSignal): Pro
return value as unknown as ProgramDetail return value as unknown as ProgramDetail
} }
async function programMutation(path: string, body: string, extraHeaders = {}): Promise<Response> { async function programMutation(
path: string,
body: string,
extraHeaders = {},
requestPrefix = 'web',
): Promise<Response> {
const csrf = await mutationCsrfToken() const csrf = await mutationCsrfToken()
return fetch(path, { return fetch(path, {
method: 'POST', method: 'POST',
headers: { headers: {
Accept: 'application/json', 'Content-Type': 'application/json', Accept: 'application/json', 'Content-Type': 'application/json',
'X-Trainlog-CSRF-Token': csrf, 'X-Trainlog-Request-ID': requestId(), ...extraHeaders, 'X-Trainlog-CSRF-Token': csrf,
'X-Trainlog-Request-ID': requestId(requestPrefix),
...extraHeaders,
}, },
body, body,
}) })
@ -194,6 +209,17 @@ export async function archiveProgram(programId: string, revision: string): Promi
} }
} }
export async function deleteProgram(programId: string, revision: string): Promise<void> {
const response = await programMutation(
`/api/v1/sessions/program/${encodeURIComponent(programId)}/delete`, '{}',
{ 'If-Match': `"${revision}"` },
'pd',
)
if (!response.ok) {
throw new Error(await errorReason(response))
}
}
export async function createPreparationFromProgram( export async function createPreparationFromProgram(
programId: string, programId: string,
programSessionId: string, programSessionId: string,

View file

@ -37,6 +37,62 @@ function preview(imported: boolean) {
} }
describe('Programs tab', () => { describe('Programs tab', () => {
it('deletes a program through a separate guarded action and restores focus on cancel', async () => {
const item = {
program_id: 'pg_test',
title: 'Cycle à retirer',
state: 'archived',
start_date: null,
end_date: null,
session_count: 4,
provenance: 'trainlog-program',
updated_at: '2026-09-18T12:00:00Z',
imported_at: '2026-09-17T12:00:00Z',
revision_id: 'pgr_revision',
preparation_count: 2,
usage: 'used',
}
const fetchMock = vi.fn((input: RequestInfo | URL, init?: RequestInit) => {
const url = String(input)
if (url.startsWith('/api/v1/sessions/programs?')) {
return jsonResponse({ ...programPage(), items: [item] })
}
if (url === '/api/v1/sync/status') {
return jsonResponse(
{ phase: 'idle', result: 'ok' },
{ 'X-Trainlog-CSRF-Token': 'a'.repeat(64) },
)
}
if (url === '/api/v1/sessions/program/pg_test/delete') {
expect(init?.method).toBe('POST')
expect((init?.headers as Record<string, string>)['If-Match']).toBe('"pgr_revision"')
expect((init?.headers as Record<string, string>)['X-Trainlog-Request-ID'])
.toMatch(/^pd_[0-9a-f]{32}$/)
return jsonResponse({ api_version: 1, program_id: 'pg_test' })
}
throw new Error(`unexpected request ${url}`)
})
vi.stubGlobal('fetch', fetchMock)
render(<ProgramsTab detailProgramId={null} onOpen={vi.fn()} onBack={vi.fn()} />)
const trash = await screen.findByRole('button', {
name: 'Supprimer le programme Cycle à retirer',
})
fireEvent.click(trash)
let dialog = screen.getByRole('alertdialog')
expect(dialog).toHaveTextContent(/toutes les préparations.*conserv/)
fireEvent.keyDown(dialog, { key: 'Escape' })
expect(screen.queryByRole('alertdialog')).not.toBeInTheDocument()
expect(document.activeElement).toBe(trash)
fireEvent.click(trash)
dialog = screen.getByRole('alertdialog')
fireEvent.click(within(dialog).getByRole('button', { name: 'Supprimer le programme' }))
await waitFor(() => expect(screen.queryByText('Cycle à retirer')).not.toBeInTheDocument())
expect(fetchMock.mock.calls.filter(([url]) =>
String(url) === '/api/v1/sessions/program/pg_test/delete')).toHaveLength(1)
})
it('shows a server preview before the only importing mutation', async () => { it('shows a server preview before the only importing mutation', async () => {
const onOpen = vi.fn() const onOpen = vi.fn()
const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => { const fetchMock = vi.fn((input: RequestInfo | URL, _init?: RequestInit) => {

View file

@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from 'react'
import { import {
archiveProgram, archiveProgram,
createPreparationFromProgram, createPreparationFromProgram,
deleteProgram,
fetchAllPrograms, fetchAllPrograms,
fetchProgram, fetchProgram,
importProgram, importProgram,
@ -10,6 +11,7 @@ import {
type ProgramImportPreview, type ProgramImportPreview,
type ProgramListItem, type ProgramListItem,
} from '../api/programs' } from '../api/programs'
import { DeleteConfirmationDialog } from '../components/DeleteConfirmationDialog'
import { useDatePreferences } from '../presentation/DatePreferences' import { useDatePreferences } from '../presentation/DatePreferences'
import { formatCivilDate, formatDateTime } from '../presentation/dateFormat' import { formatCivilDate, formatDateTime } from '../presentation/dateFormat'
@ -98,6 +100,8 @@ function ProgramDetailView({ programId, onBack, onChanged }: {
<p> <p>
{session.session_type === 'max_test' {session.session_type === 'max_test'
? 'Test MAX planifié' : 'Entraînement planifié'} ? 'Test MAX planifié' : 'Entraînement planifié'}
{session.planned_for
? ` · ${formatCivilDate(session.planned_for, dateFormat)}` : ''}
{' · '}{session.occurrences.length} exercice {' · '}{session.occurrences.length} exercice
{session.occurrences.length > 1 ? 's' : ''} {session.occurrences.length > 1 ? 's' : ''}
</p> </p>
@ -131,6 +135,7 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
}) { }) {
const { dateFormat } = useDatePreferences() const { dateFormat } = useDatePreferences()
const fileInput = useRef<HTMLInputElement>(null) const fileInput = useRef<HTMLInputElement>(null)
const deleteReturnFocus = useRef<HTMLElement>(null)
const [items, setItems] = useState<ProgramListItem[]>([]) const [items, setItems] = useState<ProgramListItem[]>([])
const [search, setSearch] = useState('') const [search, setSearch] = useState('')
const [state, setState] = useState('') const [state, setState] = useState('')
@ -139,6 +144,9 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
const [rawImport, setRawImport] = useState('') const [rawImport, setRawImport] = useState('')
const [preview, setPreview] = useState<ProgramImportPreview | null>(null) const [preview, setPreview] = useState<ProgramImportPreview | null>(null)
const [fileName, setFileName] = useState('') const [fileName, setFileName] = useState('')
const [deleteTarget, setDeleteTarget] = useState<ProgramListItem | null>(null)
const [deleteBusy, setDeleteBusy] = useState(false)
const [deleteError, setDeleteError] = useState('')
const reload = () => { const reload = () => {
setPending(true) setPending(true)
setError('') setError('')
@ -199,6 +207,22 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
setPreview(null) setPreview(null)
setRawImport('') setRawImport('')
} }
const confirmDelete = async () => {
if (!deleteTarget) return
setDeleteBusy(true)
setDeleteError('')
try {
await deleteProgram(deleteTarget.program_id, deleteTarget.revision_id)
setItems((current) => current.filter(
(candidate) => candidate.program_id !== deleteTarget.program_id,
))
setDeleteTarget(null)
} catch (reason) {
setDeleteError(reason instanceof Error ? reason.message : 'Suppression impossible')
} finally {
setDeleteBusy(false)
}
}
return <section className="programs-tab"> return <section className="programs-tab">
<div className="sessions-toolbar"> <div className="sessions-toolbar">
<label className="session-search"> <label className="session-search">
@ -264,27 +288,57 @@ export function ProgramsTab({ detailProgramId, onOpen, onBack }: {
{!pending && !error && items.length === 0 && {!pending && !error && items.length === 0 &&
<p className="empty-inline">Aucun programme.</p>} <p className="empty-inline">Aucun programme.</p>}
<div className="program-list"> <div className="program-list">
{items.map((program) => <button {items.map((program) => <article
type="button"
key={program.program_id} key={program.program_id}
className="session-row program-row" className="session-row program-row"
onClick={() => onOpen(program.program_id)}
> >
<span> <button
<small>{program.state === 'active' ? 'Programme actif' : 'Programme archivé'}</small> type="button"
<strong>{program.title}</strong> className="session-row-main"
</span> onClick={() => onOpen(program.program_id)}
<span> >
{program.start_date <span>
? formatCivilDate(program.start_date, dateFormat) : 'Dates libres'} <small>{program.state === 'active' ? 'Programme actif' : 'Programme archivé'}</small>
</span> <strong>{program.title}</strong>
<span> </span>
{program.session_count} séance{program.session_count > 1 ? 's' : ''} <span>
</span> {program.start_date
<span> ? formatCivilDate(program.start_date, dateFormat) : 'Dates libres'}
{program.provenance} · {formatDateTime(program.updated_at, dateFormat)} </span>
</span> <span>
</button>)} {program.session_count} séance{program.session_count > 1 ? 's' : ''}
{program.preparation_count > 0
? ` · ${program.preparation_count} préparation${program.preparation_count > 1 ? 's' : ''}`
: ' · jamais utilisé'}
</span>
<span>
{program.provenance} · importé {formatDateTime(program.imported_at, dateFormat)}
</span>
</button>
<button
type="button"
className="trash-action"
aria-label={`Supprimer le programme ${program.title}`}
title="Supprimer le programme"
onClick={(event) => {
deleteReturnFocus.current = event.currentTarget
setDeleteError('')
setDeleteTarget(program)
}}
>🗑</button>
</article>)}
</div> </div>
{deleteTarget && <DeleteConfirmationDialog
title={deleteTarget.title}
itemType={deleteTarget.state === 'active' ? 'Programme actif' : 'Programme archivé'}
consequence="Le programme disparaîtra définitivement de cette liste. Ses séances sources, ses exercices et toutes les préparations déjà créées seront conservés."
cancelLabel="Conserver"
confirmLabel="Supprimer le programme"
busy={deleteBusy}
error={deleteError}
returnFocus={deleteReturnFocus}
onCancel={() => setDeleteTarget(null)}
onConfirm={() => void confirmDelete()}
/>}
</section> </section>
} }

View file

@ -167,12 +167,17 @@ h2 { color: var(--text); font-size: var(--font-size-lg); line-height: 1.25; lett
.trash-action:hover, .trash-action:hover,
.trash-action:focus-visible { background: rgb(243 139 168 / 12%); } .trash-action:focus-visible { background: rgb(243 139 168 / 12%); }
.program-list, .program-session-list { display: grid; gap: .75rem; } .program-list, .program-session-list { display: grid; gap: .75rem; }
.program-row { box-shadow: 0 .45rem 1.4rem rgb(0 0 0 / 10%); }
.program-row .session-row-main { grid-template-columns: minmax(13rem, 2fr) repeat(3, minmax(8rem, 1fr)); }
.program-session-list { padding: 0; list-style: none; } .program-session-list { padding: 0; list-style: none; }
.program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; } .program-session-list li { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding: 1rem; border: 1px solid var(--surface-1); border-radius: .7rem; }
.program-session-list p { margin: .35rem 0 0; color: var(--subtext-0); } .program-session-list p { margin: .35rem 0 0; color: var(--subtext-0); }
.import-preview { margin: 1rem 0; padding: 1rem; border: 1px solid var(--blue); border-radius: .75rem; background: var(--mantle); } .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; } .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) { .program-session-list li { align-items: stretch; flex-direction: column; } } @media (max-width: 600px) {
.program-session-list li { align-items: stretch; flex-direction: column; }
.program-row .session-row-main { grid-template-columns: 1fr; }
}
.detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); } .detail-summary, .detail-tile { margin-bottom: 1rem; padding: 1.1rem; border: 1px solid var(--surface-1); border-radius: .75rem; background: var(--base); }
.detail-occurrences li { display: grid; gap: .35rem; } .detail-occurrences li { display: grid; gap: .35rem; }
.detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); } .detail-occurrences span, .detail-occurrences small { color: var(--subtext-0); }