feat(cardio): add cardio session type
This commit is contained in:
parent
070a29298f
commit
3ded6ee425
71 changed files with 1115 additions and 149 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
128
tests/test_cardio_session_exchange.py
Normal file
128
tests/test_cardio_session_exchange.py
Normal 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()
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
126
tools/cardio_session_exchange.py
Normal file
126
tools/cardio_session_exchange.py
Normal 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
|
||||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,"
|
||||
|
|
|
|||
|
|
@ -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,"
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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"]):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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, "
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
73
tui/tests/test_schema_v32_migration.c
Normal file
73
tui/tests/test_schema_v32_migration.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue