feat(cardio): add heart rate persistence and sync

This commit is contained in:
fy59 2026-09-23 09:40:05 +02:00
parent c13549338c
commit 14c3ac294e
60 changed files with 1445 additions and 88 deletions

View file

@ -2,6 +2,14 @@
## Unreleased — 0.1.5
- Added the Cardio V1 persistence/synchronization foundation. Android schema
v27 owns durable timestamped heart-rate captures, samples and raw RR values;
desktop schema v30 imports the optional `trainlog-heart-rate` V1 companion
transactionally. Exact generation membership is retained until a correlated
consumed ACK, after which those stopped captures remain local history but are
excluded from ordinary re-publication. Desktop/Web remain sensor consumers
only; Bluetooth Classic Trainlog synchronization is unchanged.
Development opened after stable v0.1.4 release.
## 0.1.4 — 2026-09-22

View file

@ -36,7 +36,7 @@ Programs. Sessions → Programmes remains the technical administration/import,
list, detail, archive, and delete surface.
Web Exercises administration, the factual Dashboard/Analyse read model, and the
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version
0.1.5 development is focused on cardio and heart-rate sensor capture.
0.1.5 development has the Android/desktop heart-rate persistence and synchronization foundation in place; Android BLE sensor acquisition is the current implementation cursor.
## Releases

View file

@ -225,7 +225,7 @@ internal class AndroidBackupService(
manifest.getInt("version") == VERSION &&
manifest.getString("package") == context.packageName &&
manifest.getBoolean("plaintext") &&
manifest.getInt("schema") in 17..26
manifest.getInt("schema") in 17..27
) {
"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..26) { "Unsupported Android schema ${db.version}." }
require(db.version in 17..27) { "Unsupported Android schema ${db.version}." }
return db.version
} finally {
db.close()

View file

@ -69,6 +69,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
"program-executions" to
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
"sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-v1.json", false),
"heart-rate" to Kind("trainlog-heart-rate", 1, "heart-rate-v1.json", false),
)
private val SUPPORTED =
(ANDROID_KINDS.values +
@ -422,6 +423,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(generationId, entry.getString("entry_id"), entry.getString("revision_id")),
)
}
val heartRate = JSONObject(checkNotNull(artifacts["heart-rate"]))
val captures = heartRate.getJSONArray("captures")
for (index in 0 until captures.length()) {
db.execSQL(
"INSERT INTO heart_rate_generation_captures(generation_id,capture_id) VALUES(?,?)",
arrayOf(generationId, captures.getJSONObject(index).getString("capture_id")),
)
}
}
return CapturedSyncGeneration(generationId, runId, digest, stage)
} catch (error: Exception) {
@ -1059,6 +1068,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(ack.getString("generation_id"), ack.getString("consumed_at"),
ack.getString("generation_id")),
)
db.execSQL(
"UPDATE heart_rate_captures SET acknowledged_at=? WHERE " +
"acknowledged_at IS NULL AND EXISTS(" +
"SELECT 1 FROM heart_rate_generation_captures m " +
"WHERE m.generation_id=? AND " +
"m.capture_id=heart_rate_captures.capture_id)",
arrayOf(ack.getString("consumed_at"), ack.getString("generation_id")),
)
}
target
}

View file

@ -22,6 +22,10 @@ import com.labfytools.trainlog.model.ExerciseProfile
import com.labfytools.trainlog.model.ExerciseEditInput
import com.labfytools.trainlog.model.ExerciseFeedback
import com.labfytools.trainlog.model.DraftExerciseFeedback
import com.labfytools.trainlog.model.HeartRateCapture
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.HeartRateRrInterval
import com.labfytools.trainlog.model.HeartRateSample
import com.labfytools.trainlog.model.LatestExerciseMax
import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.PreparedSession
@ -486,6 +490,21 @@ class TrainlogRepository(
data class Rejected(val reason: String) : SleepDiaryImportResult
}
sealed interface StartHeartRateCaptureResult {
data class Started(val captureId: String) : StartHeartRateCaptureResult
data object Invalid : StartHeartRateCaptureResult
data object Conflict : StartHeartRateCaptureResult
data object DatabaseError : StartHeartRateCaptureResult
}
sealed interface HeartRateMutationResult {
data class Applied(val sequence: Long? = null) : HeartRateMutationResult
data object Invalid : HeartRateMutationResult
data object NotFound : HeartRateMutationResult
data object Conflict : HeartRateMutationResult
data object DatabaseError : HeartRateMutationResult
}
private fun sleepId(prefix: String): String = "${prefix}_${UUID.randomUUID()}"
private fun sleepIdentity(value: String, prefix: String): Boolean =
@ -1139,6 +1158,391 @@ class TrainlogRepository(
)
}
private fun heartRateContextIdValid(kind: HeartRateContextKind, value: String): Boolean =
when (kind) {
HeartRateContextKind.SESSION,
HeartRateContextKind.CARDIO ->
Regex("^se_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$")
.matches(value)
HeartRateContextKind.SLEEP -> sleepIdentity(value, "sl")
}
private fun heartRateContextExists(
db: SQLiteDatabase,
kind: HeartRateContextKind,
contextId: String,
): Boolean =
when (kind) {
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",
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",
arrayOf(contextId),
).use { it.moveToFirst() }
HeartRateContextKind.SLEEP ->
db.rawQuery(
"SELECT 1 FROM sleep_diary_entries WHERE entry_id=? AND deleted=0 LIMIT 1",
arrayOf(contextId),
).use { it.moveToFirst() }
}
fun startHeartRateCapture(
contextKind: HeartRateContextKind,
contextId: String,
startedAt: String,
sensorName: String? = null,
): StartHeartRateCaptureResult {
val normalizedSensor = sensorName?.trim()?.takeIf { it.isNotEmpty() }
if (
!heartRateContextIdValid(contextKind, contextId) ||
TrainlogTimestamp.parse(startedAt) == null ||
(normalizedSensor != null &&
normalizedSensor.toByteArray(StandardCharsets.UTF_8).size >
MAX_HEART_RATE_SENSOR_NAME_UTF8_BYTES)
) return StartHeartRateCaptureResult.Invalid
val db = database.writableDatabase
db.beginTransaction()
return try {
if (!heartRateContextExists(db, contextKind, contextId)) {
StartHeartRateCaptureResult.Invalid
} else if (
db.rawQuery(
"SELECT 1 FROM heart_rate_captures WHERE active_slot=1 LIMIT 1",
null,
).use { it.moveToFirst() }
) {
StartHeartRateCaptureResult.Conflict
} else {
val captureId = "hrc_${UUID.randomUUID()}"
db.execSQL(
"INSERT INTO heart_rate_captures(" +
"capture_id,context_kind,context_id,started_at,ended_at,active_slot," +
"active_exercise_entry_id,sensor_name,acknowledged_at) " +
"VALUES(?,?,?,?,NULL,1,NULL,?,NULL)",
arrayOf(captureId, contextKind.wireValue, contextId, startedAt, normalizedSensor),
)
db.setTransactionSuccessful()
StartHeartRateCaptureResult.Started(captureId)
}
} catch (_: SQLiteConstraintException) {
StartHeartRateCaptureResult.Conflict
} catch (_: android.database.sqlite.SQLiteException) {
StartHeartRateCaptureResult.DatabaseError
} finally {
db.endTransaction()
}
}
fun setHeartRateActiveExercise(
captureId: String,
exerciseEntryId: String?,
): HeartRateMutationResult {
if (
!sleepIdentity(captureId, "hrc") ||
(exerciseEntryId != null && !SESSION_ENTRY_ID_V4_PATTERN.matches(exerciseEntryId))
) return HeartRateMutationResult.Invalid
val db = database.writableDatabase
db.beginTransaction()
return try {
val context =
db.rawQuery(
"SELECT context_kind,context_id FROM heart_rate_captures " +
"WHERE capture_id=? AND active_slot=1",
arrayOf(captureId),
).use { cursor ->
if (!cursor.moveToFirst()) null else cursor.getString(0) to cursor.getString(1)
} ?: return HeartRateMutationResult.NotFound
if (context.first !in setOf(
HeartRateContextKind.SESSION.wireValue,
HeartRateContextKind.CARDIO.wireValue,
)
) {
HeartRateMutationResult.Invalid
} else if (
exerciseEntryId != null &&
!db.rawQuery(
"SELECT 1 FROM draft_session_exercises de " +
"JOIN active_session_draft d ON d.id=de.draft_id " +
"WHERE d.id=1 AND d.session_id=? AND de.entry_id=? LIMIT 1",
arrayOf(context.second, exerciseEntryId),
).use { it.moveToFirst() }
) {
HeartRateMutationResult.Invalid
} else {
val values = ContentValues().apply {
if (exerciseEntryId == null) putNull("active_exercise_entry_id")
else put("active_exercise_entry_id", exerciseEntryId)
}
if (
db.update(
"heart_rate_captures",
values,
"capture_id=? AND active_slot=1",
arrayOf(captureId),
) != 1
) HeartRateMutationResult.Conflict
else {
db.setTransactionSuccessful()
HeartRateMutationResult.Applied()
}
}
} catch (_: android.database.sqlite.SQLiteException) {
HeartRateMutationResult.DatabaseError
} finally {
db.endTransaction()
}
}
fun appendHeartRateSample(
captureId: String,
observedAt: String,
bpm: Int,
sensorContactDetected: Boolean? = null,
energyExpended: Int? = null,
rrIntervals: List<HeartRateRrInterval> = emptyList(),
): HeartRateMutationResult {
val observed = TrainlogTimestamp.parse(observedAt)
if (
!sleepIdentity(captureId, "hrc") ||
observed == null ||
bpm !in 0..65535 ||
(energyExpended != null && energyExpended !in 0..65535) ||
rrIntervals.size > MAX_HEART_RATE_RR_PER_SAMPLE ||
rrIntervals.withIndex().any { (index, rr) ->
rr.index != index || rr.value1024 !in 0..65535
}
) return HeartRateMutationResult.Invalid
val db = database.writableDatabase
db.beginTransaction()
return try {
val capture =
db.rawQuery(
"SELECT started_at,active_exercise_entry_id FROM heart_rate_captures " +
"WHERE capture_id=? AND active_slot=1",
arrayOf(captureId),
).use { cursor ->
if (!cursor.moveToFirst()) null
else cursor.getString(0) to
if (cursor.isNull(1)) null else cursor.getString(1)
} ?: return HeartRateMutationResult.NotFound
val started = TrainlogTimestamp.parse(capture.first)
?: return HeartRateMutationResult.DatabaseError
if (observed < started) return HeartRateMutationResult.Invalid
val sequence =
db.rawQuery(
"SELECT COALESCE(MAX(sequence),-1)+1 FROM heart_rate_samples WHERE capture_id=?",
arrayOf(captureId),
).use { cursor ->
check(cursor.moveToFirst())
cursor.getLong(0)
}
if (sequence >= MAX_HEART_RATE_SAMPLES_PER_CAPTURE.toLong()) {
return HeartRateMutationResult.Conflict
}
db.execSQL(
"INSERT INTO heart_rate_samples(" +
"capture_id,sequence,observed_at,bpm,exercise_entry_id," +
"sensor_contact_detected,energy_expended) VALUES(?,?,?,?,?,?,?)",
arrayOf<Any?>(
captureId,
sequence,
observedAt,
bpm,
capture.second,
sensorContactDetected?.let { if (it) 1 else 0 },
energyExpended,
),
)
rrIntervals.forEach { rr ->
db.execSQL(
"INSERT INTO heart_rate_rr_intervals(" +
"capture_id,sample_sequence,rr_index,value_1024) VALUES(?,?,?,?)",
arrayOf<Any>(captureId, sequence, rr.index, rr.value1024),
)
}
db.setTransactionSuccessful()
HeartRateMutationResult.Applied(sequence)
} catch (_: SQLiteConstraintException) {
HeartRateMutationResult.Conflict
} catch (_: android.database.sqlite.SQLiteException) {
HeartRateMutationResult.DatabaseError
} finally {
db.endTransaction()
}
}
fun stopHeartRateCapture(
captureId: String,
endedAt: String,
): HeartRateMutationResult {
val ended = TrainlogTimestamp.parse(endedAt)
if (!sleepIdentity(captureId, "hrc") || ended == null)
return HeartRateMutationResult.Invalid
val db = database.writableDatabase
db.beginTransaction()
return try {
val startedText =
db.rawQuery(
"SELECT started_at FROM heart_rate_captures WHERE capture_id=? AND active_slot=1",
arrayOf(captureId),
).use { cursor ->
if (!cursor.moveToFirst()) null else cursor.getString(0)
} ?: return HeartRateMutationResult.NotFound
val started: TrainlogTimestampKey =
TrainlogTimestamp.parse(startedText)
?: return HeartRateMutationResult.DatabaseError
if (ended < started) return HeartRateMutationResult.Invalid
val values = ContentValues().apply {
put("ended_at", endedAt)
putNull("active_slot")
putNull("active_exercise_entry_id")
}
if (
db.update(
"heart_rate_captures",
values,
"capture_id=? AND active_slot=1",
arrayOf(captureId),
) != 1
) HeartRateMutationResult.Conflict
else {
db.setTransactionSuccessful()
HeartRateMutationResult.Applied()
}
} catch (_: android.database.sqlite.SQLiteException) {
HeartRateMutationResult.DatabaseError
} finally {
db.endTransaction()
}
}
fun activeHeartRateCapture(): HeartRateCapture? {
val db = database.readableDatabase
return db.rawQuery(
"SELECT capture_id,context_kind,context_id,started_at,ended_at," +
"active_exercise_entry_id,sensor_name FROM heart_rate_captures " +
"WHERE active_slot=1 LIMIT 1",
null,
).use { cursor ->
if (!cursor.moveToFirst()) null
else HeartRateCapture(
captureId = cursor.getString(0),
contextKind = HeartRateContextKind.fromWire(cursor.getString(1)),
contextId = cursor.getString(2),
startedAt = cursor.getString(3),
endedAt = if (cursor.isNull(4)) null else cursor.getString(4),
activeExerciseEntryId = if (cursor.isNull(5)) null else cursor.getString(5),
sensorName = if (cursor.isNull(6)) null else cursor.getString(6),
)
}
}
internal fun buildHeartRateV1Json(): String {
val db = database.readableDatabase
val captures = JSONArray()
db.rawQuery(
"SELECT c.capture_id,c.context_kind,c.context_id,c.started_at,c.ended_at,c.sensor_name " +
"FROM heart_rate_captures c WHERE c.ended_at IS NOT NULL " +
"AND c.acknowledged_at IS NULL AND NOT EXISTS(" +
"SELECT 1 FROM heart_rate_generation_captures m " +
"JOIN sync_generations g ON g.generation_id=m.generation_id " +
"WHERE m.capture_id=c.capture_id AND " +
"g.status IN('captured','published','waiting_acknowledgement')) " +
"ORDER BY c.ended_at,c.capture_id LIMIT ?",
arrayOf(MAX_HEART_RATE_CAPTURES_PER_GENERATION.toString()),
).use { cursor ->
while (cursor.moveToNext()) {
val captureId = cursor.getString(0)
val samples = JSONArray()
val rrBySequence = mutableMapOf<Long, JSONArray>()
db.rawQuery(
"SELECT sample_sequence,rr_index,value_1024 FROM heart_rate_rr_intervals " +
"WHERE capture_id=? ORDER BY sample_sequence,rr_index",
arrayOf(captureId),
).use { rrCursor ->
var currentSequence: Long? = null
var expectedIndex = 0
while (rrCursor.moveToNext()) {
val sequence = rrCursor.getLong(0)
if (sequence != currentSequence) {
currentSequence = sequence
expectedIndex = 0
}
check(rrCursor.getInt(1) == expectedIndex++)
rrBySequence.getOrPut(sequence) { JSONArray() }.put(rrCursor.getInt(2))
}
}
db.rawQuery(
"SELECT sequence,observed_at,bpm,exercise_entry_id," +
"sensor_contact_detected,energy_expended FROM heart_rate_samples " +
"WHERE capture_id=? ORDER BY sequence",
arrayOf(captureId),
).use { sampleCursor ->
var count = 0
while (sampleCursor.moveToNext()) {
check(++count <= MAX_HEART_RATE_SAMPLES_PER_CAPTURE)
val sequence = sampleCursor.getLong(0)
val rr = rrBySequence.remove(sequence) ?: JSONArray()
samples.put(
JSONObject()
.put("sequence", sequence)
.put("observed_at", sampleCursor.getString(1))
.put("bpm", sampleCursor.getInt(2))
.put(
"exercise_entry_id",
if (sampleCursor.isNull(3)) JSONObject.NULL
else sampleCursor.getString(3),
)
.put(
"sensor_contact_detected",
if (sampleCursor.isNull(4)) JSONObject.NULL
else sampleCursor.getInt(4) != 0,
)
.put(
"energy_expended",
if (sampleCursor.isNull(5)) JSONObject.NULL
else sampleCursor.getInt(5),
)
.put("rr_intervals_1024", rr),
)
}
}
check(rrBySequence.isEmpty())
captures.put(
JSONObject()
.put("capture_id", captureId)
.put("context_kind", cursor.getString(1))
.put("context_id", cursor.getString(2))
.put("started_at", cursor.getString(3))
.put("ended_at", cursor.getString(4))
.put(
"sensor_name",
if (cursor.isNull(5)) JSONObject.NULL else cursor.getString(5),
)
.put("samples", samples),
)
}
}
return JSONObject()
.put("format", "trainlog-heart-rate")
.put("version", 1)
.put("generated_at", OffsetDateTime.now().toString())
.put("captures", captures)
.toString()
}
/** Create a transactionally consistent SQLite snapshot without exposing WAL files. */
internal fun backupDatabaseTo(destination: File) {
require(!destination.exists()) { "Backup snapshot destination already exists." }
@ -1185,6 +1589,7 @@ class TrainlogRepository(
artifacts["causal-deletions"] = buildCausalDeletionExportV1Json()
artifacts["program-executions"] = buildProgramExecutionsV1Json()
artifacts["sleep-diary"] = buildSleepDiaryV1Json()
artifacts["heart-rate"] = buildHeartRateV1Json()
artifacts
}
@ -9554,6 +9959,10 @@ private val SESSION_ENTRY_ID_V4_PATTERN = Regex(
"^sxe_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$",
)
private const val MAX_AI_DRAFTS = 256
private const val MAX_HEART_RATE_CAPTURES_PER_GENERATION = 256
private const val MAX_HEART_RATE_SAMPLES_PER_CAPTURE = 200000
private const val MAX_HEART_RATE_RR_PER_SAMPLE = 64
private const val MAX_HEART_RATE_SENSOR_NAME_UTF8_BYTES = 160
private const val MAX_AI_DRAFT_ENTRIES = 64
private const val MAX_AI_TARGET_SETS = 99
private const val MAX_AI_TARGET_REPS = 999
@ -9572,7 +9981,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
26,
27,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -9626,6 +10035,7 @@ private class TrainlogDatabaseHelper(
createSyncedProgramTables(db)
createProgramExecutionProvenance(db)
createSleepDiaryTables(db)
createHeartRateTables(db)
seedEquipment(db)
}
@ -9839,6 +10249,14 @@ private class TrainlogDatabaseHelper(
createSleepDiaryTables(db)
version = 26
}
if (version < 27 && newVersion >= 27) {
/* WHY: heart-rate samples are Android-owned measured field data.
* CONTRACT: v27 adds only cardio captures/samples and generation
* publication state. INVARIANT: no workout or sleep row is
* rewritten and no historical timestamp is invented. */
createHeartRateTables(db)
version = 27
}
if (version != newVersion) {
error(
@ -9977,6 +10395,71 @@ private class TrainlogDatabaseHelper(
PRIMARY KEY(generation_id,entry_id))""".trimIndent())
}
private fun createHeartRateTables(db: SQLiteDatabase) {
db.execSQL(
"""CREATE TABLE IF NOT EXISTS heart_rate_captures(
capture_id TEXT PRIMARY KEY,
context_kind TEXT NOT NULL CHECK(context_kind IN('session','cardio','sleep')),
context_id TEXT NOT NULL,
started_at TEXT NOT NULL,
ended_at TEXT,
active_slot INTEGER UNIQUE CHECK(active_slot IS NULL OR active_slot=1),
active_exercise_entry_id TEXT,
sensor_name TEXT,
acknowledged_at TEXT,
CHECK(capture_id GLOB 'hrc_*'),
CHECK((context_kind IN('session','cardio') AND context_id GLOB 'se_*') OR
(context_kind='sleep' AND context_id GLOB 'sl_*')),
CHECK(active_exercise_entry_id IS NULL OR active_exercise_entry_id GLOB 'sxe_*'),
CHECK(sensor_name IS NULL OR length(CAST(sensor_name AS BLOB))<=160)
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS heart_rate_samples(
capture_id TEXT NOT NULL
REFERENCES heart_rate_captures(capture_id) ON DELETE CASCADE,
sequence INTEGER NOT NULL CHECK(sequence>=0),
observed_at TEXT NOT NULL,
bpm INTEGER NOT NULL CHECK(bpm BETWEEN 0 AND 65535),
exercise_entry_id TEXT,
sensor_contact_detected INTEGER
CHECK(sensor_contact_detected IS NULL OR sensor_contact_detected IN(0,1)),
energy_expended INTEGER
CHECK(energy_expended IS NULL OR energy_expended BETWEEN 0 AND 65535),
PRIMARY KEY(capture_id,sequence),
CHECK(exercise_entry_id IS NULL OR exercise_entry_id GLOB 'sxe_*')
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS heart_rate_rr_intervals(
capture_id TEXT NOT NULL,
sample_sequence INTEGER NOT NULL,
rr_index INTEGER NOT NULL CHECK(rr_index BETWEEN 0 AND 63),
value_1024 INTEGER NOT NULL CHECK(value_1024 BETWEEN 0 AND 65535),
PRIMARY KEY(capture_id,sample_sequence,rr_index),
FOREIGN KEY(capture_id,sample_sequence)
REFERENCES heart_rate_samples(capture_id,sequence) ON DELETE CASCADE
)""".trimIndent(),
)
db.execSQL(
"""CREATE TABLE IF NOT EXISTS heart_rate_generation_captures(
generation_id TEXT NOT NULL
REFERENCES sync_generations(generation_id) ON DELETE RESTRICT,
capture_id TEXT NOT NULL
REFERENCES heart_rate_captures(capture_id) ON DELETE RESTRICT,
PRIMARY KEY(generation_id,capture_id)
)""".trimIndent(),
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS heart_rate_samples_time " +
"ON heart_rate_samples(observed_at,capture_id,sequence)",
)
db.execSQL(
"CREATE INDEX IF NOT EXISTS heart_rate_captures_context " +
"ON heart_rate_captures(context_kind,context_id,started_at)",
)
}
private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {
fun addColumn(table: String, name: String, declaration: String) {
if (!tableHasColumn(db, table, name)) {

View file

@ -0,0 +1,46 @@
/*
* Android HeartRateModels model.
*
* Android owns sensor acquisition. Desktop/Web only consume synchronized
* measured facts and never connect to the heart-rate sensor.
*/
package com.labfytools.trainlog.model
enum class HeartRateContextKind(val wireValue: String) {
SESSION("session"),
CARDIO("cardio"),
SLEEP("sleep");
companion object {
fun fromWire(value: String): HeartRateContextKind =
entries.firstOrNull { it.wireValue == value }
?: error("Unknown heart-rate context: $value")
}
}
data class HeartRateRrInterval(
val index: Int,
/** Raw Bluetooth Heart Rate Service unit: 1/1024 second. */
val value1024: Int,
)
data class HeartRateSample(
val sequence: Long,
val observedAt: String,
val bpm: Int,
val exerciseEntryId: String? = null,
val sensorContactDetected: Boolean? = null,
val energyExpended: Int? = null,
val rrIntervals: List<HeartRateRrInterval> = emptyList(),
)
data class HeartRateCapture(
val captureId: String,
val contextKind: HeartRateContextKind,
val contextId: String,
val startedAt: String,
val endedAt: String?,
val activeExerciseEntryId: String?,
val sensorName: String?,
val samples: List<HeartRateSample> = emptyList(),
)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,54 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
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 AndroidV27HeartRateMigrationTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun v26AddsEmptyHeartRateDomainAndReopens() {
val name = "heart-rate-v26-${UUID.randomUUID()}.db"
TrainlogRepository(context, name).also {
JSONObject(it.buildHeartRateV1Json())
it.close()
}
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
).use { db ->
db.execSQL("DROP TABLE heart_rate_generation_captures")
db.execSQL("DROP TABLE heart_rate_rr_intervals")
db.execSQL("DROP TABLE heart_rate_samples")
db.execSQL("DROP TABLE heart_rate_captures")
db.version = 26
}
var repository = TrainlogRepository(context, name)
try {
assertEquals(0, JSONObject(repository.buildHeartRateV1Json())
.getJSONArray("captures").length())
repository.close()
repository = TrainlogRepository(context, name)
SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(27, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

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

View file

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

View file

@ -0,0 +1,96 @@
package com.labfytools.trainlog.data
import android.content.Context
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.HeartRateContextKind
import com.labfytools.trainlog.model.HeartRateRrInterval
import com.labfytools.trainlog.model.SleepDiaryDraft
import java.util.UUID
import org.json.JSONObject
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNull
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 HeartRateRepositoryTest {
private val context: Context = ApplicationProvider.getApplicationContext()
@Test
fun sleepCaptureStoresRawSamplesAndExportsOnlyAfterStop() {
val name = "heart-rate-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
try {
val created =
repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-22",
nightEndDate = "2026-09-23",
createdAt = "2026-09-22T22:00:00+02:00",
updatedAt = "2026-09-22T22:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val started =
repository.startHeartRateCapture(
HeartRateContextKind.SLEEP,
created.entryId,
"2026-09-22T22:30:00+02:00",
"Synthetic HR sensor",
) as TrainlogRepository.StartHeartRateCaptureResult.Started
assertEquals(started.captureId, repository.activeHeartRateCapture()?.captureId)
val sample =
repository.appendHeartRateSample(
captureId = started.captureId,
observedAt = "2026-09-22T22:30:01+02:00",
bpm = 61,
sensorContactDetected = true,
energyExpended = 7,
rrIntervals = listOf(
HeartRateRrInterval(0, 1024),
HeartRateRrInterval(1, 1000),
),
) as TrainlogRepository.HeartRateMutationResult.Applied
assertEquals(0L, sample.sequence)
assertEquals(
0,
JSONObject(repository.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
assertTrue(
repository.stopHeartRateCapture(
started.captureId,
"2026-09-23T06:30:00+02:00",
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertNull(repository.activeHeartRateCapture())
val root = JSONObject(repository.buildHeartRateV1Json())
assertEquals("trainlog-heart-rate", root.getString("format"))
assertEquals(1, root.getInt("version"))
val capture = root.getJSONArray("captures").getJSONObject(0)
assertEquals(created.entryId, capture.getString("context_id"))
assertEquals("sleep", capture.getString("context_kind"))
assertEquals("Synthetic HR sensor", capture.getString("sensor_name"))
val stored = capture.getJSONArray("samples").getJSONObject(0)
assertEquals(61, stored.getInt("bpm"))
assertTrue(stored.getBoolean("sensor_contact_detected"))
assertEquals(7, stored.getInt("energy_expended"))
assertEquals(1024, stored.getJSONArray("rr_intervals_1024").getInt(0))
assertEquals(1000, stored.getJSONArray("rr_intervals_1024").getInt(1))
} finally {
repository.close()
context.deleteDatabase(name)
}
}
}

View file

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

View file

@ -3,11 +3,14 @@ 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.HeartRateContextKind
import com.labfytools.trainlog.model.HeartRateRrInterval
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.SleepDiaryDraft
import com.labfytools.trainlog.model.TrackingMode
import java.io.File
import java.nio.file.Files
@ -506,7 +509,7 @@ class SyncGenerationServiceTest {
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(26, it.getInt(0))
assertEquals(27, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())
@ -855,7 +858,54 @@ class SyncGenerationServiceTest {
)
)!!
.groupValues[1]
val sleepEntry =
source.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-09-22",
nightEndDate = "2026-09-23",
createdAt = "2026-09-22T22:00:00+02:00",
updatedAt = "2026-09-22T22:00:00+02:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
val heartCapture =
source.startHeartRateCapture(
HeartRateContextKind.SLEEP,
sleepEntry.entryId,
"2026-09-22T22:30:00+02:00",
"Synthetic HR",
) as TrainlogRepository.StartHeartRateCaptureResult.Started
assertTrue(
source.appendHeartRateSample(
heartCapture.captureId,
"2026-09-22T22:30:01+02:00",
61,
true,
7,
listOf(HeartRateRrInterval(0, 1024)),
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
assertTrue(
source.stopHeartRateCapture(
heartCapture.captureId,
"2026-09-22T22:31:00+02:00",
) is TrainlogRepository.HeartRateMutationResult.Applied,
)
val androidToDesktop = sourceService.capture(root, desktopPeer)
assertEquals(
1,
JSONObject(
java.io.File(androidToDesktop.stagingDirectory, "heart-rate-v1.json")
.readText(),
)
.getJSONArray("captures")
.length(),
)
val androidPublished =
sourceService.publish(
androidToDesktop,
@ -872,10 +922,31 @@ class SyncGenerationServiceTest {
"--ack-output",
androidAckFile.absolutePath,
)
val androidAck = JSONObject(androidAckFile.readText())
assertEquals(
"desktop ACK diagnostic: ${androidAck.getString("diagnostic")}",
"consumed",
androidAck.getString("result"),
)
assertEquals(
"acknowledged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()),
)
assertEquals(
0,
JSONObject(source.buildHeartRateV1Json()).getJSONArray("captures").length(),
)
assertEquals(
"1|1|1",
run(
"sqlite3",
desktopConsumerDb.absolutePath,
"SELECT (SELECT count(*) FROM heart_rate_captures) || '|' || " +
"(SELECT count(*) FROM heart_rate_samples) || '|' || " +
"(SELECT count(*) FROM heart_rate_rr_intervals);",
)
.trim(),
)
val androidSessionId = source.listSessions().single().sessionId
assertEquals(
"1",

View file

@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
).use { db ->
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
assertTrue(cursor.moveToFirst())
assertEquals(26, cursor.getInt(0))
assertEquals(27, 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(26, cursor.getInt(0))
assertEquals(27, cursor.getInt(0))
}
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
assertTrue(cursor.moveToFirst())

View file

@ -112,8 +112,8 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v29; Sleep Diary V1 is additive in v29; Cardio v30 work is implementation-started but not yet closed |
| Android SQLite | schema v26; Sleep Diary V1 is additive in v26; Cardio v27 work is implementation-started but not yet closed |
| Desktop SQLite | schema v30; synchronized Heart Rate V1 storage is additive in v30 |
| Android SQLite | schema v27; Android-owned Heart Rate V1 capture storage is additive in v27 |
| 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` |
@ -147,7 +147,8 @@ parallel implementation of its rules.
| Private full-generation rollout | `TRAINLOG_SYNC_FINALIZATION_AND_ROLLOUT_V1=PASS` (authenticated Bluetooth primary transport with direct MTP recovery on one authorized phone) |
| Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` |
| Current operational cursor | `TRAINLOG_CARDIO_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED`: Android-only sensor ownership, training timeline, Sleep one-tap capture, cardio session/calibration and BPM-guidance contract frozen; persistence/sync implementation is the next closeout gate |
| Cardio persistence/sync | `TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS`: Android v27 + desktop v30, strict optional `trainlog-heart-rate` V1 Android→desktop companion, generation relation and correlated ACK |
| Current operational cursor | `TRAINLOG_CARDIO_BLE_DIAGNOSTICS_V1`: standard Heart Rate Service parser, Android BLE discovery and real-sensor characterization |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |

View file

@ -1,5 +1,25 @@
# Desktop database
## Schema v30: synchronized Heart Rate V1
Schema v30 additively owns Android-recorded heart-rate captures after
full-generation synchronization. `heart_rate_captures` stores one immutable
stopped capture with its stable `hrc_` identity, owner kind (`session` or
`sleep`), stable owner identity, exact start/end timestamps, optional sensor
name, and import time.
`heart_rate_samples` stores the received timestamp, measured BPM, optional
exercise `entry_id` snapshot, optional sensor-contact state, and optional
cumulative energy value. `heart_rate_rr_intervals` preserves zero or more raw
Heart Rate Service RR values in their native 1/1024-second units. Desktop never
creates these measurements and never connects to the sensor.
The v29→v30 migration is additive and creates no cardio row. Heart-rate data
travels Android→desktop through the separate optional `trainlog-heart-rate`
V1 generation companion. Exact replay is idempotent; a stable capture/sample
identity with different measured content is rejected. Existing workout,
Sleep Diary, and frozen mobile formats are unchanged.
## Schema v29: Sleep Diary V1
Schema v29 additively owns `sleep_diary_entries`, immutable
@ -9,14 +29,14 @@ one night and its associated day. Point events have an offset-bearing
their start. The 18:00-to-18:00 agenda is only a projection and is never stored
as cells.
The same unreleased migration owns `sleep_medications`, immutable
The same stable v0.1.4 migration owns `sleep_medications`, immutable
`sleep_medication_revisions`, and revision-owned `sleep_medication_intakes`.
Catalog defaults assist capture but do not own historical facts: every intake
stores the displayed medication name and effective optional positive dose/unit
snapshot. Renaming or deactivating a catalog item therefore cannot rewrite a
past entry. Development databases that had already opened the earlier v29
draft are completed idempotently on open; `user_version` remains 29 and no
existing row is rewritten.
draft are completed idempotently on open before the additive v30 migration;
no existing sleep row is rewritten.
Mutations advance `current_revision_id` only when the caller supplies the
current parent. Logical deletion advances the revision and retains the

View file

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

View file

@ -1060,7 +1060,36 @@ format. It lists bounded durable desktop AI proposals and relevant execution
drafts as distinct kinds. Completed sessions remain outside this projection,
and finalized or causally deleted execution drafts are excluded.
## 19. Legacy active-protocol limitations
## 19. Heart-rate companion V1
`trainlog-heart-rate` V1 is an optional full-generation participant produced
only by Android. Its logical name is `heart-rate` and its immutable generation
file is `heart-rate-v1.json`. Desktop never produces sensor measurements and
never connects to the heart-rate device.
A normal Android generation includes only stopped captures that have not
already been acknowledged and are not already retained by an outstanding
captured/published/waiting-ACK generation. Capture, sample and RR identities
are respectively `capture_id`, `capture_id + sequence`, and
`capture_id + sequence + rr_index`.
Desktop schema v30 validates and imports the whole companion inside the
containing generation transaction. Exact replay is idempotent; reusing one
stable measurement identity with different content rejects the generation.
Android schema v27 records the exact `capture_id -> generation_id` relation
when the immutable generation is captured. Only a correlated `consumed` ACK
sets those captures' acknowledgement timestamps. Rejected generations do not
advance cardio acknowledgement. Acknowledged captures remain in Android
history but are omitted from normal later publication, preventing a full
night's samples from being retransmitted on every sync.
The companion changes neither the frozen Bluetooth Classic RFCOMM transport,
MTP recovery, manifest/ACK framing, Sleep Diary V1 nor mobile-export semantics.
Heart-rate BLE/GATT acquisition remains Android-only and is a separate
implementation layer.
## 20. Legacy active-protocol limitations
The Web end-to-end tranche adds an explicit opt-in application orchestrator.
`POST /api/v1/sync` admits one correlated run and `GET /api/v1/sync/status` is

View file

@ -1648,3 +1648,25 @@ checks passed, both outgoing desktop generations were acknowledged, and the
Android UI displayed completion. The private journal retains exact IDs and
hashes. Drive and `connectedDebugAndroidTest` were not run; the personal phone
was used only for non-destructive install, backup and manual sync validation.
## Cardio persistence/synchronization V1 validation
`TRAINLOG_CARDIO_PERSISTENCE_SYNC_V1=PASS` is covered without requiring a
physical heart-rate sensor. Android migration coverage advances schema v26 to
v27 and verifies durable raw BPM/contact/energy/RR capture; desktop migration
coverage advances v29 to v30 and verifies the additive cardio tables/indexes.
`heart_rate_exchange` validates the strict `trainlog-heart-rate` V1 envelope,
cardio/session/sleep ownership, timestamp bounds, raw RR preservation, exact
idempotent replay, and rejection of identity reuse with different measured
content. The Android full-generation regression captures a stopped sleep-owned
measurement, publishes it, consumes it through the production desktop
generation tool, accepts the correlated ACK, verifies one desktop
capture/sample/RR row, and proves the acknowledged Android capture is no longer
emitted.
The 2026-09-23 closeout passes the complete native Meson inventory:
**102/102 passed, 0 failed**. The complete Android JVM inventory reports
**258 tests: 253 passed, five skipped, zero failures/errors**. The five skips
remain optional historical/external fixture gates and are not Cardio V1
failures.

View file

@ -0,0 +1,126 @@
#!/usr/bin/env python3
"""Heart Rate V1 companion validation and idempotent desktop import tests."""
import json
import sqlite3
import sys
import tempfile
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "tools"))
import heart_rate_exchange as heart_rate
CAPTURE = "hrc_11111111-1111-4111-8111-111111111111"
SESSION = "se_22222222-2222-4222-8222-222222222222"
ENTRY = "sxe_33333333-3333-4333-8333-333333333333"
def schema(db: sqlite3.Connection) -> None:
db.executescript(
"""
PRAGMA foreign_keys=ON;
CREATE TABLE heart_rate_captures(
capture_id TEXT PRIMARY KEY,context_kind TEXT,context_id TEXT,
started_at TEXT,ended_at TEXT,sensor_name TEXT,imported_at TEXT);
CREATE TABLE heart_rate_samples(
capture_id TEXT,sequence INTEGER,observed_at TEXT,bpm INTEGER,
exercise_entry_id TEXT,sensor_contact_detected INTEGER,
energy_expended INTEGER,PRIMARY KEY(capture_id,sequence),
FOREIGN KEY(capture_id) REFERENCES heart_rate_captures(capture_id));
CREATE TABLE heart_rate_rr_intervals(
capture_id TEXT,sample_sequence INTEGER,rr_index INTEGER,value_1024 INTEGER,
PRIMARY KEY(capture_id,sample_sequence,rr_index),
FOREIGN KEY(capture_id,sample_sequence)
REFERENCES heart_rate_samples(capture_id,sequence));
PRAGMA user_version=30;
"""
)
def document() -> dict:
return {
"format": "trainlog-heart-rate",
"version": 1,
"generated_at": "2026-09-22T19:00:00+02:00",
"captures": [{
"capture_id": CAPTURE,
"context_kind": "session",
"context_id": SESSION,
"started_at": "2026-09-22T18:00:00+02:00",
"ended_at": "2026-09-22T18:30:00+02:00",
"sensor_name": "Synthetic HR",
"samples": [{
"sequence": 0,
"observed_at": "2026-09-22T18:01:00+02:00",
"bpm": 92,
"exercise_entry_id": ENTRY,
"sensor_contact_detected": True,
"energy_expended": 12,
"rr_intervals_1024": [700, 710],
}],
}],
}
class HeartRateExchangeTest(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.db = sqlite3.connect(Path(self.temp.name) / "heart.sqlite")
schema(self.db)
def tearDown(self):
self.db.close()
self.temp.cleanup()
def test_import_exact_replay_and_raw_rr(self):
payload = heart_rate.validate(document())
self.assertEqual((1, 0), heart_rate.apply(self.db, payload))
self.assertEqual((0, 1), heart_rate.apply(self.db, payload))
self.assertEqual(
(92, ENTRY, 1, 12),
self.db.execute(
"SELECT bpm,exercise_entry_id,sensor_contact_detected,energy_expended "
"FROM heart_rate_samples"
).fetchone(),
)
self.assertEqual(
[(700,), (710,)],
self.db.execute(
"SELECT value_1024 FROM heart_rate_rr_intervals ORDER BY rr_index"
).fetchall(),
)
def test_same_identity_with_different_measurement_is_rejected(self):
payload = heart_rate.validate(document())
heart_rate.apply(self.db, payload)
changed = document()
changed["captures"][0]["samples"][0]["bpm"] = 93
with self.assertRaisesRegex(ValueError, "different content"):
heart_rate.apply(self.db, heart_rate.validate(changed))
def test_cardio_context_accepts_session_identity_and_exercise_marker(self):
cardio = document()
cardio["captures"][0]["context_kind"] = "cardio"
validated = heart_rate.validate(cardio)
self.assertEqual("cardio", validated["captures"][0]["context_kind"])
self.assertEqual(ENTRY, validated["captures"][0]["samples"][0]["exercise_entry_id"])
def test_rejects_sample_outside_capture_and_sleep_exercise_marker(self):
outside = document()
outside["captures"][0]["samples"][0]["observed_at"] = "2026-09-22T17:59:59+02:00"
with self.assertRaisesRegex(ValueError, "outside capture"):
heart_rate.validate(outside)
sleep = document()
capture = sleep["captures"][0]
capture["context_kind"] = "sleep"
capture["context_id"] = "sl_44444444-4444-4444-8444-444444444444"
with self.assertRaisesRegex(ValueError, "exercise context"):
heart_rate.validate(sleep)
if __name__ == "__main__":
unittest.main()

View file

@ -66,12 +66,13 @@ class DeploymentToolsTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.5")
self.assertEqual(inventory["desktop_schema"], 29)
self.assertEqual(inventory["android_schema"], 26)
self.assertEqual(inventory["desktop_schema"], 30)
self.assertEqual(inventory["android_schema"], 27)
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("heart-rate-v1", inventory["protocols"])
self.assertIn("bluetooth-files-v1", inventory["protocols"])
self.assertTrue((output / "bin/trainlog").is_file())
self.assertTrue((output / "bin/trainlog-sync-once").is_file())

View file

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

View file

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

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30)
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-v29 requis")
raise ValueError("schema desktop v15-v30 requis")
root = {
"format": "TRAINLOG_AI_EXPORT",

View file

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

View file

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

View file

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

View file

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

View file

@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(11, 30))
supported_versions = tuple(range(11, 31))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v16 requis")
raise ValueError("schema desktop v11-v30 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal BODY ZONES protection requires the staged artifact")
exercises = []

View file

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

View file

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

View file

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

View file

@ -16,7 +16,7 @@ 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):
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30):
raise ValueError("desktop schema v26, v27, v28 or v29 required")
programs = []
for row in db.execute(

View file

@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict:
supported_versions = (24, 25, 26, 27, 28, 29)
supported_versions = (24, 25, 26, 27, 28, 29, 30)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24, v25, v26, v27, v28 or v29 required")
deliveries = []

View file

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

View file

@ -0,0 +1,247 @@
#!/usr/bin/env python3
"""Validate and import the Android-owned Heart Rate V1 companion."""
import argparse
import json
import re
import sqlite3
from datetime import datetime
from pathlib import Path
from trainlog_sqlite import connect_database
FORMAT = "trainlog-heart-rate"
VERSION = 1
MAX_BYTES = 64 * 1024 * 1024
MAX_CAPTURES = 256
MAX_SAMPLES = 200_000
MAX_RR = 64
UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}"
CAPTURE_ID = re.compile(rf"^hrc_{UUID4}$")
SESSION_ID = re.compile(rf"^se_{UUID4}$")
SLEEP_ID = re.compile(rf"^sl_{UUID4}$")
ENTRY_ID = re.compile(rf"^sxe_{UUID4}$")
def fail(message: str) -> None:
raise ValueError(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 timestamp(value: object) -> tuple[str, datetime]:
if not isinstance(value, str) or re.search(r"(?:Z|[+-][0-9]{2}:[0-9]{2})$", value) is None:
fail("invalid heart-rate timestamp")
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError as error:
raise ValueError("invalid heart-rate timestamp") from error
if parsed.utcoffset() is None:
fail("heart-rate timestamp lacks offset")
return value, parsed
def validate(root: object) -> dict:
if not isinstance(root, dict) or set(root) != {
"format", "version", "generated_at", "captures"
}:
fail("invalid heart-rate envelope")
if root["format"] != FORMAT or type(root["version"]) is not int or root["version"] != VERSION:
fail("unsupported heart-rate format")
timestamp(root["generated_at"])
captures = root["captures"]
if not isinstance(captures, list) or len(captures) > MAX_CAPTURES:
fail("heart-rate capture bound exceeded")
seen_captures = set()
for capture in captures:
keys = {
"capture_id", "context_kind", "context_id", "started_at", "ended_at",
"sensor_name", "samples",
}
if not isinstance(capture, dict) or set(capture) != keys:
fail("invalid heart-rate capture")
capture_id = capture["capture_id"]
if not isinstance(capture_id, str) or not CAPTURE_ID.fullmatch(capture_id) or capture_id in seen_captures:
fail("invalid or duplicate heart-rate capture identity")
seen_captures.add(capture_id)
kind = capture["context_kind"]
context_id = capture["context_id"]
if kind in ("session", "cardio"):
if not isinstance(context_id, str) or not SESSION_ID.fullmatch(context_id):
fail("invalid heart-rate session context")
elif kind == "sleep":
if not isinstance(context_id, str) or not SLEEP_ID.fullmatch(context_id):
fail("invalid heart-rate sleep context")
else:
fail("invalid heart-rate context kind")
_, started = timestamp(capture["started_at"])
_, ended = timestamp(capture["ended_at"])
if ended < started:
fail("invalid heart-rate capture interval")
sensor_name = capture["sensor_name"]
if sensor_name is not None and (
not isinstance(sensor_name, str) or not sensor_name.strip() or len(sensor_name.encode()) > 160
):
fail("invalid heart-rate sensor name")
samples = capture["samples"]
if not isinstance(samples, list) or len(samples) > MAX_SAMPLES:
fail("heart-rate sample bound exceeded")
for expected, sample in enumerate(samples):
sample_keys = {
"sequence", "observed_at", "bpm", "exercise_entry_id",
"sensor_contact_detected", "energy_expended", "rr_intervals_1024",
}
if not isinstance(sample, dict) or set(sample) != sample_keys:
fail("invalid heart-rate sample")
if type(sample["sequence"]) is not int or sample["sequence"] != expected:
fail("invalid heart-rate sample sequence")
_, observed = timestamp(sample["observed_at"])
if observed < started or observed > ended:
fail("heart-rate sample outside capture")
if type(sample["bpm"]) is not int or not 0 <= sample["bpm"] <= 65535:
fail("invalid heart-rate bpm")
entry_id = sample["exercise_entry_id"]
if entry_id is not None and (
kind not in ("session", "cardio") or
not isinstance(entry_id, str) or
not ENTRY_ID.fullmatch(entry_id)
):
fail("invalid heart-rate exercise context")
contact = sample["sensor_contact_detected"]
if contact is not None and type(contact) is not bool:
fail("invalid sensor contact state")
energy = sample["energy_expended"]
if energy is not None and (type(energy) is not int or not 0 <= energy <= 65535):
fail("invalid heart-rate energy value")
rr = sample["rr_intervals_1024"]
if not isinstance(rr, list) or len(rr) > MAX_RR or any(
type(value) is not int or not 0 <= value <= 65535 for value in rr
):
fail("invalid heart-rate RR interval")
return root
def load(path: Path) -> dict:
if path.stat().st_size > MAX_BYTES:
fail("heart-rate artifact exceeds 64 MiB")
return validate(json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object))
def _same_capture(db: sqlite3.Connection, capture: dict) -> bool:
row = db.execute(
"SELECT context_kind,context_id,started_at,ended_at,sensor_name "
"FROM heart_rate_captures WHERE capture_id=?",
(capture["capture_id"],),
).fetchone()
if row is None:
return False
if tuple(row) != (
capture["context_kind"],
capture["context_id"],
capture["started_at"],
capture["ended_at"],
capture["sensor_name"],
):
fail("heart-rate capture identity reused with different metadata")
stored_samples = db.execute(
"SELECT sequence,observed_at,bpm,exercise_entry_id,sensor_contact_detected,"
"energy_expended FROM heart_rate_samples WHERE capture_id=? ORDER BY sequence",
(capture["capture_id"],),
).fetchall()
if len(stored_samples) != len(capture["samples"]):
fail("heart-rate capture identity reused with different samples")
rr_by_sequence = {}
for sequence, rr_index, value in db.execute(
"SELECT sample_sequence,rr_index,value_1024 FROM heart_rate_rr_intervals "
"WHERE capture_id=? ORDER BY sample_sequence,rr_index",
(capture["capture_id"],),
):
values = rr_by_sequence.setdefault(sequence, [])
if rr_index != len(values):
fail("stored heart-rate RR sequence is not contiguous")
values.append(value)
for row, sample in zip(stored_samples, capture["samples"]):
contact = None if sample["sensor_contact_detected"] is None else int(sample["sensor_contact_detected"])
if tuple(row) != (
sample["sequence"], sample["observed_at"], sample["bpm"],
sample["exercise_entry_id"], contact, sample["energy_expended"],
):
fail("heart-rate sample identity reused with different content")
if rr_by_sequence.pop(sample["sequence"], []) != sample["rr_intervals_1024"]:
fail("heart-rate RR identity reused with different content")
if rr_by_sequence:
fail("heart-rate capture has orphan RR intervals")
return True
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
applied = 0
unchanged = 0
for capture in root["captures"]:
if _same_capture(db, capture):
unchanged += 1
continue
db.execute(
"INSERT INTO heart_rate_captures("
"capture_id,context_kind,context_id,started_at,ended_at,sensor_name,imported_at"
") VALUES(?,?,?,?,?,?,?)",
(
capture["capture_id"], capture["context_kind"], capture["context_id"],
capture["started_at"], capture["ended_at"], capture["sensor_name"],
root["generated_at"],
),
)
db.executemany(
"INSERT INTO heart_rate_samples("
"capture_id,sequence,observed_at,bpm,exercise_entry_id,"
"sensor_contact_detected,energy_expended) VALUES(?,?,?,?,?,?,?)",
[
(
capture["capture_id"], sample["sequence"], sample["observed_at"],
sample["bpm"], sample["exercise_entry_id"],
None if sample["sensor_contact_detected"] is None
else int(sample["sensor_contact_detected"]),
sample["energy_expended"],
)
for sample in capture["samples"]
],
)
rr_rows = []
for sample in capture["samples"]:
rr_rows.extend(
(
capture["capture_id"], sample["sequence"], index, value
)
for index, value in enumerate(sample["rr_intervals_1024"])
)
db.executemany(
"INSERT INTO heart_rate_rr_intervals("
"capture_id,sample_sequence,rr_index,value_1024) VALUES(?,?,?,?)",
rr_rows,
)
applied += 1
return applied, unchanged
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("input", type=Path)
parser.add_argument("--database", required=True, type=Path)
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] != 30:
fail("desktop schema v30 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()
if __name__ == "__main__":
main()

View file

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

View file

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

View file

@ -53,9 +53,9 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 30)
supported_versions = range(8, 31)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis")
fail("schema desktop v8 à v30 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact")
imported = skipped = 0

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -18,6 +18,7 @@ RUNTIME_TOOLS = [
"sync_drive_transport.py",
"sync_generation_exchange.py",
"sleep_diary_exchange.py",
"heart_rate_exchange.py",
"trainlog_sqlite.py",
"validate_json.py",
"validate_training_knowledge.py",
@ -114,8 +115,8 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.5",
"desktop_schema": 29,
"android_schema": 26,
"desktop_schema": 30,
"android_schema": 27,
"protocols": [
"mobile-export-v3",
"mobile-history-v4",
@ -130,6 +131,7 @@ def main() -> int:
"programs-v1",
"program-executions-v1",
"sleep-diary-v1",
"heart-rate-v1",
],
"entry_points": [
"bin/trainlog",

View file

@ -87,7 +87,7 @@ 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):
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30):
fail("desktop schema v27, v28 or v29 required")
added = 0
advanced = 0

View file

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

View file

@ -21,6 +21,7 @@ from pathlib import Path, PurePosixPath
from trainlog_sqlite import connect_database
import causal_delete_exchange
import execution_draft_exchange
import heart_rate_exchange
import import_equipment_associations
import import_equipment_definitions
import import_exercise_aliases
@ -81,6 +82,7 @@ ARTIFACTS = (
)
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
("trainlog-program-executions", 1),
("trainlog-heart-rate", 1),
}
@ -209,7 +211,7 @@ 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)
supported_versions = (24, 25, 26, 27, 28, 29, 30)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24, v25, v26, v27, v28 or v29 required")
@ -667,6 +669,8 @@ def consume_desktop(database: Path, directory: Path) -> dict:
feedback = import_training_feedback.load(required("feedback"))
sleep_diary = (sleep_diary_exchange.load(listed["sleep-diary"])
if "sleep-diary" in listed else None)
heart_rate = (heart_rate_exchange.load(listed["heart-rate"])
if "heart-rate" in listed else None)
program_executions = (
program_execution_exchange.load(listed["program-executions"])
if "program-executions" in listed else None
@ -710,6 +714,8 @@ def consume_desktop(database: Path, directory: Path) -> dict:
execution_draft_exchange.import_document(db, drafts, own_transaction=False)
if sleep_diary is not None:
sleep_diary_exchange.apply(db, sleep_diary)
if heart_rate is not None:
heart_rate_exchange.apply(db, heart_rate)
if program_executions is not None:
program_execution_exchange.apply_executions(db, program_executions)
ack = record_consumed(db, manifest, manifest_digest)

View file

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

View file

@ -823,6 +823,14 @@ test_schema_v29_migration = executable(
)
test('schema_v29_migration', test_schema_v29_migration)
test_schema_v30_migration = executable(
'test_schema_v30_migration',
'tests/test_schema_v30_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('schema_v30_migration', test_schema_v30_migration)
test_schema_v7_migration = executable(
'test_schema_v7_migration',
'tests/test_schema_v7_migration.c',
@ -902,6 +910,11 @@ test(
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'],
)
test(
'heart_rate_exchange',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_heart_rate_exchange.py'],
)
test(
'session_preparation_export',
find_program('python3'),

View file

@ -1343,6 +1343,40 @@ static const char *const MIGRATE_V28_TO_V29_SQL =
"PRIMARY KEY(generation_id,entry_id));"
"PRAGMA user_version=29;COMMIT;";
/* WHY: heart-rate is Android-owned measured field data, but desktop remains
* the canonical long-term synchronized store. CONTRACT: v30 adds only the
* immutable imported capture/sample tables. INVARIANT: no workout, Sleep
* Diary, generation or existing observation row is rewritten or inferred. */
static const char *const MIGRATE_V29_TO_V30_SQL =
"BEGIN IMMEDIATE;"
"CREATE TABLE IF NOT EXISTS heart_rate_captures("
"capture_id TEXT PRIMARY KEY,context_kind TEXT NOT NULL CHECK(context_kind IN('session','cardio','sleep')),"
"context_id TEXT NOT NULL,started_at TEXT NOT NULL,ended_at TEXT NOT NULL,sensor_name TEXT,"
"imported_at TEXT NOT NULL,CHECK(capture_id GLOB 'hrc_*'),"
"CHECK((context_kind IN('session','cardio') AND context_id GLOB 'se_*') OR "
"(context_kind='sleep' AND context_id GLOB 'sl_*')),"
"CHECK(sensor_name IS NULL OR length(CAST(sensor_name AS BLOB))<=160));"
"CREATE TABLE IF NOT EXISTS heart_rate_samples("
"capture_id TEXT NOT NULL REFERENCES heart_rate_captures(capture_id) ON DELETE CASCADE,"
"sequence INTEGER NOT NULL CHECK(sequence>=0),observed_at TEXT NOT NULL,"
"bpm INTEGER NOT NULL CHECK(bpm BETWEEN 0 AND 65535),exercise_entry_id TEXT,"
"sensor_contact_detected INTEGER CHECK(sensor_contact_detected IS NULL OR "
"sensor_contact_detected IN(0,1)),energy_expended INTEGER CHECK(energy_expended IS NULL OR "
"energy_expended BETWEEN 0 AND 65535),PRIMARY KEY(capture_id,sequence),"
"CHECK(exercise_entry_id IS NULL OR exercise_entry_id GLOB 'sxe_*'));"
"CREATE TABLE IF NOT EXISTS heart_rate_rr_intervals("
"capture_id TEXT NOT NULL,sample_sequence INTEGER NOT NULL,"
"rr_index INTEGER NOT NULL CHECK(rr_index BETWEEN 0 AND 63),"
"value_1024 INTEGER NOT NULL CHECK(value_1024 BETWEEN 0 AND 65535),"
"PRIMARY KEY(capture_id,sample_sequence,rr_index),"
"FOREIGN KEY(capture_id,sample_sequence) REFERENCES "
"heart_rate_samples(capture_id,sequence) ON DELETE CASCADE);"
"CREATE INDEX IF NOT EXISTS heart_rate_samples_time ON "
"heart_rate_samples(observed_at,capture_id,sequence);"
"CREATE INDEX IF NOT EXISTS heart_rate_captures_context ON "
"heart_rate_captures(context_kind,context_id,started_at);"
"PRAGMA user_version=30;COMMIT;";
/* 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
@ -2176,7 +2210,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
} else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
version == 26 || version == 27 || version == 28 || version == 29) {
version == 26 || version == 27 || version == 28 || version == 29 || version == 30) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -2324,6 +2358,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, ENSURE_V29_SLEEP_MEDICATION_SQL);
}
if (status == TRAINLOG_STATUS_OK && version < 30) {
status = execute_sql(database, MIGRATE_V29_TO_V30_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -2343,7 +2380,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 v29" : "migrate database to schema v29",
version == 0 ? "create schema v30" : "migrate database to schema v30",
database->connection,
SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -49,7 +49,7 @@ int main(void) {
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 29);
CHECK(scalar(raw, "PRAGMA user_version") == 30);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events',"
@ -61,6 +61,9 @@ int main(void) {
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('sleep_medications','sleep_medication_revisions',"
"'sleep_medication_intakes')") == 3);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('heart_rate_captures','heart_rate_samples','heart_rate_rr_intervals')") == 3);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") ==
1);
@ -79,13 +82,13 @@ int main(void) {
NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL;
/* An unpublished v29 may already exist locally. Opening it completes the
* same v29 contract without rewriting user data or changing user_version. */
/* A valid v29 may already exist locally. Opening it completes the historical
* v29 repair and then advances additively to the current v30 schema. */
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 29);
CHECK(scalar(raw, "PRAGMA user_version") == 30);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
"'sleep_medication%'") == 3);
@ -99,7 +102,7 @@ cleanup:
}
(void)unlink(path);
if (result == EXIT_SUCCESS) {
puts("PASS schema v28 to v29 migration and reopen");
puts("PASS schema v28 through v29 to current migration and reopen");
}
return result;
}

View file

@ -0,0 +1,75 @@
#define _POSIX_C_SOURCE 200809L
#include <sqlite3.h>
#include <stdio.h>
#include <stdlib.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-v30-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,
"DROP TABLE heart_rate_rr_intervals;"
"DROP TABLE heart_rate_samples;"
"DROP TABLE heart_rate_captures;"
"PRAGMA user_version=29;",
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 == 30);
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('heart_rate_captures','heart_rate_samples','heart_rate_rr_intervals')") == 3);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='index' AND name IN "
"('heart_rate_samples_time','heart_rate_captures_context')") == 2);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") == 1);
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 v29 to v30 heart-rate migration");
return result;
}

View file

@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic,
"migrate database to schema v29: SQLite rc=",
strlen("migrate database to schema v29: SQLite rc=")) == 0);
"migrate database to schema v30: SQLite rc=",
strlen("migrate database to schema v30: 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=30;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=31;", 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 30 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 31 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;

View file

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

View file

@ -308,7 +308,7 @@ static bool test_http_contract(TrainlogDatabase *database) {
CHECK(strstr(response, "Access-Control-Allow-Origin") == NULL);
CHECK(strstr(response,
"{\"api_version\":1,\"status\":\"ok\",\"product\":\"trainlog\","
"\"version\":\"0.1.4\"}") != NULL);
"\"version\":\"0.1.5\"}") != NULL);
{
char token[80];
CHECK(exchange(port,