fix(sync): ignore stale sleep snapshot ancestors
This commit is contained in:
parent
dd6d2a180d
commit
0f7d793960
4 changed files with 209 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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))))
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Reference in a new issue