feat(cardio): add cardio session type

This commit is contained in:
fy59 2026-09-23 12:34:01 +02:00
parent 070a29298f
commit 3ded6ee425
71 changed files with 1115 additions and 149 deletions

View file

@ -37,6 +37,14 @@
causal compensating revision, while Corriger la nuit retains the complete
editor. Heart-rate acquisition links directly to the stable sleep `entry_id`
and closes at Levé; no return-to-sleep or sleep-stage inference is added.
- Added the true `cardio` session type without widening frozen session
codecs. Android schema v29 and desktop schema v32 introduce canonical
`session_kind=training|max_test|cardio` while the historical
`session_type` compatibility column remains `training|max_test`.
Completed cardio sessions are excluded from `history-v4` and move through
the separate optional `trainlog-cardio-sessions` V1 generation companion;
exact replay is idempotent and identity collision with non-cardio history is
rejected.
Development opened after stable v0.1.4 release.

View file

@ -36,7 +36,7 @@ Programs. Sessions → Programmes remains the technical administration/import,
list, detail, archive, and delete surface.
Web Exercises administration, the factual Dashboard/Analyse read model, and the
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version
0.1.5 development now has Android/desktop heart-rate persistence and sync plus validated persistent CYCPLUS acquisition and the global `♥ BPM` indicator; training exercise timestamps are the current implementation cursor.
0.1.5 development now has validated persistent CYCPLUS acquisition, the global `♥ BPM` indicator, training exercise timestamps, one-tap Android Sleep capture, and the dedicated synchronized `cardio` session type; Calibration cardio is the current implementation cursor.
## Releases

View file

@ -225,7 +225,7 @@ internal class AndroidBackupService(
manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..28
manifest.getInt("schema") in 17..29
) {
"Unsupported backup."
}
@ -319,7 +319,7 @@ internal class AndroidBackupService(
}
)
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
require(db.version in 17..28) { "Unsupported Android schema ${db.version}." }
require(db.version in 17..29) { "Unsupported Android schema ${db.version}." }
return db.version
} finally {
db.close()

View file

@ -52,6 +52,8 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
linkedMapOf(
"catalog" to Kind("trainlog-pc-catalog", 1, "catalog-v1.json"),
"history" to Kind("trainlog-mobile-export", 4, "history-v4.json"),
"cardio-sessions" to
Kind("trainlog-cardio-sessions", 1, "cardio-sessions-v1.json", false),
"execution-drafts" to
Kind("trainlog-execution-drafts", 1, "execution-drafts-v1.json"),
"exercise-aliases" to

View file

@ -530,6 +530,9 @@ class TrainlogRepository(
data class DatabaseError(val message: String) : SessionExerciseTimingResult
}
private fun legacySessionTypeWire(type: SessionType): String =
if (type == SessionType.MAX_TEST) SessionType.MAX_TEST.wireValue else SessionType.TRAINING.wireValue
private fun sleepId(prefix: String): String = "${prefix}_${UUID.randomUUID()}"
private fun sleepIdentity(value: String, prefix: String): Boolean =
@ -1485,7 +1488,7 @@ class TrainlogRepository(
fun activeSessionTimelineContext(): ActiveSessionTimelineContext? {
val db = database.readableDatabase
return db.rawQuery(
"SELECT d.session_id,d.started_at,d.session_type,t.entry_id " +
"SELECT d.session_id,d.started_at,d.session_kind,t.entry_id " +
"FROM active_session_draft d LEFT JOIN session_timeline_exercises t " +
"ON t.session_id=d.session_id AND t.active_slot=1 WHERE d.id=1",
null,
@ -1742,13 +1745,13 @@ class TrainlogRepository(
HeartRateContextKind.SESSION ->
db.rawQuery(
"SELECT 1 FROM active_session_draft WHERE id=1 AND session_id=? " +
"AND session_type IN('training','max_test') LIMIT 1",
"AND session_kind IN('training','max_test') LIMIT 1",
arrayOf(contextId),
).use { it.moveToFirst() }
HeartRateContextKind.CARDIO ->
db.rawQuery(
"SELECT 1 FROM active_session_draft WHERE id=1 AND session_id=? " +
"AND session_type='cardio' LIMIT 1",
"AND session_kind='cardio' LIMIT 1",
arrayOf(contextId),
).use { it.moveToFirst() }
HeartRateContextKind.SLEEP ->
@ -2206,6 +2209,7 @@ class TrainlogRepository(
val artifacts = linkedMapOf("catalog" to buildPcCatalogJson())
afterFirstArtifact?.invoke()
artifacts["history"] = buildMobileExport(4, true)
artifacts["cardio-sessions"] = buildCardioSessionsV1Json()
artifacts["execution-drafts"] = buildExecutionDraftExportV1Json()
artifacts["exercise-aliases"] = buildExerciseAliasesJson()
artifacts["exercise-profile-state"] = buildExerciseProfileStateJson()
@ -2379,7 +2383,7 @@ class TrainlogRepository(
data class FrequencyCount(var all: Int = 0, var maxima: Int = 0)
val frequency = sortedMapOf<String, FrequencyCount>()
val sessionTimes = mutableListOf<Pair<TrainlogTimestampKey, String>>()
query("SELECT started_at,session_type FROM sessions AS s WHERE $ACTUAL_SESSION_PREDICATE") { c ->
query("SELECT started_at,session_kind FROM sessions AS s WHERE $ACTUAL_SESSION_PREDICATE") { c ->
val time = timestamp(c.getString(0)) ?: return@query
sessionTimes += time to c.getString(1)
if (included(time)) {
@ -4489,11 +4493,8 @@ class TrainlogRepository(
startedAt
)
put(
"session_type",
draft.sessionType
.wireValue
)
put("session_type", legacySessionTypeWire(draft.sessionType))
put("session_kind", draft.sessionType.wireValue)
put("ended_at", endedAt)
}
@ -4719,7 +4720,7 @@ class TrainlogRepository(
SELECT
s.session_id,
s.started_at,
s.session_type,
s.session_kind,
COUNT(se.id)
FROM sessions AS s
LEFT JOIN session_exercises AS se
@ -5680,7 +5681,7 @@ class TrainlogRepository(
}
private fun causalSessionSnapshot(db: SQLiteDatabase, sessionId: String): JSONArray? {
val header = db.rawQuery("SELECT id,session_id,started_at,ended_at,session_type,notes FROM sessions WHERE session_id=?", arrayOf(sessionId)).use { cursor ->
val header = db.rawQuery("SELECT id,session_id,started_at,ended_at,session_kind,notes FROM sessions WHERE session_id=?", arrayOf(sessionId)).use { cursor ->
if (!cursor.moveToFirst()) null else Pair(cursor.getLong(0), causalCursorRow(cursor).also { it.remove(0) })
} ?: return null
val occurrences = JSONArray()
@ -6030,7 +6031,11 @@ class TrainlogRepository(
if (note.isNull("value")) null else note.getString("value"))
}
private fun buildMobileExport(version: Int, completeCausalEnvelope: Boolean = false): String {
private fun buildMobileExport(
version: Int,
completeCausalEnvelope: Boolean = false,
cardioOnly: Boolean = false,
): String {
require(version in 1..4)
if (!completeCausalEnvelope && database.readableDatabase.rawQuery(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1", null,
@ -6059,8 +6064,11 @@ class TrainlogRepository(
val sessionArray = JSONArray()
/* CONTRACT: only completed `sessions` are part of mobile export v1;
* active draft tables are intentionally outside the frozen artifact. */
val sessionFilter =
if (cardioOnly) "session_kind='cardio'" else "session_kind<>'cardio'"
db.rawQuery(
"SELECT id,session_id,started_at,session_type,ended_at,notes FROM sessions ORDER BY started_at ASC,id ASC;",
"SELECT id,session_id,started_at,session_type,ended_at,notes FROM sessions " +
"WHERE $sessionFilter ORDER BY started_at ASC,id ASC;",
null,
).use { sessions ->
while (sessions.moveToNext()) {
@ -6285,6 +6293,23 @@ class TrainlogRepository(
/** Explicit staged enriched-history codec; normal synchronization remains V3. */
fun buildMobileExportV4Json(): String = buildMobileExport(4)
/**
* Cardio history is a separate companion so frozen mobile V4 never gains a
* third session_type. The internal V4-shaped fact projection is rewritten
* only at this new envelope boundary.
*/
internal fun buildCardioSessionsV1Json(): String {
val projected = JSONObject(buildMobileExport(4, true, cardioOnly = true))
projected.put("format", "trainlog-cardio-sessions")
projected.put("version", 1)
projected.remove("body_observations")
val sessions = projected.getJSONArray("sessions")
for (index in 0 until sessions.length()) {
sessions.getJSONObject(index).put("session_type", "cardio")
}
return projected.toString()
}
/** Deterministic staged lifecycle artifact; raw form/UI columns are excluded. */
fun buildExecutionDraftExportV1Json(): String {
val db = database.readableDatabase
@ -6293,7 +6318,7 @@ class TrainlogRepository(
val drafts = JSONArray()
db.rawQuery(
"SELECT session_id,session_type,source_session_id,started_at,revision_id,parent_revision_id,notes " +
"FROM active_session_draft WHERE id=1;", null,
"FROM active_session_draft WHERE id=1 AND session_kind<>'cardio';", null,
).use { header -> if (header.moveToFirst()) {
val sessionId = checkNotNull(if (header.isNull(0)) null else header.getString(0))
drafts.put(buildExecutionDraftJson(db, sessionId, header.getString(1),
@ -6677,7 +6702,7 @@ class TrainlogRepository(
}
val rowId = existingRowId ?: run {
val values = ContentValues().apply {
put("session_id", sessionId); put("started_at", startedAt); put("session_type", type)
put("session_id", sessionId); put("started_at", startedAt); put("session_type", type); put("session_kind", type)
if (version == 4 && !session.isNull("ended_at")) put("ended_at", session.getString("ended_at"))
}
db.insertOrThrow("sessions", null, values)
@ -8870,7 +8895,7 @@ class TrainlogRepository(
val kept = if (hasMore) selected.take(limit) else selected
val rows = kept.map { selectedRow ->
db.rawQuery(
"""SELECT se.id,s.session_id,se.entry_id,s.started_at,s.session_type,
"""SELECT se.id,s.session_id,se.entry_id,s.started_at,s.session_kind,
eq.equipment_id,eq.display_name,eq.load_semantics,
se.recording_mode,se.tracking_mode,se.data_fields,
ca.duration_seconds,ca.speed_kmh,ca.distance_km
@ -9025,7 +9050,7 @@ class TrainlogRepository(
db.rawQuery(
"""
SELECT
d.session_type,
d.session_kind,
d.set_count_text,
d.reps_text,
d.duration_text,
@ -9295,7 +9320,8 @@ class TrainlogRepository(
val now = OffsetDateTime.now().toString()
val values =
ContentValues().apply {
put("session_type", draft.sessionType.wireValue)
put("session_type", legacySessionTypeWire(draft.sessionType))
put("session_kind", draft.sessionType.wireValue)
draft.sessionId?.let { put("session_id", it) }
draft.startedAt?.let { put("started_at", it) }
put("revision_id", draft.revisionId)
@ -9532,7 +9558,8 @@ class TrainlogRepository(
/* CONTRACT: this exact explicit finalization instant anchors
* H+; old NULL values remain unknown and are never backfilled. */
put("ended_at", forcedEndedAt ?: OffsetDateTime.now().toString())
put("session_type", draft.sessionType.wireValue)
put("session_type", legacySessionTypeWire(draft.sessionType))
put("session_kind", draft.sessionType.wireValue)
}
sessionRowId = db.insertOrThrow("sessions", null, sessionValues)
} else {
@ -10665,7 +10692,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
28,
29,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -10952,6 +10979,31 @@ private class TrainlogDatabaseHelper(
seedActiveSessionTimeline(db)
version = 28
}
if (version < 29 && newVersion >= 29) {
/* WHY: frozen legacy session_type admits only training/max_test.
* CONTRACT: v29 adds the canonical product session_kind without
* widening that legacy compatibility column. INVARIANT: old rows
* preserve their exact type and cardio never leaks into V3/V4. */
if (!tableHasColumn(db, "sessions", "session_kind")) {
db.execSQL(
"ALTER TABLE sessions ADD COLUMN session_kind TEXT NOT NULL " +
"DEFAULT 'training' CHECK(session_kind IN('training','max_test','cardio'));",
)
}
db.execSQL(
"UPDATE sessions SET session_kind=session_type;",
)
if (!tableHasColumn(db, "active_session_draft", "session_kind")) {
db.execSQL(
"ALTER TABLE active_session_draft ADD COLUMN session_kind TEXT NOT NULL " +
"DEFAULT 'training' CHECK(session_kind IN('training','max_test','cardio'));",
)
}
db.execSQL(
"UPDATE active_session_draft SET session_kind=session_type;",
)
version = 29
}
if (version != newVersion) {
error(
@ -11938,6 +11990,8 @@ private class TrainlogDatabaseHelper(
'max_test'
)
),
session_kind TEXT NOT NULL DEFAULT 'training'
CHECK(session_kind IN ('training','max_test','cardio')),
source_program_id TEXT,
source_program_session_id TEXT,
CHECK(
@ -12159,6 +12213,8 @@ private class TrainlogDatabaseHelper(
'max_test'
)
),
session_kind TEXT NOT NULL DEFAULT 'training'
CHECK(session_kind IN ('training','max_test','cardio')),
selected_exercise_row_id INTEGER
REFERENCES exercises(id)
ON DELETE SET NULL,

View file

@ -9,17 +9,14 @@ enum class SessionType(
val wireValue: String,
) {
TRAINING("training"),
MAX_TEST("max_test");
MAX_TEST("max_test"),
CARDIO("cardio");
companion object {
fun fromWire(
value: String,
): SessionType =
if (value == "max_test") {
MAX_TEST
} else {
TRAINING
}
entries.firstOrNull { it.wireValue == value } ?: TRAINING
}
}

View file

@ -256,6 +256,19 @@ fun SessionScreen(
)
},
)
val cardio = currentDraft.sessionType == SessionType.CARDIO
TrainlogButton(
label = strings.getString(R.string.session_cardio),
modifier = Modifier.weight(1f),
style = if (cardio) TrainlogButtonStyle.SUCCESS else TrainlogButtonStyle.SECONDARY,
maxLines = 2,
onClick = {
persistDraft(
currentDraft.copy(sessionType = SessionType.CARDIO),
null,
)
},
)
}
}
@ -279,23 +292,19 @@ fun SessionScreen(
) {
TrainlogInfo(
text =
strings.getString(R.string.session_type_value,
if (
currentDraft.sessionType ==
SessionType.MAX_TEST
) {
strings.getString(R.string.session_max_test)
} else {
strings.getString(R.string.session_training)
}),
strings.getString(
R.string.session_type_value,
when (currentDraft.sessionType) {
SessionType.TRAINING -> strings.getString(R.string.session_training)
SessionType.MAX_TEST -> strings.getString(R.string.session_max_test)
SessionType.CARDIO -> strings.getString(R.string.session_cardio)
},
),
color =
if (
currentDraft.sessionType ==
SessionType.MAX_TEST
) {
colors.warning
} else {
colors.accent
when (currentDraft.sessionType) {
SessionType.MAX_TEST -> colors.warning
SessionType.CARDIO -> colors.success
SessionType.TRAINING -> colors.accent
},
)

View file

@ -559,4 +559,5 @@
<string name="sleep_quick_medication_unavailable">Medication unavailable.</string>
<string name="sleep_quick_conflict">The night changed; reload before trying again.</string>
<string name="sleep_quick_error">Unable to record the action.</string>
<string name="session_cardio">Cardio</string>
</resources>

View file

@ -569,4 +569,5 @@
<string name="sleep_quick_medication_unavailable">Médicament indisponible.</string>
<string name="sleep_quick_conflict">La nuit a changé ; rechargez avant de recommencer.</string>
<string name="sleep_quick_error">Enregistrement impossible.</string>
<string name="session_cardio">Cardio</string>
</resources>

View file

@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
TrainlogRepository(context, name).useForTest { it.listSessions() }
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(28, scalar(db, "PRAGMA user_version"))
assertEquals(29, scalar(db, "PRAGMA user_version"))
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(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()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(28, scalar(db, "PRAGMA user_version"))
assertEquals(29, scalar(db, "PRAGMA user_version"))
/* v13 intentionally adds the frozen machine identities and
* 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") }

View file

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

View file

@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
reopened.close()
}
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(28, scalar(db, "PRAGMA user_version"))
assertEquals(29, scalar(db, "PRAGMA user_version"))
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
assertEquals("ok", text(db, "PRAGMA integrity_check"))
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->

View file

@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(28, it.version) }
).use { assertEquals(29, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(28, it.version) }
).use { assertEquals(29, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(28, db.version)
assertEquals(29, db.version)
db.rawQuery(
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
null,

View file

@ -0,0 +1,108 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
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 AndroidV29CardioSessionMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v28BackfillsCanonicalSessionKindFromFrozenLegacyType() {
val name = "cardio-v28-" + UUID.randomUUID() + ".db"
var repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Cardio migration exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
assertTrue(
repository.saveSession(
SessionDraft(
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 5)),
),
),
),
) is SaveSessionResult.Saved,
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
exercises =
listOf(
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 6)),
),
),
),
),
)
repository.close()
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("UPDATE sessions SET session_kind='cardio'")
db.execSQL("UPDATE active_session_draft SET session_kind='cardio'")
db.version = 28
}
repository = TrainlogRepository(context, name)
assertEquals(1, repository.listSessions().size)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(29, db.version)
db.rawQuery(
"SELECT session_type,session_kind FROM sessions",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("training", cursor.getString(1))
}
db.rawQuery(
"SELECT session_type,session_kind FROM active_session_draft WHERE id=1",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("training", cursor.getString(1))
}
}
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,194 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.nio.file.Files
import java.util.UUID
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 CardioSessionSyncGenerationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun dedicatedCardioCompanionImportsWithoutWideningFrozenHistory() {
val sourceName = "cardio-sync-" + UUID.randomUUID() + ".db"
val root = Files.createTempDirectory("trainlog-cardio-sync-").toFile()
val source = TrainlogRepository(context, sourceName)
try {
val exercise =
(source.createExercise(
NewExerciseProfile(
"Cardio sync exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val entry =
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 15)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
source.saveActiveSessionDraft(
ActiveSessionDraft(
sessionType = SessionType.CARDIO,
exercises = listOf(entry),
),
),
)
assertTrue(
source.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
)
val sessionId = source.listSessions().single().sessionId
val repositoryRoot =
generateSequence(java.io.File(checkNotNull(System.getProperty("user.dir")))) {
it.parentFile
}.first { java.io.File(it, "tools/sync_generation_exchange.py").isFile }
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
}
val desktopDb = java.io.File(root, "desktop.sqlite")
run(
java.io.File(
repositoryRoot,
"build/tui/sync-generation-fixture",
).absolutePath,
desktopDb.absolutePath,
)
val desktopPeer =
Regex("PEER_ID=(peer_[^\\s]+)")
.find(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"peer-id",
"--database",
desktopDb.absolutePath,
),
)!!
.groupValues[1]
val service = SyncGenerationService(source)
val captured = service.capture(root, desktopPeer)
val history =
JSONObject(
java.io.File(
captured.stagingDirectory,
"history-v4.json",
).readText(),
)
assertEquals(0, history.getJSONArray("sessions").length())
val cardio =
JSONObject(
java.io.File(
captured.stagingDirectory,
"cardio-sessions-v1.json",
).readText(),
)
assertEquals("trainlog-cardio-sessions", cardio.getString("format"))
assertEquals(1, cardio.getJSONArray("sessions").length())
assertEquals(
"cardio",
cardio
.getJSONArray("sessions")
.getJSONObject(0)
.getString("session_type"),
)
val published = service.publish(captured, java.io.File(root, "objects"))
val ackFile = java.io.File(root, "ack.json")
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
ackFile.absolutePath,
)
val ack = JSONObject(ackFile.readText())
assertEquals(
"desktop ACK diagnostic: " + ack.getString("diagnostic"),
"consumed",
ack.getString("result"),
)
assertEquals(
"training|cardio|15",
run(
"sqlite3",
desktopDb.absolutePath,
"SELECT s.session_type || '|' || s.session_kind || '|' || ps.reps " +
"FROM sessions s JOIN session_exercises se ON se.session_row_id=s.id " +
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id " +
"WHERE s.session_id='$sessionId';",
).trim(),
)
assertEquals(
"acknowledged",
service.acceptAcknowledgement(ackFile.readBytes()),
)
assertEquals(
ack.toString(),
JSONObject(
run(
"python3",
java.io.File(
repositoryRoot,
"tools/sync_generation_exchange.py",
).absolutePath,
"consume-desktop",
published.absolutePath,
"--database",
desktopDb.absolutePath,
"--ack-output",
java.io.File(root, "replay-ack.json").absolutePath,
).let { java.io.File(root, "replay-ack.json").readText() },
).toString(),
)
} finally {
source.close()
context.deleteDatabase(sourceName)
root.deleteRecursively()
}
}
}

View file

@ -0,0 +1,95 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.ActiveSessionDraft
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SessionType
import com.labfytools.trainlog.model.TrackingMode
import java.util.UUID
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 CardioSessionTypeTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun cardioIsCanonicalLocallyAndExcludedFromFrozenMobileHistory() {
val name = "cardio-kind-" + UUID.randomUUID() + ".db"
val repository = TrainlogRepository(context, name)
try {
val exercise =
(repository.createExercise(
NewExerciseProfile(
"Cardio kind exercise",
RecordingMode.SETS,
TrackingMode.REPS,
0,
),
) as CreateExerciseResult.Created).exercise
val occurrence =
SessionExerciseDraft(
exercise = exercise,
sets = listOf(SessionSetDraft(reps = 1)),
)
assertEquals(
ActiveDraftMutationResult.Saved,
repository.saveActiveSessionDraft(
ActiveSessionDraft(
sessionType = SessionType.CARDIO,
exercises = listOf(occurrence),
),
),
)
val loaded =
repository.loadActiveSessionDraft() as ActiveDraftLoadResult.Loaded
assertEquals(SessionType.CARDIO, loaded.draft.sessionType)
val db = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
db.rawQuery(
"SELECT session_type,session_kind FROM active_session_draft WHERE id=1",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("cardio", cursor.getString(1))
}
db.close()
assertTrue(
repository.finalizeActiveSessionDraft() is FinalizeActiveDraftResult.Saved,
)
assertEquals(
SessionType.CARDIO,
repository.listSessions().single().sessionType,
)
val frozen = JSONObject(repository.buildMobileExportV4Json())
assertEquals(0, frozen.getJSONArray("sessions").length())
val verify = context.openOrCreateDatabase(name, Context.MODE_PRIVATE, null)
verify.rawQuery(
"SELECT session_type,session_kind FROM sessions",
null,
).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals("training", cursor.getString(0))
assertEquals("cardio", cursor.getString(1))
}
verify.close()
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

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

View file

@ -296,7 +296,7 @@ class StatisticsRepositoryTest {
}
private fun session(db: SQLiteDatabase, id: String, timestamp: String, type: String = "training") {
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type) VALUES(?,?,?)", arrayOf(id, timestamp, type))
db.execSQL("INSERT INTO sessions(session_id,started_at,session_type,session_kind) VALUES(?,?,?,?)", arrayOf(id, timestamp, type, type))
}
private fun occurrence(

View file

@ -509,7 +509,7 @@ class SyncGenerationServiceTest {
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(28, it.getInt(0))
assertEquals(29, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())

View file

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

View file

@ -112,8 +112,8 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v31; session exercise timeline import is additive in v31 |
| Android SQLite | schema v28; active training timeline/outbox is additive in v28 |
| Desktop SQLite | schema v32; canonical `session_kind` adds true cardio sessions while frozen legacy `session_type` remains training/max_test |
| Android SQLite | schema v29; canonical `session_kind` adds true cardio sessions while legacy draft/history codecs remain training/max_test |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.5` development, synchronized across Android and desktop; latest stable release: `v0.1.4` |
@ -152,7 +152,8 @@ parallel implementation of its rules.
| Cardio persistent acquisition | `TRAINLOG_CARDIO_PERSISTENT_ACQUISITION_V1=PASS`: selected sensor persists, dedicated connected-device foreground service reconnects, stale BPM is cleared, and the global Android `♥ BPM` indicator is grey/orange/green by connection state; real CYCPLUS restart and Bluetooth-off/on recovery validated on private vc40 |
| Training timeline | `TRAINLOG_CARDIO_TRAINING_TIMELINE_V1=PASS`: real session bounds reuse canonical lifecycle timestamps; exercise start/end are explicit user actions; live BPM samples snapshot the active stable `entry_id`; session finalization refuses an unclosed exercise; timeline sync is Android→desktop idempotent |
| Sleep Android one-tap | `TRAINLOG_SLEEP_ANDROID_ONE_TAP_V1=SOFTWARE_PASS / DEVICE_VISUAL_PENDING`: existing Sleep Diary V1 now has one-tap Couché / medication / Réveil / Levé, guarded 10 s undo, full correction route and sleep-entry-owned cardio capture; vc41 installed without data clear, but final visual device review was blocked by the phone lock screen |
| Current operational cursor | `TRAINLOG_CARDIO_SESSION_TYPE_V1`: add the true `cardio` session type without silently widening frozen legacy formats |
| Cardio session type | `TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS`: Android v29 + desktop v32 use canonical `session_kind=cardio`; frozen mobile/history and draft formats keep legacy training/max_test; dedicated `trainlog-cardio-sessions` V1 syncs completed cardio sessions Android→desktop idempotently |
| Current operational cursor | `TRAINLOG_CARDIO_CALIBRATION_V1`: add the first versioned Calibration cardio exercise, observed peak BPM and recovery timeline without claiming a physiological maximum |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |

View file

@ -1,5 +1,27 @@
# Desktop database
## Schema v32: canonical cardio session kind
Schema v32 adds the product-owned `sessions.session_kind` column with the
explicit domain `training | max_test | cardio`. The pre-existing
`sessions.session_type` column remains intentionally frozen to
`training | max_test` for compatibility with historical codecs and MAX
semantics. A cardio row therefore stores `session_type=training` only as a
legacy compatibility projection and `session_kind=cardio` as the canonical
product fact.
The v31→v32 migration introspects historical `sessions` layouts before
backfill. When the legacy `session_type` column exists it seeds
`session_kind` from it; very old historical fixtures that legitimately lack
that column keep the session kind produced by their earlier migration chain.
No existing session identity, occurrence, set, timeline, Program provenance or
heart-rate fact is rewritten.
Completed cardio sessions synchronize Android→desktop through the separate
optional `trainlog-cardio-sessions` V1 generation companion. The frozen
mobile/history V4 and execution-draft formats are not widened to a third
session type.
## Schema v31: synchronized session exercise timeline
Schema v31 additively owns Android-recorded execution timing for completed

View file

@ -69,7 +69,7 @@ sensor. Heart-rate ingestion must not reuse or overload the frozen Trainlog
Bluetooth synchronization transport contract.
```text
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_SESSION_TYPE_V1
CURRENT_OPERATIONAL_CURSOR=TRAINLOG_CARDIO_CALIBRATION_V1
```
The focused `TRAINLOG_SYNC_CAUSAL_DELETE_V1_CLOSEOUT` is complete before this

View file

@ -1105,7 +1105,29 @@ Heart-rate samples use the same active `entry_id` at acquisition time. Samples
between exercise end and the next exercise start retain no fabricated exercise
owner and therefore remain factual rest/transition data.
## 21. Legacy active-protocol limitations
## 21. Cardio sessions companion V1
`trainlog-cardio-sessions` V1 is an optional Android→desktop
full-generation participant. It carries completed sessions whose canonical
Android `session_kind` is `cardio`. These sessions are deliberately absent
from the frozen `history-v4` artifact.
The companion reuses the same bounded exercise/occurrence/set/continuous fact
shape internally but owns its own envelope and requires every public
`session_type` to be exactly `cardio`. Desktop schema v32 imports those
facts transactionally, stores legacy `session_type=training` only for
compatibility, and stores canonical `session_kind=cardio`.
Exact replay is idempotent. Reusing a cardio `session_id` that already belongs
to non-cardio canonical history is rejected. The companion is consumed before
session-timeline and heart-rate participants in the same generation, so the
stable `session_id` and occurrence facts exist before their timing and sensor
facts are applied.
Programs, manual preparations, AI execution drafts, mobile V3/V4 and
`TRAINLOG_FORMAT_V1` are not widened by this companion.
## 22. Legacy active-protocol limitations
The Web end-to-end tranche adds an explicit opt-in application orchestrator.
`POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is

View file

@ -1770,3 +1770,31 @@ The private Android installation was advanced non-destructively to Trainlog
data clear. The device locked before the final visual Sleep-screen inspection,
so no lock bypass was attempted and no real Sleep business button was pressed;
that visual review remains explicitly pending.
## Cardio session type V1 validation
`TRAINLOG_CARDIO_SESSION_TYPE_V1=PASS` covers the new canonical session kind
without widening frozen historical codecs. Android schema v29 migration tests
prove that v28 training/max-test rows backfill from the legacy
`session_type`, including a structurally adversarial retained-column fixture.
A product-level Android regression creates, reloads and finalizes a real
`SessionType.CARDIO` draft, verifies legacy `session_type=training` plus
canonical `session_kind=cardio`, and proves the cardio session is absent from
`history-v4`.
The end-to-end generation regression proves the complementary path:
`history-v4` contains zero cardio sessions, `cardio-sessions-v1.json`
contains the completed cardio session, the production desktop generation
consumer imports it as legacy training/canonical cardio with its performed set,
the correlated ACK is accepted, and exact replay remains idempotent.
Desktop schema v32 migration coverage validates the additive canonical column,
historical-layout tolerance and foreign-key integrity.
`cardio_session_exchange` separately covers strict cardio-only validation,
exact replay and rejection of a stable identity colliding with non-cardio
history.
The 2026-09-23 closeout passes the complete native Meson inventory:
**106/106 passed, 0 failed**. The complete Android JVM inventory reports
**277 tests: 272 passed, five skipped, zero failures/errors**; `assembleDebug`
also succeeds. The five skips remain optional historical/external fixture gates.

View file

@ -0,0 +1,128 @@
#!/usr/bin/env python3
import json
import sqlite3
import subprocess
import sys
import tempfile
import unittest
from copy import deepcopy
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import cardio_session_exchange as cardio
from trainlog_sqlite import connect_database
import import_equipment_definitions
SESSION = "se_11111111-1111-4111-8111-111111111111"
EXERCISE = "ex_22222222-2222-4222-8222-222222222222"
ENTRY = "sxe_33333333-3333-4333-8333-333333333333"
def document() -> dict:
return {
"format": "trainlog-cardio-sessions",
"version": 1,
"generated_at": "2026-09-23T10:00:00+02:00",
"exercises": [
{
"exercise_id": EXERCISE,
"name": "Cardio exchange exercise",
"recording_mode": "sets",
"tracking_mode": "reps",
"data_fields": 0,
}
],
"sessions": [
{
"session_id": SESSION,
"started_at": "2026-09-23T09:00:00+02:00",
"ended_at": "2026-09-23T09:30:00+02:00",
"session_type": "cardio",
"note": None,
"exercises": [
{
"exercise_id": EXERCISE,
"name": "Cardio exchange exercise",
"recording_mode": "sets",
"tracking_mode": "reps",
"data_fields": 0,
"load_mode": "none",
"rest_seconds": 0,
"entry_id": ENTRY,
"position": 0,
"equipment_id": None,
"target": None,
"note": None,
"sets": [{"reps": 12}],
}
],
}
],
}
class CardioSessionExchangeTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db_path = Path(self.temp.name) / "trainlog.sqlite"
fixture = ROOT / "build/tui/sync-generation-fixture"
subprocess.run([str(fixture), str(self.db_path)], check=True, capture_output=True)
self.db = connect_database(self.db_path)
self.known_equipment = import_equipment_definitions.supplied_ids(
ROOT / "catalog/equipment-v1.json"
)
def tearDown(self):
self.db.close()
self.temp.cleanup()
def test_import_and_exact_replay_keep_legacy_type_frozen(self):
payload = cardio.validate(document(), self.known_equipment)
first = cardio.apply(self.db, payload, complete_causal_envelope=True)
second = cardio.apply(self.db, payload, complete_causal_envelope=True)
self.assertEqual(1, first["sessions_imported"])
self.assertEqual(0, second["sessions_imported"])
self.assertEqual(
("training", "cardio"),
tuple(
self.db.execute(
"SELECT session_type,session_kind FROM sessions WHERE session_id=?",
(SESSION,),
).fetchone()
),
)
self.assertEqual(
(ENTRY, 12),
tuple(
self.db.execute(
"SELECT se.entry_id,ps.reps FROM session_exercises se "
"JOIN performed_sets ps ON ps.session_exercise_row_id=se.id "
"JOIN sessions s ON s.id=se.session_row_id WHERE s.session_id=?",
(SESSION,),
).fetchone()
),
)
def test_rejects_non_cardio_session_type(self):
changed = document()
changed["sessions"][0]["session_type"] = "training"
with self.assertRaisesRegex(ValueError, "must be cardio"):
cardio.validate(changed, self.known_equipment)
def test_rejects_identity_collision_with_non_cardio_history(self):
payload = cardio.validate(document(), self.known_equipment)
cardio.apply(self.db, payload, complete_causal_envelope=True)
self.db.execute(
"UPDATE sessions SET session_kind='training' WHERE session_id=?",
(SESSION,),
)
with self.assertRaisesRegex(ValueError, "collides with non-cardio"):
cardio.apply(self.db, payload, complete_causal_envelope=True)
if __name__ == "__main__":
unittest.main()

View file

@ -66,12 +66,13 @@ class DeploymentToolsTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.5")
self.assertEqual(inventory["desktop_schema"], 31)
self.assertEqual(inventory["android_schema"], 28)
self.assertEqual(inventory["desktop_schema"], 32)
self.assertEqual(inventory["android_schema"], 29)
self.assertIn("session-preparations-v2", inventory["protocols"])
self.assertIn("programs-v1", inventory["protocols"])
self.assertIn("program-executions-v1", inventory["protocols"])
self.assertIn("sleep-diary-v1", inventory["protocols"])
self.assertIn("cardio-sessions-v1", inventory["protocols"])
self.assertIn("heart-rate-v1", inventory["protocols"])
self.assertIn("session-timeline-v1", inventory["protocols"])
self.assertIn("bluetooth-files-v1", inventory["protocols"])

View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Validate and import Android-owned dedicated cardio sessions V1."""
import json
from pathlib import Path
import import_mobile_export
FORMAT = "trainlog-cardio-sessions"
VERSION = 1
MAX_BYTES = 64 * 1024 * 1024
ROOT_KEYS = {"format", "version", "generated_at", "exercises", "sessions"}
class CardioSessionError(ValueError):
pass
def fail(message: str) -> None:
raise CardioSessionError(message)
def unique_object(pairs):
result = {}
for key, value in pairs:
if key in result:
fail("duplicate JSON field: " + key)
result[key] = value
return result
def load(path: Path) -> dict:
if path.stat().st_size > MAX_BYTES:
fail("cardio session artifact exceeds 64 MiB")
try:
root = json.loads(
path.read_text(encoding="utf-8"),
object_pairs_hook=unique_object,
)
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise CardioSessionError("invalid cardio session JSON") from error
if not isinstance(root, dict) or set(root) != ROOT_KEYS:
fail("invalid cardio session envelope")
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
fail("unsupported cardio session format")
if not isinstance(root["exercises"], list) or not isinstance(root["sessions"], list):
fail("invalid cardio session arrays")
for index, session in enumerate(root["sessions"]):
if not isinstance(session, dict) or session.get("session_type") != "cardio":
fail(f"sessions[{index}].session_type must be cardio")
return root
def mobile_projection(root: dict) -> dict:
sessions = []
for source in root["sessions"]:
session = dict(source)
session["session_type"] = "training"
sessions.append(session)
return {
"format": "trainlog-mobile-export",
"version": 4,
"generated_at": root["generated_at"],
"exercises": root["exercises"],
"sessions": sessions,
"body_observations": [],
}
def validate(root: dict, known_equipment_ids: set[str]) -> dict:
if not isinstance(root, dict) or set(root) != ROOT_KEYS:
fail("invalid cardio session envelope")
if root.get("format") != FORMAT or type(root.get("version")) is not int or root["version"] != VERSION:
fail("unsupported cardio session format")
if not isinstance(root.get("sessions"), list) or not isinstance(root.get("exercises"), list):
fail("invalid cardio session arrays")
for index, session in enumerate(root["sessions"]):
if not isinstance(session, dict) or session.get("session_type") != "cardio":
fail(f"sessions[{index}].session_type must be cardio")
projection = mobile_projection(root)
try:
import_mobile_export.validate_payload(projection, known_equipment_ids)
except Exception as error:
raise CardioSessionError(str(error)) from error
return root
def apply(
connection,
root: dict,
*,
complete_causal_envelope: bool = False,
) -> dict:
session_ids = [session["session_id"] for session in root["sessions"]]
for session_id in session_ids:
row = connection.execute(
"SELECT session_kind FROM sessions WHERE session_id=?",
(session_id,),
).fetchone()
if row is not None and row[0] != "cardio":
fail("cardio session identity collides with non-cardio history")
projection = mobile_projection(root)
try:
report = import_mobile_export.apply_payload(
connection,
projection,
complete_causal_envelope=complete_causal_envelope,
)
except Exception as error:
raise CardioSessionError(str(error)) from error
for session_id in session_ids:
row = connection.execute(
"SELECT session_kind FROM sessions WHERE session_id=?",
(session_id,),
).fetchone()
if row is not None:
if row[0] not in ("training", "cardio"):
fail("cardio session identity has incompatible stored kind")
connection.execute(
"UPDATE sessions SET session_kind='cardio' WHERE session_id=?",
(session_id,),
)
return report

View file

@ -38,8 +38,8 @@ def digest(value) -> str:
def require_schema(db: sqlite3.Connection) -> None:
version = db.execute("PRAGMA user_version").fetchone()[0]
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
raise CausalError(f"desktop schema v20-v31 required, found v{version}")
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32):
raise CausalError(f"desktop schema v20-v32 required, found v{version}")
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):

View file

@ -182,8 +182,8 @@ def causal_payload(draft):
def require_schema(connection):
version = connection.execute("PRAGMA user_version").fetchone()[0]
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
raise LifecycleError(f"desktop schema v19-v31 required, found v{version}")
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32):
raise LifecycleError(f"desktop schema v19-v32 required, found v{version}")
def import_document(connection, document, own_transaction=True):

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32)
MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
"""Build the raw export using only explicitly ordered SELECT statements."""
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
raise ValueError("schema desktop v15-v31 requis")
raise ValueError("schema desktop v15-v32 requis")
root = {
"format": "TRAINLOG_AI_EXPORT",

View file

@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31)
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v31 requis")
raise ValueError("schema desktop v18-v32 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:

View file

@ -28,9 +28,9 @@ def main():
args = parser.parse_args()
connection = connect_database(args.database)
try:
supported_versions = tuple(range(8, 32))
supported_versions = tuple(range(8, 33))
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v31 requis")
raise ValueError("schema desktop v8 à v32 requis")
known_equipment = load_supplied_equipment_ids(args.catalog)
known_equipment.update(row[0] for row in connection.execute(
"SELECT equipment_id FROM custom_equipment"))

View file

@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(8, 32))
supported_versions = tuple(range(8, 33))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v31 requis")
raise ValueError("schema desktop v8 à v32 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal equipment protection requires the staged artifact")
equipment = [dict(row) for row in connection.execute(

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
raise ValueError("schema desktop v12-v31 requis")
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32):
raise ValueError("schema desktop v12-v32 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(11, 32))
supported_versions = tuple(range(11, 33))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v31 requis")
raise ValueError("schema desktop v11-v32 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 ValueError("causal BODY ZONES protection requires the staged artifact")
exercises = []

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

@ -71,7 +71,7 @@ def main():
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
# catalogue artifact is unchanged, but it must read the current
# canonical desktop schema rather than accept a stale pre-v8 database.
if version not in range(8, 32):
if version not in range(8, 33):
raise SystemExit(
"PC_CATALOG_EXPORT=FAIL "
f"schema={version}"

View file

@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
supported_versions = range(11, 32)
supported_versions = range(11, 33)
legacy_v2_schema = args.version == 2 and schema_version == 10
if schema_version not in supported_versions and not legacy_v2_schema:
raise ValueError("schema desktop v11-v31 requis (v10 accepté pour export V2 explicite)")
raise ValueError("schema desktop v11-v32 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")

View file

@ -16,8 +16,8 @@ def nullable(row, index):
def export_programs(database: Path) -> dict:
with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31):
raise ValueError("desktop schema v26, v27, v28 or v29 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32):
raise ValueError("desktop schema v26 through v32 required")
programs = []
for row in db.execute(
"SELECT program_id,revision_id,title,note,state,start_date,end_date,created_at,"

View file

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

View file

@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database)
try:
supported_versions = range(15, 32)
supported_versions = range(15, 33)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v31 requis")
raise ValueError("schema desktop v15-v32 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

@ -236,8 +236,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31):
fail("desktop schema v30-v31 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32):
fail("desktop schema v30-v32 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind.
"""
supported_versions = range(18, 32)
supported_versions = range(18, 33)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v31 requis")
raise ImportFailure("schema desktop v18-v32 requis")
draft, entries = validate(payload, connection)
digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

View file

@ -206,9 +206,9 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
try:
supported_versions = range(8, 32)
supported_versions = range(8, 33)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v31 requis")
fail("schema desktop v8 à v32 requis")
proof_path = args.mobile_export
if proof_path is None:
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")

View file

@ -53,9 +53,9 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 32)
supported_versions = range(8, 33)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v31 requis")
fail("schema desktop v8 à v32 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():
fail("causal equipment protection requires the staged artifact")
imported = skipped = 0

View file

@ -56,9 +56,9 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 32)
supported_versions = range(12, 33)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v31 requis")
fail("schema desktop v12-v32 requis")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
if target is None: fail(f"cible canonique absente: {canonical}")

View file

@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries."""
supported_versions = range(11, 32)
supported_versions = range(11, 33)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v31 requis")
raise ImportFailure("schema desktop v11-v32 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct

View file

@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
fail("schema desktop v17-v31 requis")
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32):
fail("schema desktop v17-v32 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]

View file

@ -847,9 +847,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in range(5, 32):
if version not in range(5, 33):
raise ImportFailure(
f"base desktop schema v5 à v31 attendue, version trouvée: {version}"
f"base desktop schema v5 à v32 attendue, version trouvée: {version}"
)
@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en
require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v31")
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v32")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON")
supported_versions = range(15, 32)
supported_versions = range(15, 33)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v31 requis")
fail("schema desktop v15-v32 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set()
for index,item in enumerate(root["exercise_feedback"]):

View file

@ -18,7 +18,8 @@ RUNTIME_TOOLS = [
"sync_drive_transport.py",
"sync_generation_exchange.py",
"sleep_diary_exchange.py",
"heart_rate_exchange.py",
"cardio_session_exchange.py",
"heart_rate_exchange.py",
"session_timeline_exchange.py",
"trainlog_sqlite.py",
"validate_json.py",
@ -116,8 +117,8 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.5",
"desktop_schema": 31,
"android_schema": 28,
"desktop_schema": 32,
"android_schema": 29,
"protocols": [
"mobile-export-v3",
"mobile-history-v4",
@ -132,6 +133,7 @@ def main() -> int:
"programs-v1",
"program-executions-v1",
"sleep-diary-v1",
"cardio-sessions-v1",
"heart-rate-v1",
"session-timeline-v1",
],

View file

@ -87,8 +87,8 @@ def load(path: Path) -> dict:
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31):
fail("desktop schema v27, v28 or v29 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32):
fail("desktop schema v27 through v32 required")
added = 0
advanced = 0
skipped = 0

View file

@ -184,8 +184,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] != 31:
fail("desktop schema v31 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32):
fail("desktop schema v31-v32 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

@ -422,8 +422,8 @@ def main() -> None:
import_command.add_argument("--database", required=True, type=Path)
args = parser.parse_args()
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31):
fail("desktop schema v30-v31 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32):
fail("desktop schema v30-v32 required")
if args.command == "export":
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
separators=(",", ":")), encoding="utf-8")

View file

@ -20,6 +20,7 @@ from pathlib import Path, PurePosixPath
from trainlog_sqlite import connect_database
import causal_delete_exchange
import cardio_session_exchange
import execution_draft_exchange
import heart_rate_exchange
import session_timeline_exchange
@ -83,6 +84,7 @@ ARTIFACTS = (
)
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
("trainlog-program-executions", 1),
("trainlog-cardio-sessions", 1),
("trainlog-heart-rate", 1),
("trainlog-session-timeline", 1),
}
@ -213,9 +215,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31)
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required")
raise GenerationError("desktop schema v24, v25, v26 through v32 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
@ -665,6 +667,12 @@ def consume_desktop(database: Path, directory: Path) -> dict:
history = import_mobile_export.load_payload(required("history"))
known_equipment = set(reserved) | {item[0] for item in definitions}
import_mobile_export.validate_payload(history, known_equipment)
cardio_sessions = (
cardio_session_exchange.load(listed["cardio-sessions"])
if "cardio-sessions" in listed else None
)
if cardio_sessions is not None:
cardio_session_exchange.validate(cardio_sessions, known_equipment)
association_payload = strict_json(required("equipment-associations").read_bytes(), MAX_ARTIFACT)
zones_payload = strict_json(required("body-zones").read_bytes(), MAX_ARTIFACT)
parsed_zones = import_exercise_body_zones.parse_payload(zones_payload)
@ -704,6 +712,12 @@ def consume_desktop(database: Path, directory: Path) -> dict:
import_exercise_profile_state.apply_profile_state(db, profile, allow_pending=True)
import_equipment_definitions.apply_definitions(db, definitions, complete_causal_envelope=True)
import_mobile_export.apply_payload(db, history, complete_causal_envelope=True)
if cardio_sessions is not None:
cardio_session_exchange.apply(
db,
cardio_sessions,
complete_causal_envelope=True,
)
import_exercise_profile_state.apply_profile_state(db, profile)
import_exercise_aliases.apply_aliases(db, aliases)
import_equipment_associations.apply_associations(

View file

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

View file

@ -47,7 +47,8 @@ typedef enum TrainlogLoadMode {
typedef enum TrainlogSessionType {
TRAINLOG_SESSION_TRAINING = 0,
TRAINLOG_SESSION_MAX_TEST
TRAINLOG_SESSION_MAX_TEST,
TRAINLOG_SESSION_CARDIO
} TrainlogSessionType;
typedef struct TrainlogExercise {

View file

@ -839,6 +839,14 @@ test_schema_v31_migration = executable(
)
test('schema_v31_migration', test_schema_v31_migration)
test_schema_v32_migration = executable(
'test_schema_v32_migration',
'tests/test_schema_v32_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('schema_v32_migration', test_schema_v32_migration)
test_schema_v7_migration = executable(
'test_schema_v7_migration',
'tests/test_schema_v7_migration.c',
@ -928,6 +936,11 @@ test(
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_session_timeline_exchange.py'],
)
test(
'cardio_session_exchange',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_cardio_session_exchange.py'],
)
test(
'session_preparation_export',
find_program('python3'),

View file

@ -1393,6 +1393,49 @@ static const char *const MIGRATE_V30_TO_V31_SQL =
"session_exercise_timeline(started_at,session_id,entry_id);"
"PRAGMA user_version=31;COMMIT;";
/* WHY: legacy session_type is a frozen training/max_test compatibility column.
* CONTRACT: v32 adds canonical local session_kind and backfills it exactly
* from legacy history. INVARIANT: no legacy codec or MAX classification is
* widened to cardio. */
static const char *const MIGRATE_V31_TO_V32_SQL =
"BEGIN IMMEDIATE;"
"PRAGMA user_version=32;COMMIT;";
static TrainlogStatus ensure_v32_session_kind(TrainlogDatabase *database) {
sqlite3_stmt *statement = NULL;
bool found_kind = false;
bool found_legacy_type = false;
int result = sqlite3_prepare_v2(
database->connection, "PRAGMA table_info(sessions);", -1, &statement, NULL);
if (result != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
while ((result = sqlite3_step(statement)) == SQLITE_ROW) {
const char *name = (const char *)sqlite3_column_text(statement, 1);
if (name != NULL && strcmp(name, "session_kind") == 0) {
found_kind = true;
} else if (name != NULL && strcmp(name, "session_type") == 0) {
found_legacy_type = true;
}
}
if (result != SQLITE_DONE || sqlite3_finalize(statement) != SQLITE_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (!found_kind &&
execute_sql(database,
"ALTER TABLE sessions ADD COLUMN session_kind TEXT NOT NULL "
"DEFAULT 'training' CHECK(session_kind IN('training','max_test','cardio'));") !=
TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
if (found_legacy_type &&
execute_sql(database, "UPDATE sessions SET session_kind=session_type;") !=
TRAINLOG_STATUS_OK) {
return TRAINLOG_STATUS_DATABASE_ERROR;
}
return TRAINLOG_STATUS_OK;
}
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
* before medication capture joined the same unreleased migration. CONTRACT:
* this additive repair is identical to the tail of v28 -> v29 and runs only
@ -2227,7 +2270,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
version == 31) {
version == 31 || version == 32) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -2381,6 +2424,12 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK && version < 31) {
status = execute_sql(database, MIGRATE_V30_TO_V31_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 32) {
status = ensure_v32_session_kind(database);
}
if (status == TRAINLOG_STATUS_OK && version < 32) {
status = execute_sql(database, MIGRATE_V31_TO_V32_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -2400,7 +2449,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status != TRAINLOG_STATUS_OK) {
set_open_diagnostic(output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v31" : "migrate database to schema v31",
version == 0 ? "create schema v32" : "migrate database to schema v32",
database->connection,
SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
@ -2903,6 +2952,9 @@ static const char *session_type_to_sql(TrainlogSessionType type) {
case TRAINLOG_SESSION_MAX_TEST:
return "max_test";
case TRAINLOG_SESSION_CARDIO:
return "cardio";
default:
return NULL;
}
@ -3135,6 +3187,11 @@ static bool session_type_from_sql(const char *text, TrainlogSessionType *output)
return true;
}
if (strcmp(text, "cardio") == 0) {
*output = TRAINLOG_SESSION_CARDIO;
return true;
}
return false;
}
@ -3912,16 +3969,19 @@ static TrainlogStatus insert_session_header(TrainlogDatabase *database,
const TrainlogSessionInput *session,
sqlite3_int64 *output_row_id) {
static const char *const SQL = "INSERT INTO sessions("
"session_id, started_at, ended_at, session_type, notes"
") VALUES(?1, ?2, ?3, ?4, ?5);";
"session_id, started_at, ended_at, session_type, session_kind, notes"
") VALUES(?1, ?2, ?3, ?4, ?5, ?6);";
sqlite3_stmt *statement = NULL;
const char *session_type;
const char *session_kind;
const char *legacy_session_type;
int rc;
session_type = session_type_to_sql(session->session_type);
session_kind = session_type_to_sql(session->session_type);
legacy_session_type =
session->session_type == TRAINLOG_SESSION_CARDIO ? "training" : session_kind;
if (session_type == NULL) {
if (session_kind == NULL || legacy_session_type == NULL) {
return TRAINLOG_STATUS_INVALID_ARGUMENT;
}
@ -3943,13 +4003,17 @@ static TrainlogStatus insert_session_header(TrainlogDatabase *database,
}
if (rc == SQLITE_OK) {
rc = sqlite3_bind_text(statement, 4, session_type, -1, SQLITE_STATIC);
rc = sqlite3_bind_text(statement, 4, legacy_session_type, -1, SQLITE_STATIC);
}
if (rc == SQLITE_OK) {
rc = sqlite3_bind_text(statement, 5, session_kind, -1, SQLITE_STATIC);
}
if (rc == SQLITE_OK) {
rc = session->notes != NULL && session->notes[0] != '\0'
? sqlite3_bind_text(statement, 5, session->notes, -1, SQLITE_TRANSIENT)
: sqlite3_bind_null(statement, 5);
? sqlite3_bind_text(statement, 6, session->notes, -1, SQLITE_TRANSIENT)
: sqlite3_bind_null(statement, 6);
}
if (rc != SQLITE_OK) {
@ -4524,7 +4588,7 @@ trainlog_database_replace_session_exercises(TrainlogDatabase *database,
}
rc = sqlite3_prepare_v2(database->connection,
"SELECT session_type FROM sessions WHERE id=?1;",
"SELECT session_kind FROM sessions WHERE id=?1;",
-1,
&statement,
NULL);
@ -4736,7 +4800,7 @@ TrainlogStatus trainlog_database_list_sessions(TrainlogDatabase *database,
"s.session_id, "
"s.started_at, "
"COALESCE(s.ended_at, ''), "
"s.session_type, "
"s.session_kind, "
"COUNT(se.id) "
"FROM sessions AS s "
"LEFT JOIN session_exercises AS se "
@ -5254,7 +5318,7 @@ trainlog_database_get_session_details(TrainlogDatabase *database,
TrainlogPersistedExerciseDetail *output_exercises,
size_t exercise_capacity,
size_t *output_exercise_count) {
static const char *const HEADER_SQL = "SELECT started_at, COALESCE(ended_at, ''), session_type "
static const char *const HEADER_SQL = "SELECT started_at, COALESCE(ended_at, ''), session_kind "
"FROM sessions "
"WHERE session_id = ?1;";
@ -5781,7 +5845,7 @@ TrainlogStatus trainlog_database_list_exercise_performance(TrainlogDatabase *dat
static const char *const SQL = "SELECT "
"s.session_id, "
"s.started_at, "
"s.session_type, "
"s.session_kind, "
"se.tracking_mode, "
"se.load_mode, "
"ps.id, "

View file

@ -53,7 +53,7 @@ int main(void) {
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 31);
CHECK(scalar(raw, "PRAGMA user_version") == 32);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events',"
@ -97,7 +97,7 @@ int main(void) {
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 31);
CHECK(scalar(raw, "PRAGMA user_version") == 32);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
"'sleep_medication%'") == 3);

View file

@ -52,7 +52,7 @@ int main(void) {
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 31);
CHECK(version == 32);
trainlog_database_close(production);
production = NULL;

View file

@ -49,7 +49,7 @@ int main(void) {
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 31);
CHECK(version == 32);
trainlog_database_close(production);
production = NULL;

View file

@ -0,0 +1,73 @@
#define _POSIX_C_SOURCE 200809L
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include "trainlog/database.h"
#define CHECK(value) do { if (!(value)) { fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); goto cleanup; } } while (0)
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;
}
int main(void) {
char path[] = "/tmp/trainlog-schema-v32-XXXXXX";
TrainlogDatabase *production = NULL;
sqlite3 *raw = NULL;
int descriptor = mkstemp(path);
int version = 0;
int result = EXIT_FAILURE;
CHECK(descriptor >= 0 && close(descriptor) == 0);
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw,
"ALTER TABLE sessions DROP COLUMN session_kind;"
"PRAGMA user_version=31;",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 32);
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM pragma_table_info('sessions') "
"WHERE name='session_kind'") == 1);
CHECK(sqlite3_exec(raw,
"INSERT INTO sessions(session_id,started_at,ended_at,session_type,session_kind)"
"VALUES('se_11111111-1111-4111-8111-111111111111',"
"'2026-09-23T09:00:00+02:00','2026-09-23T09:30:00+02:00',"
"'training','cardio');",
NULL, NULL, NULL) == SQLITE_OK);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sessions WHERE session_type='training' "
"AND session_kind='cardio'") == 1);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0);
result = EXIT_SUCCESS;
cleanup:
if (production != NULL) trainlog_database_close(production);
if (raw != NULL) (void)sqlite3_close(raw);
(void)unlink(path);
if (result == EXIT_SUCCESS) puts("PASS schema v31 to v32 cardio session kind migration");
return result;
}

View file

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

View file

@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "migrate database to schema v31") != NULL);
CHECK(strstr(diagnostic, "migrate database to schema v32") != NULL);
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);