fix(sync): carry sleep revision ancestry
This commit is contained in:
parent
a88e85c235
commit
0a35ddacbf
4 changed files with 264 additions and 31 deletions
|
|
@ -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<String>()
|
||||
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<String>? {
|
||||
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<String>()
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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))))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
Loading…
Reference in a new issue