feat(cardio): add heart rate persistence and sync
This commit is contained in:
parent
c13549338c
commit
14c3ac294e
60 changed files with 1445 additions and 88 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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)) {
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
)
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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") }
|
||||||
|
|
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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 ->
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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))
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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"),
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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` |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
126
tests/test_heart_rate_exchange.py
Normal file
126
tests/test_heart_rate_exchange.py
Normal 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()
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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"))
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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}"
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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 = []
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
247
tools/heart_rate_exchange.py
Normal file
247
tools/heart_rate_exchange.py
Normal 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()
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"]
|
||||||
|
|
|
||||||
|
|
@ -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")}
|
||||||
|
|
|
||||||
|
|
@ -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"]):
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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")
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
75
tui/tests/test_schema_v30_migration.c
Normal file
75
tui/tests/test_schema_v30_migration.c
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue