fix(sync): ignore stale sleep snapshot ancestors

This commit is contained in:
fy59 2026-09-22 13:00:23 +02:00
parent dd6d2a180d
commit 0f7d793960
4 changed files with 209 additions and 0 deletions

View file

@ -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<String>()
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

View file

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

View file

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

View file

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