From 0a35ddacbf4d6b84cb04342b4f8bfcf370a5c576 Mon Sep 17 00:00:00 2001 From: fy59 Date: Tue, 22 Sep 2026 13:34:28 +0200 Subject: [PATCH] fix(sync): carry sleep revision ancestry --- .../trainlog/data/TrainlogRepository.kt | 112 ++++++++++++++++-- .../trainlog/data/SleepDiaryRepositoryTest.kt | 43 +++++-- tests/test_sleep_diary_exchange.py | 45 +++++++ tools/sleep_diary_exchange.py | 95 +++++++++++++-- 4 files changed, 264 insertions(+), 31 deletions(-) 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 11e3777..274e04f 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 @@ -513,6 +513,63 @@ class TrainlogRepository( return false } + private fun sleepRevisionAncestry( + db: SQLiteDatabase, + parentSql: String, + ownerId: String, + currentRevision: String, + ): JSONArray { + val result = JSONArray() + val seen = mutableSetOf() + var revision: String? = currentRevision + var first = true + while (revision != null) { + val current = revision + check(seen.add(current) && result.length() < MAX_SLEEP_ANCESTRY) { + "sleep revision ancestry invalid" + } + result.put(current) + revision = db.rawQuery(parentSql, arrayOf(ownerId, current)).use { cursor -> + if (!cursor.moveToFirst()) { + check(!first) { "sleep current revision is missing" } + null + } else { + if (cursor.isNull(0)) null else cursor.getString(0) + } + } + first = false + } + return result + } + + private fun sleepIncomingAncestry( + item: JSONObject, + revisionId: String, + parentRevisionId: String?, + prefix: String, + ): List? { + if (!item.has("ancestry")) return null + val values = item.optJSONArray("ancestry") + ?: throw IllegalArgumentException("invalid sleep ancestry") + if (values.length() !in 1..MAX_SLEEP_ANCESTRY) + throw IllegalArgumentException("invalid sleep ancestry") + val result = mutableListOf() + for (index in 0 until values.length()) { + val value = values.optString(index, "") + if (!sleepIdentity(value, prefix) || value in result) + throw IllegalArgumentException("invalid sleep ancestry") + result += value + } + if (result.first() != revisionId) + throw IllegalArgumentException("invalid sleep ancestry tip") + if (parentRevisionId == null) { + if (result.size != 1) throw IllegalArgumentException("invalid sleep ancestry root") + } else if (result.size < 2 || result[1] != parentRevisionId) { + throw IllegalArgumentException("invalid sleep ancestry parent") + } + return result + } + private fun sleepDraftValid(draft: SleepDiaryDraft): Boolean = try { val startDate = LocalDate.parse(draft.nightStartDate) val endDate = LocalDate.parse(draft.nightEndDate) @@ -785,11 +842,19 @@ class TrainlogRepository( .put("dose_value", if (values.isNull(4)) JSONObject.NULL else values.getDouble(4)) .put("dose_unit", if (values.isNull(5)) JSONObject.NULL else values.getString(5)) .put("note", values.getString(6)).put("created_at", values.getString(7))) } + val ancestry = sleepRevisionAncestry( + db, + "SELECT parent_revision_id FROM sleep_diary_revisions " + + "WHERE entry_id=? AND revision_id=?", + cursor.getString(0), + revisionId, + ) entries.put(JSONObject().put("entry_id", cursor.getString(0)) .put("night_start_date", cursor.getString(1)).put("night_end_date", cursor.getString(2)) .put("created_at", cursor.getString(3)).put("updated_at", cursor.getString(4)) .put("revision_id", revisionId) .put("parent_revision_id", revision[0] ?: JSONObject.NULL) + .put("ancestry", ancestry) .put("deleted", cursor.getInt(6) != 0) .put("sleep_quality", revision[1] ?: JSONObject.NULL) .put("wake_quality", revision[2] ?: JSONObject.NULL) @@ -801,17 +866,30 @@ class TrainlogRepository( db.rawQuery("SELECT m.medication_id,m.created_at,m.updated_at,m.current_revision_id,m.deleted," + "r.parent_revision_id,r.name,r.default_dose_value,r.default_dose_unit,r.form,r.note,r.active " + "FROM sleep_medications m JOIN sleep_medication_revisions r ON r.revision_id=m.current_revision_id " + - "ORDER BY m.medication_id", null).use { cursor -> while (cursor.moveToNext()) medications.put( - JSONObject().put("medication_id", cursor.getString(0)).put("created_at", cursor.getString(1)) - .put("updated_at", cursor.getString(2)).put("revision_id", cursor.getString(3)) - .put("deleted", cursor.getInt(4) != 0) - .put("parent_revision_id", if (cursor.isNull(5)) JSONObject.NULL else cursor.getString(5)) - .put("name", cursor.getString(6)) - .put("default_dose_value", if (cursor.isNull(7)) JSONObject.NULL else cursor.getDouble(7)) - .put("default_dose_unit", if (cursor.isNull(8)) JSONObject.NULL else cursor.getString(8)) - .put("form", cursor.getString(9)).put("note", cursor.getString(10)) - .put("active", cursor.getInt(11) != 0), - ) } + "ORDER BY m.medication_id", null).use { cursor -> + while (cursor.moveToNext()) { + val ancestry = sleepRevisionAncestry( + db, + "SELECT parent_revision_id FROM sleep_medication_revisions " + + "WHERE medication_id=? AND revision_id=?", + cursor.getString(0), + cursor.getString(3), + ) + medications.put( + JSONObject().put("medication_id", cursor.getString(0)) + .put("created_at", cursor.getString(1)) + .put("updated_at", cursor.getString(2)).put("revision_id", cursor.getString(3)) + .put("deleted", cursor.getInt(4) != 0) + .put("parent_revision_id", if (cursor.isNull(5)) JSONObject.NULL else cursor.getString(5)) + .put("ancestry", ancestry) + .put("name", cursor.getString(6)) + .put("default_dose_value", if (cursor.isNull(7)) JSONObject.NULL else cursor.getDouble(7)) + .put("default_dose_unit", if (cursor.isNull(8)) JSONObject.NULL else cursor.getString(8)) + .put("form", cursor.getString(9)).put("note", cursor.getString(10)) + .put("active", cursor.getInt(11) != 0), + ) + } + } return JSONObject().put("format", "trainlog-sleep-diary").put("version", 1) .put("generated_at", OffsetDateTime.now().toString()).put("entries", entries) .put("medications", medications).toString() @@ -840,6 +918,7 @@ class TrainlogRepository( if (!sleepIdentity(id, "med") || !sleepIdentity(revision, "medr") || parent != null && !sleepIdentity(parent, "medr")) return SleepDiaryImportResult.Rejected("invalid medication identity") + val ancestry = sleepIncomingAncestry(medication, revision, parent, "medr") val local = db.rawQuery("SELECT current_revision_id,deleted FROM sleep_medications WHERE medication_id=?", arrayOf(id)).use { if (it.moveToFirst()) it.getString(0) to it.getInt(1) else null } if (local?.first == revision) { val same = db.rawQuery("SELECT m.created_at,m.updated_at,m.deleted,r.parent_revision_id," + @@ -871,7 +950,10 @@ class TrainlogRepository( )) { continue } - if (local != null && local.first != parent) return SleepDiaryImportResult.Rejected("concurrent medication revision") + val remoteDescendsFromLocal = + local != null && ancestry?.drop(1)?.contains(local.first) == true + if (local != null && !remoteDescendsFromLocal && 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 * revision while retaining an unavailable parent identity; @@ -893,6 +975,7 @@ class TrainlogRepository( if (!sleepIdentity(entryId, "sl") || !sleepIdentity(revisionId, "slr") || (parent != null && !sleepIdentity(parent, "slr"))) return SleepDiaryImportResult.Rejected("invalid sleep diary identity") + val ancestry = sleepIncomingAncestry(item, revisionId, parent, "slr") val local = db.rawQuery( "SELECT current_revision_id,deleted FROM sleep_diary_entries WHERE entry_id=?", arrayOf(entryId), @@ -976,7 +1059,9 @@ class TrainlogRepository( unchanged++ continue } - if (local != null && parent != local.first) + val remoteDescendsFromLocal = + local != null && ancestry?.drop(1)?.contains(local.first) == true + if (local != null && !remoteDescendsFromLocal && parent != local.first) return SleepDiaryImportResult.Rejected("concurrent sleep diary revision") /* The snapshot may start at a non-root current revision on a * fresh peer. Preserve its causal parent identity even though @@ -7780,6 +7865,7 @@ class TrainlogRepository( private companion object { const val MAX_OCCURRENCE_PAGE_SIZE = 32 const val MAX_SET_PAGE_SIZE = 64 + const val MAX_SLEEP_ANCESTRY = 256 } /** 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 cd343f8..a300f02 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 @@ -74,7 +74,7 @@ class SleepDiaryRepositoryTest { } @Test - fun staleCurrentSnapshotDoesNotRegressNewerLocalTip() { + fun ancestryAllowsMultiHopRemoteAdvance() { val sourceName = "sleep-stale-source-${UUID.randomUUID()}.db" val destinationName = "sleep-stale-destination-${UUID.randomUUID()}.db" val source = TrainlogRepository(context, sourceName) @@ -148,18 +148,45 @@ class SleepDiaryRepositoryTest { "2026-09-21T08:15:00+02:00", ) is TrainlogRepository.SaveSleepDiaryResult.Saved, ) - val secondSnapshot = source.buildSleepDiaryV1Json() + val afterSecond = source.listSleepDiary().single() + val thirdSaved = source.saveSleepDiary( + SleepDiaryDraft( + entryId = afterSecond.entryId, + expectedRevision = secondSaved.revisionId, + nightStartDate = afterSecond.nightStartDate, + nightEndDate = afterSecond.nightEndDate, + createdAt = afterSecond.createdAt, + updatedAt = "2026-09-21T08:20:00+02:00", + sleepQuality = afterSecond.sleepQuality, + wakeQuality = afterSecond.wakeQuality, + dayForm = SleepQuality.TB, + treatmentAndNotes = afterSecond.treatmentAndNotes, + events = afterSecond.events, + intakes = afterSecond.intakes, + ), + ) as TrainlogRepository.SaveSleepDiaryResult.Saved + assertTrue( + source.validateSleepDiary( + thirdSaved.entryId, + thirdSaved.revisionId, + "2026-09-21T08:25:00+02:00", + ) is TrainlogRepository.SaveSleepDiaryResult.Saved, + ) + val latestSnapshot = source.buildSleepDiaryV1Json() + val ancestry = + org.json.JSONObject(latestSnapshot) + .getJSONArray("entries") + .getJSONObject(0) + .getJSONArray("ancestry") + assertTrue((0 until ancestry.length()).any { ancestry.getString(it) == firstSaved.revisionId }) assertEquals( TrainlogRepository.SleepDiaryImportResult.Applied(1, 0), - destination.applySleepDiaryV1Json(secondSnapshot), + destination.applySleepDiaryV1Json(latestSnapshot), ) - assertEquals( - TrainlogRepository.SleepDiaryImportResult.Applied(0, 1), - destination.applySleepDiaryV1Json(firstSnapshot), - ) - assertEquals(secondSaved.revisionId, destination.listSleepDiary().single().revisionId) + assertEquals(thirdSaved.revisionId, destination.listSleepDiary().single().revisionId) assertEquals(SleepQuality.MOY, destination.listSleepDiary().single().wakeQuality) + assertEquals(SleepQuality.TB, destination.listSleepDiary().single().dayForm) assertEquals("Medication revised", destination.listSleepMedications().single().name) } finally { source.close() diff --git a/tests/test_sleep_diary_exchange.py b/tests/test_sleep_diary_exchange.py index 12d56b7..4a42d94 100644 --- a/tests/test_sleep_diary_exchange.py +++ b/tests/test_sleep_diary_exchange.py @@ -87,6 +87,51 @@ class SleepExchangeTest(unittest.TestCase): ).fetchone()[0], ) + def test_ancestry_allows_multi_hop_remote_advance_but_not_sibling(self): + first = "slr_20000000-0000-4000-8000-000000000002" + middle = "slr_30000000-0000-4000-8000-000000000003" + latest = "slr_40000000-0000-4000-8000-000000000004" + first_document = self.document(entry(first)) + latest_document = self.document(entry(latest, middle)) + latest_document["entries"][0]["ancestry"] = [latest, middle, first] + + medication_first = first_document["medications"][0]["revision_id"] + medication_middle = "medr_a0000000-0000-4000-8000-00000000000a" + medication_latest = "medr_b0000000-0000-4000-8000-00000000000b" + medication = latest_document["medications"][0] + medication["revision_id"] = medication_latest + medication["parent_revision_id"] = medication_middle + medication["ancestry"] = [medication_latest, medication_middle, medication_first] + medication["updated_at"] = "2026-10-25T18:05:00+01:00" + medication["name"] = "Synthetic medication revised" + + self.assertEqual((1, 0), exchange.apply(self.db, first_document)) + self.assertEqual((1, 0), exchange.apply(self.db, latest_document)) + self.assertEqual( + latest, + self.db.execute( + "SELECT current_revision_id FROM sleep_diary_entries WHERE entry_id=?", + (first_document["entries"][0]["entry_id"],), + ).fetchone()[0], + ) + self.assertEqual( + medication_latest, + self.db.execute( + "SELECT current_revision_id FROM sleep_medications WHERE medication_id=?", + (medication["medication_id"],), + ).fetchone()[0], + ) + + sibling = self.document( + entry("slr_50000000-0000-4000-8000-000000000005", first) + ) + sibling["entries"][0]["ancestry"] = [ + sibling["entries"][0]["revision_id"], + first, + ] + with self.assertRaisesRegex(ValueError, "concurrent"): + exchange.apply(self.db, sibling) + 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 aa1884c..49bd63a 100644 --- a/tools/sleep_diary_exchange.py +++ b/tools/sleep_diary_exchange.py @@ -18,6 +18,7 @@ MAX_ENTRIES = 3660 MAX_EVENTS = 64 MAX_INTAKES = 32 MAX_MEDICATIONS = 1000 +MAX_ANCESTRY = 256 UUID4 = r"[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}" ENTRY_ID = re.compile(rf"^sl_{UUID4}$") REVISION_ID = re.compile(rf"^slr_{UUID4}$") @@ -69,7 +70,7 @@ def validate(root: object) -> dict: keys = {"entry_id", "night_start_date", "night_end_date", "created_at", "updated_at", "revision_id", "parent_revision_id", "deleted", "sleep_quality", "wake_quality", "day_form", "treatment_and_notes", "events", "intakes"} - if not isinstance(item, dict) or set(item) != keys: + if not isinstance(item, dict) or set(item) not in (keys, keys | {"ancestry"}): fail("invalid sleep diary entry") if not ENTRY_ID.fullmatch(item["entry_id"]) or item["entry_id"] in seen_entries: fail("invalid or duplicate sleep diary entry identity") @@ -79,6 +80,18 @@ def validate(root: object) -> dict: parent = item["parent_revision_id"] if parent is not None and (not isinstance(parent, str) or not REVISION_ID.fullmatch(parent) or parent == item["revision_id"]): fail("invalid sleep diary parent revision") + ancestry = item.get("ancestry") + if ancestry is not None: + if (not isinstance(ancestry, list) or not 1 <= len(ancestry) <= MAX_ANCESTRY or + ancestry[0] != item["revision_id"] or len(set(ancestry)) != len(ancestry) or + any(not isinstance(value, str) or not REVISION_ID.fullmatch(value) + for value in ancestry)): + fail("invalid sleep diary ancestry") + if parent is None: + if len(ancestry) != 1: + fail("invalid sleep diary ancestry root") + elif len(ancestry) < 2 or ancestry[1] != parent: + fail("invalid sleep diary ancestry parent") try: start_date = date.fromisoformat(item["night_start_date"]) end_date = date.fromisoformat(item["night_end_date"]) @@ -145,7 +158,7 @@ def validate(root: object) -> dict: for medication in medications: keys = {"medication_id", "revision_id", "parent_revision_id", "created_at", "updated_at", "name", "default_dose_value", "default_dose_unit", "form", "note", "active", "deleted"} - if not isinstance(medication, dict) or set(medication) != keys: + if not isinstance(medication, dict) or set(medication) not in (keys, keys | {"ancestry"}): fail("invalid medication") if (not MEDICATION_ID.fullmatch(medication["medication_id"]) or medication["medication_id"] in seen_medications or not MEDICATION_REVISION_ID.fullmatch(medication["revision_id"])): @@ -153,6 +166,18 @@ def validate(root: object) -> dict: seen_medications.add(medication["medication_id"]) parent = medication["parent_revision_id"] if parent is not None and not MEDICATION_REVISION_ID.fullmatch(parent): fail("invalid medication parent") + ancestry = medication.get("ancestry") + if ancestry is not None: + if (not isinstance(ancestry, list) or not 1 <= len(ancestry) <= MAX_ANCESTRY or + ancestry[0] != medication["revision_id"] or len(set(ancestry)) != len(ancestry) or + any(not isinstance(value, str) or not MEDICATION_REVISION_ID.fullmatch(value) + for value in ancestry)): + fail("invalid medication ancestry") + if parent is None: + if len(ancestry) != 1: + fail("invalid medication ancestry root") + elif len(ancestry) < 2 or ancestry[1] != parent: + fail("invalid medication ancestry parent") timestamp(medication["created_at"]); timestamp(medication["updated_at"]) if not isinstance(medication["name"], str) or not medication["name"].strip(): fail("invalid medication name") has_dose = medication["default_dose_value"] is not None @@ -168,6 +193,27 @@ def load(path: Path) -> dict: return validate(json.loads(path.read_text(encoding="utf-8"), object_pairs_hook=unique_object)) +def revision_ancestry(db: sqlite3.Connection, parent_sql: str, owner_id: str, + current_revision: str, pattern: re.Pattern[str]) -> list[str]: + result = [] + revision = current_revision + seen = set() + first = True + while revision is not None: + if revision in seen or len(result) >= MAX_ANCESTRY or not pattern.fullmatch(revision): + fail("invalid sleep revision ancestry") + seen.add(revision) + result.append(revision) + row = db.execute(parent_sql, (owner_id, revision)).fetchone() + if row is None: + if first: + fail("sleep current revision is missing") + break + revision = row[0] + first = False + return result + + def build(db: sqlite3.Connection) -> dict: entries = [] # CONTRACT: local durability does not publish a draft. A generation may @@ -187,6 +233,14 @@ def build(db: sqlite3.Connection) -> dict: ).fetchone() if revision is None: fail("sleep diary current revision is missing") + ancestry = revision_ancestry( + db, + "SELECT parent_revision_id FROM sleep_diary_revisions " + "WHERE entry_id=? AND revision_id=?", + entry_id, + revision_id, + REVISION_ID, + ) events = [ {"event_id": row[0], "type": row[1], "start_at": row[2], "end_at": row[3]} for row in db.execute( @@ -200,15 +254,23 @@ def build(db: sqlite3.Connection) -> dict: for row in db.execute("SELECT intake_id,medication_id,medication_name,taken_at,dose_value,dose_unit,note,created_at FROM sleep_medication_intakes WHERE revision_id=? ORDER BY intake_id", (revision_id,))] entries.append({"entry_id": entry_id, "night_start_date": night_start, "night_end_date": night_end, "created_at": created_at, "updated_at": updated_at, - "revision_id": revision_id, "parent_revision_id": revision[0], "deleted": bool(deleted), - "sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3], + "revision_id": revision_id, "parent_revision_id": revision[0], "ancestry": ancestry, + "deleted": bool(deleted), "sleep_quality": revision[1], "wake_quality": revision[2], "day_form": revision[3], "treatment_and_notes": revision[4] or "", "events": events, "intakes": intakes}) medications = [] for row in db.execute("SELECT m.medication_id,m.created_at,m.updated_at,m.current_revision_id,m.deleted,r.parent_revision_id,r.name,r.default_dose_value,r.default_dose_unit,r.form,r.note,r.active FROM sleep_medications m JOIN sleep_medication_revisions r ON r.revision_id=m.current_revision_id ORDER BY m.medication_id"): + ancestry = revision_ancestry( + db, + "SELECT parent_revision_id FROM sleep_medication_revisions " + "WHERE medication_id=? AND revision_id=?", + row[0], + row[3], + MEDICATION_REVISION_ID, + ) medications.append({"medication_id": row[0], "created_at": row[1], "updated_at": row[2], "revision_id": row[3], "deleted": bool(row[4]), "parent_revision_id": row[5], - "name": row[6], "default_dose_value": row[7], "default_dose_unit": row[8], - "form": row[9], "note": row[10], "active": bool(row[11])}) + "ancestry": ancestry, "name": row[6], "default_dose_value": row[7], + "default_dose_unit": row[8], "form": row[9], "note": row[10], "active": bool(row[11])}) return validate({"format": FORMAT, "version": VERSION, "generated_at": datetime.now().astimezone().isoformat(), "entries": entries, "medications": medications}) @@ -241,7 +303,11 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: if local is not None and local[0] == medication["revision_id"]: persisted = next(value for value in build(db)["medications"] if value["medication_id"] == medication["medication_id"]) - if persisted != medication: + persisted_payload = dict(persisted) + incoming_payload = dict(medication) + persisted_payload.pop("ancestry", None) + incoming_payload.pop("ancestry", None) + if persisted_payload != incoming_payload: fail("medication revision identity reused with different content") continue if local is not None and revision_is_ancestor( @@ -253,7 +319,11 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: local[0], ): continue - if local is not None and medication["parent_revision_id"] != local[0]: fail("concurrent medication revision") + remote_descends_from_local = ( + local is not None and local[0] in medication.get("ancestry", [])[1:] + ) + if local is not None and not remote_descends_from_local 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") if local is None: @@ -270,7 +340,11 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: persisted = next( value for value in build(db)["entries"] if value["entry_id"] == item["entry_id"] ) - if persisted != item: + persisted_payload = dict(persisted) + incoming_payload = dict(item) + persisted_payload.pop("ancestry", None) + incoming_payload.pop("ancestry", None) + if persisted_payload != incoming_payload: fail("sleep diary revision identity reused with different content") db.execute( "INSERT INTO sleep_diary_publication_state(entry_id,validated_revision_id,validated_at," @@ -295,7 +369,8 @@ def apply(db: sqlite3.Connection, root: dict) -> tuple[int, int]: # 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]: + remote_descends_from_local = local is not None and local[0] in item.get("ancestry", [])[1:] + if local is not None and not remote_descends_from_local and item["parent_revision_id"] != local[0]: fail("concurrent sleep diary revision") if local is None and item["parent_revision_id"] is not None: fail("sleep diary revision has an unknown parent")