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 ## 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. Development opened after stable v0.1.4 release.
## 0.1.4 — 2026-09-22 ## 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. list, detail, archive, and delete surface.
Web Exercises administration, the factual Dashboard/Analyse read model, and the Web Exercises administration, the factual Dashboard/Analyse read model, and the
synchronized Sleep Diary workspace are the stable v0.1.4 baseline. Version 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 ## Releases

View file

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

View file

@ -69,6 +69,7 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
"program-executions" to "program-executions" to
Kind("trainlog-program-executions", 1, "program-executions-v1.json", false), Kind("trainlog-program-executions", 1, "program-executions-v1.json", false),
"sleep-diary" to Kind("trainlog-sleep-diary", 1, "sleep-diary-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 = private val SUPPORTED =
(ANDROID_KINDS.values + (ANDROID_KINDS.values +
@ -422,6 +423,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(generationId, entry.getString("entry_id"), entry.getString("revision_id")), 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) return CapturedSyncGeneration(generationId, runId, digest, stage)
} catch (error: Exception) { } catch (error: Exception) {
@ -1059,6 +1068,14 @@ internal class SyncGenerationService(private val repository: TrainlogRepository)
arrayOf(ack.getString("generation_id"), ack.getString("consumed_at"), arrayOf(ack.getString("generation_id"), ack.getString("consumed_at"),
ack.getString("generation_id")), 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 target
} }

View file

@ -22,6 +22,10 @@ import com.labfytools.trainlog.model.ExerciseProfile
import com.labfytools.trainlog.model.ExerciseEditInput import com.labfytools.trainlog.model.ExerciseEditInput
import com.labfytools.trainlog.model.ExerciseFeedback import com.labfytools.trainlog.model.ExerciseFeedback
import com.labfytools.trainlog.model.DraftExerciseFeedback 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.LatestExerciseMax
import com.labfytools.trainlog.model.NewExerciseProfile import com.labfytools.trainlog.model.NewExerciseProfile
import com.labfytools.trainlog.model.PreparedSession import com.labfytools.trainlog.model.PreparedSession
@ -486,6 +490,21 @@ class TrainlogRepository(
data class Rejected(val reason: String) : SleepDiaryImportResult 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 sleepId(prefix: String): String = "${prefix}_${UUID.randomUUID()}"
private fun sleepIdentity(value: String, prefix: String): Boolean = 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. */ /** Create a transactionally consistent SQLite snapshot without exposing WAL files. */
internal fun backupDatabaseTo(destination: File) { internal fun backupDatabaseTo(destination: File) {
require(!destination.exists()) { "Backup snapshot destination already exists." } require(!destination.exists()) { "Backup snapshot destination already exists." }
@ -1185,6 +1589,7 @@ class TrainlogRepository(
artifacts["causal-deletions"] = buildCausalDeletionExportV1Json() artifacts["causal-deletions"] = buildCausalDeletionExportV1Json()
artifacts["program-executions"] = buildProgramExecutionsV1Json() artifacts["program-executions"] = buildProgramExecutionsV1Json()
artifacts["sleep-diary"] = buildSleepDiaryV1Json() artifacts["sleep-diary"] = buildSleepDiaryV1Json()
artifacts["heart-rate"] = buildHeartRateV1Json()
artifacts 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}$", "^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_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_DRAFT_ENTRIES = 64
private const val MAX_AI_TARGET_SETS = 99 private const val MAX_AI_TARGET_SETS = 99
private const val MAX_AI_TARGET_REPS = 999 private const val MAX_AI_TARGET_REPS = 999
@ -9572,7 +9981,7 @@ private class TrainlogDatabaseHelper(
appContext, appContext,
databaseName, databaseName,
null, null,
26, 27,
) { ) {
override fun onConfigure( override fun onConfigure(
db: SQLiteDatabase, db: SQLiteDatabase,
@ -9626,6 +10035,7 @@ private class TrainlogDatabaseHelper(
createSyncedProgramTables(db) createSyncedProgramTables(db)
createProgramExecutionProvenance(db) createProgramExecutionProvenance(db)
createSleepDiaryTables(db) createSleepDiaryTables(db)
createHeartRateTables(db)
seedEquipment(db) seedEquipment(db)
} }
@ -9839,6 +10249,14 @@ private class TrainlogDatabaseHelper(
createSleepDiaryTables(db) createSleepDiaryTables(db)
version = 26 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) { if (version != newVersion) {
error( error(
@ -9977,6 +10395,71 @@ private class TrainlogDatabaseHelper(
PRIMARY KEY(generation_id,entry_id))""".trimIndent()) 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) { private fun createSyncDataLifecycleTables(db: SQLiteDatabase) {
fun addColumn(table: String, name: String, declaration: String) { fun addColumn(table: String, name: String, declaration: String) {
if (!tableHasColumn(db, table, name)) { if (!tableHasColumn(db, table, name)) {

View file

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

View file

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

View file

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

View file

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

View file

@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
context.getDatabasePath(name).path, context.getDatabasePath(name).path,
null, null,
SQLiteDatabase.OPEN_READONLY, SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(26, it.version) } ).use { assertEquals(27, it.version) }
} finally { } finally {
repository.close() repository.close()
context.deleteDatabase(name) 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.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db -> SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor -> db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(26, cursor.getInt(0)) assertTrue(cursor.moveToFirst()); assertEquals(27, cursor.getInt(0))
} }
db.rawQuery("SELECT id FROM exercises", null).use { cursor -> db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -43,7 +43,7 @@ class BridgeBackupMigrationTest {
null, null,
android.database.sqlite.SQLiteDatabase.OPEN_READONLY, android.database.sqlite.SQLiteDatabase.OPEN_READONLY,
) )
.use { assertEquals(26, it.version) } .use { assertEquals(27, it.version) }
} finally { } finally {
repository.close() repository.close()
context.deleteDatabase(name) 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) repository = TrainlogRepository(context, name)
assertEquals(1, repository.listPreparedSessions().size) assertEquals(1, repository.listPreparedSessions().size)
repository.close() repository.close()
assertEquals("26", scalarText(name, "PRAGMA user_version")) assertEquals("27", scalarText(name, "PRAGMA user_version"))
assertEquals( assertEquals(
"0", "0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"), scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),

View file

@ -3,11 +3,14 @@ package com.labfytools.trainlog.data
import android.content.Context import android.content.Context
import android.database.sqlite.SQLiteDatabase import android.database.sqlite.SQLiteDatabase
import androidx.test.core.app.ApplicationProvider 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.NewExerciseProfile
import com.labfytools.trainlog.model.RecordingMode import com.labfytools.trainlog.model.RecordingMode
import com.labfytools.trainlog.model.SessionDraft import com.labfytools.trainlog.model.SessionDraft
import com.labfytools.trainlog.model.SessionExerciseDraft import com.labfytools.trainlog.model.SessionExerciseDraft
import com.labfytools.trainlog.model.SessionSetDraft import com.labfytools.trainlog.model.SessionSetDraft
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.TrackingMode import com.labfytools.trainlog.model.TrackingMode
import java.io.File import java.io.File
import java.nio.file.Files import java.nio.file.Files
@ -506,7 +509,7 @@ class SyncGenerationServiceTest {
.use { db -> .use { db ->
db.rawQuery("PRAGMA user_version", null).use { db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst()) assertTrue(it.moveToFirst())
assertEquals(26, it.getInt(0)) assertEquals(27, it.getInt(0))
} }
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst()) assertTrue(it.moveToFirst())
@ -855,7 +858,54 @@ class SyncGenerationServiceTest {
) )
)!! )!!
.groupValues[1] .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) val androidToDesktop = sourceService.capture(root, desktopPeer)
assertEquals(
1,
JSONObject(
java.io.File(androidToDesktop.stagingDirectory, "heart-rate-v1.json")
.readText(),
)
.getJSONArray("captures")
.length(),
)
val androidPublished = val androidPublished =
sourceService.publish( sourceService.publish(
androidToDesktop, androidToDesktop,
@ -872,10 +922,31 @@ class SyncGenerationServiceTest {
"--ack-output", "--ack-output",
androidAckFile.absolutePath, androidAckFile.absolutePath,
) )
val androidAck = JSONObject(androidAckFile.readText())
assertEquals(
"desktop ACK diagnostic: ${androidAck.getString("diagnostic")}",
"consumed",
androidAck.getString("result"),
)
assertEquals( assertEquals(
"acknowledged", "acknowledged",
sourceService.acceptAcknowledgement(androidAckFile.readBytes()), 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 val androidSessionId = source.listSessions().single().sessionId
assertEquals( assertEquals(
"1", "1",

View file

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

View file

@ -112,8 +112,8 @@ parallel implementation of its rules.
| Boundary | Current state | | Boundary | Current state |
|---|---| |---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v29; Sleep Diary V1 is additive in v29; Cardio v30 work is implementation-started but not yet closed | | Desktop SQLite | schema v30; synchronized Heart Rate V1 storage is additive in v30 |
| Android SQLite | schema v26; Sleep Diary V1 is additive in v26; Cardio v27 work is implementation-started but not yet closed | | 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 | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only | | Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.5` development, synchronized across Android and desktop; latest stable release: `v0.1.4` | | 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) | | 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` | | Local Web | `TRAINLOG_WEB_V1=CONTRACT_FROZEN / IMPLEMENTATION_STARTED` |
| Web Exercises V1 | `TRAINLOG_WEB_EXERCISES_V1=PASS` | | 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 | | 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` | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |

View file

@ -1,5 +1,25 @@
# Desktop database # 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: Sleep Diary V1
Schema v29 additively owns `sleep_diary_entries`, immutable 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 their start. The 18:00-to-18:00 agenda is only a projection and is never stored
as cells. 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`. `sleep_medication_revisions`, and revision-owned `sleep_medication_intakes`.
Catalog defaults assist capture but do not own historical facts: every intake Catalog defaults assist capture but do not own historical facts: every intake
stores the displayed medication name and effective optional positive dose/unit stores the displayed medication name and effective optional positive dose/unit
snapshot. Renaming or deactivating a catalog item therefore cannot rewrite a snapshot. Renaming or deactivating a catalog item therefore cannot rewrite a
past entry. Development databases that had already opened the earlier v29 past entry. Development databases that had already opened the earlier v29
draft are completed idempotently on open; `user_version` remains 29 and no draft are completed idempotently on open before the additive v30 migration;
existing row is rewritten. no existing sleep row is rewritten.
Mutations advance `current_revision_id` only when the caller supplies the Mutations advance `current_revision_id` only when the caller supplies the
current parent. Logical deletion advances the revision and retains 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. Bluetooth synchronization transport contract.
```text ```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 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, drafts as distinct kinds. Completed sessions remain outside this projection,
and finalized or causally deleted execution drafts are excluded. 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. 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 `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 Android UI displayed completion. The private journal retains exact IDs and
hashes. Drive and `connectedDebugAndroidTest` were not run; the personal phone hashes. Drive and `connectedDebugAndroidTest` were not run; the personal phone
was used only for non-destructive install, backup and manual sync validation. 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) self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text()) inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.5") self.assertEqual(inventory["product_version"], "0.1.5")
self.assertEqual(inventory["desktop_schema"], 29) self.assertEqual(inventory["desktop_schema"], 30)
self.assertEqual(inventory["android_schema"], 26) self.assertEqual(inventory["android_schema"], 27)
self.assertIn("session-preparations-v2", inventory["protocols"]) self.assertIn("session-preparations-v2", inventory["protocols"])
self.assertIn("programs-v1", inventory["protocols"]) self.assertIn("programs-v1", inventory["protocols"])
self.assertIn("program-executions-v1", inventory["protocols"]) self.assertIn("program-executions-v1", inventory["protocols"])
self.assertIn("sleep-diary-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.assertIn("bluetooth-files-v1", inventory["protocols"])
self.assertTrue((output / "bin/trainlog").is_file()) self.assertTrue((output / "bin/trainlog").is_file())
self.assertTrue((output / "bin/trainlog-sync-once").is_file()) self.assertTrue((output / "bin/trainlog-sync-once").is_file())

View file

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

View file

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

View file

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

View file

@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection): def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations.""" """Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction owned_transaction = not connection.in_transaction
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 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: 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( columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")} "PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns: if "published_at" not in columns:

View file

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

View file

@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database) connection = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: try:
supported_versions = tuple(range(8, 30)) supported_versions = tuple(range(8, 31))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à 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(): 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") raise ValueError("causal equipment protection requires the staged artifact")
equipment = [dict(row) for row in connection.execute( equipment = [dict(row) for row in connection.execute(

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database) con = connect_database(args.database)
try: try:
if con.execute("PRAGMA user_version").fetchone()[0] not in ( if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
raise ValueError("schema desktop v12-v29 requis") raise ValueError("schema desktop v12-v30 requis")
rows = con.execute( rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY" "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 = connect_database(args.database)
connection.row_factory = sqlite3.Row connection.row_factory = sqlite3.Row
try: 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: 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(): 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") raise ValueError("causal BODY ZONES protection requires the staged artifact")
exercises = [] exercises = []

View file

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

View file

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

View file

@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row con.row_factory = sqlite3.Row
try: try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0] schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 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") 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 legacy_v2_schema = args.version == 2 and schema_version == 10
if schema_version not in supported_versions and not legacy_v2_schema: if schema_version not in supported_versions and not legacy_v2_schema:
raise ValueError("schema desktop v11-v16 requis (v10 accepté pour export V2 explicite)") raise ValueError("schema desktop v11-v30 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute( if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone(): "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones") 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: def export_programs(database: Path) -> dict:
with closing(connect_database(database)) as db: 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") raise ValueError("desktop schema v26, v27, v28 or v29 required")
programs = [] programs = []
for row in db.execute( for row in db.execute(

View file

@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict: 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: 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, v27, v28 or v29 required")
deliveries = [] 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() parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database) db=connect_database(args.database)
try: try:
supported_versions = range(15, 30) supported_versions = range(15, 31)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-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") 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() 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() 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; INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind. any failure leaves no draft, child, or import-identity row behind.
""" """
supported_versions = range(18, 30) supported_versions = range(18, 31)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v29 requis") raise ImportFailure("schema desktop v18-v30 requis")
draft, entries = validate(payload, connection) draft, entries = validate(payload, connection)
digest = canonical_digest(payload) digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") 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")) payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database) connection = connect_database(args.database)
try: try:
supported_versions = range(8, 30) supported_versions = range(8, 31)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis") fail("schema desktop v8 à v30 requis")
proof_path = args.mobile_export proof_path = args.mobile_export
if proof_path is None: if proof_path is None:
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json") 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): def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction.""" """Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 30) supported_versions = range(8, 31)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v21 requis") fail("schema desktop v8 à 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(): 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") fail("causal equipment protection requires the staged artifact")
imported = skipped = 0 imported = skipped = 0

View file

@ -56,9 +56,9 @@ def load(path):
def apply_aliases(con, aliases): def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction.""" """Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 30) supported_versions = range(12, 31)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v21 requis") fail("schema desktop v12-v30 requis")
for source, canonical in aliases: for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() 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}") 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): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries.""" """Apply validated zone rows without owning transaction boundaries."""
supported_versions = range(11, 30) supported_versions = range(11, 31)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-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") 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={} connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct # 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") if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in ( if con.execute("PRAGMA user_version").fetchone()[0] not in (
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30):
fail("schema desktop v17-v29 requis") fail("schema desktop v17-v30 requis")
applied=equal=ancestor=pending=0;seen=set() applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]: for item in root["exercises"]:
validate(item);eid=item["exercise_id"] 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 # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table. # readable for legacy artifacts and are never made to fake that table.
if version not in range(5, 30): if version not in range(5, 31):
raise ImportFailure( 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) require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29): 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 à v29") raise ImportFailure("mobile V4 exige le schéma desktop v19 à v30")
if schema_version >= 20: if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute( protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} "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): def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0 roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON") db.execute("PRAGMA foreign_keys=ON")
supported_versions = range(15, 30) supported_versions = range(15, 31)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-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") 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() seen=set()
for index,item in enumerate(root["exercise_feedback"]): for index,item in enumerate(root["exercise_feedback"]):

View file

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

View file

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

View file

@ -21,6 +21,7 @@ from pathlib import Path, PurePosixPath
from trainlog_sqlite import connect_database from trainlog_sqlite import connect_database
import causal_delete_exchange import causal_delete_exchange
import execution_draft_exchange import execution_draft_exchange
import heart_rate_exchange
import import_equipment_associations import import_equipment_associations
import import_equipment_definitions import import_equipment_definitions
import import_exercise_aliases import import_exercise_aliases
@ -81,6 +82,7 @@ ARTIFACTS = (
) )
SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | { SUPPORTED = {(row[1], row[2]) for row in ARTIFACTS} | {
("trainlog-program-executions", 1), ("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: 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: 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, v27, v28 or v29 required")
@ -667,6 +669,8 @@ def consume_desktop(database: Path, directory: Path) -> dict:
feedback = import_training_feedback.load(required("feedback")) feedback = import_training_feedback.load(required("feedback"))
sleep_diary = (sleep_diary_exchange.load(listed["sleep-diary"]) sleep_diary = (sleep_diary_exchange.load(listed["sleep-diary"])
if "sleep-diary" in listed else None) 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_executions = (
program_execution_exchange.load(listed["program-executions"]) program_execution_exchange.load(listed["program-executions"])
if "program-executions" in listed else None 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) execution_draft_exchange.import_document(db, drafts, own_transaction=False)
if sleep_diary is not None: if sleep_diary is not None:
sleep_diary_exchange.apply(db, sleep_diary) 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: if program_executions is not None:
program_execution_exchange.apply_executions(db, program_executions) program_execution_exchange.apply_executions(db, program_executions)
ack = record_consumed(db, manifest, manifest_digest) ack = record_consumed(db, manifest, manifest_digest)

View file

@ -17,7 +17,7 @@
#include "trainlog/model.h" #include "trainlog/model.h"
#include "trainlog/status.h" #include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 29 #define TRAINLOG_DATABASE_SCHEMA_VERSION 30
typedef struct TrainlogDatabase TrainlogDatabase; 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_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 = executable(
'test_schema_v7_migration', 'test_schema_v7_migration',
'tests/test_schema_v7_migration.c', 'tests/test_schema_v7_migration.c',
@ -902,6 +910,11 @@ test(
find_program('python3'), find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'], 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( test(
'session_preparation_export', 'session_preparation_export',
find_program('python3'), find_program('python3'),

View file

@ -1343,6 +1343,40 @@ static const char *const MIGRATE_V28_TO_V29_SQL =
"PRIMARY KEY(generation_id,entry_id));" "PRIMARY KEY(generation_id,entry_id));"
"PRAGMA user_version=29;COMMIT;"; "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 /* WHY: schema v29 was already opened on the private 0.1.4 review installation
* before medication capture joined the same unreleased migration. CONTRACT: * before medication capture joined the same unreleased migration. CONTRACT:
* this additive repair is identical to the tail of v28 -> v29 and runs only * 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 || } else if (version == 11 || version == 12 || version == 13 || version == 14 || version == 15 ||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 || version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 || version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
version == 26 || version == 27 || version == 28 || version == 29) { version == 26 || version == 27 || version == 28 || version == 29 || version == 30) {
status = TRAINLOG_STATUS_OK; status = TRAINLOG_STATUS_OK;
} else { } else {
if (version == 1) { if (version == 1) {
@ -2324,6 +2358,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK) { if (status == TRAINLOG_STATUS_OK) {
status = execute_sql(database, ENSURE_V29_SLEEP_MEDICATION_SQL); 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) { if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database); 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) { if (status != TRAINLOG_STATUS_OK) {
set_open_diagnostic(output_diagnostic, set_open_diagnostic(output_diagnostic,
output_diagnostic_capacity, output_diagnostic_capacity,
version == 0 ? "create schema v29" : "migrate database to schema v29", version == 0 ? "create schema v30" : "migrate database to schema v30",
database->connection, database->connection,
SQLITE_ERROR); SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -49,7 +49,7 @@ int main(void) {
trainlog_database_close(production); trainlog_database_close(production);
production = NULL; production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 29); CHECK(scalar(raw, "PRAGMA user_version") == 30);
CHECK(scalar(raw, CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events'," "('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 " "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('sleep_medications','sleep_medication_revisions'," "('sleep_medications','sleep_medication_revisions',"
"'sleep_medication_intakes')") == 3); "'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_foreign_key_check") == 0);
CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") == CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_integrity_check WHERE integrity_check='ok'") ==
1); 1);
@ -79,13 +82,13 @@ int main(void) {
NULL) == SQLITE_OK); NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK);
raw = NULL; raw = NULL;
/* An unpublished v29 may already exist locally. Opening it completes the /* A valid v29 may already exist locally. Opening it completes the historical
* same v29 contract without rewriting user data or changing user_version. */ * v29 repair and then advances additively to the current v30 schema. */
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
trainlog_database_close(production); trainlog_database_close(production);
production = NULL; production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 29); CHECK(scalar(raw, "PRAGMA user_version") == 30);
CHECK(scalar(raw, CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE " "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
"'sleep_medication%'") == 3); "'sleep_medication%'") == 3);
@ -99,7 +102,7 @@ cleanup:
} }
(void)unlink(path); (void)unlink(path);
if (result == EXIT_SUCCESS) { 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; 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); TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strncmp(diagnostic, CHECK(strncmp(diagnostic,
"migrate database to schema v29: SQLite rc=", "migrate database to schema v30: SQLite rc=",
strlen("migrate database to schema v29: SQLite rc=")) == 0); strlen("migrate database to schema v30: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL);
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
CHECK(fd >= 0); CHECK(fd >= 0);
CHECK(close(fd) == 0); CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=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(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL); CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 30 is newer") != NULL); CHECK(strstr(diagnostic, "schema version 31 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0); CHECK(unlink(path) == 0);
return true; return true;

View file

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

View file

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