fix(sync): recover divergent sleep revision identities
This commit is contained in:
parent
a652644e24
commit
bd795495df
72 changed files with 1503 additions and 99 deletions
15
CHANGELOG.md
15
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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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") }
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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 ->
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' " +
|
||||
|
|
|
|||
|
|
@ -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'," +
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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<String>()
|
||||
val expectedParents = mutableMapOf<String, String?>()
|
||||
val expectedNotes = mutableMapOf<String, String>()
|
||||
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()
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
310
tests/test_sleep_revision_collision_recovery.py
Normal file
310
tests/test_sleep_revision_collision_recovery.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}"
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 = []
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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"]):
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
444
tools/recover_sleep_revision_collision.py
Normal file
444
tools/recover_sleep_revision_collision.py
Normal file
|
|
@ -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()
|
||||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
100
tui/tests/test_schema_v35_migration.c
Normal file
100
tui/tests/test_schema_v35_migration.c
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
#define _POSIX_C_SOURCE 200809L
|
||||
|
||||
#include <sqlite3.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <unistd.h>
|
||||
|
||||
#include "trainlog/database.h"
|
||||
|
||||
#define CHECK(value) \
|
||||
do { \
|
||||
if (!(value)) { \
|
||||
fprintf(stderr, "CHECK failed line %d: %s\n", __LINE__, #value); \
|
||||
goto cleanup; \
|
||||
} \
|
||||
} while (0)
|
||||
|
||||
static int scalar(sqlite3 *database, const char *sql) {
|
||||
sqlite3_stmt *statement = NULL;
|
||||
int value = -1;
|
||||
if (sqlite3_prepare_v2(database, sql, -1, &statement, NULL) == SQLITE_OK &&
|
||||
sqlite3_step(statement) == SQLITE_ROW) {
|
||||
value = sqlite3_column_int(statement, 0);
|
||||
}
|
||||
(void)sqlite3_finalize(statement);
|
||||
return value;
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue