diff --git a/CHANGELOG.md b/CHANGELOG.md index 2dcccec..659c895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,21 @@ ## Unreleased — 0.1.6 +- Recovered a real divergent Sleep revision identity without choosing an + arbitrary winner. The unpublished desktop branch was re-identified with its + exact content, the immutable Android revision retained its published ID and + parent, and a fresh successor preserved the non-conflicting qualities, + events and medication occurrences. The original immutable generation now + has a `consumed` / `sqlite-commit-full` ACK and the repaired 24 September + session is available through Desktop and Web with its timeline, feedback, + cardio and Program provenance. +- Added Desktop schema v36 and Android schema v33 append-only guards for Sleep + revision payloads. Both importers now reject a known historical revision ID + whose parent or canonical content differs, even when that revision is below + the current tip. A dedicated transactional recovery utility and regressions + cover exact replay, parent/content collisions, medication quantities, + rollback, immutable generation retry, and long UUID/parent chains. + - Fixed Android finalization of Program-derived sessions containing a completed target-only continuous occurrence. The repository now materializes the performed duration from the persisted exercise timeline, including preserved diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt index 7f2fca9..7f9a335 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/AndroidBackupService.kt @@ -225,7 +225,7 @@ internal class AndroidBackupService( manifest.getInt("version") == VERSION && manifest.getString("package") == context.packageName && manifest.getBoolean("plaintext") && - manifest.getInt("schema") in 17..32 + manifest.getInt("schema") in 17..33 ) { "Unsupported backup." } @@ -319,7 +319,7 @@ internal class AndroidBackupService( } ) require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() }) - require(db.version in 17..32) { "Unsupported Android schema ${db.version}." } + require(db.version in 17..33) { "Unsupported Android schema ${db.version}." } return db.version } finally { db.close() diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index dc5d17e..212d54f 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -1457,6 +1457,18 @@ class TrainlogRepository( "SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?", arrayOf(entryId), ).use { if (it.moveToFirst()) it.getString(0) to it.getInt(1) else null } + val knownOwner = db.rawQuery( + "SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?", + arrayOf(revisionId), + ).use { if (it.moveToFirst()) it.getString(0) else null } + if (knownOwner != null && + (knownOwner != entryId || + !sleepDiaryRevisionMatches(db, item, wireVersion, entryId, revisionId, parent)) + ) { + return SleepDiaryImportResult.Rejected( + "sleep diary revision identity reused with different content", + ) + } if (local?.first == revisionId) { val same = db.rawQuery( "SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted," + @@ -1611,6 +1623,78 @@ class TrainlogRepository( } } + private fun sleepDiaryRevisionMatches( + db: SQLiteDatabase, + item: JSONObject, + wireVersion: Int, + entryId: String, + revisionId: String, + parentRevisionId: String?, + ): Boolean { + /* WHY: an incoming snapshot can name an ancestor rather than the + * current tip. CONTRACT: stale replay is idempotent only after its + * immutable revision-owned payload has been compared byte-semantically. + * INVARIANT: descendants never hide identity reuse in history. */ + var same = db.rawQuery( + "SELECT entry_id,parent_revision_id,created_at,sleep_quality,wake_quality,day_form," + + "COALESCE(treatment_and_notes,'')," + + "(SELECT COUNT(*) FROM sleep_diary_events e WHERE e.revision_id=r.revision_id)," + + "(SELECT COUNT(*) FROM sleep_medication_intakes i WHERE i.revision_id=r.revision_id) " + + "FROM sleep_diary_revisions r WHERE revision_id=?", + arrayOf(revisionId), + ).use { current -> + current.moveToFirst() && current.getString(0) == entryId && + (if (current.isNull(1)) null else current.getString(1)) == parentRevisionId && + current.getString(2) == item.getString("updated_at") && + (if (current.isNull(3)) null else current.getString(3)) == + (if (item.isNull("sleep_quality")) null else item.getString("sleep_quality")) && + (if (current.isNull(4)) null else current.getString(4)) == + (if (item.isNull("wake_quality")) null else item.getString("wake_quality")) && + (if (current.isNull(5)) null else current.getString(5)) == + (if (item.isNull("day_form")) null else item.getString("day_form")) && + current.getString(6) == item.getString("treatment_and_notes") && + current.getInt(7) == item.getJSONArray("events").length() && + current.getInt(8) == item.getJSONArray("intakes").length() + } + val events = item.getJSONArray("events") + for (index in 0 until events.length()) { + val event = events.getJSONObject(index) + same = same && db.rawQuery( + "SELECT event_type,start_at,end_at FROM sleep_diary_events " + + "WHERE revision_id=? AND event_id=?", + arrayOf(revisionId, event.getString("event_id")), + ).use { current -> + current.moveToFirst() && current.getString(0) == event.getString("type") && + current.getString(1) == event.getString("start_at") && + (if (current.isNull(2)) null else current.getString(2)) == + (if (event.isNull("end_at")) null else event.getString("end_at")) + } + } + val intakes = item.getJSONArray("intakes") + for (index in 0 until intakes.length()) { + val intake = intakes.getJSONObject(index) + same = same && db.rawQuery( + "SELECT medication_id,medication_name,taken_at,dose_value,dose_unit,quantity," + + "COALESCE(note,''),created_at FROM sleep_medication_intakes " + + "WHERE revision_id=? AND intake_id=?", + arrayOf(revisionId, intake.getString("intake_id")), + ).use { current -> + current.moveToFirst() && + current.getString(0) == intake.getString("medication_id") && + current.getString(1) == intake.getString("medication_name") && + current.getString(2) == intake.getString("taken_at") && + (if (current.isNull(3)) null else current.getDouble(3)) == + (if (intake.isNull("dose_value")) null else intake.getDouble("dose_value")) && + (if (current.isNull(4)) null else current.getString(4)) == + (if (intake.isNull("dose_unit")) null else intake.getString("dose_unit")) && + current.getInt(5) == (if (wireVersion == 1) 1 else intake.getInt("quantity")) && + current.getString(6) == intake.getString("note") && + current.getString(7) == intake.getString("created_at") + } + } + return same + } + private fun markSleepDiaryImported( db: SQLiteDatabase, entryId: String, @@ -12203,7 +12287,7 @@ private class TrainlogDatabaseHelper( appContext, databaseName, null, - 32, + 33, ) { override fun onConfigure( db: SQLiteDatabase, @@ -12549,6 +12633,14 @@ private class TrainlogDatabaseHelper( createSleepQuickPublicationTable(db) version = 32 } + if (version < 33 && newVersion >= 33) { + /* WHY: a revision UUID is shared causal identity, so changing any + * owned payload row in place can make peers disagree permanently. + * CONTRACT: v33 makes Sleep revision payloads append-only; edits + * continue to append through repository APIs with a fresh UUID. */ + createSleepImmutabilityTriggers(db) + version = 33 + } if (version != newVersion) { error( @@ -12688,6 +12780,28 @@ private class TrainlogDatabaseHelper( entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT, revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT, PRIMARY KEY(generation_id,entry_id))""".trimIndent()) + createSleepImmutabilityTriggers(db) + } + + private fun createSleepImmutabilityTriggers(db: SQLiteDatabase) { + /* INVARIANT: only owner tips/publication state may advance in place. + * Revision rows and their event/intake children are append-only. */ + listOf( + Triple("sleep_diary_revisions", "update", "sleep diary revision is immutable"), + Triple("sleep_diary_revisions", "delete", "sleep diary revision is immutable"), + Triple("sleep_diary_events", "update", "sleep diary event is immutable"), + Triple("sleep_diary_events", "delete", "sleep diary event is immutable"), + Triple("sleep_medication_intakes", "update", "sleep medication intake is immutable"), + Triple("sleep_medication_intakes", "delete", "sleep medication intake is immutable"), + Triple("sleep_medication_revisions", "update", "sleep medication revision is immutable"), + Triple("sleep_medication_revisions", "delete", "sleep medication revision is immutable"), + ).forEach { (table, operation, message) -> + db.execSQL( + "CREATE TRIGGER IF NOT EXISTS ${table}_immutable_$operation " + + "BEFORE ${operation.uppercase()} ON $table BEGIN " + + "SELECT RAISE(ABORT,'$message'); END", + ) + } } private fun createSleepQuickPublicationTable(db: SQLiteDatabase) { diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt index a931977..f928503 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV10PlanningMigrationTest.kt @@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest { TrainlogRepository(context, name).useForTest { it.listSessions() } SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(32, scalar(db, "PRAGMA user_version")) + assertEquals(33, scalar(db, "PRAGMA user_version")) for (table in listOf("session_exercises", "draft_session_exercises")) { assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL")) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt index 5d8b950..4563ef4 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV11ExerciseAliasMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(32, scalar(db, "PRAGMA user_version")) + assertEquals(33, scalar(db, "PRAGMA user_version")) /* v13 intentionally adds the frozen machine identities and * exercise-owned metadata; all non-exercise facts stay exact. */ val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") } diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt index f77b6bf..639ce54 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV16AiSessionDraftMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest { migrated.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(32, scalar(db, "PRAGMA user_version")) + assertEquals(33, scalar(db, "PRAGMA user_version")) assertEquals(before, listOf( scalar(db, "SELECT COUNT(*) FROM sessions"), scalar(db, "SELECT COUNT(*) FROM performed_sets"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt index a74ecc6..19e5357 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV25ProgramExecutionMigrationTest.kt @@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest { reopened.close() } SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(32, scalar(db, "PRAGMA user_version")) + assertEquals(33, scalar(db, "PRAGMA user_version")) assertEquals(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL")) assertEquals("ok", text(db, "PRAGMA integrity_check")) db.rawQuery("PRAGMA foreign_key_check", null).use { cursor -> diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt index 2fca2a5..e079c34 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV26SleepMigrationTest.kt @@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest { context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY, - ).use { assertEquals(32, it.version) } + ).use { assertEquals(33, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt index e37cd4b..e7bc70e 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV27HeartRateMigrationTest.kt @@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest { context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY, - ).use { assertEquals(32, it.version) } + ).use { assertEquals(33, it.version) } } finally { repository.close() context.deleteDatabase(name) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt index c7848c3..a126551 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV28SessionTimelineMigrationTest.kt @@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest { null, SQLiteDatabase.OPEN_READONLY, ).use { db -> - assertEquals(32, db.version) + assertEquals(33, db.version) db.rawQuery( "SELECT session_id,started_at,ended_at FROM session_timeline_sessions", null, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt index 9ee3673..3639dd9 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV29CardioSessionMigrationTest.kt @@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest { null, SQLiteDatabase.OPEN_READONLY, ).use { db -> - assertEquals(32, db.version) + assertEquals(33, db.version) db.rawQuery( "SELECT session_type,session_kind FROM sessions", null, diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt index 8c0bcc1..5a1f26e 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV30CardioCalibrationMigrationTest.kt @@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest { null, SQLiteDatabase.OPEN_READONLY, ).use { db -> - assertEquals(32, db.version) + assertEquals(33, db.version) db.rawQuery( "SELECT COUNT(*) FROM exercises WHERE " + "exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " + diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt index 668c95a..18e53aa 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV31CardioGuidanceMigrationTest.kt @@ -42,7 +42,7 @@ class AndroidV31CardioGuidanceMigrationTest { null, SQLiteDatabase.OPEN_READONLY, ).use { db -> - assertEquals(32, db.version) + assertEquals(33, db.version) db.rawQuery( "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" + "'cardio_guidance_runs','cardio_guidance_phases'," + diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV32SleepQuantityMigrationTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV32SleepQuantityMigrationTest.kt index 989e3cc..18d866b 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV32SleepQuantityMigrationTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/AndroidV32SleepQuantityMigrationTest.kt @@ -59,7 +59,7 @@ class AndroidV32SleepQuantityMigrationTest { migrated.listSleepDiary() migrated.close() SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db -> - assertEquals(32, scalar(db, "PRAGMA user_version")) + assertEquals(33, scalar(db, "PRAGMA user_version")) assertEquals( 1, scalar( diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt index 598900a..1629323 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/BodyZonesTest.kt @@ -328,7 +328,7 @@ class BodyZonesTest { SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null, SQLiteDatabase.OPEN_READONLY).use { db -> db.rawQuery("PRAGMA user_version", null).use { cursor -> - assertTrue(cursor.moveToFirst()); assertEquals(32, cursor.getInt(0)) + assertTrue(cursor.moveToFirst()); assertEquals(33, cursor.getInt(0)) } db.rawQuery("SELECT id FROM exercises", null).use { cursor -> assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0)) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt index 84e6328..45de1cb 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SessionPreparationExchangeTest.kt @@ -360,7 +360,7 @@ class SessionPreparationExchangeTest { repository = TrainlogRepository(context, name) assertEquals(1, repository.listPreparedSessions().size) repository.close() - assertEquals("32", scalarText(name, "PRAGMA user_version")) + assertEquals("33", scalarText(name, "PRAGMA user_version")) assertEquals( "0", scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"), diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt index a300f02..8504d48 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt @@ -1,6 +1,8 @@ package com.labfytools.trainlog.data import android.content.Context +import android.database.sqlite.SQLiteDatabase +import android.database.sqlite.SQLiteException import androidx.test.core.app.ApplicationProvider import com.labfytools.trainlog.model.SleepDiaryDraft import com.labfytools.trainlog.model.SleepDiaryEvent @@ -11,6 +13,7 @@ import com.labfytools.trainlog.model.MedicationIntake import com.labfytools.trainlog.model.SleepMedication import java.util.UUID import org.junit.Assert.assertEquals +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -73,6 +76,141 @@ class SleepDiaryRepositoryTest { } finally { repository.close(); context.deleteDatabase(name) } } + @Test + fun persistedSleepRevisionPayloadIsAppendOnly() { + val name = "sleep-immutable-${UUID.randomUUID()}.db" + val repository = TrainlogRepository(context, name) + val saved = try { + repository.saveSleepDiary( + SleepDiaryDraft( + nightStartDate = "2026-10-24", + nightEndDate = "2026-10-25", + createdAt = "2026-10-24T22:00:00+02:00", + updatedAt = "2026-10-25T07:00:00+01:00", + sleepQuality = SleepQuality.B, + wakeQuality = null, + dayForm = null, + treatmentAndNotes = "", + events = emptyList(), + ), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + } finally { + repository.close() + } + val raw = SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, + null, + SQLiteDatabase.OPEN_READWRITE, + ) + try { + assertEquals(33, raw.version) + assertThrows(SQLiteException::class.java) { + raw.execSQL( + "UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?", + arrayOf(saved.revisionId), + ) + } + assertThrows(SQLiteException::class.java) { + raw.execSQL( + "DELETE FROM sleep_diary_revisions WHERE revision_id=?", + arrayOf(saved.revisionId), + ) + } + assertEquals( + "B", + raw.rawQuery( + "SELECT sleep_quality FROM sleep_diary_revisions WHERE revision_id=?", + arrayOf(saved.revisionId), + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + cursor.getString(0) + }, + ) + } finally { + raw.close() + context.deleteDatabase(name) + } + } + + @Test + fun successiveSleepEditsKeepUniqueIdentitiesAndExactParentage() { + val name = "sleep-revision-chain-${UUID.randomUUID()}.db" + val repository = TrainlogRepository(context, name) + val identities = mutableListOf() + val expectedParents = mutableMapOf() + val expectedNotes = mutableMapOf() + try { + val created = repository.saveSleepDiary( + SleepDiaryDraft( + nightStartDate = "2026-10-24", + nightEndDate = "2026-10-25", + createdAt = "2026-10-24T22:00:00+02:00", + updatedAt = "2026-10-25T07:00:00+01:00", + sleepQuality = null, + wakeQuality = null, + dayForm = null, + treatmentAndNotes = "revision-root", + events = emptyList(), + ), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + identities += created.revisionId + expectedParents[created.revisionId] = null + expectedNotes[created.revisionId] = "revision-root" + + repeat(64) { index -> + val current = repository.listSleepDiary().single() + val note = "revision-$index" + val hour = 8 + index / 60 + val minute = index % 60 + val saved = repository.saveSleepDiary( + SleepDiaryDraft( + entryId = current.entryId, + expectedRevision = current.revisionId, + nightStartDate = current.nightStartDate, + nightEndDate = current.nightEndDate, + createdAt = current.createdAt, + updatedAt = "2026-10-25T%02d:%02d:00+01:00".format(hour, minute), + sleepQuality = current.sleepQuality, + wakeQuality = current.wakeQuality, + dayForm = current.dayForm, + treatmentAndNotes = note, + events = current.events, + intakes = current.intakes, + ), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + expectedParents[saved.revisionId] = current.revisionId + expectedNotes[saved.revisionId] = note + identities += saved.revisionId + } + } finally { + repository.close() + } + + assertEquals(65, identities.toSet().size) + val raw = SQLiteDatabase.openDatabase( + context.getDatabasePath(name).path, + null, + SQLiteDatabase.OPEN_READONLY, + ) + try { + identities.forEach { revisionId -> + raw.rawQuery( + "SELECT parent_revision_id,treatment_and_notes " + + "FROM sleep_diary_revisions WHERE revision_id=?", + arrayOf(revisionId), + ).use { cursor -> + assertTrue(cursor.moveToFirst()) + val parent = if (cursor.isNull(0)) null else cursor.getString(0) + assertEquals(expectedParents[revisionId], parent) + assertEquals(expectedNotes[revisionId], cursor.getString(1)) + } + } + } finally { + raw.close() + context.deleteDatabase(name) + } + } + @Test fun ancestryAllowsMultiHopRemoteAdvance() { val sourceName = "sleep-stale-source-${UUID.randomUUID()}.db" @@ -188,6 +326,15 @@ class SleepDiaryRepositoryTest { assertEquals(SleepQuality.MOY, destination.listSleepDiary().single().wakeQuality) assertEquals(SleepQuality.TB, destination.listSleepDiary().single().dayForm) assertEquals("Medication revised", destination.listSleepMedications().single().name) + + val reusedAncestor = org.json.JSONObject(firstSnapshot) + reusedAncestor.getJSONArray("entries").getJSONObject(0).put("sleep_quality", "M") + assertEquals( + TrainlogRepository.SleepDiaryImportResult.Rejected( + "sleep diary revision identity reused with different content", + ), + destination.applySleepDiaryV1Json(reusedAncestor.toString()), + ) } finally { source.close() destination.close() diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt index b68182f..0436ffd 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SyncGenerationServiceTest.kt @@ -878,7 +878,7 @@ class SyncGenerationServiceTest { .use { db -> db.rawQuery("PRAGMA user_version", null).use { assertTrue(it.moveToFirst()) - assertEquals(32, it.getInt(0)) + assertEquals(33, it.getInt(0)) } db.rawQuery("SELECT COUNT(*) FROM exercises", null).use { assertTrue(it.moveToFirst()) diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt index 7595c78..4569fd3 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/TrainlogRepositoryDraftTest.kt @@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(32, cursor.getInt(0)) + assertEquals(33, cursor.getInt(0)) } db.rawQuery( "SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " + @@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest { ).use { db -> db.rawQuery("PRAGMA user_version;", null).use { cursor -> assertTrue(cursor.moveToFirst()) - assertEquals(32, cursor.getInt(0)) + assertEquals(33, cursor.getInt(0)) } db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor -> assertTrue(cursor.moveToFirst()) diff --git a/docs/android.md b/docs/android.md index 51d7d7c..76233df 100644 --- a/docs/android.md +++ b/docs/android.md @@ -25,6 +25,14 @@ revision. Quick publication makes a current fact eligible for generation; it does not validate a day and does not change the existing validation/ACK lifecycle. +Android schema v33 enforces that same immutable identity boundary inside +SQLite. Eight append-only triggers reject update or deletion of Sleep diary +revisions, events, medication intakes, and medication revisions. Repository +imports compare a known ancestor's complete canonical payload before the stale +ancestor shortcut, so a reused ID with a different parent or content cannot be +hidden below a later local tip. All user edits continue to create a fresh +UUIDv4 revision and preserve the previous payload and causal parent. + The quick Sleep capture derives one night identity from the existing 18:00-to-18:00 date rule. A medication action can create that pending entry before `BED_TIME`, later medication actions reuse it, and Couché appends diff --git a/docs/current_state.md b/docs/current_state.md index c410fa4..a7ca447 100644 --- a/docs/current_state.md +++ b/docs/current_state.md @@ -97,7 +97,7 @@ The rollout packager now keeps the legacy request daemon and one-shot helper in the same relocatable, hashed bundle as the Web generation coordinator, avoiding mixed-checkout user-service execution during the controlled rollout. -Snapshot date: **2026-09-23**. +Snapshot date: **2026-09-24**. `TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP, generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and @@ -128,8 +128,8 @@ parallel implementation of its rules. | Boundary | Current state | |---|---| | Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` | -| Desktop SQLite | schema v35; v35 additively gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 | -| Android SQLite | schema v32; v32 additively gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip | +| Desktop SQLite | schema v36; v36 makes Sleep revision payload tables append-only; v35 gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 | +| Android SQLite | schema v33; v33 makes Sleep revision payload tables append-only; v32 gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip | | Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport | | Desktop terminal backend | Notcurses only | | Trainlog product version | `0.1.6` development, synchronized across Android and desktop; latest stable release: `v0.1.5` | @@ -178,6 +178,7 @@ parallel implementation of its rules. | Current operational cursor | 0.1.6 Sleep graph overlays, shared agenda estimate and denser responsive agenda axis validated and privately served from Web code commit `a60e1aa`; immutable stable v0.1.5 remains unchanged. | | Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda and selected HR detail; a separate inclusive night-start date range drives preview/export from the loaded period. The agenda axis and rows share 24 exact hour columns; estimated sleep duration is consistent and remains non-persistent. | | Sleep Réveil interval policy | Android Réveil creates a revisioned 30-minute `long_awake` window; repeated taps extend it and Levé clamps it. The real 2026-09-23 night is causally repaired on Android/desktop and renders two orange intervals in agenda, HR and PDF; Android 307 passed plus five optional skips, and Web 208/208 passed. | +| Sleep revision collision recovery | `TRAINLOG_SLEEP_REVISION_COLLISION_RECOVERY_V1=PASS`: one unpublished divergent desktop tip was re-identified without payload loss, the immutable Android identity was restored exactly, and a fresh successor retained the non-conflicting union. The original generation was consumed with a full SQLite ACK; the real 24/09 session, Sleep facts, cardio samples, timeline, feedback, and Program provenance are visible on Desktop/Web. Schema v36/v33 and importer payload checks prevent recurrence. | | Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered | | Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` | | Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` | diff --git a/docs/database.md b/docs/database.md index fcec25c..f99bce6 100644 --- a/docs/database.md +++ b/docs/database.md @@ -1,5 +1,21 @@ # Desktop database +## Schema v36: append-only Sleep revision payloads + +Schema v36 adds database-level `BEFORE UPDATE` and `BEFORE DELETE` guards for +Sleep diary revisions, their events and medication intakes, and medication +catalog revisions. A stable revision identity therefore cannot be detached +from its canonical parent or payload even through a direct SQLite write. +Normal correction remains append-only: it inserts a fresh UUIDv4 revision and +advances the entry or medication current pointer transactionally. The migration +is additive and does not rewrite any existing Sleep identity, payload, causal +edge, generation membership, publication state, or acknowledgement. + +Importers additionally compare the immutable payload of every already-known +Sleep revision before treating an incoming ancestor as stale/idempotent. Thus +an exact historical replay remains accepted, while reuse of the same identity +with a different parent or content is an explicit collision. + ## Schema v35: Sleep Diary quantity V2 Schema v35 additively adds `sleep_medication_intakes.quantity`, a required diff --git a/docs/reviews/sleep_revision_collision_recovery_v1_evidence.md b/docs/reviews/sleep_revision_collision_recovery_v1_evidence.md new file mode 100644 index 0000000..073641f --- /dev/null +++ b/docs/reviews/sleep_revision_collision_recovery_v1_evidence.md @@ -0,0 +1,73 @@ +# Sleep revision collision recovery V1 evidence + +Date: 2026-09-24 + +## Collision + +```text +COLLIDING_REVISION_ID=slr_d6cac4c6-dab7-4327-bc06-5cc95f7a2dfa +ANDROID_PARENT=slr_e98ad36c-94e2-452c-8068-a7e3d2ea4dd4 +DESKTOP_PARENT=slr_2bbf573d-4262-49b0-896e-1bcabf3b002a +ANDROID_PUBLISHED=YES +DESKTOP_PUBLISHED=NO +ANDROID_REFERENCED_BY_IMMUTABLE_GENERATION=YES +DESKTOP_REFERENCED_BY_IMMUTABLE_GENERATION=NO +``` + +The two records had the same entry identity, night envelope, creation time, +notes, day form, and four event identities (`IDENTICAL`). Their causal parents +were different (`PARENT_CONFLICT`). Desktop contained two non-null quality +values where Android retained nulls (`NON_CONFLICTING_ADDITION`). Desktop also +contained five distinct medication occurrences absent from the older Android +branch (`MISSING_ON_ANDROID` / `NON_CONFLICTING_ADDITION`), with a total +quantity of six. No same occurrence ID had divergent content. Medication names, +unit doses, and notes are deliberately omitted from this repository record. + +The root cause was the manual real-night causal repair recorded in +`docs/reviews/sleep_awake_selection_pdf_v1_evidence.md`: two independently +derived tips were assigned the same preselected new revision ID. No production +UUID collision or repository clone function produced the reuse. The faulty +transition was therefore that operational repair, not an Android/Desktop save +function. The production boundaries hardened by this tranche are +`sleep_diary_exchange.apply()` and +`TrainlogRepository.applySleepDiaryV1Json()`; database schemas v36/v33 also +make revision payloads append-only. + +## Backups and isolated simulation + +Immediate pre-recovery evidence is outside Git at +`/home/fy59/Downloads/trainlog-sleep-collision-recovery-20260924T140000+0200/`. +The Android application archive SHA-256 is +`7d0d95c9401df4baa66503658b8e309e4f6cb7359e5404517460a97afbce668a`, +the extracted Android database SHA-256 is +`6fb99e87a24041202a867e8487dd95ef088f8e189ebb6d6e60956f999bc01ae0`, +and the Desktop database SHA-256 is +`4a44a29a68683ddfaea4c20c121cd6f32309660d6b4141ca716c3fb20d89b225`. +Both databases reported `integrity_check=ok` and an empty foreign-key check. + +Before canonical mutation, isolated copies retained both branches, replayed +the immutable generation, produced `consumed` / `sqlite-commit-full`, and +replayed the exact ACK byte-identically. The imported fixture had one session +ending `2026-09-24T11:16:00+02:00`, 7 exercises, 15 sets, 5 feedbacks, +7 exercise intervals, 4,919 BPM samples, 4,919 RR intervals, and one Program +execution. Sleep retained four events, five medication occurrences, and total +quantity six; both copied databases remained integrity/FK clean. + +## Canonical recovery and replay + +The unpublished Desktop branch was retained exactly under +`slr_42250548-5237-4225-a12d-7e68a0b57076`. The immutable Android branch kept +the collision identity and its original parent. The reconciliation successor +`slr_670c7516-f39e-4196-a861-8f3b657dbee1` descends from that published branch +and contains the non-conflicting union. The target manifest remains SHA-256 +`ea3e7ed5622a04f514db069db29cccff1b4702c5cc0e4e253a578ffe8806fc70`; +no generation artifact was rewritten. + +Generation `gen_76222040-50d7-4f5d-bcfb-ad9c1e7786f8` was consumed through the +production consumer with `sqlite-commit-full`, and Android accepted its exact +consumed ACK. Two subsequent normal Android generations were also consumed and +acknowledged during the recovery validation. The canonical database and Web +API expose the single original session identity at 11:16, all 7 exercises, +15 sets, 5 feedbacks, 4,919 BPM/RR values, one Program execution, and the +reconciled Sleep tip with four events, five medication occurrences, and total +quantity six. diff --git a/docs/roadmap.md b/docs/roadmap.md index 4a0dd34..e50fcd8 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -31,7 +31,7 @@ estimates. ## Current baseline Stable v0.1.5 is the release baseline for the v0.1.6 development cycle. -Desktop schema v35, Android schema v32, Notcurses, direct `Documents/Trainlog` +Desktop schema v36, Android schema v33, Notcurses, direct `Documents/Trainlog` storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session Generator V1 is hidden pending V2. AI session-draft exchange retains its diff --git a/docs/sync_exchange.md b/docs/sync_exchange.md index dcff46e..111f31a 100644 --- a/docs/sync_exchange.md +++ b/docs/sync_exchange.md @@ -1264,3 +1264,22 @@ packaged user-session deployment. Before every invocation the bounded combined stdout/stderr result is truncated. Resolution, launch, argparse, exception, signal, and non-zero-exit diagnostics therefore describe only the current run and are retained in the synchronization error/history when the run fails. + +## Sleep revision identity collision recovery + +A Sleep `revision_id` names one immutable parent plus canonical payload. The +generation consumer accepts the same identity only when both are equal; it +rejects a different parent or payload even if the incoming revision is an +ancestor of the local current tip. Ordinary Sleep edits always use a new +revision identity. + +The bounded recovery tool is intentionally narrower than general conflict +resolution. It may re-identify a divergent local tip only after proving that +the tip has no descendant, validation/ACK state, or immutable generation +reference. It retains that branch unchanged, restores the manifest-owned peer +revision under its published identity, and creates a fresh single-parent +successor containing only non-conflicting scalar additions and exact-ID unions +of events and medication occurrences. Conflicting non-null scalars, notes, or +same occurrence IDs with different payloads require a human decision. The +generation manifest and artifacts are never rewritten; only an exact rejected +consumer/producer ledger may be rearmed for normal replay. diff --git a/docs/tests.md b/docs/tests.md index d747738..79cdd57 100644 --- a/docs/tests.md +++ b/docs/tests.md @@ -1,5 +1,26 @@ # Tests and validation +## Sleep revision collision recovery + +Collision regressions distinguish exact replay from same-ID parent or payload +reuse, validate known historical ancestors before stale-snapshot handling, and +exercise safe re-identification of an unpublished branch. They preserve +quantity one and two, distinct same-time medication occurrences, event and +intake unions, rollback, immutable-generation references, exact ledger rearm, +and idempotent replay. SQLite migration tests prove that Desktop v36 and +Android v33 reject direct update/delete of all revision-owned Sleep payload +tables. Android also creates a 65-revision edit chain and verifies unique UUIDs, +exact parentage, and unchanged historical payloads. + +The retained real-data simulation replays the original immutable full +generation against isolated copies and verifies one session ending at 11:16, +7 exercises, 15 sets, 5 feedbacks, 7 exercise intervals, 4,919 BPM samples, +4,919 RR intervals, Program provenance, `consumed` / `sqlite-commit-full`, a +byte-identical ACK replay, and clean integrity/foreign-key checks. A later +normal generation was consumed and acknowledged, proving continued pipeline +use. Sensitive medication names and dose values remain outside repository +evidence. + ## Web UI refinement 0.1.6 Web regressions cover calendar-week activity grouping, global rather than @@ -50,9 +71,10 @@ Other UI regressions cover persisted Dashboard sizing and reset behavior, English preference persistence across a remount, centralized Dashboard English labels, non-stretched chart aspect ratios, and distinct missing/partial exercise-timing messages. -The closeout run passes Web **32 files / 205 tests**, TypeScript typecheck and -the production build; native Meson **111/111**; Android `testDebugUnitTest` and -`assembleDebug`; JSON/import validators; changed-C formatting; and diff checks. +The current closeout run passes Web **32 files / 209 tests**, TypeScript +typecheck and the production build; native Meson **113/113**; Android **315 +tests** with five optional skips plus `assembleDebug`; JSON/import validators; +changed-C formatting; and diff checks. The restarted private service serves the rebuilt executable byte-for-byte from the development checkout and reports healthy Trainlog `0.1.6`. A non-mutating Firefox smoke on the real 2026-09-23 night confirms the agenda and detail both diff --git a/tests/test_sleep_diary_exchange.py b/tests/test_sleep_diary_exchange.py index 34e412c..c239574 100644 --- a/tests/test_sleep_diary_exchange.py +++ b/tests/test_sleep_diary_exchange.py @@ -58,6 +58,15 @@ class SleepExchangeTest(unittest.TestCase): exported = exchange.build(self.db) self.assertTrue(exported["entries"][0]["deleted"]) + def test_known_revision_with_different_parent_is_an_identity_collision(self): + revision = "slr_20000000-0000-4000-8000-000000000002" + self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(revision)))) + conflicting = self.document( + entry(revision, "slr_30000000-0000-4000-8000-000000000003") + ) + with self.assertRaisesRegex(ValueError, "identity reused"): + exchange.apply(self.db, conflicting) + def test_v1_hydrates_quantity_one_and_v2_preserves_quantity(self): first = "slr_20000000-0000-4000-8000-000000000002" legacy = self.document(entry(first)) diff --git a/tests/test_sleep_revision_collision_recovery.py b/tests/test_sleep_revision_collision_recovery.py new file mode 100644 index 0000000..968dcf1 --- /dev/null +++ b/tests/test_sleep_revision_collision_recovery.py @@ -0,0 +1,310 @@ +#!/usr/bin/env python3 +"""Transactional Sleep revision collision recovery regressions.""" + +import copy +import sqlite3 +import sys +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(ROOT / "tools")) + +import recover_sleep_revision_collision as recovery +import sleep_diary_exchange as exchange + + +ENTRY = "sl_10000000-0000-4000-8000-000000000001" +REMOTE_PARENT = "slr_20000000-0000-4000-8000-000000000002" +LOCAL_PARENT = "slr_30000000-0000-4000-8000-000000000003" +COLLISION = "slr_40000000-0000-4000-8000-000000000004" +LOCAL_REIDENTIFIED = "slr_50000000-0000-4000-8000-000000000005" +RECONCILIATION = "slr_60000000-0000-4000-8000-000000000006" +RECOVERED_AT = "2026-10-25T18:30:00+01:00" +GENERATION = "gen_a0000000-0000-4000-8000-00000000000a" +RUN = "sy_b0000000-0000-4000-8000-00000000000b" +PRODUCER = "peer_c0000000-0000-4000-8000-00000000000c" +CONSUMER = "peer_d0000000-0000-4000-8000-00000000000d" +MANIFEST_DIGEST = "a" * 64 + + +def incoming_entry() -> dict: + return { + "entry_id": ENTRY, + "night_start_date": "2026-10-24", + "night_end_date": "2026-10-25", + "created_at": "2026-10-24T22:00:00+02:00", + "updated_at": "2026-10-25T07:00:00+01:00", + "revision_id": COLLISION, + "parent_revision_id": REMOTE_PARENT, + "ancestry": [COLLISION, REMOTE_PARENT], + "deleted": False, + "sleep_quality": None, + "wake_quality": None, + "day_form": None, + "treatment_and_notes": "", + "events": [{ + "event_id": "sle_70000000-0000-4000-8000-000000000007", + "type": "sleep", + "start_at": "2026-10-24T23:00:00+02:00", + "end_at": "2026-10-25T06:00:00+01:00", + }], + "intakes": [], + } + + +def document() -> dict: + return { + "format": exchange.FORMAT, + "version": 2, + "generated_at": "2026-10-25T18:00:00+01:00", + "entries": [incoming_entry()], + "medications": [], + } + + +def manifest() -> dict: + return { + "generation_id": GENERATION, + "run_id": RUN, + "producer": {"peer_id": PRODUCER, "kind": "android"}, + "consumer_peer_id": CONSUMER, + "generated_at": "2026-10-25T18:00:00+01:00", + "parent_generation_id": None, + } + + +class SleepRevisionCollisionRecoveryTest(unittest.TestCase): + def setUp(self) -> None: + self.db = sqlite3.connect(":memory:") + self.db.execute("PRAGMA foreign_keys=ON") + self.db.executescript(""" + CREATE TABLE sleep_diary_entries(entry_id TEXT PRIMARY KEY,night_start_date TEXT,night_end_date TEXT,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER); + CREATE TABLE sleep_diary_revisions(revision_id TEXT PRIMARY KEY,entry_id TEXT REFERENCES sleep_diary_entries(entry_id),parent_revision_id TEXT,created_at TEXT,sleep_quality TEXT,wake_quality TEXT,day_form TEXT,treatment_and_notes TEXT); + CREATE TABLE sleep_diary_events(revision_id TEXT REFERENCES sleep_diary_revisions(revision_id),event_id TEXT,event_type TEXT,start_at TEXT,end_at TEXT,PRIMARY KEY(revision_id,event_id)); + CREATE TABLE sleep_medication_intakes(revision_id TEXT REFERENCES sleep_diary_revisions(revision_id),intake_id TEXT,medication_id TEXT,medication_name TEXT,taken_at TEXT,dose_value REAL,dose_unit TEXT,quantity INTEGER,note TEXT,created_at TEXT,PRIMARY KEY(revision_id,intake_id)); + CREATE TABLE sleep_medications(medication_id TEXT PRIMARY KEY,created_at TEXT,updated_at TEXT,current_revision_id TEXT,deleted INTEGER); + CREATE TABLE sleep_medication_revisions(revision_id TEXT PRIMARY KEY,medication_id TEXT,parent_revision_id TEXT,created_at TEXT,name TEXT,default_dose_value REAL,default_dose_unit TEXT,form TEXT,note TEXT,active INTEGER); + CREATE TABLE sleep_diary_publication_state(entry_id TEXT PRIMARY KEY,validated_revision_id TEXT,validated_at TEXT,acknowledged_revision_id TEXT,acknowledged_at TEXT); + CREATE TABLE sleep_diary_generation_entries(generation_id TEXT,entry_id TEXT,revision_id TEXT,PRIMARY KEY(generation_id,entry_id)); + CREATE TABLE sync_consumed_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,consumed_at TEXT,result TEXT,durability TEXT,diagnostic TEXT,ack_json TEXT); + CREATE TABLE sync_acknowledgements(ack_id TEXT PRIMARY KEY,generation_id TEXT,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,manifest_sha256 TEXT,result TEXT,durability TEXT,created_at TEXT,diagnostic TEXT,payload_sha256 TEXT); + CREATE TABLE sync_generations(generation_id TEXT PRIMARY KEY,run_id TEXT,producer_peer_id TEXT,consumer_peer_id TEXT,producer_kind TEXT,generated_at TEXT,parent_generation_id TEXT,manifest_sha256 TEXT,status TEXT,manifest_json TEXT,staging_path TEXT,acknowledged_at TEXT); + """) + item = incoming_entry() + self.db.execute( + "INSERT INTO sleep_diary_entries VALUES(?,?,?,?,?,?,0)", + (ENTRY, item["night_start_date"], item["night_end_date"], item["created_at"], + item["updated_at"], COLLISION), + ) + # The fixture intentionally reproduces two parents and one reused id. + for revision_id, parent in ((REMOTE_PARENT, None), (LOCAL_PARENT, REMOTE_PARENT)): + self.db.execute( + "INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)", + (revision_id, ENTRY, parent, item["updated_at"], None, None, None, ""), + ) + self.db.execute( + "INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)", + (COLLISION, ENTRY, LOCAL_PARENT, item["updated_at"], "TB", "B", None, ""), + ) + self.db.execute( + "INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)", + (COLLISION, item["events"][0]["event_id"], "sleep", + item["events"][0]["start_at"], item["events"][0]["end_at"]), + ) + for suffix, quantity in (("8", 1), ("9", 2)): + self.db.execute( + "INSERT INTO sleep_medication_intakes VALUES(?,?,?,?,?,?,?,?,?,?)", + (COLLISION, f"mdi_80000000-0000-4000-8000-00000000000{suffix}", + "med_90000000-0000-4000-8000-000000000009", "Fixture medication", + "2026-10-24T22:30:00+02:00", 10.0, "mg", quantity, "", + "2026-10-24T22:30:00+02:00"), + ) + self.db.execute( + "INSERT INTO sleep_diary_publication_state VALUES(?,?,?,?,?)", + (ENTRY, REMOTE_PARENT, item["updated_at"], REMOTE_PARENT, item["updated_at"]), + ) + self.db.commit() + + def tearDown(self) -> None: + self.db.close() + + def ids(self): + values = iter((LOCAL_REIDENTIFIED, RECONCILIATION)) + return lambda: next(values) + + def state(self): + return { + "entry": self.db.execute("SELECT * FROM sleep_diary_entries").fetchall(), + "revisions": self.db.execute( + "SELECT * FROM sleep_diary_revisions ORDER BY revision_id" + ).fetchall(), + "events": self.db.execute( + "SELECT * FROM sleep_diary_events ORDER BY revision_id,event_id" + ).fetchall(), + "intakes": self.db.execute( + "SELECT * FROM sleep_medication_intakes ORDER BY revision_id,intake_id" + ).fetchall(), + } + + def test_unpublished_branch_is_preserved_and_replay_is_idempotent(self): + report = recovery.recover( + self.db, document(), COLLISION, RECOVERED_AT, self.ids() + ) + self.assertEqual(LOCAL_REIDENTIFIED, report["local_reidentified_revision_id"]) + self.assertEqual(RECONCILIATION, report["reconciliation_revision_id"]) + self.assertEqual((2, 1), (report["intake_count"], report["event_count"])) + self.assertEqual( + RECONCILIATION, + self.db.execute("SELECT current_revision_id FROM sleep_diary_entries").fetchone()[0], + ) + self.assertEqual( + (REMOTE_PARENT, None, None), + self.db.execute( + "SELECT parent_revision_id,sleep_quality,wake_quality FROM sleep_diary_revisions " + "WHERE revision_id=?", (COLLISION,), + ).fetchone(), + ) + self.assertEqual( + (COLLISION, "TB", "B"), + self.db.execute( + "SELECT parent_revision_id,sleep_quality,wake_quality FROM sleep_diary_revisions " + "WHERE revision_id=?", (RECONCILIATION,), + ).fetchone(), + ) + self.assertEqual( + [(1,), (2,)], + self.db.execute( + "SELECT quantity FROM sleep_medication_intakes WHERE revision_id=? " + "ORDER BY intake_id", (RECONCILIATION,), + ).fetchall(), + ) + self.assertEqual((0, 1), exchange.apply(self.db, document())) + self.assertEqual([], self.db.execute("PRAGMA foreign_key_check").fetchall()) + + def test_historical_identity_mismatch_is_rejected_after_recovery(self): + recovery.recover(self.db, document(), COLLISION, RECOVERED_AT, self.ids()) + conflicting = copy.deepcopy(document()) + conflicting["entries"][0]["sleep_quality"] = "M" + with self.assertRaisesRegex(ValueError, "identity reused"): + exchange.apply(self.db, conflicting) + + def test_failure_rolls_back_every_reidentification_write(self): + before = self.state() + with self.assertRaisesRegex(recovery.RecoveryError, "injected"): + recovery.recover( + self.db, document(), COLLISION, RECOVERED_AT, self.ids(), + fail_after_reidentification=True, + ) + self.assertEqual(before, self.state()) + + def test_published_local_branch_is_never_reidentified(self): + self.db.execute( + "UPDATE sleep_diary_publication_state SET validated_revision_id=? WHERE entry_id=?", + (COLLISION, ENTRY), + ) + self.db.commit() + with self.assertRaisesRegex(recovery.RecoveryError, "publication state"): + recovery.recover(self.db, document(), COLLISION, RECOVERED_AT, self.ids()) + + def test_two_non_null_scalar_values_require_human_decision(self): + conflicting = document() + conflicting["entries"][0]["sleep_quality"] = "M" + with self.assertRaisesRegex(recovery.RecoveryError, "human decision"): + recovery.recover(self.db, conflicting, COLLISION, RECOVERED_AT, self.ids()) + + def test_same_intake_identity_with_different_quantity_is_rejected(self): + conflicting = document() + conflicting["entries"][0]["intakes"] = [{ + "intake_id": "mdi_80000000-0000-4000-8000-000000000008", + "medication_id": "med_90000000-0000-4000-8000-000000000009", + "medication_name": "Fixture medication", + "taken_at": "2026-10-24T22:30:00+02:00", + "dose_value": 10.0, + "dose_unit": "mg", + "quantity": 2, + "note": "", + "created_at": "2026-10-24T22:30:00+02:00", + }] + with self.assertRaisesRegex(recovery.RecoveryError, "identity reused"): + recovery.recover(self.db, conflicting, COLLISION, RECOVERED_AT, self.ids()) + + def insert_rejection_ack(self) -> None: + ack_id = "ack_" + GENERATION.removeprefix("gen_") + self.db.execute( + "INSERT INTO sync_acknowledgements VALUES(?,?,?,?,?,?,?,?,?,?,?)", + (ack_id, GENERATION, RUN, PRODUCER, CONSUMER, MANIFEST_DIGEST, "rejected", + "sqlite-commit-rejection", RECOVERED_AT, "", "b" * 64), + ) + + def test_desktop_rejection_is_rearmed_with_recovery_transaction(self): + self.db.execute( + "INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)", + (GENERATION, RUN, PRODUCER, CONSUMER, None, MANIFEST_DIGEST, RECOVERED_AT, + "rejected", "sqlite-commit-rejection", "", "{}"), + ) + self.insert_rejection_ack() + self.db.commit() + report = recovery.recover( + self.db, document(), COLLISION, RECOVERED_AT, self.ids(), + rejected_generation=(manifest(), MANIFEST_DIGEST), + ) + self.assertTrue(report["desktop_rejection_rearmed"]) + self.assertEqual(0, self.db.execute( + "SELECT COUNT(*) FROM sync_consumed_generations WHERE generation_id=?", (GENERATION,) + ).fetchone()[0]) + self.assertEqual(0, self.db.execute( + "SELECT COUNT(*) FROM sync_acknowledgements WHERE generation_id=?", (GENERATION,) + ).fetchone()[0]) + + def test_post_recovery_schema_rejection_can_be_rearmed_exactly(self): + recovery.recover(self.db, document(), COLLISION, RECOVERED_AT, self.ids()) + self.db.execute( + "INSERT INTO sync_consumed_generations VALUES(?,?,?,?,?,?,?,?,?,?,?)", + (GENERATION, RUN, PRODUCER, CONSUMER, None, MANIFEST_DIGEST, RECOVERED_AT, + "rejected", "sqlite-commit-rejection", "schema desktop v8 à v35 requis", "{}"), + ) + self.insert_rejection_ack() + self.db.commit() + report = recovery.rearm_desktop_consumer( + self.db, document(), manifest(), MANIFEST_DIGEST, COLLISION + ) + self.assertEqual("ready_for_retry", report["status"]) + self.assertFalse(report["immutable_artifacts_rewritten"]) + self.assertEqual(0, self.db.execute( + "SELECT COUNT(*) FROM sync_consumed_generations WHERE generation_id=?", (GENERATION,) + ).fetchone()[0]) + self.assertEqual(0, self.db.execute( + "SELECT COUNT(*) FROM sync_acknowledgements WHERE generation_id=?", (GENERATION,) + ).fetchone()[0]) + + def test_android_rearm_preserves_manifest_and_sleep_mapping(self): + self.db.execute( + "INSERT INTO sync_generations VALUES(?,?,?,?,?,?,?,?,?,?,?,?)", + (GENERATION, RUN, PRODUCER, CONSUMER, "android", manifest()["generated_at"], + None, MANIFEST_DIGEST, "rejected", "{}", "/immutable", RECOVERED_AT), + ) + self.db.execute( + "INSERT INTO sleep_diary_generation_entries VALUES(?,?,?)", + (GENERATION, ENTRY, COLLISION), + ) + self.insert_rejection_ack() + self.db.commit() + report = recovery.rearm_android_producer( + self.db, manifest(), MANIFEST_DIGEST, COLLISION + ) + self.assertFalse(report["immutable_artifacts_rewritten"]) + self.assertEqual( + ("waiting_acknowledgement", None, "{}", "/immutable"), + self.db.execute( + "SELECT status,acknowledged_at,manifest_json,staging_path FROM sync_generations " + "WHERE generation_id=?", (GENERATION,), + ).fetchone(), + ) + self.assertEqual(0, self.db.execute( + "SELECT COUNT(*) FROM sync_acknowledgements WHERE generation_id=?", (GENERATION,) + ).fetchone()[0]) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_sync_deployment_tools.py b/tests/test_sync_deployment_tools.py index 142a348..31dd04a 100644 --- a/tests/test_sync_deployment_tools.py +++ b/tests/test_sync_deployment_tools.py @@ -77,7 +77,7 @@ class DeploymentToolsTest(unittest.TestCase): self.assertEqual(result.returncode, 0, result.stderr) inventory = json.loads((output / "candidate-inventory.json").read_text()) self.assertEqual(inventory["product_version"], "0.1.6") - self.assertEqual(inventory["desktop_schema"], 35) + self.assertEqual(inventory["desktop_schema"], 36) self.assertEqual(inventory["android_schema"], 32) self.assertIn("session-preparations-v2", inventory["protocols"]) self.assertIn("programs-v1", inventory["protocols"]) diff --git a/tools/cardio_calibration_exchange.py b/tools/cardio_calibration_exchange.py index 3e19b21..742ab4e 100644 --- a/tools/cardio_calibration_exchange.py +++ b/tools/cardio_calibration_exchange.py @@ -271,8 +271,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35): - fail("desktop schema v33-v35 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36): + fail("desktop schema v33-v36 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/cardio_guidance_exchange.py b/tools/cardio_guidance_exchange.py index 4773361..305417a 100644 --- a/tools/cardio_guidance_exchange.py +++ b/tools/cardio_guidance_exchange.py @@ -395,8 +395,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35): - fail("desktop schema v34-v35 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36): + fail("desktop schema v34-v36 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/causal_delete_exchange.py b/tools/causal_delete_exchange.py index 19af9f0..1371a34 100644 --- a/tools/causal_delete_exchange.py +++ b/tools/causal_delete_exchange.py @@ -38,8 +38,8 @@ def digest(value) -> str: def require_schema(db: sqlite3.Connection) -> None: version = db.execute("PRAGMA user_version").fetchone()[0] - if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): - raise CausalError(f"desktop schema v20-v35 required, found v{version}") + if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): + raise CausalError(f"desktop schema v20-v36 required, found v{version}") def target_snapshot(db: sqlite3.Connection, kind: str, target: str): diff --git a/tools/execution_draft_exchange.py b/tools/execution_draft_exchange.py index a65d0df..044e310 100644 --- a/tools/execution_draft_exchange.py +++ b/tools/execution_draft_exchange.py @@ -182,8 +182,8 @@ def causal_payload(draft): def require_schema(connection): version = connection.execute("PRAGMA user_version").fetchone()[0] - if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): - raise LifecycleError(f"desktop schema v19-v35 required, found v{version}") + if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): + raise LifecycleError(f"desktop schema v19-v36 required, found v{version}") def import_document(connection, document, own_transaction=True): diff --git a/tools/export_ai_history.py b/tools/export_ai_history.py index 99df6ae..95a70e6 100644 --- a/tools/export_ai_history.py +++ b/tools/export_ai_history.py @@ -12,7 +12,7 @@ from pathlib import Path from trainlog_sqlite import configure_connection -SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35) +SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36) MEASUREMENT_FIELDS = ( "body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm", "hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm", @@ -125,7 +125,7 @@ def build_export(connection, generated_at): """Build the raw export using only explicitly ordered SELECT statements.""" schema_version = connection.execute("PRAGMA user_version").fetchone()[0] if schema_version not in SUPPORTED_SCHEMA_VERSIONS: - raise ValueError("schema desktop v15-v35 requis") + raise ValueError("schema desktop v15-v36 requis") root = { "format": "TRAINLOG_AI_EXPORT", diff --git a/tools/export_ai_session_drafts.py b/tools/export_ai_session_drafts.py index e523eeb..4a2bd1b 100644 --- a/tools/export_ai_session_drafts.py +++ b/tools/export_ai_session_drafts.py @@ -20,9 +20,9 @@ ID_PREFIX = "aid_" def ensure_publication_state(connection): """Install the additive v18 cursor for direct syncd/tool invocations.""" owned_transaction = not connection.in_transaction - supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35) + supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v18-v35 requis") + raise ValueError("schema desktop v18-v36 requis") columns = {row[1] for row in connection.execute( "PRAGMA table_info(ai_session_drafts)")} if "published_at" not in columns: diff --git a/tools/export_equipment_associations.py b/tools/export_equipment_associations.py index 5acf940..98dc379 100644 --- a/tools/export_equipment_associations.py +++ b/tools/export_equipment_associations.py @@ -28,9 +28,9 @@ def main(): args = parser.parse_args() connection = connect_database(args.database) try: - supported_versions = tuple(range(8, 36)) + supported_versions = tuple(range(8, 37)) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v35 requis") + raise ValueError("schema desktop v8 à v36 requis") known_equipment = load_supplied_equipment_ids(args.catalog) known_equipment.update(row[0] for row in connection.execute( "SELECT equipment_id FROM custom_equipment")) diff --git a/tools/export_equipment_definitions.py b/tools/export_equipment_definitions.py index 9e41882..6065131 100644 --- a/tools/export_equipment_definitions.py +++ b/tools/export_equipment_definitions.py @@ -24,9 +24,9 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(8, 36)) + supported_versions = tuple(range(8, 37)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v8 à v35 requis") + raise ValueError("schema desktop v8 à v36 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal equipment protection requires the staged artifact") equipment = [dict(row) for row in connection.execute( diff --git a/tools/export_exercise_aliases.py b/tools/export_exercise_aliases.py index f9c4a01..303fa2c 100644 --- a/tools/export_exercise_aliases.py +++ b/tools/export_exercise_aliases.py @@ -17,8 +17,8 @@ def main(): con = connect_database(args.database) try: if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): - raise ValueError("schema desktop v12-v35 requis") + 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): + raise ValueError("schema desktop v12-v36 requis") rows = con.execute( "SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases " "ORDER BY source_exercise_id COLLATE BINARY" diff --git a/tools/export_exercise_body_zones.py b/tools/export_exercise_body_zones.py index 8e48960..dad2c0e 100644 --- a/tools/export_exercise_body_zones.py +++ b/tools/export_exercise_body_zones.py @@ -41,9 +41,9 @@ def main(complete_causal_envelope=False): connection = connect_database(args.database) connection.row_factory = sqlite3.Row try: - supported_versions = tuple(range(11, 36)) + supported_versions = tuple(range(11, 37)) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v11-v35 requis") + raise ValueError("schema desktop v11-v36 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal BODY ZONES protection requires the staged artifact") exercises = [] diff --git a/tools/export_exercise_profile_state.py b/tools/export_exercise_profile_state.py index 109aa84..9382d47 100644 --- a/tools/export_exercise_profile_state.py +++ b/tools/export_exercise_profile_state.py @@ -25,7 +25,7 @@ def main(): con.row_factory = sqlite3.Row try: version = con.execute("PRAGMA user_version").fetchone()[0] - if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): + if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}") rows = con.execute(""" SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields, diff --git a/tools/export_pc_catalog.py b/tools/export_pc_catalog.py index b8d1989..603a083 100755 --- a/tools/export_pc_catalog.py +++ b/tools/export_pc_catalog.py @@ -71,7 +71,7 @@ def main(): # CONTRACT: v8 adds only desktop-local custom equipment. The PC # catalogue artifact is unchanged, but it must read the current # canonical desktop schema rather than accept a stale pre-v8 database. - if version not in range(8, 36): + if version not in range(8, 37): raise SystemExit( "PC_CATALOG_EXPORT=FAIL " f"schema={version}" diff --git a/tools/export_pc_mobile.py b/tools/export_pc_mobile.py index 202cf0a..58e4692 100644 --- a/tools/export_pc_mobile.py +++ b/tools/export_pc_mobile.py @@ -83,12 +83,12 @@ def main(complete_causal_envelope=False): con.row_factory = sqlite3.Row try: schema_version = con.execute("PRAGMA user_version").fetchone()[0] - if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): + if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): raise ValueError("mobile V4 exige le schéma desktop v19 ou v20") - supported_versions = range(11, 36) + supported_versions = range(11, 37) legacy_v2_schema = args.version == 2 and schema_version == 10 if schema_version not in supported_versions and not legacy_v2_schema: - raise ValueError("schema desktop v11-v35 requis (v10 accepté pour export V2 explicite)") + raise ValueError("schema desktop v11-v36 requis (v10 accepté pour export V2 explicite)") if not complete_causal_envelope and schema_version >= 20 and con.execute( "SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone(): raise ValueError("causal protection refuses a mobile snapshot that omits tombstones") diff --git a/tools/export_programs.py b/tools/export_programs.py index 19e4363..87b11ec 100644 --- a/tools/export_programs.py +++ b/tools/export_programs.py @@ -16,7 +16,7 @@ def nullable(row, index): def export_programs(database: Path) -> dict: with closing(connect_database(database)) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35): + if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): raise ValueError("desktop schema v26 through v32 required") programs = [] for row in db.execute( diff --git a/tools/export_session_preparations.py b/tools/export_session_preparations.py index c62161f..0e1e782 100644 --- a/tools/export_session_preparations.py +++ b/tools/export_session_preparations.py @@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128 def build_export(connection: sqlite3.Connection) -> dict: - supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35) + supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: raise ValueError("desktop schema v24, v25, v26 through v32 required") deliveries = [] diff --git a/tools/export_training_feedback.py b/tools/export_training_feedback.py index b0be40f..3fa6948 100644 --- a/tools/export_training_feedback.py +++ b/tools/export_training_feedback.py @@ -10,9 +10,9 @@ def main(complete_causal_envelope=False): parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args() db=connect_database(args.database) try: - supported_versions = range(15, 36) + supported_versions = range(15, 37) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ValueError("schema desktop v15-v35 requis") + raise ValueError("schema desktop v15-v36 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact") roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall() followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall() diff --git a/tools/heart_rate_exchange.py b/tools/heart_rate_exchange.py index bbf5039..525f6aa 100644 --- a/tools/heart_rate_exchange.py +++ b/tools/heart_rate_exchange.py @@ -236,8 +236,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35): - fail("desktop schema v30-v35 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36): + fail("desktop schema v30-v36 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/import_ai_session_draft.py b/tools/import_ai_session_draft.py index c5b3dff..10d357b 100644 --- a/tools/import_ai_session_draft.py +++ b/tools/import_ai_session_draft.py @@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None): INVARIANT: validation and every child insert share one IMMEDIATE transaction; any failure leaves no draft, child, or import-identity row behind. """ - supported_versions = range(18, 36) + supported_versions = range(18, 37) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v18-v35 requis") + raise ImportFailure("schema desktop v18-v36 requis") draft, entries = validate(payload, connection) digest = canonical_digest(payload) imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z") diff --git a/tools/import_equipment_associations.py b/tools/import_equipment_associations.py index b1a103e..10fd5e3 100644 --- a/tools/import_equipment_associations.py +++ b/tools/import_equipment_associations.py @@ -206,9 +206,9 @@ def main(): payload = json.loads(args.artifact.read_text(encoding="utf-8")) connection = connect_database(args.database) try: - supported_versions = range(8, 36) + supported_versions = range(8, 37) if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v35 requis") + fail("schema desktop v8 à v36 requis") proof_path = args.mobile_export if proof_path is None: candidate = args.artifact.with_name("trainlog-mobile-export-v2.json") diff --git a/tools/import_equipment_definitions.py b/tools/import_equipment_definitions.py index 12db306..0ad905b 100644 --- a/tools/import_equipment_definitions.py +++ b/tools/import_equipment_definitions.py @@ -53,9 +53,9 @@ def validate(payload, reserved): def apply_definitions(connection, definitions, complete_causal_envelope=False): """Apply validated definitions without owning the surrounding transaction.""" - supported_versions = range(8, 36) + supported_versions = range(8, 37) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v8 à v35 requis") + fail("schema desktop v8 à v36 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone(): fail("causal equipment protection requires the staged artifact") imported = skipped = 0 diff --git a/tools/import_exercise_aliases.py b/tools/import_exercise_aliases.py index 45bfa32..ec0ae68 100644 --- a/tools/import_exercise_aliases.py +++ b/tools/import_exercise_aliases.py @@ -56,9 +56,9 @@ def load(path): def apply_aliases(con, aliases): """Apply parsed aliases inside the caller-owned transaction.""" - supported_versions = range(12, 36) + supported_versions = range(12, 37) if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v12-v35 requis") + fail("schema desktop v12-v36 requis") for source, canonical in aliases: target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone() if target is None: fail(f"cible canonique absente: {canonical}") diff --git a/tools/import_exercise_body_zones.py b/tools/import_exercise_body_zones.py index 01379bb..9bbe6d4 100644 --- a/tools/import_exercise_body_zones.py +++ b/tools/import_exercise_body_zones.py @@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary): def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False): """Apply validated zone rows without owning transaction boundaries.""" - supported_versions = range(11, 36) + supported_versions = range(11, 37) if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise ImportFailure("schema desktop v11-v35 requis") + raise ImportFailure("schema desktop v11-v36 requis") if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact") connection.execute("PRAGMA foreign_keys=ON");grouped={} # CONTRACT: resolve complete alias groups before the first write; direct diff --git a/tools/import_exercise_profile_state.py b/tools/import_exercise_profile_state.py index 0ba0c70..db793d0 100644 --- a/tools/import_exercise_profile_state.py +++ b/tools/import_exercise_profile_state.py @@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False): if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide") parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row if con.execute("PRAGMA user_version").fetchone()[0] not in ( - 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): - fail("schema desktop v17-v35 requis") + 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): + fail("schema desktop v17-v36 requis") applied=equal=ancestor=pending=0;seen=set() for item in root["exercises"]: validate(item);eid=item["exercise_id"] diff --git a/tools/import_mobile_export.py b/tools/import_mobile_export.py index dadc73f..2271db6 100755 --- a/tools/import_mobile_export.py +++ b/tools/import_mobile_export.py @@ -847,9 +847,9 @@ def require_supported_schema(connection): # CONTRACT: v9 owns explicit max_results; earlier supported schemas remain # readable for legacy artifacts and are never made to fake that table. - if version not in range(5, 36): + if version not in range(5, 37): raise ImportFailure( - f"base desktop schema v5 à v35 attendue, version trouvée: {version}" + f"base desktop schema v5 à v36 attendue, version trouvée: {version}" ) @@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en require_supported_schema(connection) schema_version = connection.execute("PRAGMA user_version;").fetchone()[0] - if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35): - raise ImportFailure("mobile V4 exige le schéma desktop v19 à v35") + if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36): + raise ImportFailure("mobile V4 exige le schéma desktop v19 à v36") if schema_version >= 20: protected = {(row[0], row[1]) for row in connection.execute( "SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")} diff --git a/tools/import_training_feedback.py b/tools/import_training_feedback.py index f13033c..eaec2dc 100644 --- a/tools/import_training_feedback.py +++ b/tools/import_training_feedback.py @@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions): def apply_feedback(db,root,complete_causal_envelope=False): roots_added=roots_skipped=revisions_added=revisions_skipped=0 db.execute("PRAGMA foreign_keys=ON") - supported_versions = range(15, 36) + supported_versions = range(15, 37) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - fail("schema desktop v15-v35 requis") + fail("schema desktop v15-v36 requis") if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact") seen=set() for index,item in enumerate(root["exercise_feedback"]): diff --git a/tools/package_sync_candidate.py b/tools/package_sync_candidate.py index cbbfce2..7285bba 100644 --- a/tools/package_sync_candidate.py +++ b/tools/package_sync_candidate.py @@ -119,7 +119,7 @@ def main() -> int: ["git", "rev-parse", "HEAD"], cwd=ROOT, text=True ).strip(), "product_version": "0.1.6", - "desktop_schema": 35, + "desktop_schema": 36, "android_schema": 32, "protocols": [ "mobile-export-v3", diff --git a/tools/program_execution_exchange.py b/tools/program_execution_exchange.py index c699ce7..1d40d06 100644 --- a/tools/program_execution_exchange.py +++ b/tools/program_execution_exchange.py @@ -87,7 +87,7 @@ def load(path: Path) -> dict: def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]: - if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35): + if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36): fail("desktop schema v27 through v32 required") added = 0 advanced = 0 diff --git a/tools/recover_sleep_revision_collision.py b/tools/recover_sleep_revision_collision.py new file mode 100644 index 0000000..db2162e --- /dev/null +++ b/tools/recover_sleep_revision_collision.py @@ -0,0 +1,444 @@ +#!/usr/bin/env python3 +"""Recover one unpublished local Sleep branch from an immutable peer collision.""" + +import argparse +import hashlib +import json +import re +import sqlite3 +import uuid +from datetime import datetime +from pathlib import Path +from typing import Callable + +import sleep_diary_exchange as exchange + + +REVISION_ID = re.compile(r"slr_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}") + + +class RecoveryError(ValueError): + """A collision cannot be recovered without changing established facts.""" + + +def _new_revision_id() -> str: + return f"slr_{uuid.uuid4()}" + + +def _canonical_digest(value: object) -> str: + payload = json.dumps(value, ensure_ascii=False, sort_keys=True, + separators=(",", ":")).encode("utf-8") + return hashlib.sha256(payload).hexdigest() + + +def _incoming_entry(document: dict, collision_id: str) -> dict: + matches = [item for item in document["entries"] if item["revision_id"] == collision_id] + if len(matches) != 1: + raise RecoveryError("collision revision is not unique in incoming artifact") + return matches[0] + + +def _merge_scalar(local: object, remote: object, label: str) -> object: + if local == remote: + return local + if local is None: + return remote + if remote is None: + return local + raise RecoveryError(f"human decision required for {label}") + + +def _merge_identity_list(local: list[dict], remote: list[dict], identity: str, + label: str) -> list[dict]: + merged = {value[identity]: value for value in local} + for value in remote: + existing = merged.get(value[identity]) + if existing is not None and existing != value: + raise RecoveryError(f"{label} identity reused with different content") + merged[value[identity]] = value + return [merged[key] for key in sorted(merged)] + + +def _copy_local_revision(db: sqlite3.Connection, entry_id: str, source_id: str, + target_id: str) -> None: + db.execute( + "INSERT INTO sleep_diary_revisions " + "SELECT ?,entry_id,parent_revision_id,created_at,sleep_quality,wake_quality,day_form," + "treatment_and_notes FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", + (target_id, source_id, entry_id), + ) + db.execute( + "INSERT INTO sleep_diary_events " + "SELECT ?,event_id,event_type,start_at,end_at FROM sleep_diary_events WHERE revision_id=?", + (target_id, source_id), + ) + db.execute( + "INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id," + "medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) " + "SELECT ?,intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit," + "quantity,note,created_at FROM sleep_medication_intakes WHERE revision_id=?", + (target_id, source_id), + ) + + +def _insert_revision(db: sqlite3.Connection, item: dict, revision_id: str, + parent_revision_id: str | None, created_at: str) -> None: + db.execute( + "INSERT INTO sleep_diary_revisions VALUES(?,?,?,?,?,?,?,?)", + (revision_id, item["entry_id"], parent_revision_id, created_at, + item["sleep_quality"], item["wake_quality"], item["day_form"], + item["treatment_and_notes"]), + ) + db.executemany( + "INSERT INTO sleep_diary_events VALUES(?,?,?,?,?)", + [(revision_id, value["event_id"], value["type"], value["start_at"], value["end_at"]) + for value in item["events"]], + ) + db.executemany( + "INSERT INTO sleep_medication_intakes(revision_id,intake_id,medication_id," + "medication_name,taken_at,dose_value,dose_unit,quantity,note,created_at) " + "VALUES(?,?,?,?,?,?,?,?,?,?)", + [(revision_id, value["intake_id"], value["medication_id"], + value["medication_name"], value["taken_at"], value["dose_value"], + value["dose_unit"], value["quantity"], value["note"], value["created_at"]) + for value in item["intakes"]], + ) + + +def recover(db: sqlite3.Connection, document: dict, collision_id: str, recovered_at: str, + id_factory: Callable[[], str] = _new_revision_id, + fail_after_reidentification: bool = False, + rejected_generation: tuple[dict, str] | None = None) -> dict: + """Preserve both branches and advance through one non-conflicting successor. + + CONTRACT: only a local current revision that has never crossed an immutable + publication boundary may be re-identified. The incoming revision retains its + published identity and exact payload. A fresh successor descends from that + published revision, while the re-identified local side branch remains durable. + INVARIANT: every mutation commits in one IMMEDIATE transaction or none does. + """ + exchange.validate(document) + if document["version"] != 2 or not REVISION_ID.fullmatch(collision_id): + raise RecoveryError("Sleep Diary V2 collision identity required") + incoming = _incoming_entry(document, collision_id) + incoming_payload = exchange.incoming_revision_payload(incoming, 2) + owner = db.execute( + "SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?", (collision_id,) + ).fetchone() + if owner is None or owner[0] != incoming["entry_id"]: + raise RecoveryError("local collision revision is unavailable") + entry_id = owner[0] + current = db.execute( + "SELECT night_start_date,night_end_date,created_at,updated_at,current_revision_id,deleted " + "FROM sleep_diary_entries WHERE entry_id=?", (entry_id,) + ).fetchone() + if current is None or current[4] != collision_id: + raise RecoveryError("local collision revision is not the current tip") + local_payload = exchange.persisted_revision_payload(db, entry_id, collision_id) + if local_payload == incoming_payload: + raise RecoveryError("revision is already an identical replay") + if tuple(current[:3]) != (incoming["night_start_date"], incoming["night_end_date"], + incoming["created_at"]) or bool(current[5]) != incoming["deleted"]: + raise RecoveryError("entry envelope requires a human decision") + if db.execute( + "SELECT 1 FROM sleep_diary_revisions WHERE parent_revision_id=? LIMIT 1", (collision_id,) + ).fetchone() is not None: + raise RecoveryError("local collision revision already has descendants") + publication = db.execute( + "SELECT validated_revision_id,acknowledged_revision_id FROM sleep_diary_publication_state " + "WHERE entry_id=?", (entry_id,) + ).fetchone() + if publication is not None and collision_id in publication: + raise RecoveryError("local collision revision already crossed publication state") + try: + immutable_reference = db.execute( + "SELECT 1 FROM sleep_diary_generation_entries WHERE entry_id=? AND revision_id=? LIMIT 1", + (entry_id, collision_id), + ).fetchone() + except sqlite3.OperationalError: + immutable_reference = None + if immutable_reference is not None: + raise RecoveryError("local collision revision is referenced by an immutable generation") + if incoming["parent_revision_id"] is not None and db.execute( + "SELECT 1 FROM sleep_diary_revisions WHERE revision_id=? AND entry_id=?", + (incoming["parent_revision_id"], entry_id), + ).fetchone() is None: + raise RecoveryError("incoming causal parent is unavailable locally") + + merged = dict(incoming) + merged["sleep_quality"] = _merge_scalar( + local_payload["sleep_quality"], incoming["sleep_quality"], "sleep_quality" + ) + merged["wake_quality"] = _merge_scalar( + local_payload["wake_quality"], incoming["wake_quality"], "wake_quality" + ) + merged["day_form"] = _merge_scalar( + local_payload["day_form"], incoming["day_form"], "day_form" + ) + if local_payload["treatment_and_notes"] != incoming["treatment_and_notes"]: + raise RecoveryError("human decision required for treatment_and_notes") + merged["events"] = _merge_identity_list( + local_payload["events"], incoming["events"], "event_id", "sleep event" + ) + merged["intakes"] = _merge_identity_list( + local_payload["intakes"], incoming["intakes"], "intake_id", "medication intake" + ) + + local_revision_id = id_factory() + reconciliation_revision_id = id_factory() + if (not REVISION_ID.fullmatch(local_revision_id) or + not REVISION_ID.fullmatch(reconciliation_revision_id) or + len({collision_id, local_revision_id, reconciliation_revision_id}) != 3): + raise RecoveryError("revision identity generator returned an invalid or duplicate id") + + db.execute("BEGIN IMMEDIATE") + try: + _copy_local_revision(db, entry_id, collision_id, local_revision_id) + changed = db.execute( + "UPDATE sleep_diary_entries SET current_revision_id=? " + "WHERE entry_id=? AND current_revision_id=?", + (local_revision_id, entry_id, collision_id), + ).rowcount + if changed != 1: + raise RecoveryError("guarded local branch re-identification failed") + if fail_after_reidentification: + raise RecoveryError("injected recovery failure") + db.execute("DELETE FROM sleep_diary_events WHERE revision_id=?", (collision_id,)) + db.execute("DELETE FROM sleep_medication_intakes WHERE revision_id=?", (collision_id,)) + db.execute("DELETE FROM sleep_diary_revisions WHERE revision_id=?", (collision_id,)) + _insert_revision(db, incoming, collision_id, incoming["parent_revision_id"], + incoming["updated_at"]) + merged["updated_at"] = recovered_at + _insert_revision(db, merged, reconciliation_revision_id, collision_id, recovered_at) + changed = db.execute( + "UPDATE sleep_diary_entries SET updated_at=?,current_revision_id=? " + "WHERE entry_id=? AND current_revision_id=?", + (recovered_at, reconciliation_revision_id, entry_id, local_revision_id), + ).rowcount + if changed != 1: + raise RecoveryError("guarded reconciliation advance failed") + rejection_rearmed = False + if rejected_generation is not None: + rejection_rearmed = _rearm_desktop_rejection( + db, rejected_generation[0], rejected_generation[1] + ) + if db.execute("PRAGMA foreign_key_check").fetchone() is not None: + raise RecoveryError("foreign key check failed during recovery") + db.commit() + except Exception: + db.rollback() + raise + merged_payload = dict(merged) + merged_payload["revision_id"] = reconciliation_revision_id + merged_payload["parent_revision_id"] = collision_id + return { + "entry_id": entry_id, + "collision_revision_id": collision_id, + "local_reidentified_revision_id": local_revision_id, + "reconciliation_revision_id": reconciliation_revision_id, + "reconciliation_parent_revision_id": collision_id, + "local_payload_sha256": _canonical_digest(local_payload), + "incoming_payload_sha256": _canonical_digest(incoming_payload), + "merged_payload_sha256": _canonical_digest( + exchange.incoming_revision_payload(merged_payload, 2) + ), + "event_count": len(merged["events"]), + "intake_count": len(merged["intakes"]), + "desktop_rejection_rearmed": rejection_rearmed, + } + + +def _rearm_desktop_rejection(db: sqlite3.Connection, manifest: dict, + manifest_digest: str) -> bool: + generation_id = manifest["generation_id"] + row = db.execute( + "SELECT run_id,producer_peer_id,consumer_peer_id,parent_generation_id,manifest_sha256," + "result,durability,diagnostic FROM sync_consumed_generations WHERE generation_id=?", + (generation_id,), + ).fetchone() + if row is None: + return False + expected = ( + manifest["run_id"], manifest["producer"]["peer_id"], manifest["consumer_peer_id"], + manifest["parent_generation_id"], manifest_digest, "rejected", "sqlite-commit-rejection", + ) + if tuple(row[:7]) != expected: + raise RecoveryError("generation rejection ledger does not match immutable manifest") + # The deployed consumer could lose ValueError text while recording this + # exact collision (StopIteration has an empty message). The collision has + # already been proven directly against the manifest-owned Sleep artifact. + if row[7] not in ( + "", + "sleep diary revision identity reused with different content", + "schema desktop v8 à v35 requis", + ): + raise RecoveryError("generation rejection has an unrelated diagnostic") + if db.execute( + "SELECT 1 FROM sync_consumed_generations WHERE parent_generation_id=? LIMIT 1", + (generation_id,), + ).fetchone() is not None: + raise RecoveryError("rejected generation already has a durable descendant") + ack = db.execute( + "SELECT ack_id,result,durability,manifest_sha256 FROM sync_acknowledgements " + "WHERE generation_id=?", (generation_id,), + ).fetchall() + expected_ack_id = "ack_" + generation_id.removeprefix("gen_") + if ack != [(expected_ack_id, "rejected", "sqlite-commit-rejection", manifest_digest)]: + raise RecoveryError("generation rejection ACK does not match immutable manifest") + db.execute("DELETE FROM sync_acknowledgements WHERE generation_id=?", (generation_id,)) + db.execute("DELETE FROM sync_consumed_generations WHERE generation_id=?", (generation_id,)) + return True + + +def rearm_desktop_consumer(db: sqlite3.Connection, document: dict, manifest: dict, + manifest_digest: str, collision_id: str) -> dict: + """Rearm an exact retry after recovery or a recovery-adjacent schema rejection. + + CONTRACT: the published collision identity must already contain the exact + manifest-owned payload. Only the matching rejected consumption and ACK are + removed; immutable generation bytes and recovered Sleep history are unchanged. + """ + exchange.validate(document) + incoming = _incoming_entry(document, collision_id) + persisted = exchange.persisted_revision_payload(db, incoming["entry_id"], collision_id) + if persisted != exchange.incoming_revision_payload(incoming, 2): + raise RecoveryError("recovered collision payload no longer matches immutable artifact") + db.execute("BEGIN IMMEDIATE") + try: + rearmed = _rearm_desktop_rejection(db, manifest, manifest_digest) + if not rearmed: + raise RecoveryError("desktop has no matching rejected generation to rearm") + db.commit() + except Exception: + db.rollback() + raise + return { + "generation_id": manifest["generation_id"], + "manifest_sha256": manifest_digest, + "status": "ready_for_retry", + "immutable_artifacts_rewritten": False, + } + + +def rearm_android_producer(db: sqlite3.Connection, manifest: dict, manifest_digest: str, + collision_id: str) -> dict: + """Rearm one rejected immutable Android generation after causal recovery. + + CONTRACT: manifest bytes and generation-to-Sleep mapping remain unchanged. + Only the exact rejection ACK is removed and its producer state returns to + waiting_acknowledgement so the normal retry can record a consumed ACK. + """ + generation_id = manifest["generation_id"] + db.execute("BEGIN IMMEDIATE") + try: + row = db.execute( + "SELECT run_id,producer_peer_id,consumer_peer_id,generated_at,parent_generation_id," + "manifest_sha256,status FROM sync_generations WHERE generation_id=?", + (generation_id,), + ).fetchone() + expected = ( + manifest["run_id"], manifest["producer"]["peer_id"], manifest["consumer_peer_id"], + manifest["generated_at"], manifest["parent_generation_id"], manifest_digest, "rejected", + ) + if row is None or tuple(row) != expected: + raise RecoveryError("Android rejected generation does not match immutable manifest") + mapping = db.execute( + "SELECT revision_id FROM sleep_diary_generation_entries WHERE generation_id=? " + "AND entry_id=(SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?)", + (generation_id, collision_id), + ).fetchone() + if mapping != (collision_id,): + raise RecoveryError("Android generation does not own the collided Sleep revision") + ack = db.execute( + "SELECT ack_id,result,durability,manifest_sha256 FROM sync_acknowledgements " + "WHERE generation_id=?", (generation_id,), + ).fetchall() + expected_ack_id = "ack_" + generation_id.removeprefix("gen_") + if ack != [(expected_ack_id, "rejected", "sqlite-commit-rejection", manifest_digest)]: + raise RecoveryError("Android rejection ACK does not match immutable manifest") + db.execute("DELETE FROM sync_acknowledgements WHERE generation_id=?", (generation_id,)) + changed = db.execute( + "UPDATE sync_generations SET status='waiting_acknowledgement',acknowledged_at=NULL " + "WHERE generation_id=? AND status='rejected'", (generation_id,), + ).rowcount + if changed != 1: + raise RecoveryError("Android generation rearm guard failed") + if db.execute("PRAGMA foreign_key_check").fetchone() is not None: + raise RecoveryError("foreign key check failed during Android rearm") + db.commit() + except Exception: + db.rollback() + raise + return { + "generation_id": generation_id, + "manifest_sha256": manifest_digest, + "status": "waiting_acknowledgement", + "immutable_artifacts_rewritten": False, + } + + +def main() -> None: + parser = argparse.ArgumentParser() + subparsers = parser.add_subparsers(dest="command", required=True) + desktop = subparsers.add_parser("recover-desktop") + desktop.add_argument("--database", required=True, type=Path) + desktop.add_argument("--generation-directory", required=True, type=Path) + desktop.add_argument("--collision-id", required=True) + desktop.add_argument("--recovered-at", default=None) + desktop.add_argument("--report", type=Path) + desktop_retry = subparsers.add_parser("rearm-desktop") + desktop_retry.add_argument("--database", required=True, type=Path) + desktop_retry.add_argument("--generation-directory", required=True, type=Path) + desktop_retry.add_argument("--collision-id", required=True) + desktop_retry.add_argument("--report", type=Path) + android = subparsers.add_parser("rearm-android") + android.add_argument("--database", required=True, type=Path) + android.add_argument("--generation-directory", required=True, type=Path) + android.add_argument("--collision-id", required=True) + android.add_argument("--report", type=Path) + args = parser.parse_args() + import sync_generation_exchange as generation + + manifest_path = args.generation_directory / "manifest.json" + preliminary = json.loads(manifest_path.read_text(encoding="utf-8")) + manifest, manifest_digest = generation.validate_published( + args.generation_directory, preliminary["consumer_peer_id"] + ) + sleep_descriptor = next( + (value for value in manifest["artifacts"] if value["logical_name"] == "sleep-diary"), + None, + ) + if sleep_descriptor is None: + raise RecoveryError("generation has no Sleep Diary artifact") + artifact = args.generation_directory / Path(sleep_descriptor["filename"]).name + document = exchange.load(artifact) + with exchange.connect_database(args.database) as db: + version = db.execute("PRAGMA user_version").fetchone()[0] + if args.command == "recover-desktop": + if version != 35: + raise RecoveryError("desktop schema v35 pre-immutability recovery required") + recovered_at = args.recovered_at or datetime.now().astimezone().isoformat() + report = recover( + db, document, args.collision_id, recovered_at, + rejected_generation=(manifest, manifest_digest), + ) + elif args.command == "rearm-desktop": + if version != 36: + raise RecoveryError("desktop schema v36 post-recovery retry required") + report = rearm_desktop_consumer( + db, document, manifest, manifest_digest, args.collision_id + ) + else: + if version not in (32, 33): + raise RecoveryError("Android schema v32 or v33 required") + report = rearm_android_producer( + db, manifest, manifest_digest, args.collision_id + ) + encoded = json.dumps(report, ensure_ascii=False, sort_keys=True, indent=2) + "\n" + if args.report is not None: + args.report.write_text(encoded, encoding="utf-8") + print(encoded, end="") + + +if __name__ == "__main__": + main() diff --git a/tools/session_timeline_exchange.py b/tools/session_timeline_exchange.py index 75acfa5..e69f369 100644 --- a/tools/session_timeline_exchange.py +++ b/tools/session_timeline_exchange.py @@ -184,8 +184,8 @@ def main() -> None: args = parser.parse_args() root = load(args.input) with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35): - fail("desktop schema v31-v35 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36): + fail("desktop schema v31-v36 required") db.execute("BEGIN IMMEDIATE") apply(db, root) db.commit() diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py index 1f64313..bcf6d98 100644 --- a/tools/sleep_diary_exchange.py +++ b/tools/sleep_diary_exchange.py @@ -260,6 +260,75 @@ def _persisted_entry(db: sqlite3.Connection, row: tuple) -> dict: } +def persisted_revision_payload(db: sqlite3.Connection, entry_id: str, + revision_id: str) -> dict | None: + """Return the immutable content owned by one revision identity. + + WHY: a replay can name an older revision while the mutable entry tip has + advanced. Comparing only the current tip would let a reused historical + identity hide behind its descendant. CONTRACT: parent, scalar observations, + events, and medication occurrences are immutable for a revision_id. + """ + revision = db.execute( + "SELECT parent_revision_id,created_at,sleep_quality,wake_quality,day_form," + "COALESCE(treatment_and_notes,'') FROM sleep_diary_revisions " + "WHERE revision_id=? AND entry_id=?", + (revision_id, entry_id), + ).fetchone() + if revision is None: + return None + events = [ + {"event_id": value[0], "type": value[1], "start_at": value[2], "end_at": value[3]} + for value in db.execute( + "SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events " + "WHERE revision_id=? ORDER BY event_id", (revision_id,) + ) + ] + intakes = [ + {"intake_id": value[0], "medication_id": value[1], "medication_name": value[2], + "taken_at": value[3], "dose_value": value[4], "dose_unit": value[5], + "quantity": value[6], "note": value[7], "created_at": value[8]} + for value in db.execute( + "SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit," + "quantity,COALESCE(note,''),created_at FROM sleep_medication_intakes " + "WHERE revision_id=? ORDER BY intake_id", (revision_id,) + ) + ] + return { + "entry_id": entry_id, + "revision_id": revision_id, + "parent_revision_id": revision[0], + "created_at": revision[1], + "sleep_quality": revision[2], + "wake_quality": revision[3], + "day_form": revision[4], + "treatment_and_notes": revision[5], + "events": events, + "intakes": intakes, + } + + +def incoming_revision_payload(item: dict, wire_version: int) -> dict: + """Normalize V1 quantity before immutable-identity comparison.""" + intakes = [] + for intake in item["intakes"]: + normalized = dict(intake) + normalized.setdefault("quantity", 1 if wire_version == 1 else None) + intakes.append(normalized) + return { + "entry_id": item["entry_id"], + "revision_id": item["revision_id"], + "parent_revision_id": item["parent_revision_id"], + "created_at": item["updated_at"], + "sleep_quality": item["sleep_quality"], + "wake_quality": item["wake_quality"], + "day_form": item["day_form"], + "treatment_and_notes": item["treatment_and_notes"], + "events": sorted(item["events"], key=lambda value: value["event_id"]), + "intakes": sorted(intakes, key=lambda value: value["intake_id"]), + } + + def build(db: sqlite3.Connection) -> dict: # CONTRACT: local durability does not publish a draft. A generation may # capture only the current tip explicitly validated by the user. @@ -358,6 +427,17 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: "SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?", (item["entry_id"],), ).fetchone() + known_owner = db.execute( + "SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?", + (item["revision_id"],), + ).fetchone() + if known_owner is not None: + persisted_revision = persisted_revision_payload( + db, known_owner[0], item["revision_id"] + ) + incoming_revision = incoming_revision_payload(item, wire_version) + if known_owner[0] != item["entry_id"] or persisted_revision != incoming_revision: + fail("sleep diary revision identity reused with different content") if local is not None and local[0] == item["revision_id"]: # INVARIANT: an identical durable revision may still be a local # draft because publication state is intentionally separate. The @@ -462,8 +542,8 @@ def main() -> None: import_command.add_argument("--database", required=True, type=Path) args = parser.parse_args() with connect_database(args.database) as db: - if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35): - fail("desktop schema v30-v35 required") + if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36): + fail("desktop schema v30-v36 required") if args.command == "export": args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True, separators=(",", ":")), encoding="utf-8") diff --git a/tools/sync_generation_exchange.py b/tools/sync_generation_exchange.py index f12ce07..9343491 100644 --- a/tools/sync_generation_exchange.py +++ b/tools/sync_generation_exchange.py @@ -219,9 +219,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None): def require_schema(db: sqlite3.Connection) -> None: - supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35) + supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36) if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions: - raise GenerationError("desktop schema v24 through v35 required") + raise GenerationError("desktop schema v24 through v36 required") def peer_identity(db: sqlite3.Connection, kind: str) -> str: diff --git a/tui/include/trainlog/database.h b/tui/include/trainlog/database.h index d0c5d4b..3a46c57 100644 --- a/tui/include/trainlog/database.h +++ b/tui/include/trainlog/database.h @@ -17,7 +17,7 @@ #include "trainlog/model.h" #include "trainlog/status.h" -#define TRAINLOG_DATABASE_SCHEMA_VERSION 35 +#define TRAINLOG_DATABASE_SCHEMA_VERSION 36 typedef struct TrainlogDatabase TrainlogDatabase; diff --git a/tui/meson.build b/tui/meson.build index 8a2879e..c3ee3ba 100644 --- a/tui/meson.build +++ b/tui/meson.build @@ -889,6 +889,14 @@ test_schema_v34_migration = executable( ) test('schema_v34_migration', test_schema_v34_migration) +test_schema_v35_migration = executable( + 'test_schema_v35_migration', + 'tests/test_schema_v35_migration.c', + dependencies: trainlog_core_dep, + c_args: strict_c_args, +) +test('schema_v35_migration', test_schema_v35_migration) + test_schema_v7_migration = executable( 'test_schema_v7_migration', 'tests/test_schema_v7_migration.c', @@ -968,6 +976,11 @@ test( find_program('python3'), args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'], ) +test( + 'sleep_revision_collision_recovery', + find_program('python3'), + args: [meson.project_source_root() / 'tests/test_sleep_revision_collision_recovery.py'], +) test( 'heart_rate_exchange', find_program('python3'), diff --git a/tui/src/database.c b/tui/src/database.c index aeac48f..d3edac4 100644 --- a/tui/src/database.c +++ b/tui/src/database.c @@ -1549,6 +1549,35 @@ static TrainlogStatus ensure_v35_medication_quantity(TrainlogDatabase *database) return TRAINLOG_STATUS_OK; } +/* WHY: revision identity is the causal content address used by synchronized + * peers; changing a persisted row in place can make two peers assign different + * facts to the same UUID. CONTRACT: v36 makes every Sleep revision payload + * append-only. Tips and publication markers remain mutable through their owning + * tables. INVARIANT: correction always appends a fresh revision identity. */ +static const char *const MIGRATE_V35_TO_V36_SQL = + "BEGIN IMMEDIATE;" + "CREATE TRIGGER IF NOT EXISTS sleep_diary_revisions_immutable_update BEFORE UPDATE ON " + "sleep_diary_revisions BEGIN SELECT RAISE(ABORT,'sleep diary revision is immutable');END;" + "CREATE TRIGGER IF NOT EXISTS sleep_diary_revisions_immutable_delete BEFORE DELETE ON " + "sleep_diary_revisions BEGIN SELECT RAISE(ABORT,'sleep diary revision is immutable');END;" + "CREATE TRIGGER IF NOT EXISTS sleep_diary_events_immutable_update BEFORE UPDATE ON " + "sleep_diary_events " + "BEGIN SELECT RAISE(ABORT,'sleep diary event is immutable');END;" + "CREATE TRIGGER IF NOT EXISTS sleep_diary_events_immutable_delete BEFORE DELETE ON " + "sleep_diary_events " + "BEGIN SELECT RAISE(ABORT,'sleep diary event is immutable');END;" + "CREATE TRIGGER IF NOT EXISTS sleep_medication_intakes_immutable_update BEFORE UPDATE ON " + "sleep_medication_intakes BEGIN SELECT RAISE(ABORT,'sleep medication intake is immutable');END;" + "CREATE TRIGGER IF NOT EXISTS sleep_medication_intakes_immutable_delete BEFORE DELETE ON " + "sleep_medication_intakes BEGIN SELECT RAISE(ABORT,'sleep medication intake is immutable');END;" + "CREATE TRIGGER IF NOT EXISTS sleep_medication_revisions_immutable_update BEFORE UPDATE ON " + "sleep_medication_revisions BEGIN SELECT RAISE(ABORT,'sleep medication revision is " + "immutable');END;" + "CREATE TRIGGER IF NOT EXISTS sleep_medication_revisions_immutable_delete BEFORE DELETE ON " + "sleep_medication_revisions BEGIN SELECT RAISE(ABORT,'sleep medication revision is " + "immutable');END;" + "PRAGMA user_version=36;COMMIT;"; + /* WHY: schema v29 was already opened on the private 0.1.4 review installation * before medication capture joined the same unreleased migration. CONTRACT: * this additive repair is identical to the tail of v28 -> v29 and runs only @@ -2383,7 +2412,8 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, version == 16 || version == 17 || version == 18 || version == 19 || version == 20 || version == 21 || version == 22 || version == 23 || version == 24 || version == 25 || version == 26 || version == 27 || version == 28 || version == 29 || version == 30 || - version == 31 || version == 32 || version == 33 || version == 34 || version == 35) { + version == 31 || version == 32 || version == 33 || version == 34 || version == 35 || + version == 36) { status = TRAINLOG_STATUS_OK; } else { if (version == 1) { @@ -2552,6 +2582,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status == TRAINLOG_STATUS_OK && version < 35) { status = ensure_v35_medication_quantity(database); } + if (status == TRAINLOG_STATUS_OK && version < 36) { + status = execute_sql(database, MIGRATE_V35_TO_V36_SQL); + } if (status == TRAINLOG_STATUS_OK) { status = ensure_v18_ai_draft_publication_state(database); } @@ -2571,7 +2604,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database, if (status != TRAINLOG_STATUS_OK) { set_open_diagnostic(output_diagnostic, output_diagnostic_capacity, - version == 0 ? "create schema v35" : "migrate database to schema v35", + version == 0 ? "create schema v36" : "migrate database to schema v36", database->connection, SQLITE_ERROR); (void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL); diff --git a/tui/tests/test_schema_v29_migration.c b/tui/tests/test_schema_v29_migration.c index ad65ce5..e5b609d 100644 --- a/tui/tests/test_schema_v29_migration.c +++ b/tui/tests/test_schema_v29_migration.c @@ -53,7 +53,7 @@ int main(void) { trainlog_database_close(production); production = NULL; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(scalar(raw, "PRAGMA user_version") == 35); + CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION); CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN " "('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events'," @@ -97,7 +97,7 @@ int main(void) { trainlog_database_close(production); production = NULL; CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(scalar(raw, "PRAGMA user_version") == 35); + CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION); CHECK(scalar(raw, "SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE " "'sleep_medication%'") == 3); diff --git a/tui/tests/test_schema_v30_migration.c b/tui/tests/test_schema_v30_migration.c index 3f50f9f..c68758f 100644 --- a/tui/tests/test_schema_v30_migration.c +++ b/tui/tests/test_schema_v30_migration.c @@ -54,7 +54,7 @@ int main(void) { CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 35); + CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION); trainlog_database_close(production); production = NULL; diff --git a/tui/tests/test_schema_v31_migration.c b/tui/tests/test_schema_v31_migration.c index 5adfc5d..1ca7f79 100644 --- a/tui/tests/test_schema_v31_migration.c +++ b/tui/tests/test_schema_v31_migration.c @@ -51,7 +51,7 @@ int main(void) { CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 35); + CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION); trainlog_database_close(production); production = NULL; diff --git a/tui/tests/test_schema_v32_migration.c b/tui/tests/test_schema_v32_migration.c index 9a6031c..7d586fd 100644 --- a/tui/tests/test_schema_v32_migration.c +++ b/tui/tests/test_schema_v32_migration.c @@ -52,7 +52,7 @@ int main(void) { CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 35); + CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION); trainlog_database_close(production); production = NULL; diff --git a/tui/tests/test_schema_v33_migration.c b/tui/tests/test_schema_v33_migration.c index cb9cca9..f55957c 100644 --- a/tui/tests/test_schema_v33_migration.c +++ b/tui/tests/test_schema_v33_migration.c @@ -52,7 +52,7 @@ int main(void) { CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 35); + CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION); trainlog_database_close(production); production = NULL; diff --git a/tui/tests/test_schema_v34_migration.c b/tui/tests/test_schema_v34_migration.c index 5ee723a..ed6b5de 100644 --- a/tui/tests/test_schema_v34_migration.c +++ b/tui/tests/test_schema_v34_migration.c @@ -61,7 +61,7 @@ int main(void) { CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK); CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK); - CHECK(version == 35); + CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION); trainlog_database_close(production); production = NULL; diff --git a/tui/tests/test_schema_v35_migration.c b/tui/tests/test_schema_v35_migration.c new file mode 100644 index 0000000..6331fae --- /dev/null +++ b/tui/tests/test_schema_v35_migration.c @@ -0,0 +1,100 @@ +#define _POSIX_C_SOURCE 200809L + +#include +#include +#include +#include + +#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) { + static const char *const DROP_TRIGGERS = + "DROP TRIGGER sleep_diary_revisions_immutable_update;" + "DROP TRIGGER sleep_diary_revisions_immutable_delete;" + "DROP TRIGGER sleep_diary_events_immutable_update;" + "DROP TRIGGER sleep_diary_events_immutable_delete;" + "DROP TRIGGER sleep_medication_intakes_immutable_update;" + "DROP TRIGGER sleep_medication_intakes_immutable_delete;" + "DROP TRIGGER sleep_medication_revisions_immutable_update;" + "DROP TRIGGER sleep_medication_revisions_immutable_delete;" + "PRAGMA user_version=35;"; + char path[] = "/tmp/trainlog-schema-v35-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_TRIGGERS, 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 == TRAINLOG_DATABASE_SCHEMA_VERSION); + trainlog_database_close(production); + production = NULL; + + CHECK(sqlite3_open(path, &raw) == SQLITE_OK); + CHECK(scalar(raw, + "SELECT COUNT(*) FROM sqlite_master WHERE type='trigger' AND name LIKE " + "'sleep_%_immutable_%'") == 8); + CHECK(sqlite3_exec( + raw, + "INSERT INTO sleep_diary_entries VALUES('sl_10000000-0000-4000-8000-000000000001'," + "'2026-10-24','2026-10-25','2026-10-24T22:00:00Z'," + "'2026-10-25T07:00:00Z','slr_20000000-0000-4000-8000-000000000002',0);" + "INSERT INTO sleep_diary_revisions VALUES(" + "'slr_20000000-0000-4000-8000-000000000002'," + "'sl_10000000-0000-4000-8000-000000000001',NULL," + "'2026-10-25T07:00:00Z',NULL,NULL,NULL,'');", + NULL, + NULL, + NULL) == SQLITE_OK); + CHECK(sqlite3_exec( + raw, "UPDATE sleep_diary_revisions SET sleep_quality='TB';", NULL, NULL, NULL) == + SQLITE_CONSTRAINT); + CHECK(sqlite3_exec(raw, "DELETE FROM sleep_diary_revisions;", NULL, NULL, NULL) == + SQLITE_CONSTRAINT); + CHECK(scalar(raw, "SELECT COUNT(*) FROM sleep_diary_revisions") == 1); + CHECK(scalar(raw, "SELECT COUNT(*) FROM pragma_foreign_key_check") == 0); + result = EXIT_SUCCESS; + +cleanup: + if (production != NULL) { + trainlog_database_close(production); + } + if (raw != NULL) { + (void)sqlite3_close(raw); + } + (void)unlink(path); + if (result == EXIT_SUCCESS) { + puts("PASS schema v35 to v36 Sleep revision immutability migration"); + } + return result; +} diff --git a/tui/tests/test_schema_v7_migration.c b/tui/tests/test_schema_v7_migration.c index 7ba9341..89ff61b 100644 --- a/tui/tests/test_schema_v7_migration.c +++ b/tui/tests/test_schema_v7_migration.c @@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) { TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); CHECK(strncmp(diagnostic, - "migrate database to schema v35: SQLite rc=", - strlen("migrate database to schema v35: SQLite rc=")) == 0); + "migrate database to schema v36: SQLite rc=", + strlen("migrate database to schema v36: SQLite rc=")) == 0); CHECK(strstr(diagnostic, "extended_rc=") != NULL); CHECK(strstr(diagnostic, "custom_equipment") != NULL); CHECK(strstr(diagnostic, "already exists") != NULL); @@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) { CHECK(fd >= 0); CHECK(close(fd) == 0); CHECK(sqlite3_open(path, &raw) == SQLITE_OK); - CHECK(sqlite3_exec(raw, "PRAGMA user_version=36;", NULL, NULL, NULL) == SQLITE_OK); + CHECK(sqlite3_exec(raw, "PRAGMA user_version=37;", NULL, NULL, NULL) == SQLITE_OK); CHECK(sqlite3_close(raw) == SQLITE_OK); CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_SCHEMA_UNSUPPORTED); CHECK(database == NULL); - CHECK(strstr(diagnostic, "schema version 36 is newer") != NULL); + CHECK(strstr(diagnostic, "schema version 37 is newer") != NULL); CHECK(strstr(diagnostic, "SQLite") == NULL); CHECK(unlink(path) == 0); return true; diff --git a/tui/tests/test_schema_v9_migration.c b/tui/tests/test_schema_v9_migration.c index 42735f7..2f3973a 100644 --- a/tui/tests/test_schema_v9_migration.c +++ b/tui/tests/test_schema_v9_migration.c @@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) { CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) == TRAINLOG_STATUS_DATABASE_ERROR); CHECK(database == NULL); - CHECK(strstr(diagnostic, "migrate database to schema v35") != NULL); + CHECK(strstr(diagnostic, "migrate database to schema v36") != NULL); CHECK(strstr(diagnostic, "performed_sets_v9") != NULL); CHECK(sqlite3_open(path, &raw) == SQLITE_OK);