diff --git a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt index 4363270..11e3777 100644 --- a/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt +++ b/android/app/src/main/java/com/labfytools/trainlog/data/TrainlogRepository.kt @@ -492,6 +492,27 @@ class TrainlogRepository( Regex("^${prefix}_[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") .matches(value) + private fun sleepRevisionIsAncestor( + db: SQLiteDatabase, + parentSql: String, + ownerId: String, + ancestorRevision: String, + currentRevision: String, + ): Boolean { + var revision: String? = currentRevision + val seen = mutableSetOf() + while (revision != null) { + val current = revision + if (current == ancestorRevision) return true + check(seen.add(current)) { "sleep revision chain cycle" } + revision = db.rawQuery(parentSql, arrayOf(ownerId, current)).use { cursor -> + if (!cursor.moveToFirst()) return false + if (cursor.isNull(0)) null else cursor.getString(0) + } + } + return false + } + private fun sleepDraftValid(draft: SleepDiaryDraft): Boolean = try { val startDate = LocalDate.parse(draft.nightStartDate) val endDate = LocalDate.parse(draft.nightEndDate) @@ -840,6 +861,16 @@ class TrainlogRepository( if (!same) return SleepDiaryImportResult.Rejected("medication revision identity reused with different content") continue } + if (local != null && sleepRevisionIsAncestor( + db, + "SELECT parent_revision_id FROM sleep_medication_revisions " + + "WHERE medication_id=? AND revision_id=?", + id, + revision, + local.first, + )) { + continue + } if (local != null && local.first != parent) return SleepDiaryImportResult.Rejected("concurrent medication revision") /* CONTRACT: this companion is a current-state snapshot, not a * revision log. A fresh peer may therefore seed the current @@ -931,6 +962,20 @@ class TrainlogRepository( unchanged++ continue } + if (local != null && sleepRevisionIsAncestor( + db, + "SELECT parent_revision_id FROM sleep_diary_revisions " + + "WHERE entry_id=? AND revision_id=?", + entryId, + revisionId, + local.first, + )) { + // Current-state snapshots can arrive out of order. A local + // descendant already proves this revision is stale rather + // than a concurrent branch, so never regress the tip. + unchanged++ + continue + } if (local != null && parent != local.first) return SleepDiaryImportResult.Rejected("concurrent sleep diary revision") /* The snapshot may start at a non-root current revision on a diff --git a/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt index 8e610ea..cd343f8 100644 --- a/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt +++ b/android/app/src/test/java/com/labfytools/trainlog/data/SleepDiaryRepositoryTest.kt @@ -73,6 +73,102 @@ class SleepDiaryRepositoryTest { } finally { repository.close(); context.deleteDatabase(name) } } + @Test + fun staleCurrentSnapshotDoesNotRegressNewerLocalTip() { + val sourceName = "sleep-stale-source-${UUID.randomUUID()}.db" + val destinationName = "sleep-stale-destination-${UUID.randomUUID()}.db" + val source = TrainlogRepository(context, sourceName) + val destination = TrainlogRepository(context, destinationName) + try { + val createdAt = "2026-09-20T20:00:00+02:00" + source.saveSleepMedication( + SleepMedication("", "", createdAt, createdAt, "Medication", 5.0, "mg", "", "", true), + ) + val firstSaved = source.saveSleepDiary( + SleepDiaryDraft( + nightStartDate = "2026-09-20", + nightEndDate = "2026-09-21", + createdAt = createdAt, + updatedAt = createdAt, + sleepQuality = SleepQuality.B, + wakeQuality = null, + dayForm = null, + treatmentAndNotes = "", + events = listOf( + SleepDiaryEvent( + "", + SleepEventType.SLEEP, + "2026-09-20T23:00:00+02:00", + "2026-09-21T07:00:00+02:00", + ), + ), + ), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + assertTrue( + source.validateSleepDiary( + firstSaved.entryId, + firstSaved.revisionId, + "2026-09-21T08:00:00+02:00", + ) is TrainlogRepository.SaveSleepDiaryResult.Saved, + ) + val firstSnapshot = source.buildSleepDiaryV1Json() + assertEquals( + TrainlogRepository.SleepDiaryImportResult.Applied(1, 0), + destination.applySleepDiaryV1Json(firstSnapshot), + ) + + val medication = source.listSleepMedications().single() + source.saveSleepMedication( + medication.copy( + name = "Medication revised", + updatedAt = "2026-09-21T08:05:00+02:00", + ), + ) + val current = source.listSleepDiary().single() + val secondSaved = source.saveSleepDiary( + SleepDiaryDraft( + entryId = current.entryId, + expectedRevision = current.revisionId, + nightStartDate = current.nightStartDate, + nightEndDate = current.nightEndDate, + createdAt = current.createdAt, + updatedAt = "2026-09-21T08:10:00+02:00", + sleepQuality = current.sleepQuality, + wakeQuality = SleepQuality.MOY, + dayForm = current.dayForm, + treatmentAndNotes = current.treatmentAndNotes, + events = current.events, + intakes = current.intakes, + ), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + assertTrue( + source.validateSleepDiary( + secondSaved.entryId, + secondSaved.revisionId, + "2026-09-21T08:15:00+02:00", + ) is TrainlogRepository.SaveSleepDiaryResult.Saved, + ) + val secondSnapshot = source.buildSleepDiaryV1Json() + assertEquals( + TrainlogRepository.SleepDiaryImportResult.Applied(1, 0), + destination.applySleepDiaryV1Json(secondSnapshot), + ) + + assertEquals( + TrainlogRepository.SleepDiaryImportResult.Applied(0, 1), + destination.applySleepDiaryV1Json(firstSnapshot), + ) + assertEquals(secondSaved.revisionId, destination.listSleepDiary().single().revisionId) + assertEquals(SleepQuality.MOY, destination.listSleepDiary().single().wakeQuality) + assertEquals("Medication revised", destination.listSleepMedications().single().name) + } finally { + source.close() + destination.close() + context.deleteDatabase(sourceName) + context.deleteDatabase(destinationName) + } + } + @Test fun currentSnapshotSeedsFreshPeerFromNonRootRevisions() { val sourceName = "sleep-source-${UUID.randomUUID()}.db" diff --git a/tests/test_sleep_diary_exchange.py b/tests/test_sleep_diary_exchange.py index eea8626..12d56b7 100644 --- a/tests/test_sleep_diary_exchange.py +++ b/tests/test_sleep_diary_exchange.py @@ -58,6 +58,35 @@ class SleepExchangeTest(unittest.TestCase): exported = exchange.build(self.db) self.assertTrue(exported["entries"][0]["deleted"]) + def test_stale_ancestor_snapshot_does_not_regress_newer_tip(self): + first = "slr_20000000-0000-4000-8000-000000000002" + second = "slr_40000000-0000-4000-8000-000000000004" + first_document = self.document(entry(first)) + second_document = self.document(entry(second, first)) + second_medication = second_document["medications"][0] + second_medication["parent_revision_id"] = first_document["medications"][0]["revision_id"] + second_medication["revision_id"] = "medr_a0000000-0000-4000-8000-00000000000a" + second_medication["updated_at"] = "2026-10-25T18:05:00+01:00" + second_medication["name"] = "Synthetic medication revised" + + self.assertEqual((1, 0), exchange.apply(self.db, first_document)) + self.assertEqual((1, 0), exchange.apply(self.db, second_document)) + self.assertEqual((0, 1), exchange.apply(self.db, first_document)) + self.assertEqual( + second, + self.db.execute( + "SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id=?", + (first_document["entries"][0]["entry_id"],), + ).fetchone()[0], + ) + self.assertEqual( + second_medication["revision_id"], + self.db.execute( + "SELECT current_revision_id FROM sleep_medications WHERE medication_id=?", + (second_medication["medication_id"],), + ).fetchone()[0], + ) + def test_rejects_concurrent_sibling_and_negative_absolute_interval(self): first = "slr_20000000-0000-4000-8000-000000000002" self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first)))) diff --git a/tools/sleep_diary_exchange.py b/tools/sleep_diary_exchange.py index defcd86..aa1884c 100644 --- a/tools/sleep_diary_exchange.py +++ b/tools/sleep_diary_exchange.py @@ -214,6 +214,24 @@ def build(db: sqlite3.Connection) -> dict: "medications": medications}) +def revision_is_ancestor(db: sqlite3.Connection, parent_sql: str, owner_id: str, + ancestor_revision: str, current_revision: str) -> bool: + """Return whether an incoming current-state revision is already in the local lineage.""" + revision = current_revision + seen = set() + while revision is not None: + if revision == ancestor_revision: + return True + if revision in seen: + fail("sleep revision chain cycle") + seen.add(revision) + row = db.execute(parent_sql, (owner_id, revision)).fetchone() + if row is None: + return False + revision = row[0] + return False + + def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: validate(root) applied = 0 @@ -226,6 +244,15 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: if persisted != medication: fail("medication revision identity reused with different content") continue + if local is not None and revision_is_ancestor( + db, + "SELECT parent_revision_id FROM sleep_medication_revisions " + "WHERE medication_id=? AND revision_id=?", + medication["medication_id"], + medication["revision_id"], + local[0], + ): + continue if local is not None and medication["parent_revision_id"] != local[0]: fail("concurrent medication revision") if local is None and medication["parent_revision_id"] is not None: fail("unknown medication parent") if local is not None and local[1] and not medication["deleted"]: fail("medication resurrection") @@ -256,6 +283,18 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: ) unchanged += 1 continue + if local is not None and revision_is_ancestor( + db, + "SELECT parent_revision_id FROM sleep_diary_revisions " + "WHERE entry_id=? AND revision_id=?", + item["entry_id"], + item["revision_id"], + local[0], + ): + # Current-state companions may legitimately arrive out of order. + # A known ancestor is stale evidence, not a concurrent branch. + unchanged += 1 + continue if local is not None and item["parent_revision_id"] != local[0]: fail("concurrent sleep diary revision") if local is None and item["parent_revision_id"] is not None: