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}$")
|
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)
|
.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 {
|
private fun sleepDraftValid(draft: SleepDiaryDraft): Boolean = try {
|
||||||
val startDate = LocalDate.parse(draft.nightStartDate)
|
val startDate = LocalDate.parse(draft.nightStartDate)
|
||||||
val endDate = LocalDate.parse(draft.nightEndDate)
|
val endDate = LocalDate.parse(draft.nightEndDate)
|
||||||
|
|
@ -840,6 +861,16 @@ class TrainlogRepository(
|
||||||
if (!same) return SleepDiaryImportResult.Rejected("medication revision identity reused with different content")
|
if (!same) return SleepDiaryImportResult.Rejected("medication revision identity reused with different content")
|
||||||
continue
|
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")
|
if (local != null && local.first != parent) return SleepDiaryImportResult.Rejected("concurrent medication revision")
|
||||||
/* CONTRACT: this companion is a current-state snapshot, not a
|
/* CONTRACT: this companion is a current-state snapshot, not a
|
||||||
* revision log. A fresh peer may therefore seed the current
|
* revision log. A fresh peer may therefore seed the current
|
||||||
|
|
@ -931,6 +962,20 @@ class TrainlogRepository(
|
||||||
unchanged++
|
unchanged++
|
||||||
continue
|
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)
|
if (local != null && parent != local.first)
|
||||||
return SleepDiaryImportResult.Rejected("concurrent sleep diary revision")
|
return SleepDiaryImportResult.Rejected("concurrent sleep diary revision")
|
||||||
/* The snapshot may start at a non-root current revision on a
|
/* The snapshot may start at a non-root current revision on a
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,102 @@ class SleepDiaryRepositoryTest {
|
||||||
} finally { repository.close(); context.deleteDatabase(name) }
|
} 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
|
@Test
|
||||||
fun currentSnapshotSeedsFreshPeerFromNonRootRevisions() {
|
fun currentSnapshotSeedsFreshPeerFromNonRootRevisions() {
|
||||||
val sourceName = "sleep-source-${UUID.randomUUID()}.db"
|
val sourceName = "sleep-source-${UUID.randomUUID()}.db"
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,35 @@ class SleepExchangeTest(unittest.TestCase):
|
||||||
exported = exchange.build(self.db)
|
exported = exchange.build(self.db)
|
||||||
self.assertTrue(exported["entries"][0]["deleted"])
|
self.assertTrue(exported["entries"][0]["deleted"])
|
||||||
|
|
||||||
|
def test_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):
|
def test_rejects_concurrent_sibling_and_negative_absolute_interval(self):
|
||||||
first = "slr_20000000-0000-4000-8000-000000000002"
|
first = "slr_20000000-0000-4000-8000-000000000002"
|
||||||
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first))))
|
self.assertEqual((1, 0), exchange.apply(self.db, self.document(entry(first))))
|
||||||
|
|
|
||||||
|
|
@ -214,6 +214,24 @@ def build(db: sqlite3.Connection) -> dict:
|
||||||
"medications": medications})
|
"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]:
|
def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
||||||
validate(root)
|
validate(root)
|
||||||
applied = 0
|
applied = 0
|
||||||
|
|
@ -226,6 +244,15 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]:
|
||||||
if persisted != medication:
|
if persisted != medication:
|
||||||
fail("medication revision identity reused with different content")
|
fail("medication revision identity reused with different content")
|
||||||
continue
|
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 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 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")
|
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
|
unchanged += 1
|
||||||
continue
|
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]:
|
if local is not None and item["parent_revision_id"] != local[0]:
|
||||||
fail("concurrent sleep diary revision")
|
fail("concurrent sleep diary revision")
|
||||||
if local is None and item["parent_revision_id"] is not None:
|
if local is None and item["parent_revision_id"] is not None:
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue