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
|
## 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
|
- Fixed Android finalization of Program-derived sessions containing a completed
|
||||||
target-only continuous occurrence. The repository now materializes the
|
target-only continuous occurrence. The repository now materializes the
|
||||||
performed duration from the persisted exercise timeline, including preserved
|
performed duration from the persisted exercise timeline, including preserved
|
||||||
|
|
|
||||||
|
|
@ -225,7 +225,7 @@ internal class AndroidBackupService(
|
||||||
manifest.getInt("version") == VERSION &&
|
manifest.getInt("version") == VERSION &&
|
||||||
manifest.getString("package") == context.packageName &&
|
manifest.getString("package") == context.packageName &&
|
||||||
manifest.getBoolean("plaintext") &&
|
manifest.getBoolean("plaintext") &&
|
||||||
manifest.getInt("schema") in 17..32
|
manifest.getInt("schema") in 17..33
|
||||||
) {
|
) {
|
||||||
"Unsupported backup."
|
"Unsupported backup."
|
||||||
}
|
}
|
||||||
|
|
@ -319,7 +319,7 @@ internal class AndroidBackupService(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
require(db.rawQuery("PRAGMA foreign_key_check", null).use { !it.moveToFirst() })
|
||||||
require(db.version in 17..32) { "Unsupported Android schema ${db.version}." }
|
require(db.version in 17..33) { "Unsupported Android schema ${db.version}." }
|
||||||
return db.version
|
return db.version
|
||||||
} finally {
|
} finally {
|
||||||
db.close()
|
db.close()
|
||||||
|
|
|
||||||
|
|
@ -1457,6 +1457,18 @@ class TrainlogRepository(
|
||||||
"SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
|
"SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
|
||||||
arrayOf(entryId),
|
arrayOf(entryId),
|
||||||
).use { if (it.moveToFirst()) it.getString(0) to it.getInt(1) else null }
|
).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) {
|
if (local?.first == revisionId) {
|
||||||
val same = db.rawQuery(
|
val same = db.rawQuery(
|
||||||
"SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted," +
|
"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(
|
private fun markSleepDiaryImported(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
entryId: String,
|
entryId: String,
|
||||||
|
|
@ -12203,7 +12287,7 @@ private class TrainlogDatabaseHelper(
|
||||||
appContext,
|
appContext,
|
||||||
databaseName,
|
databaseName,
|
||||||
null,
|
null,
|
||||||
32,
|
33,
|
||||||
) {
|
) {
|
||||||
override fun onConfigure(
|
override fun onConfigure(
|
||||||
db: SQLiteDatabase,
|
db: SQLiteDatabase,
|
||||||
|
|
@ -12549,6 +12633,14 @@ private class TrainlogDatabaseHelper(
|
||||||
createSleepQuickPublicationTable(db)
|
createSleepQuickPublicationTable(db)
|
||||||
version = 32
|
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) {
|
if (version != newVersion) {
|
||||||
error(
|
error(
|
||||||
|
|
@ -12688,6 +12780,28 @@ private class TrainlogDatabaseHelper(
|
||||||
entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,
|
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,
|
revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,
|
||||||
PRIMARY KEY(generation_id,entry_id))""".trimIndent())
|
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) {
|
private fun createSleepQuickPublicationTable(db: SQLiteDatabase) {
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,7 @@ class AndroidV10PlanningMigrationTest {
|
||||||
|
|
||||||
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
TrainlogRepository(context, name).useForTest { it.listSessions() }
|
||||||
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path.path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(32, scalar(db, "PRAGMA user_version"))
|
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||||
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
for (table in listOf("session_exercises", "draft_session_exercises")) {
|
||||||
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
assertEquals(6, scalar(db, "SELECT COUNT(*) FROM pragma_table_info('$table') WHERE name IN ('load_mode','rest_seconds','target_sets','target_reps','target_duration_seconds','target_weight_kg')"))
|
||||||
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
assertEquals(0, scalar(db, "SELECT COUNT(*) FROM $table WHERE load_mode<>'none' OR rest_seconds<>0 OR target_sets IS NOT NULL OR target_reps IS NOT NULL OR target_duration_seconds IS NOT NULL OR target_weight_kg IS NOT NULL"))
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AndroidV11ExerciseAliasMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(32, scalar(db, "PRAGMA user_version"))
|
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||||
/* v13 intentionally adds the frozen machine identities and
|
/* v13 intentionally adds the frozen machine identities and
|
||||||
* exercise-owned metadata; all non-exercise facts stay exact. */
|
* exercise-owned metadata; all non-exercise facts stay exact. */
|
||||||
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
val after = snapshot(db).filterKeys { it !in setOf("exercise_aliases", "exercise_profile_state", "exercise_profile_revisions") }
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class AndroidV16AiSessionDraftMigrationTest {
|
||||||
migrated.close()
|
migrated.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(32, scalar(db, "PRAGMA user_version"))
|
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(before, listOf(
|
assertEquals(before, listOf(
|
||||||
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
scalar(db, "SELECT COUNT(*) FROM sessions"),
|
||||||
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
scalar(db, "SELECT COUNT(*) FROM performed_sets"),
|
||||||
|
|
|
||||||
|
|
@ -69,7 +69,7 @@ class AndroidV25ProgramExecutionMigrationTest {
|
||||||
reopened.close()
|
reopened.close()
|
||||||
}
|
}
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(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(0, scalar(db, "SELECT COUNT(*) FROM sessions WHERE source_program_id IS NOT NULL"))
|
||||||
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
assertEquals("ok", text(db, "PRAGMA integrity_check"))
|
||||||
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
db.rawQuery("PRAGMA foreign_key_check", null).use { cursor ->
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
|
||||||
context.getDatabasePath(name).path,
|
context.getDatabasePath(name).path,
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { assertEquals(32, it.version) }
|
).use { assertEquals(33, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
|
||||||
context.getDatabasePath(name).path,
|
context.getDatabasePath(name).path,
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { assertEquals(32, it.version) }
|
).use { assertEquals(33, it.version) }
|
||||||
} finally {
|
} finally {
|
||||||
repository.close()
|
repository.close()
|
||||||
context.deleteDatabase(name)
|
context.deleteDatabase(name)
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest {
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { db ->
|
).use { db ->
|
||||||
assertEquals(32, db.version)
|
assertEquals(33, db.version)
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
|
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
|
||||||
null,
|
null,
|
||||||
|
|
|
||||||
|
|
@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest {
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { db ->
|
).use { db ->
|
||||||
assertEquals(32, db.version)
|
assertEquals(33, db.version)
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT session_type,session_kind FROM sessions",
|
"SELECT session_type,session_kind FROM sessions",
|
||||||
null,
|
null,
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest {
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { db ->
|
).use { db ->
|
||||||
assertEquals(32, db.version)
|
assertEquals(33, db.version)
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT COUNT(*) FROM exercises WHERE " +
|
"SELECT COUNT(*) FROM exercises WHERE " +
|
||||||
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +
|
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ class AndroidV31CardioGuidanceMigrationTest {
|
||||||
null,
|
null,
|
||||||
SQLiteDatabase.OPEN_READONLY,
|
SQLiteDatabase.OPEN_READONLY,
|
||||||
).use { db ->
|
).use { db ->
|
||||||
assertEquals(32, db.version)
|
assertEquals(33, db.version)
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
|
||||||
"'cardio_guidance_runs','cardio_guidance_phases'," +
|
"'cardio_guidance_runs','cardio_guidance_phases'," +
|
||||||
|
|
|
||||||
|
|
@ -59,7 +59,7 @@ class AndroidV32SleepQuantityMigrationTest {
|
||||||
migrated.listSleepDiary()
|
migrated.listSleepDiary()
|
||||||
migrated.close()
|
migrated.close()
|
||||||
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
assertEquals(32, scalar(db, "PRAGMA user_version"))
|
assertEquals(33, scalar(db, "PRAGMA user_version"))
|
||||||
assertEquals(
|
assertEquals(
|
||||||
1,
|
1,
|
||||||
scalar(
|
scalar(
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,7 @@ class BodyZonesTest {
|
||||||
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
|
||||||
SQLiteDatabase.OPEN_READONLY).use { db ->
|
SQLiteDatabase.OPEN_READONLY).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(32, cursor.getInt(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(33, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))
|
||||||
|
|
|
||||||
|
|
@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
|
||||||
repository = TrainlogRepository(context, name)
|
repository = TrainlogRepository(context, name)
|
||||||
assertEquals(1, repository.listPreparedSessions().size)
|
assertEquals(1, repository.listPreparedSessions().size)
|
||||||
repository.close()
|
repository.close()
|
||||||
assertEquals("32", scalarText(name, "PRAGMA user_version"))
|
assertEquals("33", scalarText(name, "PRAGMA user_version"))
|
||||||
assertEquals(
|
assertEquals(
|
||||||
"0",
|
"0",
|
||||||
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package com.labfytools.trainlog.data
|
package com.labfytools.trainlog.data
|
||||||
|
|
||||||
import android.content.Context
|
import android.content.Context
|
||||||
|
import android.database.sqlite.SQLiteDatabase
|
||||||
|
import android.database.sqlite.SQLiteException
|
||||||
import androidx.test.core.app.ApplicationProvider
|
import androidx.test.core.app.ApplicationProvider
|
||||||
import com.labfytools.trainlog.model.SleepDiaryDraft
|
import com.labfytools.trainlog.model.SleepDiaryDraft
|
||||||
import com.labfytools.trainlog.model.SleepDiaryEvent
|
import com.labfytools.trainlog.model.SleepDiaryEvent
|
||||||
|
|
@ -11,6 +13,7 @@ import com.labfytools.trainlog.model.MedicationIntake
|
||||||
import com.labfytools.trainlog.model.SleepMedication
|
import com.labfytools.trainlog.model.SleepMedication
|
||||||
import java.util.UUID
|
import java.util.UUID
|
||||||
import org.junit.Assert.assertEquals
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertThrows
|
||||||
import org.junit.Assert.assertTrue
|
import org.junit.Assert.assertTrue
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
import org.junit.runner.RunWith
|
import org.junit.runner.RunWith
|
||||||
|
|
@ -73,6 +76,141 @@ class SleepDiaryRepositoryTest {
|
||||||
} finally { repository.close(); context.deleteDatabase(name) }
|
} 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
|
@Test
|
||||||
fun ancestryAllowsMultiHopRemoteAdvance() {
|
fun ancestryAllowsMultiHopRemoteAdvance() {
|
||||||
val sourceName = "sleep-stale-source-${UUID.randomUUID()}.db"
|
val sourceName = "sleep-stale-source-${UUID.randomUUID()}.db"
|
||||||
|
|
@ -188,6 +326,15 @@ class SleepDiaryRepositoryTest {
|
||||||
assertEquals(SleepQuality.MOY, destination.listSleepDiary().single().wakeQuality)
|
assertEquals(SleepQuality.MOY, destination.listSleepDiary().single().wakeQuality)
|
||||||
assertEquals(SleepQuality.TB, destination.listSleepDiary().single().dayForm)
|
assertEquals(SleepQuality.TB, destination.listSleepDiary().single().dayForm)
|
||||||
assertEquals("Medication revised", destination.listSleepMedications().single().name)
|
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 {
|
} finally {
|
||||||
source.close()
|
source.close()
|
||||||
destination.close()
|
destination.close()
|
||||||
|
|
|
||||||
|
|
@ -878,7 +878,7 @@ class SyncGenerationServiceTest {
|
||||||
.use { db ->
|
.use { db ->
|
||||||
db.rawQuery("PRAGMA user_version", null).use {
|
db.rawQuery("PRAGMA user_version", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
assertEquals(32, it.getInt(0))
|
assertEquals(33, it.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
|
||||||
assertTrue(it.moveToFirst())
|
assertTrue(it.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -2260,7 +2260,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(32, cursor.getInt(0))
|
assertEquals(33, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery(
|
db.rawQuery(
|
||||||
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
"SELECT eq.equipment_id, ps.reps, ps.weight_kg FROM session_exercises se " +
|
||||||
|
|
@ -2387,7 +2387,7 @@ class TrainlogRepositoryDraftTest {
|
||||||
).use { db ->
|
).use { db ->
|
||||||
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
db.rawQuery("PRAGMA user_version;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
assertEquals(32, cursor.getInt(0))
|
assertEquals(33, cursor.getInt(0))
|
||||||
}
|
}
|
||||||
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
db.rawQuery("SELECT weight_kg FROM performed_sets WHERE id = 1;", null).use { cursor ->
|
||||||
assertTrue(cursor.moveToFirst())
|
assertTrue(cursor.moveToFirst())
|
||||||
|
|
|
||||||
|
|
@ -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
|
does not validate a day and does not change the existing validation/ACK
|
||||||
lifecycle.
|
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
|
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
|
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
|
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
|
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
|
||||||
mixed-checkout user-service execution during the controlled rollout.
|
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,
|
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
|
||||||
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
|
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
|
||||||
|
|
@ -128,8 +128,8 @@ parallel implementation of its rules.
|
||||||
| Boundary | Current state |
|
| Boundary | Current state |
|
||||||
|---|---|
|
|---|---|
|
||||||
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
|
||||||
| Desktop SQLite | schema v35; v35 additively gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 |
|
| 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 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 |
|
| 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 |
|
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
|
||||||
| Desktop terminal backend | Notcurses only |
|
| Desktop terminal backend | Notcurses only |
|
||||||
| Trainlog product version | `0.1.6` development, synchronized across Android and desktop; latest stable release: `v0.1.5` |
|
| 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. |
|
| 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 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 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 |
|
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
|
||||||
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
|
||||||
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,21 @@
|
||||||
# Desktop database
|
# 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: Sleep Diary quantity V2
|
||||||
|
|
||||||
Schema v35 additively adds `sleep_medication_intakes.quantity`, a required
|
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
|
## Current baseline
|
||||||
|
|
||||||
Stable v0.1.5 is the release baseline for the v0.1.6 development cycle.
|
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
|
storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training
|
||||||
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
|
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
|
||||||
Generator V1 is hidden pending V2. AI session-draft exchange retains its
|
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,
|
stdout/stderr result is truncated. Resolution, launch, argparse, exception,
|
||||||
signal, and non-zero-exit diagnostics therefore describe only the current run
|
signal, and non-zero-exit diagnostics therefore describe only the current run
|
||||||
and are retained in the synchronization error/history when the run fails.
|
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
|
# 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 UI refinement 0.1.6
|
||||||
|
|
||||||
Web regressions cover calendar-week activity grouping, global rather than
|
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
|
Dashboard sizing and reset behavior, English preference persistence across a
|
||||||
remount, centralized Dashboard English labels, non-stretched chart aspect
|
remount, centralized Dashboard English labels, non-stretched chart aspect
|
||||||
ratios, and distinct missing/partial exercise-timing messages.
|
ratios, and distinct missing/partial exercise-timing messages.
|
||||||
The closeout run passes Web **32 files / 205 tests**, TypeScript typecheck and
|
The current closeout run passes Web **32 files / 209 tests**, TypeScript
|
||||||
the production build; native Meson **111/111**; Android `testDebugUnitTest` and
|
typecheck and the production build; native Meson **113/113**; Android **315
|
||||||
`assembleDebug`; JSON/import validators; changed-C formatting; and diff checks.
|
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 restarted private service serves the rebuilt executable byte-for-byte from
|
||||||
the development checkout and reports healthy Trainlog `0.1.6`. A non-mutating
|
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
|
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)
|
exported = exchange.build(self.db)
|
||||||
self.assertTrue(exported["entries"][0]["deleted"])
|
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):
|
def test_v1_hydrates_quantity_one_and_v2_preserves_quantity(self):
|
||||||
first = "slr_20000000-0000-4000-8000-000000000002"
|
first = "slr_20000000-0000-4000-8000-000000000002"
|
||||||
legacy = self.document(entry(first))
|
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)
|
self.assertEqual(result.returncode, 0, result.stderr)
|
||||||
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
inventory = json.loads((output / "candidate-inventory.json").read_text())
|
||||||
self.assertEqual(inventory["product_version"], "0.1.6")
|
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.assertEqual(inventory["android_schema"], 32)
|
||||||
self.assertIn("session-preparations-v2", inventory["protocols"])
|
self.assertIn("session-preparations-v2", inventory["protocols"])
|
||||||
self.assertIn("programs-v1", inventory["protocols"])
|
self.assertIn("programs-v1", inventory["protocols"])
|
||||||
|
|
|
||||||
|
|
@ -271,8 +271,8 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
root = load(args.input)
|
root = load(args.input)
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36):
|
||||||
fail("desktop schema v33-v35 required")
|
fail("desktop schema v33-v36 required")
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
apply(db, root)
|
apply(db, root)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -395,8 +395,8 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
root = load(args.input)
|
root = load(args.input)
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36):
|
||||||
fail("desktop schema v34-v35 required")
|
fail("desktop schema v34-v36 required")
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
apply(db, root)
|
apply(db, root)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -38,8 +38,8 @@ def digest(value) -> str:
|
||||||
|
|
||||||
def require_schema(db: sqlite3.Connection) -> None:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
version = db.execute("PRAGMA user_version").fetchone()[0]
|
version = db.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
|
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-v35 required, found v{version}")
|
raise CausalError(f"desktop schema v20-v36 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
def target_snapshot(db: sqlite3.Connection, kind: str, target: str):
|
||||||
|
|
|
||||||
|
|
@ -182,8 +182,8 @@ def causal_payload(draft):
|
||||||
|
|
||||||
def require_schema(connection):
|
def require_schema(connection):
|
||||||
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
|
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-v35 required, found v{version}")
|
raise LifecycleError(f"desktop schema v19-v36 required, found v{version}")
|
||||||
|
|
||||||
|
|
||||||
def import_document(connection, document, own_transaction=True):
|
def import_document(connection, document, own_transaction=True):
|
||||||
|
|
|
||||||
|
|
@ -12,7 +12,7 @@ from pathlib import Path
|
||||||
from trainlog_sqlite import configure_connection
|
from trainlog_sqlite import configure_connection
|
||||||
|
|
||||||
|
|
||||||
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 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 = (
|
MEASUREMENT_FIELDS = (
|
||||||
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
|
||||||
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
|
||||||
|
|
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
|
||||||
"""Build the raw export using only explicitly ordered SELECT statements."""
|
"""Build the raw export using only explicitly ordered SELECT statements."""
|
||||||
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
|
||||||
raise ValueError("schema desktop v15-v35 requis")
|
raise ValueError("schema desktop v15-v36 requis")
|
||||||
|
|
||||||
root = {
|
root = {
|
||||||
"format": "TRAINLOG_AI_EXPORT",
|
"format": "TRAINLOG_AI_EXPORT",
|
||||||
|
|
|
||||||
|
|
@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
|
||||||
def ensure_publication_state(connection):
|
def ensure_publication_state(connection):
|
||||||
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
"""Install the additive v18 cursor for direct syncd/tool invocations."""
|
||||||
owned_transaction = not connection.in_transaction
|
owned_transaction = not connection.in_transaction
|
||||||
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 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:
|
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(
|
columns = {row[1] for row in connection.execute(
|
||||||
"PRAGMA table_info(ai_session_drafts)")}
|
"PRAGMA table_info(ai_session_drafts)")}
|
||||||
if "published_at" not in columns:
|
if "published_at" not in columns:
|
||||||
|
|
|
||||||
|
|
@ -28,9 +28,9 @@ def main():
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = tuple(range(8, 36))
|
supported_versions = tuple(range(8, 37))
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v8 à v35 requis")
|
raise ValueError("schema desktop v8 à v36 requis")
|
||||||
known_equipment = load_supplied_equipment_ids(args.catalog)
|
known_equipment = load_supplied_equipment_ids(args.catalog)
|
||||||
known_equipment.update(row[0] for row in connection.execute(
|
known_equipment.update(row[0] for row in connection.execute(
|
||||||
"SELECT equipment_id FROM custom_equipment"))
|
"SELECT equipment_id FROM custom_equipment"))
|
||||||
|
|
|
||||||
|
|
@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
supported_versions = tuple(range(8, 36))
|
supported_versions = tuple(range(8, 37))
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v8 à 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():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal equipment protection requires the staged artifact")
|
raise ValueError("causal equipment protection requires the staged artifact")
|
||||||
equipment = [dict(row) for row in connection.execute(
|
equipment = [dict(row) for row in connection.execute(
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,8 @@ def main():
|
||||||
con = connect_database(args.database)
|
con = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||||
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
|
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-v35 requis")
|
raise ValueError("schema desktop v12-v36 requis")
|
||||||
rows = con.execute(
|
rows = con.execute(
|
||||||
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
|
||||||
"ORDER BY source_exercise_id COLLATE BINARY"
|
"ORDER BY source_exercise_id COLLATE BINARY"
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
connection.row_factory = sqlite3.Row
|
connection.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
supported_versions = tuple(range(11, 36))
|
supported_versions = tuple(range(11, 37))
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v11-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():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
raise ValueError("causal BODY ZONES protection requires the staged artifact")
|
||||||
exercises = []
|
exercises = []
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ def main():
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
version = con.execute("PRAGMA user_version").fetchone()[0]
|
version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 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}")
|
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
|
||||||
rows = con.execute("""
|
rows = con.execute("""
|
||||||
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,
|
||||||
|
|
|
||||||
|
|
@ -71,7 +71,7 @@ def main():
|
||||||
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
# CONTRACT: v8 adds only desktop-local custom equipment. The PC
|
||||||
# catalogue artifact is unchanged, but it must read the current
|
# catalogue artifact is unchanged, but it must read the current
|
||||||
# canonical desktop schema rather than accept a stale pre-v8 database.
|
# canonical desktop schema rather than accept a stale pre-v8 database.
|
||||||
if version not in range(8, 36):
|
if version not in range(8, 37):
|
||||||
raise SystemExit(
|
raise SystemExit(
|
||||||
"PC_CATALOG_EXPORT=FAIL "
|
"PC_CATALOG_EXPORT=FAIL "
|
||||||
f"schema={version}"
|
f"schema={version}"
|
||||||
|
|
|
||||||
|
|
@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
|
||||||
con.row_factory = sqlite3.Row
|
con.row_factory = sqlite3.Row
|
||||||
try:
|
try:
|
||||||
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
|
||||||
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 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")
|
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
|
legacy_v2_schema = args.version == 2 and schema_version == 10
|
||||||
if schema_version not in supported_versions and not legacy_v2_schema:
|
if schema_version not in supported_versions and not legacy_v2_schema:
|
||||||
raise ValueError("schema desktop v11-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(
|
if not complete_causal_envelope and schema_version >= 20 and con.execute(
|
||||||
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
|
||||||
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
|
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ def nullable(row, index):
|
||||||
|
|
||||||
def export_programs(database: Path) -> dict:
|
def export_programs(database: Path) -> dict:
|
||||||
with closing(connect_database(database)) as db:
|
with closing(connect_database(database)) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 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")
|
raise ValueError("desktop schema v26 through v32 required")
|
||||||
programs = []
|
programs = []
|
||||||
for row in db.execute(
|
for row in db.execute(
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
|
||||||
|
|
||||||
|
|
||||||
def build_export(connection: sqlite3.Connection) -> dict:
|
def build_export(connection: sqlite3.Connection) -> dict:
|
||||||
supported_versions = (24, 25, 26, 27, 28, 29, 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:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("desktop schema v24, v25, v26 through v32 required")
|
raise ValueError("desktop schema v24, v25, v26 through v32 required")
|
||||||
deliveries = []
|
deliveries = []
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
|
||||||
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
|
||||||
db=connect_database(args.database)
|
db=connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = range(15, 36)
|
supported_versions = range(15, 37)
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ValueError("schema desktop v15-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")
|
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
|
||||||
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
|
||||||
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()
|
||||||
|
|
|
||||||
|
|
@ -236,8 +236,8 @@ def main() -> None:
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
root = load(args.input)
|
root = load(args.input)
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
|
||||||
fail("desktop schema v30-v35 required")
|
fail("desktop schema v30-v36 required")
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
apply(db, root)
|
apply(db, root)
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
|
||||||
|
|
@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
|
||||||
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
|
||||||
any failure leaves no draft, child, or import-identity row behind.
|
any failure leaves no draft, child, or import-identity row behind.
|
||||||
"""
|
"""
|
||||||
supported_versions = range(18, 36)
|
supported_versions = range(18, 37)
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ImportFailure("schema desktop v18-v35 requis")
|
raise ImportFailure("schema desktop v18-v36 requis")
|
||||||
draft, entries = validate(payload, connection)
|
draft, entries = validate(payload, connection)
|
||||||
digest = canonical_digest(payload)
|
digest = canonical_digest(payload)
|
||||||
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")
|
||||||
|
|
|
||||||
|
|
@ -206,9 +206,9 @@ def main():
|
||||||
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
|
||||||
connection = connect_database(args.database)
|
connection = connect_database(args.database)
|
||||||
try:
|
try:
|
||||||
supported_versions = range(8, 36)
|
supported_versions = range(8, 37)
|
||||||
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v8 à v35 requis")
|
fail("schema desktop v8 à v36 requis")
|
||||||
proof_path = args.mobile_export
|
proof_path = args.mobile_export
|
||||||
if proof_path is None:
|
if proof_path is None:
|
||||||
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")
|
||||||
|
|
|
||||||
|
|
@ -53,9 +53,9 @@ def validate(payload, reserved):
|
||||||
|
|
||||||
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
def apply_definitions(connection, definitions, complete_causal_envelope=False):
|
||||||
"""Apply validated definitions without owning the surrounding transaction."""
|
"""Apply validated definitions without owning the surrounding transaction."""
|
||||||
supported_versions = range(8, 36)
|
supported_versions = range(8, 37)
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v8 à 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():
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
|
||||||
fail("causal equipment protection requires the staged artifact")
|
fail("causal equipment protection requires the staged artifact")
|
||||||
imported = skipped = 0
|
imported = skipped = 0
|
||||||
|
|
|
||||||
|
|
@ -56,9 +56,9 @@ def load(path):
|
||||||
|
|
||||||
def apply_aliases(con, aliases):
|
def apply_aliases(con, aliases):
|
||||||
"""Apply parsed aliases inside the caller-owned transaction."""
|
"""Apply parsed aliases inside the caller-owned transaction."""
|
||||||
supported_versions = range(12, 36)
|
supported_versions = range(12, 37)
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v12-v35 requis")
|
fail("schema desktop v12-v36 requis")
|
||||||
for source, canonical in aliases:
|
for source, canonical in aliases:
|
||||||
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
|
||||||
if target is None: fail(f"cible canonique absente: {canonical}")
|
if target is None: fail(f"cible canonique absente: {canonical}")
|
||||||
|
|
|
||||||
|
|
@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
|
||||||
|
|
||||||
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
|
||||||
"""Apply validated zone rows without owning transaction boundaries."""
|
"""Apply validated zone rows without owning transaction boundaries."""
|
||||||
supported_versions = range(11, 36)
|
supported_versions = range(11, 37)
|
||||||
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
raise ImportFailure("schema desktop v11-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")
|
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
|
||||||
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
connection.execute("PRAGMA foreign_keys=ON");grouped={}
|
||||||
# CONTRACT: resolve complete alias groups before the first write; direct
|
# CONTRACT: resolve complete alias groups before the first write; direct
|
||||||
|
|
|
||||||
|
|
@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
|
||||||
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
|
||||||
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
|
||||||
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
if con.execute("PRAGMA user_version").fetchone()[0] not in (
|
||||||
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
|
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
|
||||||
fail("schema desktop v17-v35 requis")
|
fail("schema desktop v17-v36 requis")
|
||||||
applied=equal=ancestor=pending=0;seen=set()
|
applied=equal=ancestor=pending=0;seen=set()
|
||||||
for item in root["exercises"]:
|
for item in root["exercises"]:
|
||||||
validate(item);eid=item["exercise_id"]
|
validate(item);eid=item["exercise_id"]
|
||||||
|
|
|
||||||
|
|
@ -847,9 +847,9 @@ def require_supported_schema(connection):
|
||||||
|
|
||||||
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
|
||||||
# readable for legacy artifacts and are never made to fake that table.
|
# readable for legacy artifacts and are never made to fake that table.
|
||||||
if version not in range(5, 36):
|
if version not in range(5, 37):
|
||||||
raise ImportFailure(
|
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)
|
require_supported_schema(connection)
|
||||||
|
|
||||||
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
|
||||||
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
|
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 à v35")
|
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v36")
|
||||||
if schema_version >= 20:
|
if schema_version >= 20:
|
||||||
protected = {(row[0], row[1]) for row in connection.execute(
|
protected = {(row[0], row[1]) for row in connection.execute(
|
||||||
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
|
||||||
def apply_feedback(db,root,complete_causal_envelope=False):
|
def apply_feedback(db,root,complete_causal_envelope=False):
|
||||||
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
roots_added=roots_skipped=revisions_added=revisions_skipped=0
|
||||||
db.execute("PRAGMA foreign_keys=ON")
|
db.execute("PRAGMA foreign_keys=ON")
|
||||||
supported_versions = range(15, 36)
|
supported_versions = range(15, 37)
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
|
||||||
fail("schema desktop v15-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")
|
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
|
||||||
seen=set()
|
seen=set()
|
||||||
for index,item in enumerate(root["exercise_feedback"]):
|
for index,item in enumerate(root["exercise_feedback"]):
|
||||||
|
|
|
||||||
|
|
@ -119,7 +119,7 @@ def main() -> int:
|
||||||
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
|
||||||
).strip(),
|
).strip(),
|
||||||
"product_version": "0.1.6",
|
"product_version": "0.1.6",
|
||||||
"desktop_schema": 35,
|
"desktop_schema": 36,
|
||||||
"android_schema": 32,
|
"android_schema": 32,
|
||||||
"protocols": [
|
"protocols": [
|
||||||
"mobile-export-v3",
|
"mobile-export-v3",
|
||||||
|
|
|
||||||
|
|
@ -87,7 +87,7 @@ def load(path: Path) -> dict:
|
||||||
|
|
||||||
|
|
||||||
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
|
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 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")
|
fail("desktop schema v27 through v32 required")
|
||||||
added = 0
|
added = 0
|
||||||
advanced = 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()
|
args = parser.parse_args()
|
||||||
root = load(args.input)
|
root = load(args.input)
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36):
|
||||||
fail("desktop schema v31-v35 required")
|
fail("desktop schema v31-v36 required")
|
||||||
db.execute("BEGIN IMMEDIATE")
|
db.execute("BEGIN IMMEDIATE")
|
||||||
apply(db, root)
|
apply(db, root)
|
||||||
db.commit()
|
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:
|
def build(db: sqlite3.Connection) -> dict:
|
||||||
# CONTRACT: local durability does not publish a draft. A generation may
|
# CONTRACT: local durability does not publish a draft. A generation may
|
||||||
# capture only the current tip explicitly validated by the user.
|
# 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=?",
|
"SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
|
||||||
(item["entry_id"],),
|
(item["entry_id"],),
|
||||||
).fetchone()
|
).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"]:
|
if local is not None and local[0] == item["revision_id"]:
|
||||||
# INVARIANT: an identical durable revision may still be a local
|
# INVARIANT: an identical durable revision may still be a local
|
||||||
# draft because publication state is intentionally separate. The
|
# draft because publication state is intentionally separate. The
|
||||||
|
|
@ -462,8 +542,8 @@ def main() -> None:
|
||||||
import_command.add_argument("--database", required=True, type=Path)
|
import_command.add_argument("--database", required=True, type=Path)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
with connect_database(args.database) as db:
|
with connect_database(args.database) as db:
|
||||||
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35):
|
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
|
||||||
fail("desktop schema v30-v35 required")
|
fail("desktop schema v30-v36 required")
|
||||||
if args.command == "export":
|
if args.command == "export":
|
||||||
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
|
||||||
separators=(",", ":")), encoding="utf-8")
|
separators=(",", ":")), encoding="utf-8")
|
||||||
|
|
|
||||||
|
|
@ -219,9 +219,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
|
||||||
|
|
||||||
|
|
||||||
def require_schema(db: sqlite3.Connection) -> None:
|
def require_schema(db: sqlite3.Connection) -> None:
|
||||||
supported_versions = (24, 25, 26, 27, 28, 29, 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:
|
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:
|
def peer_identity(db: sqlite3.Connection, kind: str) -> str:
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@
|
||||||
#include "trainlog/model.h"
|
#include "trainlog/model.h"
|
||||||
#include "trainlog/status.h"
|
#include "trainlog/status.h"
|
||||||
|
|
||||||
#define TRAINLOG_DATABASE_SCHEMA_VERSION 35
|
#define TRAINLOG_DATABASE_SCHEMA_VERSION 36
|
||||||
|
|
||||||
typedef struct TrainlogDatabase TrainlogDatabase;
|
typedef struct TrainlogDatabase TrainlogDatabase;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -889,6 +889,14 @@ test_schema_v34_migration = executable(
|
||||||
)
|
)
|
||||||
test('schema_v34_migration', test_schema_v34_migration)
|
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 = executable(
|
||||||
'test_schema_v7_migration',
|
'test_schema_v7_migration',
|
||||||
'tests/test_schema_v7_migration.c',
|
'tests/test_schema_v7_migration.c',
|
||||||
|
|
@ -968,6 +976,11 @@ test(
|
||||||
find_program('python3'),
|
find_program('python3'),
|
||||||
args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'],
|
args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'],
|
||||||
)
|
)
|
||||||
|
test(
|
||||||
|
'sleep_revision_collision_recovery',
|
||||||
|
find_program('python3'),
|
||||||
|
args: [meson.project_source_root() / 'tests/test_sleep_revision_collision_recovery.py'],
|
||||||
|
)
|
||||||
test(
|
test(
|
||||||
'heart_rate_exchange',
|
'heart_rate_exchange',
|
||||||
find_program('python3'),
|
find_program('python3'),
|
||||||
|
|
|
||||||
|
|
@ -1549,6 +1549,35 @@ static TrainlogStatus ensure_v35_medication_quantity(TrainlogDatabase *database)
|
||||||
return TRAINLOG_STATUS_OK;
|
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
|
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
|
||||||
* before medication capture joined the same unreleased migration. CONTRACT:
|
* before medication capture joined the same unreleased migration. CONTRACT:
|
||||||
* this additive repair is identical to the tail of v28 -> v29 and runs only
|
* this additive repair is identical to the tail of v28 -> v29 and runs only
|
||||||
|
|
@ -2383,7 +2412,8 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
||||||
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
|
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
|
||||||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
|
||||||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
|
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;
|
status = TRAINLOG_STATUS_OK;
|
||||||
} else {
|
} else {
|
||||||
if (version == 1) {
|
if (version == 1) {
|
||||||
|
|
@ -2552,6 +2582,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
|
||||||
if (status == TRAINLOG_STATUS_OK && version < 35) {
|
if (status == TRAINLOG_STATUS_OK && version < 35) {
|
||||||
status = ensure_v35_medication_quantity(database);
|
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) {
|
if (status == TRAINLOG_STATUS_OK) {
|
||||||
status = ensure_v18_ai_draft_publication_state(database);
|
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) {
|
if (status != TRAINLOG_STATUS_OK) {
|
||||||
set_open_diagnostic(output_diagnostic,
|
set_open_diagnostic(output_diagnostic,
|
||||||
output_diagnostic_capacity,
|
output_diagnostic_capacity,
|
||||||
version == 0 ? "create schema v35" : "migrate database to schema v35",
|
version == 0 ? "create schema v36" : "migrate database to schema v36",
|
||||||
database->connection,
|
database->connection,
|
||||||
SQLITE_ERROR);
|
SQLITE_ERROR);
|
||||||
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,7 @@ int main(void) {
|
||||||
trainlog_database_close(production);
|
trainlog_database_close(production);
|
||||||
production = NULL;
|
production = NULL;
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(scalar(raw, "PRAGMA user_version") == 35);
|
CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||||
CHECK(scalar(raw,
|
CHECK(scalar(raw,
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
|
||||||
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events',"
|
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events',"
|
||||||
|
|
@ -97,7 +97,7 @@ int main(void) {
|
||||||
trainlog_database_close(production);
|
trainlog_database_close(production);
|
||||||
production = NULL;
|
production = NULL;
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(scalar(raw, "PRAGMA user_version") == 35);
|
CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
|
||||||
CHECK(scalar(raw,
|
CHECK(scalar(raw,
|
||||||
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
|
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
|
||||||
"'sleep_medication%'") == 3);
|
"'sleep_medication%'") == 3);
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,7 @@ int main(void) {
|
||||||
|
|
||||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_schema_version(production, &version) == 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);
|
trainlog_database_close(production);
|
||||||
production = NULL;
|
production = NULL;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,7 +51,7 @@ int main(void) {
|
||||||
|
|
||||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_schema_version(production, &version) == 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);
|
trainlog_database_close(production);
|
||||||
production = NULL;
|
production = NULL;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ int main(void) {
|
||||||
|
|
||||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_schema_version(production, &version) == 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);
|
trainlog_database_close(production);
|
||||||
production = NULL;
|
production = NULL;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -52,7 +52,7 @@ int main(void) {
|
||||||
|
|
||||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_schema_version(production, &version) == 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);
|
trainlog_database_close(production);
|
||||||
production = NULL;
|
production = NULL;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ int main(void) {
|
||||||
|
|
||||||
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
|
||||||
CHECK(trainlog_database_schema_version(production, &version) == 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);
|
trainlog_database_close(production);
|
||||||
production = NULL;
|
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);
|
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strncmp(diagnostic,
|
CHECK(strncmp(diagnostic,
|
||||||
"migrate database to schema v35: SQLite rc=",
|
"migrate database to schema v36: SQLite rc=",
|
||||||
strlen("migrate database to schema v35: SQLite rc=")) == 0);
|
strlen("migrate database to schema v36: SQLite rc=")) == 0);
|
||||||
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
|
||||||
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
|
||||||
CHECK(strstr(diagnostic, "already exists") != NULL);
|
CHECK(strstr(diagnostic, "already exists") != NULL);
|
||||||
|
|
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
|
||||||
CHECK(fd >= 0);
|
CHECK(fd >= 0);
|
||||||
CHECK(close(fd) == 0);
|
CHECK(close(fd) == 0);
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
CHECK(sqlite3_exec(raw, "PRAGMA user_version=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(sqlite3_close(raw) == SQLITE_OK);
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||||
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "schema version 36 is newer") != NULL);
|
CHECK(strstr(diagnostic, "schema version 37 is newer") != NULL);
|
||||||
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
CHECK(strstr(diagnostic, "SQLite") == NULL);
|
||||||
CHECK(unlink(path) == 0);
|
CHECK(unlink(path) == 0);
|
||||||
return true;
|
return true;
|
||||||
|
|
|
||||||
|
|
@ -154,7 +154,7 @@ static bool test_v9_to_v10_failure_rolls_back(void) {
|
||||||
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
|
||||||
TRAINLOG_STATUS_DATABASE_ERROR);
|
TRAINLOG_STATUS_DATABASE_ERROR);
|
||||||
CHECK(database == NULL);
|
CHECK(database == NULL);
|
||||||
CHECK(strstr(diagnostic, "migrate database to schema v35") != NULL);
|
CHECK(strstr(diagnostic, "migrate database to schema v36") != NULL);
|
||||||
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
CHECK(strstr(diagnostic, "performed_sets_v9") != NULL);
|
||||||
|
|
||||||
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue