fix(sync): recover divergent sleep revision identities

This commit is contained in:
fy59 2026-09-24 14:31:53 +02:00
parent a652644e24
commit bd795495df
72 changed files with 1503 additions and 99 deletions

View file

@ -2,6 +2,21 @@
## Unreleased — 0.1.6
- Recovered a real divergent Sleep revision identity without choosing an
arbitrary winner. The unpublished desktop branch was re-identified with its
exact content, the immutable Android revision retained its published ID and
parent, and a fresh successor preserved the non-conflicting qualities,
events and medication occurrences. The original immutable generation now
has a `consumed` / `sqlite-commit-full` ACK and the repaired 24 September
session is available through Desktop and Web with its timeline, feedback,
cardio and Program provenance.
- Added Desktop schema v36 and Android schema v33 append-only guards for Sleep
revision payloads. Both importers now reject a known historical revision ID
whose parent or canonical content differs, even when that revision is below
the current tip. A dedicated transactional recovery utility and regressions
cover exact replay, parent/content collisions, medication quantities,
rollback, immutable generation retry, and long UUID/parent chains.
- Fixed Android finalization of Program-derived sessions containing a completed
target-only continuous occurrence. The repository now materializes the
performed duration from the persisted exercise timeline, including preserved

View file

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

View file

@ -1457,6 +1457,18 @@ class TrainlogRepository(
"SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
arrayOf(entryId),
).use { if (it.moveToFirst()) it.getString(0) to it.getInt(1) else null }
val knownOwner = db.rawQuery(
"SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(revisionId),
).use { if (it.moveToFirst()) it.getString(0) else null }
if (knownOwner != null &&
(knownOwner != entryId ||
!sleepDiaryRevisionMatches(db, item, wireVersion, entryId, revisionId, parent))
) {
return SleepDiaryImportResult.Rejected(
"sleep diary revision identity reused with different content",
)
}
if (local?.first == revisionId) {
val same = db.rawQuery(
"SELECT e.night_start_date,e.night_end_date,e.created_at,e.updated_at,e.deleted," +
@ -1611,6 +1623,78 @@ class TrainlogRepository(
}
}
private fun sleepDiaryRevisionMatches(
db: SQLiteDatabase,
item: JSONObject,
wireVersion: Int,
entryId: String,
revisionId: String,
parentRevisionId: String?,
): Boolean {
/* WHY: an incoming snapshot can name an ancestor rather than the
* current tip. CONTRACT: stale replay is idempotent only after its
* immutable revision-owned payload has been compared byte-semantically.
* INVARIANT: descendants never hide identity reuse in history. */
var same = db.rawQuery(
"SELECT entry_id,parent_revision_id,created_at,sleep_quality,wake_quality,day_form," +
"COALESCE(treatment_and_notes,'')," +
"(SELECT COUNT(*) FROM sleep_diary_events e WHERE e.revision_id=r.revision_id)," +
"(SELECT COUNT(*) FROM sleep_medication_intakes i WHERE i.revision_id=r.revision_id) " +
"FROM sleep_diary_revisions r WHERE revision_id=?",
arrayOf(revisionId),
).use { current ->
current.moveToFirst() && current.getString(0) == entryId &&
(if (current.isNull(1)) null else current.getString(1)) == parentRevisionId &&
current.getString(2) == item.getString("updated_at") &&
(if (current.isNull(3)) null else current.getString(3)) ==
(if (item.isNull("sleep_quality")) null else item.getString("sleep_quality")) &&
(if (current.isNull(4)) null else current.getString(4)) ==
(if (item.isNull("wake_quality")) null else item.getString("wake_quality")) &&
(if (current.isNull(5)) null else current.getString(5)) ==
(if (item.isNull("day_form")) null else item.getString("day_form")) &&
current.getString(6) == item.getString("treatment_and_notes") &&
current.getInt(7) == item.getJSONArray("events").length() &&
current.getInt(8) == item.getJSONArray("intakes").length()
}
val events = item.getJSONArray("events")
for (index in 0 until events.length()) {
val event = events.getJSONObject(index)
same = same && db.rawQuery(
"SELECT event_type,start_at,end_at FROM sleep_diary_events " +
"WHERE revision_id=? AND event_id=?",
arrayOf(revisionId, event.getString("event_id")),
).use { current ->
current.moveToFirst() && current.getString(0) == event.getString("type") &&
current.getString(1) == event.getString("start_at") &&
(if (current.isNull(2)) null else current.getString(2)) ==
(if (event.isNull("end_at")) null else event.getString("end_at"))
}
}
val intakes = item.getJSONArray("intakes")
for (index in 0 until intakes.length()) {
val intake = intakes.getJSONObject(index)
same = same && db.rawQuery(
"SELECT medication_id,medication_name,taken_at,dose_value,dose_unit,quantity," +
"COALESCE(note,''),created_at FROM sleep_medication_intakes " +
"WHERE revision_id=? AND intake_id=?",
arrayOf(revisionId, intake.getString("intake_id")),
).use { current ->
current.moveToFirst() &&
current.getString(0) == intake.getString("medication_id") &&
current.getString(1) == intake.getString("medication_name") &&
current.getString(2) == intake.getString("taken_at") &&
(if (current.isNull(3)) null else current.getDouble(3)) ==
(if (intake.isNull("dose_value")) null else intake.getDouble("dose_value")) &&
(if (current.isNull(4)) null else current.getString(4)) ==
(if (intake.isNull("dose_unit")) null else intake.getString("dose_unit")) &&
current.getInt(5) == (if (wireVersion == 1) 1 else intake.getInt("quantity")) &&
current.getString(6) == intake.getString("note") &&
current.getString(7) == intake.getString("created_at")
}
}
return same
}
private fun markSleepDiaryImported(
db: SQLiteDatabase,
entryId: String,
@ -12203,7 +12287,7 @@ private class TrainlogDatabaseHelper(
appContext,
databaseName,
null,
32,
33,
) {
override fun onConfigure(
db: SQLiteDatabase,
@ -12549,6 +12633,14 @@ private class TrainlogDatabaseHelper(
createSleepQuickPublicationTable(db)
version = 32
}
if (version < 33 && newVersion >= 33) {
/* WHY: a revision UUID is shared causal identity, so changing any
* owned payload row in place can make peers disagree permanently.
* CONTRACT: v33 makes Sleep revision payloads append-only; edits
* continue to append through repository APIs with a fresh UUID. */
createSleepImmutabilityTriggers(db)
version = 33
}
if (version != newVersion) {
error(
@ -12688,6 +12780,28 @@ private class TrainlogDatabaseHelper(
entry_id TEXT NOT NULL REFERENCES sleep_diary_entries(entry_id) ON DELETE RESTRICT,
revision_id TEXT NOT NULL REFERENCES sleep_diary_revisions(revision_id) ON DELETE RESTRICT,
PRIMARY KEY(generation_id,entry_id))""".trimIndent())
createSleepImmutabilityTriggers(db)
}
private fun createSleepImmutabilityTriggers(db: SQLiteDatabase) {
/* INVARIANT: only owner tips/publication state may advance in place.
* Revision rows and their event/intake children are append-only. */
listOf(
Triple("sleep_diary_revisions", "update", "sleep diary revision is immutable"),
Triple("sleep_diary_revisions", "delete", "sleep diary revision is immutable"),
Triple("sleep_diary_events", "update", "sleep diary event is immutable"),
Triple("sleep_diary_events", "delete", "sleep diary event is immutable"),
Triple("sleep_medication_intakes", "update", "sleep medication intake is immutable"),
Triple("sleep_medication_intakes", "delete", "sleep medication intake is immutable"),
Triple("sleep_medication_revisions", "update", "sleep medication revision is immutable"),
Triple("sleep_medication_revisions", "delete", "sleep medication revision is immutable"),
).forEach { (table, operation, message) ->
db.execSQL(
"CREATE TRIGGER IF NOT EXISTS ${table}_immutable_$operation " +
"BEFORE ${operation.uppercase()} ON $table BEGIN " +
"SELECT RAISE(ABORT,'$message'); END",
)
}
}
private fun createSleepQuickPublicationTable(db: SQLiteDatabase) {

View file

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

View file

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

View file

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

View file

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

View file

@ -35,7 +35,7 @@ class AndroidV26SleepMigrationTest {
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(32, it.version) }
).use { assertEquals(33, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -45,7 +45,7 @@ class AndroidV27HeartRateMigrationTest {
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
).use { assertEquals(32, it.version) }
).use { assertEquals(33, it.version) }
} finally {
repository.close()
context.deleteDatabase(name)

View file

@ -78,7 +78,7 @@ class AndroidV28SessionTimelineMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(32, db.version)
assertEquals(33, db.version)
db.rawQuery(
"SELECT session_id,started_at,ended_at FROM session_timeline_sessions",
null,

View file

@ -82,7 +82,7 @@ class AndroidV29CardioSessionMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(32, db.version)
assertEquals(33, db.version)
db.rawQuery(
"SELECT session_type,session_kind FROM sessions",
null,

View file

@ -41,7 +41,7 @@ class AndroidV30CardioCalibrationMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(32, db.version)
assertEquals(33, db.version)
db.rawQuery(
"SELECT COUNT(*) FROM exercises WHERE " +
"exercise_id='ex_ca1b4a7e-1c2d-4f00-8a11-000000000001' " +

View file

@ -42,7 +42,7 @@ class AndroidV31CardioGuidanceMigrationTest {
null,
SQLiteDatabase.OPEN_READONLY,
).use { db ->
assertEquals(32, db.version)
assertEquals(33, db.version)
db.rawQuery(
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN(" +
"'cardio_guidance_runs','cardio_guidance_phases'," +

View file

@ -59,7 +59,7 @@ class AndroidV32SleepQuantityMigrationTest {
migrated.listSleepDiary()
migrated.close()
SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY).use { db ->
assertEquals(32, scalar(db, "PRAGMA user_version"))
assertEquals(33, scalar(db, "PRAGMA user_version"))
assertEquals(
1,
scalar(

View file

@ -328,7 +328,7 @@ class BodyZonesTest {
SQLiteDatabase.openDatabase(context.getDatabasePath(name).path, null,
SQLiteDatabase.OPEN_READONLY).use { db ->
db.rawQuery("PRAGMA user_version", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(32, cursor.getInt(0))
assertTrue(cursor.moveToFirst()); assertEquals(33, cursor.getInt(0))
}
db.rawQuery("SELECT id FROM exercises", null).use { cursor ->
assertTrue(cursor.moveToFirst()); assertEquals(42L, cursor.getLong(0))

View file

@ -360,7 +360,7 @@ class SessionPreparationExchangeTest {
repository = TrainlogRepository(context, name)
assertEquals(1, repository.listPreparedSessions().size)
repository.close()
assertEquals("32", scalarText(name, "PRAGMA user_version"))
assertEquals("33", scalarText(name, "PRAGMA user_version"))
assertEquals(
"0",
scalarText(name, "SELECT COUNT(*) FROM session_preparation_withdrawals"),

View file

@ -1,6 +1,8 @@
package com.labfytools.trainlog.data
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteException
import androidx.test.core.app.ApplicationProvider
import com.labfytools.trainlog.model.SleepDiaryDraft
import com.labfytools.trainlog.model.SleepDiaryEvent
@ -11,6 +13,7 @@ import com.labfytools.trainlog.model.MedicationIntake
import com.labfytools.trainlog.model.SleepMedication
import java.util.UUID
import org.junit.Assert.assertEquals
import org.junit.Assert.assertThrows
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
@ -73,6 +76,141 @@ class SleepDiaryRepositoryTest {
} finally { repository.close(); context.deleteDatabase(name) }
}
@Test
fun persistedSleepRevisionPayloadIsAppendOnly() {
val name = "sleep-immutable-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
val saved = try {
repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-10-24",
nightEndDate = "2026-10-25",
createdAt = "2026-10-24T22:00:00+02:00",
updatedAt = "2026-10-25T07:00:00+01:00",
sleepQuality = SleepQuality.B,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
} finally {
repository.close()
}
val raw = SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READWRITE,
)
try {
assertEquals(33, raw.version)
assertThrows(SQLiteException::class.java) {
raw.execSQL(
"UPDATE sleep_diary_revisions SET sleep_quality='TB' WHERE revision_id=?",
arrayOf(saved.revisionId),
)
}
assertThrows(SQLiteException::class.java) {
raw.execSQL(
"DELETE FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(saved.revisionId),
)
}
assertEquals(
"B",
raw.rawQuery(
"SELECT sleep_quality FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(saved.revisionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
cursor.getString(0)
},
)
} finally {
raw.close()
context.deleteDatabase(name)
}
}
@Test
fun successiveSleepEditsKeepUniqueIdentitiesAndExactParentage() {
val name = "sleep-revision-chain-${UUID.randomUUID()}.db"
val repository = TrainlogRepository(context, name)
val identities = mutableListOf<String>()
val expectedParents = mutableMapOf<String, String?>()
val expectedNotes = mutableMapOf<String, String>()
try {
val created = repository.saveSleepDiary(
SleepDiaryDraft(
nightStartDate = "2026-10-24",
nightEndDate = "2026-10-25",
createdAt = "2026-10-24T22:00:00+02:00",
updatedAt = "2026-10-25T07:00:00+01:00",
sleepQuality = null,
wakeQuality = null,
dayForm = null,
treatmentAndNotes = "revision-root",
events = emptyList(),
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
identities += created.revisionId
expectedParents[created.revisionId] = null
expectedNotes[created.revisionId] = "revision-root"
repeat(64) { index ->
val current = repository.listSleepDiary().single()
val note = "revision-$index"
val hour = 8 + index / 60
val minute = index % 60
val saved = repository.saveSleepDiary(
SleepDiaryDraft(
entryId = current.entryId,
expectedRevision = current.revisionId,
nightStartDate = current.nightStartDate,
nightEndDate = current.nightEndDate,
createdAt = current.createdAt,
updatedAt = "2026-10-25T%02d:%02d:00+01:00".format(hour, minute),
sleepQuality = current.sleepQuality,
wakeQuality = current.wakeQuality,
dayForm = current.dayForm,
treatmentAndNotes = note,
events = current.events,
intakes = current.intakes,
),
) as TrainlogRepository.SaveSleepDiaryResult.Saved
expectedParents[saved.revisionId] = current.revisionId
expectedNotes[saved.revisionId] = note
identities += saved.revisionId
}
} finally {
repository.close()
}
assertEquals(65, identities.toSet().size)
val raw = SQLiteDatabase.openDatabase(
context.getDatabasePath(name).path,
null,
SQLiteDatabase.OPEN_READONLY,
)
try {
identities.forEach { revisionId ->
raw.rawQuery(
"SELECT parent_revision_id,treatment_and_notes " +
"FROM sleep_diary_revisions WHERE revision_id=?",
arrayOf(revisionId),
).use { cursor ->
assertTrue(cursor.moveToFirst())
val parent = if (cursor.isNull(0)) null else cursor.getString(0)
assertEquals(expectedParents[revisionId], parent)
assertEquals(expectedNotes[revisionId], cursor.getString(1))
}
}
} finally {
raw.close()
context.deleteDatabase(name)
}
}
@Test
fun ancestryAllowsMultiHopRemoteAdvance() {
val sourceName = "sleep-stale-source-${UUID.randomUUID()}.db"
@ -188,6 +326,15 @@ class SleepDiaryRepositoryTest {
assertEquals(SleepQuality.MOY, destination.listSleepDiary().single().wakeQuality)
assertEquals(SleepQuality.TB, destination.listSleepDiary().single().dayForm)
assertEquals("Medication revised", destination.listSleepMedications().single().name)
val reusedAncestor = org.json.JSONObject(firstSnapshot)
reusedAncestor.getJSONArray("entries").getJSONObject(0).put("sleep_quality", "M")
assertEquals(
TrainlogRepository.SleepDiaryImportResult.Rejected(
"sleep diary revision identity reused with different content",
),
destination.applySleepDiaryV1Json(reusedAncestor.toString()),
)
} finally {
source.close()
destination.close()

View file

@ -878,7 +878,7 @@ class SyncGenerationServiceTest {
.use { db ->
db.rawQuery("PRAGMA user_version", null).use {
assertTrue(it.moveToFirst())
assertEquals(32, it.getInt(0))
assertEquals(33, it.getInt(0))
}
db.rawQuery("SELECT COUNT(*) FROM exercises", null).use {
assertTrue(it.moveToFirst())

View file

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

View file

@ -25,6 +25,14 @@ revision. Quick publication makes a current fact eligible for generation; it
does not validate a day and does not change the existing validation/ACK
lifecycle.
Android schema v33 enforces that same immutable identity boundary inside
SQLite. Eight append-only triggers reject update or deletion of Sleep diary
revisions, events, medication intakes, and medication revisions. Repository
imports compare a known ancestor's complete canonical payload before the stale
ancestor shortcut, so a reused ID with a different parent or content cannot be
hidden below a later local tip. All user edits continue to create a fresh
UUIDv4 revision and preserve the previous payload and causal parent.
The quick Sleep capture derives one night identity from the existing
18:00-to-18:00 date rule. A medication action can create that pending entry
before `BED_TIME`, later medication actions reuse it, and Couché appends

View file

@ -97,7 +97,7 @@ The rollout packager now keeps the legacy request daemon and one-shot helper in
the same relocatable, hashed bundle as the Web generation coordinator, avoiding
mixed-checkout user-service execution during the controlled rollout.
Snapshot date: **2026-09-23**.
Snapshot date: **2026-09-24**.
`TRAINLOG_CODE_READABILITY_V1` normalizes the recent generation-MTP,
generation/ACK, Android-backup, Web Dashboard serialization, tests, doubles and
@ -128,8 +128,8 @@ parallel implementation of its rules.
| Boundary | Current state |
|---|---|
| Frozen project exchange | `TRAINLOG_FORMAT_V1=PASS/FROZEN` |
| Desktop SQLite | schema v35; v35 additively gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 |
| Android SQLite | schema v32; v32 additively gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip |
| Desktop SQLite | schema v36; v36 makes Sleep revision payload tables append-only; v35 gives every Sleep medication intake a structured `quantity` (1..99), defaulting historical rows to 1 |
| Android SQLite | schema v33; v33 makes Sleep revision payload tables append-only; v32 gives every Sleep medication intake a structured `quantity` (1..99), defaults historical rows to 1, and records a distinct quick-publication tip |
| Mobile snapshot | V3 active; V1/V2 readable legacy inputs; explicit V4 codec staged, not selected by transport |
| Desktop terminal backend | Notcurses only |
| Trainlog product version | `0.1.6` development, synchronized across Android and desktop; latest stable release: `v0.1.5` |
@ -178,6 +178,7 @@ parallel implementation of its rules.
| Current operational cursor | 0.1.6 Sleep graph overlays, shared agenda estimate and denser responsive agenda axis validated and privately served from Web code commit `a60e1aa`; immutable stable v0.1.5 remains unchanged. |
| Sleep awake/selection/PDF refinement | `TRAINLOG_SLEEP_AWAKE_SELECTION_PDF_V1=PASS`: one temporal projection drives agenda and selected HR detail; a separate inclusive night-start date range drives preview/export from the loaded period. The agenda axis and rows share 24 exact hour columns; estimated sleep duration is consistent and remains non-persistent. |
| Sleep Réveil interval policy | Android Réveil creates a revisioned 30-minute `long_awake` window; repeated taps extend it and Levé clamps it. The real 2026-09-23 night is causally repaired on Android/desktop and renders two orange intervals in agenda, HR and PDF; Android 307 passed plus five optional skips, and Web 208/208 passed. |
| Sleep revision collision recovery | `TRAINLOG_SLEEP_REVISION_COLLISION_RECOVERY_V1=PASS`: one unpublished divergent desktop tip was re-identified without payload loss, the immutable Android identity was restored exactly, and a fresh successor retained the non-conflicting union. The original generation was consumed with a full SQLite ACK; the real 24/09 session, Sleep facts, cardio samples, timeline, feedback, and Program provenance are visible on Desktop/Web. Schema v36/v33 and importer payload checks prevent recurrence. |
| Complete synchronization gap contract | Frozen dependency contract; operational USB/Drive slices required by v0.1.2 are delivered |
| Isolated synchronization test environment | `TRAINLOG_SYNC_TEST_ENV_V1=PASS/FROZEN` |
| Synchronization characterization | `TRAINLOG_SYNC_CHARACTERIZATION_V1=PASS/FROZEN` |

View file

@ -1,5 +1,21 @@
# Desktop database
## Schema v36: append-only Sleep revision payloads
Schema v36 adds database-level `BEFORE UPDATE` and `BEFORE DELETE` guards for
Sleep diary revisions, their events and medication intakes, and medication
catalog revisions. A stable revision identity therefore cannot be detached
from its canonical parent or payload even through a direct SQLite write.
Normal correction remains append-only: it inserts a fresh UUIDv4 revision and
advances the entry or medication current pointer transactionally. The migration
is additive and does not rewrite any existing Sleep identity, payload, causal
edge, generation membership, publication state, or acknowledgement.
Importers additionally compare the immutable payload of every already-known
Sleep revision before treating an incoming ancestor as stale/idempotent. Thus
an exact historical replay remains accepted, while reuse of the same identity
with a different parent or content is an explicit collision.
## Schema v35: Sleep Diary quantity V2
Schema v35 additively adds `sleep_medication_intakes.quantity`, a required

View file

@ -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.

View file

@ -31,7 +31,7 @@ estimates.
## Current baseline
Stable v0.1.5 is the release baseline for the v0.1.6 development cycle.
Desktop schema v35, Android schema v32, Notcurses, direct `Documents/Trainlog`
Desktop schema v36, Android schema v33, Notcurses, direct `Documents/Trainlog`
storage, mobile export V3, Training Knowledge V1, Body Zones V1, Training
Feedback V1/V2, STATS V1, and Session Generator V1 are implemented. Session
Generator V1 is hidden pending V2. AI session-draft exchange retains its

View file

@ -1264,3 +1264,22 @@ packaged user-session deployment. Before every invocation the bounded combined
stdout/stderr result is truncated. Resolution, launch, argparse, exception,
signal, and non-zero-exit diagnostics therefore describe only the current run
and are retained in the synchronization error/history when the run fails.
## Sleep revision identity collision recovery
A Sleep `revision_id` names one immutable parent plus canonical payload. The
generation consumer accepts the same identity only when both are equal; it
rejects a different parent or payload even if the incoming revision is an
ancestor of the local current tip. Ordinary Sleep edits always use a new
revision identity.
The bounded recovery tool is intentionally narrower than general conflict
resolution. It may re-identify a divergent local tip only after proving that
the tip has no descendant, validation/ACK state, or immutable generation
reference. It retains that branch unchanged, restores the manifest-owned peer
revision under its published identity, and creates a fresh single-parent
successor containing only non-conflicting scalar additions and exact-ID unions
of events and medication occurrences. Conflicting non-null scalars, notes, or
same occurrence IDs with different payloads require a human decision. The
generation manifest and artifacts are never rewritten; only an exact rejected
consumer/producer ledger may be rearmed for normal replay.

View file

@ -1,5 +1,26 @@
# Tests and validation
## Sleep revision collision recovery
Collision regressions distinguish exact replay from same-ID parent or payload
reuse, validate known historical ancestors before stale-snapshot handling, and
exercise safe re-identification of an unpublished branch. They preserve
quantity one and two, distinct same-time medication occurrences, event and
intake unions, rollback, immutable-generation references, exact ledger rearm,
and idempotent replay. SQLite migration tests prove that Desktop v36 and
Android v33 reject direct update/delete of all revision-owned Sleep payload
tables. Android also creates a 65-revision edit chain and verifies unique UUIDs,
exact parentage, and unchanged historical payloads.
The retained real-data simulation replays the original immutable full
generation against isolated copies and verifies one session ending at 11:16,
7 exercises, 15 sets, 5 feedbacks, 7 exercise intervals, 4,919 BPM samples,
4,919 RR intervals, Program provenance, `consumed` / `sqlite-commit-full`, a
byte-identical ACK replay, and clean integrity/foreign-key checks. A later
normal generation was consumed and acknowledged, proving continued pipeline
use. Sensitive medication names and dose values remain outside repository
evidence.
## Web UI refinement 0.1.6
Web regressions cover calendar-week activity grouping, global rather than
@ -50,9 +71,10 @@ Other UI regressions cover persisted
Dashboard sizing and reset behavior, English preference persistence across a
remount, centralized Dashboard English labels, non-stretched chart aspect
ratios, and distinct missing/partial exercise-timing messages.
The closeout run passes Web **32 files / 205 tests**, TypeScript typecheck and
the production build; native Meson **111/111**; Android `testDebugUnitTest` and
`assembleDebug`; JSON/import validators; changed-C formatting; and diff checks.
The current closeout run passes Web **32 files / 209 tests**, TypeScript
typecheck and the production build; native Meson **113/113**; Android **315
tests** with five optional skips plus `assembleDebug`; JSON/import validators;
changed-C formatting; and diff checks.
The restarted private service serves the rebuilt executable byte-for-byte from
the development checkout and reports healthy Trainlog `0.1.6`. A non-mutating
Firefox smoke on the real 2026-09-23 night confirms the agenda and detail both

View file

@ -58,6 +58,15 @@ class SleepExchangeTest(unittest.TestCase):
exported = exchange.build(self.db)
self.assertTrue(exported["entries"][0]["deleted"])
def test_known_revision_with_different_parent_is_an_identity_collision(self):
revision = "slr_20000000-0000-4000-8000-000000000002"
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(revision))))
conflicting = self.document(
entry(revision, "slr_30000000-0000-4000-8000-000000000003")
)
with self.assertRaisesRegex(ValueError, "identity reused"):
exchange.apply(self.db, conflicting)
def test_v1_hydrates_quantity_one_and_v2_preserves_quantity(self):
first = "slr_20000000-0000-4000-8000-000000000002"
legacy = self.document(entry(first))

View 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()

View file

@ -77,7 +77,7 @@ class DeploymentToolsTest(unittest.TestCase):
self.assertEqual(result.returncode, 0, result.stderr)
inventory = json.loads((output / "candidate-inventory.json").read_text())
self.assertEqual(inventory["product_version"], "0.1.6")
self.assertEqual(inventory["desktop_schema"], 35)
self.assertEqual(inventory["desktop_schema"], 36)
self.assertEqual(inventory["android_schema"], 32)
self.assertIn("session-preparations-v2", inventory["protocols"])
self.assertIn("programs-v1", inventory["protocols"])

View file

@ -271,8 +271,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35):
fail("desktop schema v33-v35 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (33, 34, 35, 36):
fail("desktop schema v33-v36 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

@ -395,8 +395,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35):
fail("desktop schema v34-v35 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (34, 35, 36):
fail("desktop schema v34-v36 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

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

View file

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

View file

@ -12,7 +12,7 @@ from pathlib import Path
from trainlog_sqlite import configure_connection
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35)
SUPPORTED_SCHEMA_VERSIONS = (15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
MEASUREMENT_FIELDS = (
"body_weight_kg", "neck_cm", "shoulders_cm", "chest_cm", "waist_cm",
"hips_cm", "left_arm_cm", "right_arm_cm", "left_forearm_cm",
@ -125,7 +125,7 @@ def build_export(connection, generated_at):
"""Build the raw export using only explicitly ordered SELECT statements."""
schema_version = connection.execute("PRAGMA user_version").fetchone()[0]
if schema_version not in SUPPORTED_SCHEMA_VERSIONS:
raise ValueError("schema desktop v15-v35 requis")
raise ValueError("schema desktop v15-v36 requis")
root = {
"format": "TRAINLOG_AI_EXPORT",

View file

@ -20,9 +20,9 @@ ID_PREFIX = "aid_"
def ensure_publication_state(connection):
"""Install the additive v18 cursor for direct syncd/tool invocations."""
owned_transaction = not connection.in_transaction
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35)
supported_versions = (18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v18-v35 requis")
raise ValueError("schema desktop v18-v36 requis")
columns = {row[1] for row in connection.execute(
"PRAGMA table_info(ai_session_drafts)")}
if "published_at" not in columns:

View file

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

View file

@ -24,9 +24,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(8, 36))
supported_versions = tuple(range(8, 37))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v8 à v35 requis")
raise ValueError("schema desktop v8 à v36 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal equipment protection requires the staged artifact")
equipment = [dict(row) for row in connection.execute(

View file

@ -17,8 +17,8 @@ def main():
con = connect_database(args.database)
try:
if con.execute("PRAGMA user_version").fetchone()[0] not in (
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
raise ValueError("schema desktop v12-v35 requis")
12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise ValueError("schema desktop v12-v36 requis")
rows = con.execute(
"SELECT source_exercise_id,canonical_exercise_id FROM exercise_aliases "
"ORDER BY source_exercise_id COLLATE BINARY"

View file

@ -41,9 +41,9 @@ def main(complete_causal_envelope=False):
connection = connect_database(args.database)
connection.row_factory = sqlite3.Row
try:
supported_versions = tuple(range(11, 36))
supported_versions = tuple(range(11, 37))
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v11-v35 requis")
raise ValueError("schema desktop v11-v36 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone():
raise ValueError("causal BODY ZONES protection requires the staged artifact")
exercises = []

View file

@ -25,7 +25,7 @@ def main():
con.row_factory = sqlite3.Row
try:
version = con.execute("PRAGMA user_version").fetchone()[0]
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
if version not in (17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise SystemExit(f"EXERCISE_PROFILE_STATE_EXPORT=FAIL schema={version}")
rows = con.execute("""
SELECT e.exercise_id,e.recording_mode,e.tracking_mode,e.data_fields,

View file

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

View file

@ -83,12 +83,12 @@ def main(complete_causal_envelope=False):
con.row_factory = sqlite3.Row
try:
schema_version = con.execute("PRAGMA user_version").fetchone()[0]
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
if args.version == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise ValueError("mobile V4 exige le schéma desktop v19 ou v20")
supported_versions = range(11, 36)
supported_versions = range(11, 37)
legacy_v2_schema = args.version == 2 and schema_version == 10
if schema_version not in supported_versions and not legacy_v2_schema:
raise ValueError("schema desktop v11-v35 requis (v10 accepté pour export V2 explicite)")
raise ValueError("schema desktop v11-v36 requis (v10 accepté pour export V2 explicite)")
if not complete_causal_envelope and schema_version >= 20 and con.execute(
"SELECT 1 FROM sync_causal_state WHERE deleted=1 LIMIT 1").fetchone():
raise ValueError("causal protection refuses a mobile snapshot that omits tombstones")

View file

@ -16,7 +16,7 @@ def nullable(row, index):
def export_programs(database: Path) -> dict:
with closing(connect_database(database)) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
if db.execute("PRAGMA user_version").fetchone()[0] not in (26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise ValueError("desktop schema v26 through v32 required")
programs = []
for row in db.execute(

View file

@ -17,7 +17,7 @@ MAX_WITHDRAWALS = 128
def build_export(connection: sqlite3.Connection) -> dict:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35)
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("desktop schema v24, v25, v26 through v32 required")
deliveries = []

View file

@ -10,9 +10,9 @@ def main(complete_causal_envelope=False):
parser=argparse.ArgumentParser(); parser.add_argument("output",type=Path); parser.add_argument("--database",type=Path,required=True); args=parser.parse_args()
db=connect_database(args.database)
try:
supported_versions = range(15, 36)
supported_versions = range(15, 37)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ValueError("schema desktop v15-v35 requis")
raise ValueError("schema desktop v15-v36 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0] >= 20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone(): raise ValueError("causal feedback protection requires the staged artifact")
roots=db.execute("SELECT f.feedback_id,s.session_id,se.entry_id,e.exercise_id,f.observed_at FROM exercise_feedback f JOIN session_exercises se ON se.id=f.session_exercise_row_id JOIN sessions s ON s.id=se.session_row_id JOIN exercises e ON e.id=se.exercise_row_id").fetchall()
followups=db.execute("SELECT f.followup_id,s.session_id,f.observed_at FROM session_followups f JOIN sessions s ON s.id=f.session_row_id").fetchall()

View file

@ -236,8 +236,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35):
fail("desktop schema v30-v35 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
fail("desktop schema v30-v36 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

@ -193,9 +193,9 @@ def import_payload(connection, payload, imported_at=None):
INVARIANT: validation and every child insert share one IMMEDIATE transaction;
any failure leaves no draft, child, or import-identity row behind.
"""
supported_versions = range(18, 36)
supported_versions = range(18, 37)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v18-v35 requis")
raise ImportFailure("schema desktop v18-v36 requis")
draft, entries = validate(payload, connection)
digest = canonical_digest(payload)
imported_at = imported_at or dt.datetime.now(dt.timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")

View file

@ -206,9 +206,9 @@ def main():
payload = json.loads(args.artifact.read_text(encoding="utf-8"))
connection = connect_database(args.database)
try:
supported_versions = range(8, 36)
supported_versions = range(8, 37)
if connection.execute("PRAGMA user_version;").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v35 requis")
fail("schema desktop v8 à v36 requis")
proof_path = args.mobile_export
if proof_path is None:
candidate = args.artifact.with_name("trainlog-mobile-export-v2.json")

View file

@ -53,9 +53,9 @@ def validate(payload, reserved):
def apply_definitions(connection, definitions, complete_causal_envelope=False):
"""Apply validated definitions without owning the surrounding transaction."""
supported_versions = range(8, 36)
supported_versions = range(8, 37)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v8 à v35 requis")
fail("schema desktop v8 à v36 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0] >= 20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='custom_equipment' AND deleted=1 LIMIT 1").fetchone():
fail("causal equipment protection requires the staged artifact")
imported = skipped = 0

View file

@ -56,9 +56,9 @@ def load(path):
def apply_aliases(con, aliases):
"""Apply parsed aliases inside the caller-owned transaction."""
supported_versions = range(12, 36)
supported_versions = range(12, 37)
if con.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v12-v35 requis")
fail("schema desktop v12-v36 requis")
for source, canonical in aliases:
target = con.execute("SELECT id,tracking_mode,recording_mode,data_fields FROM exercises WHERE exercise_id=?", (canonical,)).fetchone()
if target is None: fail(f"cible canonique absente: {canonical}")

View file

@ -151,9 +151,9 @@ def replace(connection, row_id, primary, secondary):
def apply_body_zones(connection, parsed, mobile_proof, complete_causal_envelope=False):
"""Apply validated zone rows without owning transaction boundaries."""
supported_versions = range(11, 36)
supported_versions = range(11, 37)
if connection.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise ImportFailure("schema desktop v11-v35 requis")
raise ImportFailure("schema desktop v11-v36 requis")
if not complete_causal_envelope and connection.execute("PRAGMA user_version").fetchone()[0]>=20 and connection.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='body_zone_relation' AND deleted=1 LIMIT 1").fetchone(): raise ImportFailure("causal BODY ZONES protection requires the staged artifact")
connection.execute("PRAGMA foreign_keys=ON");grouped={}
# CONTRACT: resolve complete alias groups before the first write; direct

View file

@ -50,8 +50,8 @@ def apply_profile_state(con, root, allow_pending=False):
if not isinstance(root,dict) or set(root)!={"format","version","generated_at","exercises"} or root["format"]!="trainlog-exercise-profile-state" or type(root["version"]) is not int or root["version"]!=1 or not isinstance(root["exercises"],list): fail("enveloppe profile-state invalide")
parse_timestamp(root["generated_at"], "generated_at");con.row_factory=sqlite3.Row
if con.execute("PRAGMA user_version").fetchone()[0] not in (
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
fail("schema desktop v17-v35 requis")
17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
fail("schema desktop v17-v36 requis")
applied=equal=ancestor=pending=0;seen=set()
for item in root["exercises"]:
validate(item);eid=item["exercise_id"]

View file

@ -847,9 +847,9 @@ def require_supported_schema(connection):
# CONTRACT: v9 owns explicit max_results; earlier supported schemas remain
# readable for legacy artifacts and are never made to fake that table.
if version not in range(5, 36):
if version not in range(5, 37):
raise ImportFailure(
f"base desktop schema v5 à v35 attendue, version trouvée: {version}"
f"base desktop schema v5 à v36 attendue, version trouvée: {version}"
)
@ -2002,8 +2002,8 @@ def apply_payload(connection, payload, trace_exercises=False, complete_causal_en
require_supported_schema(connection)
schema_version = connection.execute("PRAGMA user_version;").fetchone()[0]
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v35")
if payload["version"] == 4 and schema_version not in (19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
raise ImportFailure("mobile V4 exige le schéma desktop v19 à v36")
if schema_version >= 20:
protected = {(row[0], row[1]) for row in connection.execute(
"SELECT target_kind,target_id FROM sync_causal_state WHERE deleted=1")}

View file

@ -52,9 +52,9 @@ def merge_revisions(db,table,parent_column,parent_id,revisions):
def apply_feedback(db,root,complete_causal_envelope=False):
roots_added=roots_skipped=revisions_added=revisions_skipped=0
db.execute("PRAGMA foreign_keys=ON")
supported_versions = range(15, 36)
supported_versions = range(15, 37)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
fail("schema desktop v15-v35 requis")
fail("schema desktop v15-v36 requis")
if not complete_causal_envelope and db.execute("PRAGMA user_version").fetchone()[0]>=20 and db.execute("SELECT 1 FROM sync_causal_state WHERE target_kind='feedback' AND deleted=1 LIMIT 1").fetchone():fail("causal feedback protection requires the staged artifact")
seen=set()
for index,item in enumerate(root["exercise_feedback"]):

View file

@ -119,7 +119,7 @@ def main() -> int:
["git", "rev-parse", "HEAD"], cwd=ROOT, text=True
).strip(),
"product_version": "0.1.6",
"desktop_schema": 35,
"desktop_schema": 36,
"android_schema": 32,
"protocols": [
"mobile-export-v3",

View file

@ -87,7 +87,7 @@ def load(path: Path) -> dict:
def apply_executions(db: sqlite3.Connection, root: dict) -> tuple[int, int, int]:
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35):
if db.execute("PRAGMA user_version").fetchone()[0] not in (27, 28, 29, 30, 31, 32, 33, 34, 35, 36):
fail("desktop schema v27 through v32 required")
added = 0
advanced = 0

View 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()

View file

@ -184,8 +184,8 @@ def main() -> None:
args = parser.parse_args()
root = load(args.input)
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35):
fail("desktop schema v31-v35 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (31, 32, 33, 34, 35, 36):
fail("desktop schema v31-v36 required")
db.execute("BEGIN IMMEDIATE")
apply(db, root)
db.commit()

View file

@ -260,6 +260,75 @@ def _persisted_entry(db: sqlite3.Connection, row: tuple) -> dict:
}
def persisted_revision_payload(db: sqlite3.Connection, entry_id: str,
revision_id: str) -> dict | None:
"""Return the immutable content owned by one revision identity.
WHY: a replay can name an older revision while the mutable entry tip has
advanced. Comparing only the current tip would let a reused historical
identity hide behind its descendant. CONTRACT: parent, scalar observations,
events, and medication occurrences are immutable for a revision_id.
"""
revision = db.execute(
"SELECT parent_revision_id,created_at,sleep_quality,wake_quality,day_form,"
"COALESCE(treatment_and_notes,'') FROM sleep_diary_revisions "
"WHERE revision_id=? AND entry_id=?",
(revision_id, entry_id),
).fetchone()
if revision is None:
return None
events = [
{"event_id": value[0], "type": value[1], "start_at": value[2], "end_at": value[3]}
for value in db.execute(
"SELECT event_id,event_type,start_at,end_at FROM sleep_diary_events "
"WHERE revision_id=? ORDER BY event_id", (revision_id,)
)
]
intakes = [
{"intake_id": value[0], "medication_id": value[1], "medication_name": value[2],
"taken_at": value[3], "dose_value": value[4], "dose_unit": value[5],
"quantity": value[6], "note": value[7], "created_at": value[8]}
for value in db.execute(
"SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,"
"quantity,COALESCE(note,''),created_at FROM sleep_medication_intakes "
"WHERE revision_id=? ORDER BY intake_id", (revision_id,)
)
]
return {
"entry_id": entry_id,
"revision_id": revision_id,
"parent_revision_id": revision[0],
"created_at": revision[1],
"sleep_quality": revision[2],
"wake_quality": revision[3],
"day_form": revision[4],
"treatment_and_notes": revision[5],
"events": events,
"intakes": intakes,
}
def incoming_revision_payload(item: dict, wire_version: int) -> dict:
"""Normalize V1 quantity before immutable-identity comparison."""
intakes = []
for intake in item["intakes"]:
normalized = dict(intake)
normalized.setdefault("quantity", 1 if wire_version == 1 else None)
intakes.append(normalized)
return {
"entry_id": item["entry_id"],
"revision_id": item["revision_id"],
"parent_revision_id": item["parent_revision_id"],
"created_at": item["updated_at"],
"sleep_quality": item["sleep_quality"],
"wake_quality": item["wake_quality"],
"day_form": item["day_form"],
"treatment_and_notes": item["treatment_and_notes"],
"events": sorted(item["events"], key=lambda value: value["event_id"]),
"intakes": sorted(intakes, key=lambda value: value["intake_id"]),
}
def build(db: sqlite3.Connection) -> dict:
# CONTRACT: local durability does not publish a draft. A generation may
# capture only the current tip explicitly validated by the user.
@ -358,6 +427,17 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
"SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?",
(item["entry_id"],),
).fetchone()
known_owner = db.execute(
"SELECT entry_id FROM sleep_diary_revisions WHERE revision_id=?",
(item["revision_id"],),
).fetchone()
if known_owner is not None:
persisted_revision = persisted_revision_payload(
db, known_owner[0], item["revision_id"]
)
incoming_revision = incoming_revision_payload(item, wire_version)
if known_owner[0] != item["entry_id"] or persisted_revision != incoming_revision:
fail("sleep diary revision identity reused with different content")
if local is not None and local[0] == item["revision_id"]:
# INVARIANT: an identical durable revision may still be a local
# draft because publication state is intentionally separate. The
@ -462,8 +542,8 @@ def main() -> None:
import_command.add_argument("--database", required=True, type=Path)
args = parser.parse_args()
with connect_database(args.database) as db:
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35):
fail("desktop schema v30-v35 required")
if db.execute("PRAGMA user_version").fetchone()[0] not in (30, 31, 32, 33, 34, 35, 36):
fail("desktop schema v30-v36 required")
if args.command == "export":
args.output.write_text(json.dumps(build(db), ensure_ascii=False, sort_keys=True,
separators=(",", ":")), encoding="utf-8")

View file

@ -219,9 +219,9 @@ def validate_manifest_bytes(raw: bytes, expected_consumer: str | None = None):
def require_schema(db: sqlite3.Connection) -> None:
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35)
supported_versions = (24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36)
if db.execute("PRAGMA user_version").fetchone()[0] not in supported_versions:
raise GenerationError("desktop schema v24 through v35 required")
raise GenerationError("desktop schema v24 through v36 required")
def peer_identity(db: sqlite3.Connection, kind: str) -> str:

View file

@ -17,7 +17,7 @@
#include "trainlog/model.h"
#include "trainlog/status.h"
#define TRAINLOG_DATABASE_SCHEMA_VERSION 35
#define TRAINLOG_DATABASE_SCHEMA_VERSION 36
typedef struct TrainlogDatabase TrainlogDatabase;

View file

@ -889,6 +889,14 @@ test_schema_v34_migration = executable(
)
test('schema_v34_migration', test_schema_v34_migration)
test_schema_v35_migration = executable(
'test_schema_v35_migration',
'tests/test_schema_v35_migration.c',
dependencies: trainlog_core_dep,
c_args: strict_c_args,
)
test('schema_v35_migration', test_schema_v35_migration)
test_schema_v7_migration = executable(
'test_schema_v7_migration',
'tests/test_schema_v7_migration.c',
@ -968,6 +976,11 @@ test(
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sleep_diary_exchange.py'],
)
test(
'sleep_revision_collision_recovery',
find_program('python3'),
args: [meson.project_source_root() / 'tests/test_sleep_revision_collision_recovery.py'],
)
test(
'heart_rate_exchange',
find_program('python3'),

View file

@ -1549,6 +1549,35 @@ static TrainlogStatus ensure_v35_medication_quantity(TrainlogDatabase *database)
return TRAINLOG_STATUS_OK;
}
/* WHY: revision identity is the causal content address used by synchronized
* peers; changing a persisted row in place can make two peers assign different
* facts to the same UUID. CONTRACT: v36 makes every Sleep revision payload
* append-only. Tips and publication markers remain mutable through their owning
* tables. INVARIANT: correction always appends a fresh revision identity. */
static const char *const MIGRATE_V35_TO_V36_SQL =
"BEGIN IMMEDIATE;"
"CREATE TRIGGER IF NOT EXISTS sleep_diary_revisions_immutable_update BEFORE UPDATE ON "
"sleep_diary_revisions BEGIN SELECT RAISE(ABORT,'sleep diary revision is immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_diary_revisions_immutable_delete BEFORE DELETE ON "
"sleep_diary_revisions BEGIN SELECT RAISE(ABORT,'sleep diary revision is immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_diary_events_immutable_update BEFORE UPDATE ON "
"sleep_diary_events "
"BEGIN SELECT RAISE(ABORT,'sleep diary event is immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_diary_events_immutable_delete BEFORE DELETE ON "
"sleep_diary_events "
"BEGIN SELECT RAISE(ABORT,'sleep diary event is immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_medication_intakes_immutable_update BEFORE UPDATE ON "
"sleep_medication_intakes BEGIN SELECT RAISE(ABORT,'sleep medication intake is immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_medication_intakes_immutable_delete BEFORE DELETE ON "
"sleep_medication_intakes BEGIN SELECT RAISE(ABORT,'sleep medication intake is immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_medication_revisions_immutable_update BEFORE UPDATE ON "
"sleep_medication_revisions BEGIN SELECT RAISE(ABORT,'sleep medication revision is "
"immutable');END;"
"CREATE TRIGGER IF NOT EXISTS sleep_medication_revisions_immutable_delete BEFORE DELETE ON "
"sleep_medication_revisions BEGIN SELECT RAISE(ABORT,'sleep medication revision is "
"immutable');END;"
"PRAGMA user_version=36;COMMIT;";
/* WHY: schema v29 was already opened on the private 0.1.4 review installation
* before medication capture joined the same unreleased migration. CONTRACT:
* this additive repair is identical to the tail of v28 -> v29 and runs only
@ -2383,7 +2412,8 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
version == 16 || version == 17 || version == 18 || version == 19 || version == 20 ||
version == 21 || version == 22 || version == 23 || version == 24 || version == 25 ||
version == 26 || version == 27 || version == 28 || version == 29 || version == 30 ||
version == 31 || version == 32 || version == 33 || version == 34 || version == 35) {
version == 31 || version == 32 || version == 33 || version == 34 || version == 35 ||
version == 36) {
status = TRAINLOG_STATUS_OK;
} else {
if (version == 1) {
@ -2552,6 +2582,9 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status == TRAINLOG_STATUS_OK && version < 35) {
status = ensure_v35_medication_quantity(database);
}
if (status == TRAINLOG_STATUS_OK && version < 36) {
status = execute_sql(database, MIGRATE_V35_TO_V36_SQL);
}
if (status == TRAINLOG_STATUS_OK) {
status = ensure_v18_ai_draft_publication_state(database);
}
@ -2571,7 +2604,7 @@ static TrainlogStatus initialize_or_validate_schema(TrainlogDatabase *database,
if (status != TRAINLOG_STATUS_OK) {
set_open_diagnostic(output_diagnostic,
output_diagnostic_capacity,
version == 0 ? "create schema v35" : "migrate database to schema v35",
version == 0 ? "create schema v36" : "migrate database to schema v36",
database->connection,
SQLITE_ERROR);
(void)sqlite3_exec(database->connection, "ROLLBACK;", NULL, NULL, NULL);

View file

@ -53,7 +53,7 @@ int main(void) {
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 35);
CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name IN "
"('sleep_diary_entries','sleep_diary_revisions','sleep_diary_events',"
@ -97,7 +97,7 @@ int main(void) {
trainlog_database_close(production);
production = NULL;
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(scalar(raw, "PRAGMA user_version") == 35);
CHECK(scalar(raw, "PRAGMA user_version") == TRAINLOG_DATABASE_SCHEMA_VERSION);
CHECK(scalar(raw,
"SELECT COUNT(*) FROM sqlite_master WHERE type='table' AND name LIKE "
"'sleep_medication%'") == 3);

View file

@ -54,7 +54,7 @@ int main(void) {
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 35);
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
trainlog_database_close(production);
production = NULL;

View file

@ -51,7 +51,7 @@ int main(void) {
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 35);
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
trainlog_database_close(production);
production = NULL;

View file

@ -52,7 +52,7 @@ int main(void) {
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 35);
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
trainlog_database_close(production);
production = NULL;

View file

@ -52,7 +52,7 @@ int main(void) {
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 35);
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
trainlog_database_close(production);
production = NULL;

View file

@ -61,7 +61,7 @@ int main(void) {
CHECK(trainlog_database_open(path, &production) == TRAINLOG_STATUS_OK);
CHECK(trainlog_database_schema_version(production, &version) == TRAINLOG_STATUS_OK);
CHECK(version == 35);
CHECK(version == TRAINLOG_DATABASE_SCHEMA_VERSION);
trainlog_database_close(production);
production = NULL;

View 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;
}

View file

@ -178,8 +178,8 @@ static bool test_v7_migration_sqlite_failure_has_diagnostic(void) {
TRAINLOG_STATUS_DATABASE_ERROR);
CHECK(database == NULL);
CHECK(strncmp(diagnostic,
"migrate database to schema v35: SQLite rc=",
strlen("migrate database to schema v35: SQLite rc=")) == 0);
"migrate database to schema v36: SQLite rc=",
strlen("migrate database to schema v36: SQLite rc=")) == 0);
CHECK(strstr(diagnostic, "extended_rc=") != NULL);
CHECK(strstr(diagnostic, "custom_equipment") != NULL);
CHECK(strstr(diagnostic, "already exists") != NULL);
@ -197,12 +197,12 @@ static bool test_newer_schema_has_application_diagnostic(void) {
CHECK(fd >= 0);
CHECK(close(fd) == 0);
CHECK(sqlite3_open(path, &raw) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=36;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_exec(raw, "PRAGMA user_version=37;", NULL, NULL, NULL) == SQLITE_OK);
CHECK(sqlite3_close(raw) == SQLITE_OK);
CHECK(trainlog_database_open_with_diagnostic(path, &database, diagnostic, sizeof(diagnostic)) ==
TRAINLOG_STATUS_SCHEMA_UNSUPPORTED);
CHECK(database == NULL);
CHECK(strstr(diagnostic, "schema version 36 is newer") != NULL);
CHECK(strstr(diagnostic, "schema version 37 is newer") != NULL);
CHECK(strstr(diagnostic, "SQLite") == NULL);
CHECK(unlink(path) == 0);
return true;

View file

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